Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 27 additions & 17 deletions design-system/apps/design-lab/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FoundationsPage } from "./pages/FoundationsPage";
import { LabNavigation, LabSectionNavigation } from "./components/LabNavigation";
import { LabNavigation, LabSectionNavigation, hasSectionNavigation } from "./components/LabNavigation";
import { defaultComponent, libraryCategory } from "./componentNavigation";
import {
useEffect,
useLayoutEffect,
Expand All @@ -18,7 +19,7 @@ import { Button, Dialog, DialogBody, DialogClose, DialogDescription, DialogHeade
type DensityMode,
type IconName,
} from "@openbitfun/ui";
import { OpenBitFunMark } from "@openbitfun/ui/brand";
import { OpenBitFunSolidMark } from "@openbitfun/ui/brand";
import { BrandPage } from "./pages/BrandPage";
import { componentRegistry } from "@openbitfun/ui/registry";
import {
Expand Down Expand Up @@ -174,7 +175,6 @@ export function App() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const searchResultsRef = useRef<HTMLDivElement>(null);
const searchShortcut = /Mac|iPhone|iPad/i.test(navigator.userAgent) ? "⌘ K" : "Ctrl K";

const themeName = getThemeDataName(colorScheme, contrast);
// Document-level portals live outside the page ThemeRoot. Give them the same
Expand Down Expand Up @@ -413,13 +413,16 @@ export function App() {
const activeComponent = route.page === "component"
? componentRegistry.find((component) => component.name === route.componentName)
: undefined;
const librarySelection = route.page === "components" || route.page === "mobile" || route.page === "flow-chat"
? defaultComponent(libraryCategory(route.page))
: activeComponent;

return (
<DesignSystemProvider colorScheme={colorScheme} contrast={contrast} density={density} locale={locale} nativeTooltipPolicy="application">
<ThemeRoot
className="lab-shell"
data-page={route.page}
data-component-library={["components", "component", "mobile", "flow-chat"].includes(route.page) || undefined}
data-section-navigation={hasSectionNavigation(route.page) || undefined}
data-sidebar-open={sidebarOpen || undefined}
colorScheme={colorScheme}
contrast={contrast}
Expand All @@ -433,23 +436,23 @@ export function App() {
icon={<CatalogIcon glyph={Menu} />}
onClick={() => setSidebarOpen(true)}
/>
<a className="design-wordmark-link" href="#overview" onClick={event => {
<a className="design-wordmark-link" href="#overview" aria-label="OpenBitFun Design" onClick={event => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
navigate({ page: "overview" });
}}>
<OpenBitFunMark size={28} /><span>OpenBitFun<span className="design-wordmark-suffix"> Design</span></span>
{/* The product site's header lockup: the app mark next to the wordmark. The name is
visible now, and the link's accessible label still says which site it leads to. */}
<OpenBitFunSolidMark className="design-app-mark" size={28} />
<span className="design-wordmark-name">OpenBitFun</span>
</a>
<LabNavigation page={route.page} componentName={activeComponent?.name}
onNavigate={page => page === "tokens" ? openTokenPage() : navigate({ page })} />
<div className="lab-topbar-actions">
<Button className="lab-search-trigger" size="sm" variant="fill"
aria-label={t("search.label")} aria-haspopup="dialog"
leadingIcon={<CatalogIcon name="search" />}
onClick={() => setSearchOpen(true)}>
<span className="lab-search-trigger-label">{t("search.placeholder")}</span>
<KeyHint className="lab-search-shortcut">{searchShortcut}</KeyHint>
</Button>
{/* Icon only, like the two controls beside it: the accessible name still says what it
opens, and the shortcut hint moved into the dialog it opens. */}
<IconButton className="lab-search-trigger" aria-label={t("search.label")} aria-haspopup="dialog"
icon={<CatalogIcon name="search" />} onClick={() => setSearchOpen(true)} />
<IconButton
aria-label={colorScheme === "light" ? t("theme.switchToDark") : t("theme.switchToLight")}
icon={<CatalogIcon glyph={colorScheme === "light" ? Sun : Moon} />}
Expand All @@ -469,6 +472,7 @@ export function App() {
<SearchField aria-label={t("search.label")} placeholder={t("search.placeholder")}
ref={searchInputRef} value={searchQuery} onChange={event => setSearchQuery(event.target.value)}
leadingIcon={<CatalogIcon name="search" />} clearLabel={t("design.clear")} onClear={() => setSearchQuery("")}
shortcut={<KeyHint icon={<CatalogIcon name="command-mac" />}>K</KeyHint>}
onKeyDown={event => {
if (event.nativeEvent.isComposing) return;
if (event.key === "ArrowDown") {
Expand Down Expand Up @@ -542,14 +546,20 @@ export function App() {
</DialogHeader>
<DialogBody className="lab-mobile-navigation">
<LabNavigation page={route.page} onNavigate={page => page === "tokens" ? openTokenPage() : navigate({ page })} />
<LabSectionNavigation page={route.page} onNavigate={page => page === "tokens" ? openTokenPage() : navigate({ page })} />
<LabSectionNavigation componentName={librarySelection?.name} page={route.page} onNavigate={page => page === "tokens" ? openTokenPage() : navigate({ page })} />
</DialogBody>
</Sheet>

<LabSectionNavigation page={route.page} onNavigate={page => page === "tokens" ? openTokenPage() : navigate({ page })} />
<LabSectionNavigation componentName={librarySelection?.name} page={route.page} onNavigate={page => page === "tokens" ? openTokenPage() : navigate({ page })} />
<div className="lab-content">
{route.page === "overview" && (
<OverviewPage onNavigate={(target) => navigate({ page: target })} />
<OverviewPage
colorScheme={colorScheme}
contrast={contrast}
density={density}
onNavigate={(target) => navigate({ page: target })}
tokenOverrides={tokenOverrides}
/>
)}

{route.page === "foundations" && <FoundationsPage
Expand All @@ -568,7 +578,7 @@ export function App() {

{(["components", "component", "mobile", "flow-chat"].includes(route.page)) && (
<ComponentsPage
category={route.page === "mobile" ? "mobile" : route.page === "flow-chat" ? "flow-chat" : undefined}
category={libraryCategory(route.page)}
colorScheme={colorScheme}
component={activeComponent}
contrast={contrast}
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
14 changes: 14 additions & 0 deletions design-system/apps/design-lab/src/componentNavigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { componentRegistry, type ComponentMeta } from "@openbitfun/ui/registry";

/* Which directory route belongs to which component category. The shell passes it to the page and
the rail reads it back, so both agree on the route without duplicating the mapping. */
export function libraryCategory(page: string): ComponentMeta["category"] | undefined {
return page === "mobile" ? "mobile" : page === "flow-chat" ? "flow-chat" : undefined;
}

/* The component a directory route opens with. Shared so the rail can mark the same row the page is
showing when the route names no component of its own. */
export function defaultComponent(category?: ComponentMeta["category"]): ComponentMeta | undefined {
return componentRegistry.find(item => category ? item.category === category : item.name === "Button")
?? componentRegistry[0];
}
73 changes: 73 additions & 0 deletions design-system/apps/design-lab/src/components/GuideContract.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Icon as CatalogIcon, type IconName } from "@openbitfun/ui";
import { Blocks, FileCode2, Layers3 } from "lucide-react";
import { useI18n, type MessageKey } from "../i18n";

/* The usage block the guide opens with and the homepage closes on: three steps of the package
contract beside the imports they add up to. One source, two homes — the homepage would otherwise
carry a second copy of the snippet and drift from it. */

const steps: readonly {
description: MessageKey;
icon: typeof Layers3 | IconName;
title: MessageKey;
}[] = [
{
description: "gettingStarted.stepContractDescription",
icon: Layers3,
title: "gettingStarted.stepContractTitle",
},
{
description: "gettingStarted.stepThemeDescription",
icon: "palette",
title: "gettingStarted.stepThemeTitle",
},
{
description: "gettingStarted.stepComponentDescription",
icon: Blocks,
title: "gettingStarted.stepComponentTitle",
},
];

const snippet = `import "@openbitfun/theme-openbitfun/default.css";
import "@openbitfun/ui/styles.css";
import { Button, ThemeRoot } from "@openbitfun/ui";

export function App() {
return (
<ThemeRoot colorScheme="light" density="compact">
<Button variant="primary">Continue</Button>
</ThemeRoot>
);
}`;

interface GuideStepListProps {
/** The guide puts the steps under its page title, so they are h2; a page that nests them under
its own section title needs h3. One list, whichever level the outline asks for. */
headingLevel?: 2 | 3;
}

export function GuideStepList({ headingLevel = 2 }: GuideStepListProps) {
const { t } = useI18n();
const StepHeading = headingLevel === 3 ? "h3" : "h2";

return (
<ol className="guide-step-list" aria-label={t("gettingStarted.stepsLabel")}>
{steps.map(step => {
const Icon = step.icon;
return <li key={step.title}>
<span className="guide-step-icon">{typeof Icon === "string" ? <CatalogIcon name={Icon} /> : <CatalogIcon glyph={Icon} />}</span>
<div><StepHeading>{t(step.title)}</StepHeading><p>{t(step.description)}</p></div>
</li>;
})}
</ol>
);
}

export function GuideCodeCard() {
return (
<div className="guide-code-card">
<div><CatalogIcon glyph={FileCode2} size="sm" /><span>App.tsx</span></div>
<pre><code>{snippet}</code></pre>
</div>
);
}
77 changes: 77 additions & 0 deletions design-system/apps/design-lab/src/components/LabComponentIndex.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { useEffect, useRef, useState } from "react";
import { Button, Icon as CatalogIcon, OverflowText, SearchField } from "@openbitfun/ui";
import { componentRegistry } from "@openbitfun/ui/registry";
import { useI18n } from "../i18n";
import { getComponentCategoryLabel, getComponentDescription } from "../i18n/componentMetadata";

/* The component directory belongs to the navigation, not to a second index beside it: it hangs
under the Components route in the rail, the way the Foundations chapters hang under theirs. It
keeps its own search and its own scroll region, so a list this long never pushes the other
routes out of the rail. */
export function LabComponentIndex({ selected }: { selected?: string }) {
const { t } = useI18n();
const [query, setQuery] = useState("");
const listRef = useRef<HTMLElement>(null);
const activeLinkRef = useRef<HTMLAnchorElement>(null);
const categories = Array.from(new Set(componentRegistry.map(component => component.category)));
const normalizedQuery = query.trim().toLowerCase();
const catalogComponents = componentRegistry.filter(component =>
`${component.name} ${component.category} ${component.description} ${getComponentDescription(component.name, component.description, t)} ${getComponentCategoryLabel(component.category, t)}`.toLowerCase().includes(normalizedQuery),
);

useEffect(() => {
const list = listRef.current;
const activeLink = activeLinkRef.current;
if (!list) return;
if (!activeLink) {
list.scrollTop = 0;
return;
}
// Keep the open component visible inside the directory without moving the page itself.
const listBounds = list.getBoundingClientRect();
const linkBounds = activeLink.getBoundingClientRect();
if (linkBounds.top < listBounds.top) list.scrollTop += linkBounds.top - listBounds.top;
else if (linkBounds.bottom > listBounds.bottom) list.scrollTop += linkBounds.bottom - listBounds.bottom;
}, [selected, normalizedQuery]);

return (
<div className="design-navigation-index">
<SearchField aria-label={t("design.search")} placeholder={t("design.search")} value={query}
onChange={event => setQuery(event.target.value)} clearLabel={t("design.clear")} onClear={() => setQuery("")}
leadingIcon={<CatalogIcon name="search" />} />
{/* The directory can be counted by looking, so the rail does not print the total — but a reader
who cannot see the list still needs the filtered count as the query changes. The region stays
live and comes off screen instead of out of the page. */}
<span className="design-sr-only" role="status">{t("design.results", { count: catalogComponents.length })}</span>
<nav className="design-library-list" aria-label={t("design.all")} ref={listRef}>
{categories.map(group => {
const items = catalogComponents.filter(item => item.category === group).sort((a, b) => a.name.localeCompare(b.name));
if (!items.length) return null;
return (
<section className="design-library-group" key={group} aria-labelledby={`component-group-${group}`}>
<h3 id={`component-group-${group}`}>{getComponentCategoryLabel(group, t)}</h3>
<ul>
{items.map(item => (
<li key={item.name}>
<a data-overflow-trigger className="design-library-link" href={`#component/${item.name.toLowerCase()}`}
aria-current={selected === item.name ? "page" : undefined}
ref={selected === item.name ? activeLinkRef : undefined}
title={`${item.name} · ${getComponentDescription(item.name, item.description, t)}`}>
<OverflowText>{item.name}</OverflowText>
</a>
</li>
))}
</ul>
</section>
);
})}
{catalogComponents.length === 0 && (
<div className="design-library-empty">
<p>{t("design.noResults")}</p>
<Button size="sm" variant="text" onClick={() => setQuery("")}>{t("design.clear")}</Button>
</div>
)}
</nav>
</div>
);
}
55 changes: 51 additions & 4 deletions design-system/apps/design-lab/src/components/LabNavigation.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
import { enUSMessages } from "../i18n/messages";
import { useI18n, type MessageKey } from "../i18n";
import { Fragment, useSyncExternalStore } from "react";
import { OpenBitFunSolidMark } from "@openbitfun/ui/brand";
import {
focusFoundationSection,
foundationSections,
getActiveFoundationSection,
subscribeFoundationSection,
} from "../foundationNavigation";
import { LabComponentIndex } from "./LabComponentIndex";

type Page = "components" | "foundations" | "brand" | "colors" | "tokens" | "getting-started" | "resources" | "patterns" | "flow-chat" | "flow-chat-mock" | "mobile" | "subagent-ip";
type NavigationProps = { page: string; componentName?: string; onNavigate: (page: Page) => void };
Expand All @@ -7,16 +17,26 @@ function sectionFor(page: string) {
: ["foundations", "colors", "tokens", "getting-started", "resources"].includes(page) ? "foundations"
: page === "overview" ? undefined : "components";
}
/** Whether this route has a section level at all. The shell needs to know before it renders, so
* it can give the rail a column instead of a single centred one. */
export function hasSectionNavigation(page: string) {
return sectionFor(page) !== undefined;
}
/* Top-level site navigation, labelled from the English catalog in every locale: like the
homepage slogan, these three names are the Lab's own identity rather than page copy, and a
design system's canonical naming does not move with the reader's language. Everything
inside the pages still follows the active locale. */
export function LabNavigation({ page, onNavigate }: NavigationProps) {
const { t } = useI18n();
return <nav aria-label={t("app.pagesLabel")} className="lab-navigation design-primary-navigation">
{(["components", "foundations", "brand"] as const).map(target => <a key={target} href={`#${target}`} aria-current={sectionFor(page) === target ? "true" : undefined}
onClick={event => { if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); onNavigate(target); }}>{t(`design.${target}`)}</a>)}
onClick={event => { if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); onNavigate(target); }}>{enUSMessages[`design.${target}`]}</a>)}
</nav>;
}
export function LabSectionNavigation({ page, onNavigate }: NavigationProps) {
export function LabSectionNavigation({ page, componentName, onNavigate }: NavigationProps) {
const { t } = useI18n();
const section = sectionFor(page);
const activeChapter = useSyncExternalStore(subscribeFoundationSection, getActiveFoundationSection);
const links: readonly [Page, MessageKey][] = section === "brand"
? [["brand", "design.identity"], ["subagent-ip", "design.characters"]]
: section === "foundations"
Expand All @@ -26,7 +46,34 @@ export function LabSectionNavigation({ page, onNavigate }: NavigationProps) {
: [];
if (!links.length) return null;
return <nav className="design-secondary-navigation" aria-label={t(`design.${section!}`)}>
{links.map(([target, label]) => <a key={target} href={`#${target}`} aria-current={page === target || (target === "components" && ["component", "mobile", "flow-chat"].includes(page)) ? "page" : undefined}
onClick={event => { if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); onNavigate(target); }}>{t(label)}</a>)}
{links.map(([target, label]) => <Fragment key={target}>
<a href={`#${target}`} aria-current={page === target || (target === "components" && ["component", "mobile", "flow-chat"].includes(page)) ? "page" : undefined}
data-group={target === "foundations" || target === "components" ? "open" : undefined}
className={target === "components" ? "design-navigation-library" : undefined}
onClick={event => { if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); onNavigate(target); }}>
{t(label)}
{/* The library's own row wears the mark — the same artwork and the same flattening the
topbar's wordmark uses, one step down in size (the 16px control-icon step), and
decorative: the row's text already names the route. */}
{target === "components" && <OpenBitFunSolidMark className="design-app-mark" size={16} />}
</a>
{/* Both indexes hang under their route: one tree per section, so the rail and the page never
show two competing lists of the same thing. */}
{target === "components" && <LabComponentIndex selected={componentName} />}
{target === "foundations" && <ul className="design-navigation-chapters" aria-label={t("foundations.contents")}>
{foundationSections.map(chapter => <li key={chapter.id}>
<a href={`#foundations/${chapter.id}`}
aria-current={page === "foundations" && activeChapter === chapter.id ? "location" : undefined}
onClick={event => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
// Same chapter as the address bar: no hash change follows, so move the reader here.
if (window.location.hash === `#foundations/${chapter.id}`) {
event.preventDefault();
focusFoundationSection(chapter.id);
}
}}>{t(chapter.label)}</a>
</li>)}
</ul>}
</Fragment>)}
</nav>;
}
Loading
Loading