diff --git a/design-system/apps/design-lab/src/App.tsx b/design-system/apps/design-lab/src/App.tsx index 554b7a33d8..1803dd5cce 100644 --- a/design-system/apps/design-lab/src/App.tsx +++ b/design-system/apps/design-lab/src/App.tsx @@ -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, @@ -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 { @@ -174,7 +175,6 @@ export function App() { const [sidebarOpen, setSidebarOpen] = useState(false); const searchInputRef = useRef(null); const searchResultsRef = useRef(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 @@ -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 ( } onClick={() => setSidebarOpen(true)} /> - { + { if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); navigate({ page: "overview" }); }}> - OpenBitFun Design + {/* 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. */} + + OpenBitFun page === "tokens" ? openTokenPage() : navigate({ page })} />
- + {/* 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. */} + } onClick={() => setSearchOpen(true)} /> } @@ -469,6 +472,7 @@ export function App() { setSearchQuery(event.target.value)} leadingIcon={} clearLabel={t("design.clear")} onClear={() => setSearchQuery("")} + shortcut={}>K} onKeyDown={event => { if (event.nativeEvent.isComposing) return; if (event.key === "ArrowDown") { @@ -542,14 +546,20 @@ export function App() { page === "tokens" ? openTokenPage() : navigate({ page })} /> - page === "tokens" ? openTokenPage() : navigate({ page })} /> + page === "tokens" ? openTokenPage() : navigate({ page })} /> - page === "tokens" ? openTokenPage() : navigate({ page })} /> + page === "tokens" ? openTokenPage() : navigate({ page })} />
{route.page === "overview" && ( - navigate({ page: target })} /> + navigate({ page: target })} + tokenOverrides={tokenOverrides} + /> )} {route.page === "foundations" && category ? item.category === category : item.name === "Button") + ?? componentRegistry[0]; +} diff --git a/design-system/apps/design-lab/src/components/GuideContract.tsx b/design-system/apps/design-lab/src/components/GuideContract.tsx new file mode 100644 index 0000000000..4875dd4446 --- /dev/null +++ b/design-system/apps/design-lab/src/components/GuideContract.tsx @@ -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 ( + + + + ); +}`; + +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 ( +
    + {steps.map(step => { + const Icon = step.icon; + return
  1. + {typeof Icon === "string" ? : } +
    {t(step.title)}

    {t(step.description)}

    +
  2. ; + })} +
+ ); +} + +export function GuideCodeCard() { + return ( +
+
App.tsx
+
{snippet}
+
+ ); +} diff --git a/design-system/apps/design-lab/src/components/LabComponentIndex.tsx b/design-system/apps/design-lab/src/components/LabComponentIndex.tsx new file mode 100644 index 0000000000..2bb5fc1636 --- /dev/null +++ b/design-system/apps/design-lab/src/components/LabComponentIndex.tsx @@ -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(null); + const activeLinkRef = useRef(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 ( +
+ setQuery(event.target.value)} clearLabel={t("design.clear")} onClear={() => setQuery("")} + leadingIcon={} /> + {/* 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. */} + {t("design.results", { count: catalogComponents.length })} + +
+ ); +} diff --git a/design-system/apps/design-lab/src/components/LabNavigation.tsx b/design-system/apps/design-lab/src/components/LabNavigation.tsx index 1bb81891dc..09fa9ef949 100644 --- a/design-system/apps/design-lab/src/components/LabNavigation.tsx +++ b/design-system/apps/design-lab/src/components/LabNavigation.tsx @@ -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 }; @@ -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 ; } -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" @@ -26,7 +46,34 @@ export function LabSectionNavigation({ page, onNavigate }: NavigationProps) { : []; if (!links.length) return null; return ; } diff --git a/design-system/apps/design-lab/src/design-layout.css b/design-system/apps/design-lab/src/design-layout.css index 36875207d5..b8505e1c68 100644 --- a/design-system/apps/design-lab/src/design-layout.css +++ b/design-system/apps/design-lab/src/design-layout.css @@ -1,3 +1,32 @@ +/* IBM Plex Mono, latin subset, as served by openbitfun.com. The product site sets both body and + display to it, so the homepage follows; CJK still falls back to the platform sans below. */ +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("./assets/fonts/ibm-plex-mono-latin-400.woff2") format("woff2"); +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("./assets/fonts/ibm-plex-mono-latin-500.woff2") format("woff2"); +} + +/* The reference site ships 400 and 500 only. The Lab needs one real bolder cut for the + homepage headline, so 600 (SemiBold, the same OFL 1.1 family) is self-hosted here + rather than letting the browser synthesise a fake bold from 400. */ +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("./assets/fonts/ibm-plex-mono-latin-600.woff2") format("woff2"); +} + /* The site shell uses the same theme, density and control contracts as its specimens. */ :root[data-openbitfun-design-system-root] { color-scheme: light; @@ -12,8 +41,40 @@ .lab-shell { --_lab-topbar-height: var(--openbitfun-space-16); - --_lab-section-height: calc(var(--openbitfun-control-tab-group-item-height) + var(--openbitfun-space-4)); - --_lab-content-width: 1360px; + /* Shell width cap. Measured off the reference product sites: 1440 viewport, page content + 1248 wide, 96 clear on each side. The box is the cap plus the page's own 24px inner + padding, so a wide viewport grows the margins instead of stretching the page. */ + --_lab-content-width: 1296px; + /* One margin system for the whole shell. Every page sits in the same centred column with the same + two side margins; the topbar's wordmark, the rail and the pages all measure from that column, + so no page can drift away from the homepage. A page that carries a rail takes the rail's width + out of its own column instead of widening the shell. */ + --_lab-column-inset: max(0px, calc((100% - var(--_lab-content-width)) / 2)); + /* The section navigation is a left rail rather than a horizontal tab row. It is pinned at the + column's edge plus the page's own inner padding, so its first item lines up with the wordmark + above it and the page copy beside it. */ + --_lab-rail-width: 216px; + --_lab-rail-inset: calc(var(--_lab-column-inset) + var(--openbitfun-space-6)); + /* The component directory is a long column of names and it lives in the rail, so the section that + owns it gets the width the page's own index column used to hold. */ + --_lab-rail-width-directory: 256px; + /* Shell language follows the reference product sites: soft rounded containers on a plain canvas, + hairlines for structure, and the design system's own sans label voice instead of a mono one. */ + --_lab-rule: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + /* The product site is monospace-first: body, display and labels all resolve to IBM Plex Mono, + with the platform CJK sans taking over the characters the latin subset does not cover. + Lab chrome adopts it; component instances keep the design system's published type roles. */ + --_lab-font: "IBM Plex Mono", "Noto Sans SC", "PingFang SC", "Microsoft YaHei UI", "Hiragino Sans GB", sans-serif; + --_lab-label-font: var(--_lab-font); + /* The product site's wordmark lockup (25px / 500 / -0.05em / leading 1). The site's own value, not + a design-system role; defined once so the topbar and the homepage footer cannot drift apart. */ + --_lab-wordmark-size: 25px; + --_lab-wordmark-weight: var(--openbitfun-font-weight-medium); + --_lab-wordmark-tracking: -0.05em; + --_lab-wordmark-leading: 1; + --_lab-radius-surface: var(--openbitfun-radius-lg); + --_lab-radius-inset: var(--openbitfun-radius-md); + --_lab-radius-control: var(--openbitfun-radius-md); display: block; min-height: 100dvh; color: var(--openbitfun-color-content-primary); @@ -24,7 +85,29 @@ line-height: var(--openbitfun-type-body-md-line-height); } .lab-shell [hidden] { display: none !important; } +/* The homepage's closing band is the one block that leaves the centred column and runs edge to edge + (see .design-home-footer). Where the platform reserves space for a scrollbar, 100vw is wider than + what is visible, so the band is clipped instead of widening the document. clip is not a scroll + container: the sticky topbar still pins to the viewport. */ +.lab-shell[data-page="overview"] { overflow-x: clip; } .lab-workspace, .lab-content { min-width: 0; } +/* Two columns on every page that has a section level: the rail, then the page. The topbar spans + both, so the bar keeps its own width and padding; pages without a section level (the + homepage) keep the single centred column they were designed on. */ +.lab-workspace { display: grid; grid-template-columns: minmax(0, 1fr); } +.lab-workspace > .lab-topbar { grid-column: 1 / -1; } +.lab-shell[data-section-navigation] .lab-workspace { + /* Rail gutter, rail, page, then the same gutter again on the right, so a rail page ends where the + homepage ends instead of running to the window edge. */ + grid-template-columns: calc(var(--_lab-rail-inset) + var(--_lab-rail-width)) minmax(0, 1fr) var(--_lab-column-inset); +} +.lab-shell[data-section-navigation] .lab-workspace > .design-secondary-navigation { grid-column: 1; grid-row: 2; } +.lab-shell[data-section-navigation] .lab-workspace > .lab-content { grid-column: 2; grid-row: 2; } +/* The component library is the one section whose tree is a long list of names, so its rail takes the + directory width instead of the default one. */ +.lab-shell:is([data-page="components"], [data-page="component"], [data-page="mobile"], [data-page="flow-chat"]) { + --_lab-rail-width: var(--_lab-rail-width-directory); +} .lab-topbar { position: sticky; top: 0; @@ -33,68 +116,147 @@ align-items: center; gap: var(--openbitfun-space-6); min-height: var(--_lab-topbar-height); - padding: var(--openbitfun-space-2) max(var(--openbitfun-space-6), calc((100% - var(--_lab-content-width)) / 2 + var(--openbitfun-space-6))); + padding: var(--openbitfun-space-2) calc(var(--_lab-column-inset) + var(--openbitfun-space-6)); background: var(--openbitfun-color-surface-canvas); } +/* The brand lockup is copied from the product site's header, to the pixel: the 28px app mark, + a 12px gap, then the name in the site's own 25px/500 mono with -0.05em tracking. The Lab bar + is shorter than that header, so the wordmark carries more presence here than the 13px + navigation beside it — that contrast is the site's own hierarchy, not an accident. */ .design-wordmark-link { display: inline-flex; flex: none; align-items: center; - gap: var(--openbitfun-space-2); + gap: var(--openbitfun-space-3); color: var(--openbitfun-color-content-primary); text-decoration: none; - white-space: nowrap; - font-family: var(--openbitfun-type-label-lg-font-family); - font-size: var(--openbitfun-type-label-lg-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); + font-size: var(--_lab-wordmark-size); + font-weight: var(--_lab-wordmark-weight); + letter-spacing: var(--_lab-wordmark-tracking); + line-height: var(--_lab-wordmark-leading); } -.design-wordmark-suffix { color: var(--openbitfun-color-content-secondary); font-weight: var(--openbitfun-type-body-md-font-weight); } +/* The app mark is silver artwork on a transparent canvas. The site flattens it to solid ink in + its light header; the Lab does the same, and lifts it back for the dark theme. */ +.design-app-mark { filter: brightness(0); } +:root[data-openbitfun-design-system-root][data-color-scheme="dark"] .design-app-mark { filter: brightness(0) invert(1); } .design-primary-navigation, .design-secondary-navigation { display: flex; align-items: center; gap: var(--openbitfun-control-tab-group-gap); } .design-primary-navigation { flex: 1; } +/* The section level of the site used to be a horizontal tab row under the topbar. It reads as + the top of the left column instead: one rail, always in the same place, mirroring the page's + own index beneath it. It stays a nav of real links, so nothing about routing changes. */ .design-secondary-navigation { - max-width: var(--_lab-content-width); - min-height: var(--_lab-section-height); - margin-inline: auto; - padding: var(--openbitfun-space-2) var(--openbitfun-space-6); - overflow-x: auto; + position: sticky; + top: var(--_lab-topbar-height); + align-self: start; + z-index: calc(var(--openbitfun-layer-sticky) - 1); + flex-direction: column; + align-items: stretch; + gap: 0; + /* The rail holds the viewport it is pinned in, so a route branch that carries a long list scrolls + inside the rail instead of pushing the sibling routes off the screen. */ + max-height: calc(100dvh - var(--_lab-topbar-height)); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + /* Pinned to the column's edge plus the page's own padding, measured by the grid column it sits in + rather than by its own box, so the rail's first item lines up with the wordmark above it. */ + justify-self: end; + width: var(--_lab-rail-width); + padding: var(--openbitfun-space-6) var(--openbitfun-space-4) var(--openbitfun-space-10) 0; } -/* Routes remain real links; their geometry and states follow TabGroup. */ +/* Routes remain real links; the shell squares them off and marks the active route with a rule. */ .design-primary-navigation a, .design-secondary-navigation a { + position: relative; display: inline-flex; flex: none; align-items: center; min-height: var(--openbitfun-control-tab-group-item-height); padding: var(--openbitfun-space-component-block) var(--openbitfun-control-tab-group-item-padding-inline); border: var(--openbitfun-border-width-default) solid transparent; - border-radius: var(--openbitfun-control-tab-group-item-radius); + border-radius: var(--_lab-radius-control); color: var(--openbitfun-color-content-secondary); background: transparent; - font-family: var(--openbitfun-type-label-md-font-family); + font-family: var(--_lab-label-font); font-size: var(--openbitfun-type-label-md-font-size); - font-weight: var(--openbitfun-type-label-md-font-weight); - line-height: var(--openbitfun-type-label-md-line-height); + font-weight: var(--openbitfun-font-weight-regular); + line-height: var(--openbitfun-type-modifier-leading-ui-line-height); + letter-spacing: var(--openbitfun-type-modifier-tracking-subtle-letter-spacing); text-decoration: none; white-space: nowrap; - transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + transition: color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } +/* A stacked link takes the page index's own rhythm: control height, tighter inline padding, and + the active route marked by the same inset bar the index uses. */ +.design-secondary-navigation a { + min-height: var(--openbitfun-control-height-md); + padding: var(--openbitfun-space-2); +} +/* The component library is the shell's front door, so it is the one route that wears the mark: the + row keeps its own fill and rhythm and the logo only takes the right edge. */ +.design-secondary-navigation .design-navigation-library { + justify-content: space-between; + gap: var(--openbitfun-space-3); +} +/* The rail is a tree, not a row of siblings: a route's own chapters hang directly under it, one + step indented and one step quieter. Both levels are the same list, so the page never draws a + second index of itself beside the one the shell already has. */ +.design-navigation-chapters { + display: grid; + gap: 0; + margin: 0 0 var(--openbitfun-space-2); + padding: 0; + list-style: none; +} +.design-secondary-navigation .design-navigation-chapters a { + min-height: var(--openbitfun-control-height-sm); + padding-inline-start: var(--openbitfun-space-6); + color: var(--openbitfun-color-content-muted); + font-size: var(--openbitfun-type-label-sm-font-size); +} +/* The active route is a filled surface, never a stroke: same treatment and same token the + component library's own TabGroup uses for its selected tab, so a nav highlight and a tab + highlight read as the same material. */ .design-primary-navigation a[aria-current], .design-secondary-navigation a[aria-current] { color: var(--openbitfun-color-content-primary); background: var(--openbitfun-color-selection-surface); - font-weight: var(--openbitfun-type-label-selected-font-weight); + font-weight: var(--openbitfun-font-weight-semibold); } -.design-primary-navigation a:hover, .design-secondary-navigation a:hover { color: var(--openbitfun-color-content-primary); background: var(--openbitfun-color-action-neutral-surface-hover); } +/* The group parent is the open branch, so it does not take a second filled row: the fill marks the + chapter being read, and two stacked fills would read as one heavy block. Declared after the + route fill so an open branch wins it. */ +.design-secondary-navigation a[data-group] { + background: transparent; + color: var(--openbitfun-color-content-primary); +} +.design-primary-navigation a:hover, .design-secondary-navigation a:hover { color: var(--openbitfun-color-content-primary); } .lab-topbar-actions { display: flex; align-items: center; gap: var(--openbitfun-space-2); margin-inline-start: auto; } +/* The top-level nav is centred on the topbar itself rather than on whatever slack is left + between the wordmark and the actions, so it holds the bar's centre as those clusters change + width. Only the topbar copy — the same nav is stacked inside the mobile sheet, where it has + to stay in flow. */ +.lab-topbar > .lab-navigation { + position: absolute; + inset-block-start: 50%; + inset-inline-start: 50%; + flex: none; + translate: -50% -50%; +} .lab-navigation-trigger { display: none; } .lab-search-trigger { flex: none; } -.lab-search-shortcut { margin-inline-start: var(--openbitfun-space-4); } .lab-search-dialog, .lab-settings-form { display: flex; flex-direction: column; gap: var(--openbitfun-space-4); } .lab-search-options { max-height: min(55dvh, 32rem); } .lab-settings-field { display: grid; gap: var(--openbitfun-space-2); } .lab-settings-field > span:first-child { color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-label-md-font-size); } .lab-mobile-navigation { display: flex; flex-direction: column; gap: var(--openbitfun-space-6); } -.lab-mobile-navigation :is(.design-primary-navigation, .design-secondary-navigation) { display: flex; flex-direction: column; align-items: stretch; margin: 0; padding: 0; } +/* Inside the sheet the same navs are the sheet's own stacked list, not a sticky rail, and nothing + is bounded or clipped: the sheet body does the scrolling. */ +.lab-mobile-navigation :is(.design-primary-navigation, .design-secondary-navigation) { display: flex; flex-direction: column; align-items: stretch; width: auto; margin: 0; padding: 0; position: static; z-index: auto; max-height: none; overflow: visible; } +/* In the sheet the list is unbounded, so the directory moves behind the routes: a drawer keeps its + destinations within reach instead of burying them under 129 rows. */ +.lab-mobile-navigation .design-navigation-index { flex: none; order: 1; } +.lab-mobile-navigation .design-library-list { overflow: visible; } .lab-page { width: 100%; max-width: var(--_lab-content-width); padding: var(--openbitfun-space-8) var(--openbitfun-space-6) var(--openbitfun-space-12); margin-inline: auto; } -.page-heading { display: grid; gap: var(--openbitfun-space-3); max-width: 52rem; margin-bottom: var(--openbitfun-space-6); } +.page-heading { display: grid; gap: var(--openbitfun-space-3); max-width: 52rem; padding-bottom: var(--openbitfun-space-4); margin-bottom: var(--openbitfun-space-6); } .page-heading--split > div:first-child { display: grid; min-width: 0; gap: var(--openbitfun-space-3); } .lab-shell :is(.page-heading, .component-detail-heading, .colors-heading-copy, .guide-hero, .brand-gallery-heading) h1 { margin: 0; @@ -106,67 +268,68 @@ overflow-wrap: anywhere; } .page-kicker { - color: var(--openbitfun-color-content-muted); - font-family: var(--openbitfun-type-label-sm-font-family); - font-size: var(--openbitfun-type-label-sm-font-size); - font-weight: var(--openbitfun-type-label-sm-font-weight); - line-height: var(--openbitfun-type-label-sm-line-height); - letter-spacing: var(--openbitfun-type-label-sm-letter-spacing); + display: inline-flex; + align-items: center; + gap: var(--openbitfun-space-3); + color: var(--openbitfun-color-content-caption); + font-family: var(--_lab-label-font); + font-size: var(--openbitfun-type-label-xs-font-size); + font-weight: var(--openbitfun-font-weight-regular); + line-height: var(--openbitfun-type-modifier-leading-ui-line-height); + letter-spacing: var(--openbitfun-type-modifier-tracking-wide-letter-spacing); text-transform: none; } +.page-kicker-index { color: var(--openbitfun-color-content-primary); font-weight: var(--openbitfun-font-weight-semibold); font-variant-numeric: tabular-nums; } .lab-shell :is(.page-heading, .component-detail-heading, .colors-heading-copy, .guide-hero) p { margin: 0; max-width: 46rem; color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-body-md-font-size); line-height: var(--openbitfun-type-body-md-line-height); } .design-brand-page p, .design-foundation-section p, .design-component-usage p, .design-component-tokens p { color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-body-md-font-size); line-height: var(--openbitfun-type-body-md-line-height); } -.design-component-card h2 a { display: flex; gap: var(--openbitfun-space-3); align-items: center; justify-content: space-between; color: inherit; text-decoration: none; } -.design-component-card h2 a::after { content: ""; position: absolute; inset: 0; border-radius: var(--openbitfun-radius-lg); } +.design-component-card :is(h2, h3) a { display: flex; gap: var(--openbitfun-space-3); align-items: center; justify-content: space-between; color: inherit; text-decoration: none; } +.design-component-card :is(h2, h3) a::after { content: ""; position: absolute; inset: 0; border-radius: var(--_lab-radius-surface); } .design-catalog-search { display: flex; gap: var(--openbitfun-space-5); align-items: center; margin-bottom: var(--openbitfun-space-4); } .design-catalog-search > :first-child { flex: 1; max-width: 34rem; } .design-catalog-search > span:last-child { color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-body-sm-font-size); white-space: nowrap; } -.design-catalog-filters { display: flex; gap: var(--openbitfun-space-5); flex-wrap: wrap; padding-block: var(--openbitfun-space-3); margin-bottom: var(--openbitfun-space-8); border-bottom: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); } -.design-catalog-filter { position: relative; padding: var(--openbitfun-space-2) 0 var(--openbitfun-space-3); border: 0; background: transparent; color: var(--openbitfun-color-content-muted); font: inherit; font-size: var(--openbitfun-type-body-sm-font-size); cursor: pointer; } -.design-catalog-filter[aria-pressed="true"] { color: var(--openbitfun-color-content-primary); } -.design-catalog-filter[aria-pressed="true"]::after { content: ""; position: absolute; bottom: calc(-1 * var(--openbitfun-space-3)); inset-inline: 0; height: var(--openbitfun-border-width-strong); background: currentColor; } -.lab-shell[data-component-library] .design-secondary-navigation { position: sticky; top: var(--_lab-topbar-height); z-index: calc(var(--openbitfun-layer-sticky) - 1); height: var(--_lab-section-height); background: var(--openbitfun-color-surface-canvas); } -.lab-page.design-component-library { display: grid; grid-template-columns: 256px minmax(0, 1fr); align-items: start; column-gap: var(--openbitfun-space-10); padding-block: 0; min-height: calc(100dvh - var(--_lab-topbar-height) - var(--_lab-section-height)); } -.design-library-sidebar { position: sticky; top: calc(var(--_lab-topbar-height) + var(--_lab-section-height)); height: calc(100dvh - var(--_lab-topbar-height) - var(--_lab-section-height)); min-height: 0; min-width: 0; display: flex; flex-direction: column; gap: var(--openbitfun-space-4); padding-block: var(--openbitfun-space-6); padding-inline-end: var(--openbitfun-space-5); } -.design-library-heading { display: flex; align-items: center; gap: var(--openbitfun-space-2); flex: none; } -.design-library-heading h2 { margin: 0; font-size: var(--openbitfun-type-heading-card-font-size); font-weight: var(--openbitfun-type-heading-card-font-weight); } -.design-library-count { margin-inline-start: auto; color: var(--openbitfun-color-content-muted); font-size: var(--openbitfun-type-body-xs-font-size); white-space: nowrap; } -.design-library-toggle { - display: none; } -.design-library-index { display: flex; flex-direction: column; gap: var(--openbitfun-space-4); min-height: 0; flex: 1; } -.design-library-index > :first-child { flex-shrink: 0; } -.design-library-list { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; padding: var(--openbitfun-space-1); margin-inline: calc(-1 * var(--openbitfun-space-1)); } +.design-catalog-filters { display: flex; gap: var(--openbitfun-space-2); flex-wrap: wrap; padding-block: var(--openbitfun-space-3); margin-bottom: var(--openbitfun-space-8); } +.design-catalog-filter { position: relative; padding: var(--openbitfun-space-2) var(--openbitfun-space-3); border: 0; border-radius: var(--_lab-radius-control); background: transparent; color: var(--openbitfun-color-content-muted); font: inherit; font-size: var(--openbitfun-type-body-sm-font-size); cursor: pointer; } +.design-catalog-filter:hover { background: var(--openbitfun-component-button-fill-background); color: var(--openbitfun-color-content-secondary); } +.design-catalog-filter[aria-pressed="true"] { background: var(--openbitfun-color-selection-surface); color: var(--openbitfun-color-content-primary); font-weight: var(--openbitfun-font-weight-semibold); } +.lab-page.design-component-library { display: block; padding-block: 0; min-height: calc(100dvh - var(--_lab-topbar-height)); } +/* The directory keeps its own scroll region inside the rail: a route branch, then the long list, + then the sibling routes, so neither the preview nor the rest of the section moves with it. */ +.design-navigation-index { display: flex; flex: 1 1 auto; flex-direction: column; gap: var(--openbitfun-space-2); min-height: 0; padding-block-end: var(--openbitfun-space-3); } +/* Off-screen live region: the rail announces its filtered total without printing it. */ +.design-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } +.design-library-list { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; padding: var(--openbitfun-space-1); margin-inline: calc(-1 * var(--openbitfun-space-1)); counter-reset: lab-component; } +/* Categories are separated by space, not by a rule: the same reason the page index dropped its + hairlines, and the caption weight already carries the grouping. */ .design-library-group + .design-library-group { margin-top: var(--openbitfun-space-5); } -.design-library-group h3 { margin: 0; padding: var(--openbitfun-space-2) var(--openbitfun-space-3); color: var(--openbitfun-color-content-muted); font-size: var(--openbitfun-type-label-sm-font-size); font-weight: var(--openbitfun-type-label-sm-font-weight); } -.design-library-group ul { list-style: none; padding: 0; margin: 0; display: grid; gap: var(--openbitfun-space-1); } -.design-library-link { position: relative; display: flex; align-items: center; min-height: var(--openbitfun-control-height-md); padding: var(--openbitfun-space-2) var(--openbitfun-space-3); border-radius: var(--openbitfun-radius-md); color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-label-md-font-size); line-height: var(--openbitfun-type-label-md-line-height); text-decoration: none; } -.design-library-link:hover { color: var(--openbitfun-color-content-primary); background: var(--openbitfun-color-action-neutral-surface-hover); } -.design-library-link[aria-current="page"] { color: var(--openbitfun-color-content-primary); background: var(--openbitfun-color-action-neutral-surface); font-weight: var(--openbitfun-type-label-selected-font-weight); } -.design-library-sidebar :is(a, button):focus-visible { outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); outline-offset: var(--openbitfun-focus-offset); } +.design-library-group h3 { margin: 0; padding: var(--openbitfun-space-2) var(--openbitfun-space-2); color: var(--openbitfun-color-content-caption); font-family: var(--_lab-label-font); font-size: var(--openbitfun-type-label-xs-font-size); font-weight: var(--openbitfun-font-weight-regular); letter-spacing: var(--openbitfun-type-modifier-tracking-wide-letter-spacing); } +.design-library-group ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 0; } +.design-library-link { position: relative; display: flex; align-items: center; gap: var(--openbitfun-space-3); min-height: var(--openbitfun-control-height-md); padding: var(--openbitfun-space-2); border-radius: var(--_lab-radius-control); color: var(--openbitfun-color-content-secondary); font-family: var(--_lab-label-font); font-size: var(--openbitfun-type-label-sm-font-size); line-height: var(--openbitfun-type-modifier-leading-ui-line-height); letter-spacing: var(--openbitfun-type-modifier-tracking-subtle-letter-spacing); text-decoration: none; } +.design-library-link > * { min-width: 0; } +.design-library-link::before { counter-increment: lab-component; content: counter(lab-component, decimal-leading-zero); flex: none; color: var(--openbitfun-color-content-caption); font-variant-numeric: tabular-nums; } +.design-library-link:hover { color: var(--openbitfun-color-content-primary); background: transparent; } +.design-library-link[aria-current="page"] { color: var(--openbitfun-color-content-primary); background: var(--openbitfun-color-selection-surface); font-weight: var(--openbitfun-font-weight-semibold); } +.design-library-link[aria-current="page"]::before { color: var(--openbitfun-color-content-primary); } .design-library-empty { padding: var(--openbitfun-space-3); color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-body-sm-font-size); } -.design-library-footer { flex: none; display: grid; gap: var(--openbitfun-space-3); padding: var(--openbitfun-space-4) var(--openbitfun-space-3) 0; } -.design-library-footer a { display: flex; align-items: center; justify-content: space-between; gap: var(--openbitfun-space-2); color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-body-sm-font-size); text-decoration: none; } -.design-library-footer a:hover { color: var(--openbitfun-color-content-primary); } .design-library-content { min-width: 0; padding-block: var(--openbitfun-space-6) var(--openbitfun-space-12); } .design-library-detail { min-width: 0; } -.design-library-detail .component-detail-heading { min-height: 0; padding: 0; } +.design-library-detail .component-detail-heading { min-height: 0; padding: 0 0 var(--openbitfun-space-4); margin-bottom: var(--openbitfun-space-4); } .design-library-detail .component-detail-heading > div { min-width: 0; gap: var(--openbitfun-space-3); } .design-library-detail .component-detail-heading h1 { margin: 0; overflow-wrap: anywhere; } .design-library-detail .component-preview-main { gap: var(--openbitfun-space-6); } .design-library-detail .design-state-comparison { margin-block: 0; } -.design-library-detail .component-inspector { top: calc(var(--_lab-topbar-height) + var(--_lab-section-height) + var(--openbitfun-space-6)); max-height: calc(100dvh - var(--_lab-topbar-height) - var(--_lab-section-height) - var(--openbitfun-space-12)); } +.design-library-detail .component-inspector { top: calc(var(--_lab-topbar-height) + var(--openbitfun-space-6)); max-height: calc(100dvh - var(--_lab-topbar-height) - var(--openbitfun-space-12)); } .design-catalog-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 330px), 1fr)); gap: var(--openbitfun-space-8) var(--openbitfun-space-5); } -.design-component-card { position: relative; min-width: 0; border-radius: var(--openbitfun-radius-lg); } -.design-component-card-preview { height: 240px; overflow: hidden; display: flex; align-items: center; justify-content: center; gap: var(--openbitfun-space-3); padding: var(--openbitfun-space-6); border: 0; border-radius: var(--openbitfun-radius-lg); background: var(--openbitfun-color-surface-subtle); pointer-events: none; } +.design-component-card { position: relative; min-width: 0; border-radius: var(--_lab-radius-surface); } +.design-component-card-preview { height: 240px; overflow: hidden; display: flex; align-items: center; justify-content: center; gap: var(--openbitfun-space-3); padding: var(--openbitfun-space-6); border-radius: var(--_lab-radius-surface); background: var(--openbitfun-color-surface-subtle); pointer-events: none; } .design-component-card-preview > * { max-width: 100%; } .design-component-card-preview .flow-chat-preview-frame { min-width: 0; width: 100%; } .design-component-card-preview .lab-brand-solid-stage { display: contents; } .design-component-card-copy { padding: var(--openbitfun-space-4) var(--openbitfun-space-1) 0; } -.design-component-card h2 { margin: var(--openbitfun-space-2) 0 var(--openbitfun-space-2); font-size: var(--openbitfun-type-heading-panel-font-size); overflow-wrap: anywhere; } +.design-component-card :is(h2, h3) { margin: var(--openbitfun-space-2) 0 var(--openbitfun-space-2); font-size: var(--openbitfun-type-heading-panel-font-size); overflow-wrap: anywhere; } .design-component-card p { color: var(--openbitfun-color-content-secondary); margin: 0; font-size: var(--openbitfun-type-body-sm-font-size); line-height: var(--openbitfun-type-body-sm-line-height); } -.design-component-card:hover .design-component-card-preview { border-color: var(--openbitfun-color-border-strong); } +.design-component-card:hover .design-component-card-preview { background: var(--openbitfun-color-surface-tertiary); } .design-component-card:focus-within { outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); outline-offset: var(--openbitfun-focus-offset); } -.design-context-link { display: flex; align-items: center; justify-content: space-between; gap: var(--openbitfun-space-5); padding: var(--openbitfun-space-5); margin-bottom: var(--openbitfun-space-8); background: var(--openbitfun-color-surface-subtle); border-radius: var(--openbitfun-radius-lg); } +.design-context-link { display: flex; align-items: center; justify-content: space-between; gap: var(--openbitfun-space-5); padding: var(--openbitfun-space-5); margin-bottom: var(--openbitfun-space-8); border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); background: transparent; border-radius: var(--_lab-radius-surface); } .design-context-link h2 { margin: 0 0 var(--openbitfun-space-2); font-size: var(--openbitfun-type-heading-card-font-size); } .design-context-link p { margin: 0; color: var(--openbitfun-color-content-secondary); } .design-directory-footer, .design-preview-caption { display: flex; align-items: center; justify-content: space-between; gap: var(--openbitfun-space-4); padding-top: var(--openbitfun-space-6); } @@ -175,14 +338,20 @@ .design-state-comparison > summary, .design-tool-reference > summary { padding: var(--openbitfun-space-4) 0; cursor: pointer; font-size: var(--openbitfun-type-label-lg-font-size); } .design-detail-layout { display: grid; grid-template-columns: minmax(0, 1fr); align-items: start; gap: var(--openbitfun-space-6); } .design-detail-layout[data-inspector-open] { grid-template-columns: minmax(0, 1fr) 280px; } -.design-detail-layout .component-inspector { position: sticky; top: calc(var(--_lab-topbar-height) + var(--_lab-section-height) + var(--openbitfun-space-4)); max-height: calc(100dvh - var(--_lab-topbar-height) - var(--_lab-section-height) - var(--openbitfun-space-8)); overflow-y: auto; } +.design-detail-layout .component-inspector { position: sticky; top: calc(var(--_lab-topbar-height) + var(--openbitfun-space-4)); max-height: calc(100dvh - var(--_lab-topbar-height) - var(--openbitfun-space-8)); overflow-y: auto; } .design-detail-layout .component-inspector-content { display: block; } -.design-detail-layout .component-preview-panel { border: 0; border-radius: var(--openbitfun-radius-lg); overflow: visible; } -.design-preview-toolbar { flex-wrap: wrap; gap: var(--openbitfun-space-3); padding: var(--openbitfun-space-4); border-bottom: 0; } -.design-preview-options { display: flex; align-items: center; flex-wrap: wrap; gap: var(--openbitfun-space-3); padding: 0 var(--openbitfun-space-4) var(--openbitfun-space-3); } +/* The preview is one framed specimen, not a box inside a box: the panel keeps no shell of its own, + and the heading and the option row share a single closing rule instead of stacking two bands. */ +.design-detail-layout .component-preview-panel { border: var(--_lab-rule); border-radius: var(--_lab-radius-surface); background: var(--openbitfun-color-surface-panel); overflow: hidden; } +.design-preview-toolbar { flex-wrap: wrap; gap: var(--openbitfun-space-3); padding: var(--openbitfun-space-3) var(--openbitfun-space-4); border-bottom: var(--_lab-rule); } +.component-preview-panel:has(.design-preview-options) .design-preview-toolbar { border-bottom: 0; } +.design-preview-options { display: flex; align-items: center; flex-wrap: wrap; gap: var(--openbitfun-space-3); padding: var(--openbitfun-space-3) var(--openbitfun-space-4); border-bottom: var(--_lab-rule); } .design-preview-options .component-inspector-select { display: flex; align-items: center; gap: var(--openbitfun-space-2); } +/* Flex drops the 82px column floor the inspector grid gives this caption, so a two-character label + shrinks past its own width and stacks vertically. The caption is a fixed label: never shrink it. */ +.design-preview-options .component-inspector-select > span:first-child { flex: none; white-space: nowrap; } .design-preview-options select { width: auto; max-width: 15rem; } -.design-selected-stage { display: flex; flex-direction: column; justify-content: center; min-height: 300px; min-width: 0; padding: var(--openbitfun-space-10); background: var(--openbitfun-color-surface-panel); border-radius: 0 0 var(--openbitfun-radius-lg) var(--openbitfun-radius-lg); } +.design-selected-stage { display: flex; flex-direction: column; justify-content: center; min-height: 300px; min-width: 0; padding: var(--openbitfun-space-8); background: var(--openbitfun-color-surface-panel); border-radius: inherit; } .design-selected-component { min-width: 0; display: flex; align-items: center; justify-content: center; gap: var(--openbitfun-space-4); width: 100%; margin-inline: auto; } .design-selected-component > * { max-width: 100%; } .design-selected-component > label { display: flex; gap: var(--openbitfun-space-2); align-items: center; } @@ -204,8 +373,8 @@ .design-component-tokens .component-inspector-token-list { flex-direction: row; flex-wrap: wrap; } .design-state-comparison .component-preview-canvas { max-height: 75vh; overflow: auto; padding: var(--openbitfun-space-5); } .design-state-comparison .component-preview-matrix { width: max-content; min-width: 100%; } -.design-button-overview { display: grid; min-width: 0; gap: var(--openbitfun-space-6); padding: var(--openbitfun-space-5); overflow-x: auto; border-radius: 0 0 var(--openbitfun-radius-lg) var(--openbitfun-radius-lg); } -.design-button-overview .button-state-surface { min-width: 640px; border-radius: var(--openbitfun-radius-md); padding-bottom: var(--openbitfun-space-4); } +.design-button-overview { display: grid; min-width: 0; gap: var(--openbitfun-space-6); padding: var(--openbitfun-space-5); overflow-x: auto; border-radius: inherit; } +.design-button-overview .button-state-surface { min-width: 640px; border-radius: var(--_lab-radius-inset); padding-bottom: var(--openbitfun-space-4); } .design-button-overview .component-preview-matrix { width: 100%; min-width: 640px; grid-template-columns: 96px repeat(var(--_preview-state-count), minmax(100px, 1fr)); grid-template-rows: var(--openbitfun-space-10); grid-auto-rows: minmax(var(--openbitfun-space-16), auto); } .design-section-index { display: flex; gap: var(--openbitfun-space-5); flex-wrap: wrap; padding-bottom: var(--openbitfun-space-6); } .design-section-index a { color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-body-sm-font-size); } @@ -215,37 +384,91 @@ .design-token-owner-preview > div { display: grid; align-items: center; justify-items: center; min-height: 240px; padding: var(--openbitfun-space-5); background: var(--openbitfun-color-surface-subtle); border-radius: var(--openbitfun-radius-lg); } .design-token-owner-preview > div > * { max-width: 100%; } @media (max-width: 1100px) { - .lab-page.design-component-library { grid-template-columns: 224px minmax(0, 1fr); column-gap: var(--openbitfun-space-6); } + /* Two left columns would eat the reading measure, so the rail gets narrower here instead. Its + margin is the shell's, not a local number: the rail stays on the page's edge at every width. */ + .lab-shell { --_lab-rail-width: 176px; } .design-library-detail .component-inspector { position: static; max-height: none; } .design-detail-layout[data-inspector-open] { grid-template-columns: minmax(0, 1fr); } - .design-detail-layout .component-inspector { grid-row: 1; position: static; max-height: calc(100dvh - var(--_lab-topbar-height) - var(--_lab-section-height) - var(--openbitfun-space-8)); top: calc(var(--_lab-topbar-height) + var(--_lab-section-height) + var(--openbitfun-space-4)); } + .design-detail-layout .component-inspector { grid-row: 1; position: static; max-height: calc(100dvh - var(--_lab-topbar-height) - var(--openbitfun-space-8)); top: calc(var(--_lab-topbar-height) + var(--openbitfun-space-4)); } } @media (max-width: 860px) { - .lab-page.design-component-library { grid-template-columns: minmax(0, 1fr); } - .design-library-sidebar { position: static; height: auto; padding-inline-end: 0; padding-block: var(--openbitfun-space-3); gap: 0; } - .design-library-toggle { - display: inline-flex; } - .design-library-sidebar:not([data-open]) .design-library-index { display: none; } - .design-library-index { padding-block-start: var(--openbitfun-space-4); } - .design-library-list { flex: none; max-height: 40dvh; } - .design-library-footer { grid-template-columns: repeat(2, minmax(0, 1fr)); padding-block-end: var(--openbitfun-space-2); } + /* Below the navigation breakpoint the rail gives its column back and the same nav is reached + through the topbar's navigation sheet. */ + .lab-shell[data-section-navigation] .lab-workspace { grid-template-columns: minmax(0, 1fr); } + .lab-shell[data-section-navigation] .lab-workspace > .lab-content { grid-column: 1; } + .lab-workspace > .design-secondary-navigation { display: none; } .design-library-content { padding-block-start: var(--openbitfun-space-6); padding-block: var(--openbitfun-space-6) var(--openbitfun-space-12); } .design-preview-caption, .design-context-link { align-items: flex-start; flex-direction: column; } .design-selected-stage { padding: var(--openbitfun-space-5); background: var(--openbitfun-color-surface-panel); } } @media (max-width: 600px) { .lab-page { padding: var(--openbitfun-space-8) var(--openbitfun-space-4) var(--openbitfun-space-12); } - .design-secondary-navigation { padding-inline: var(--openbitfun-space-4); } .design-catalog-search { align-items: stretch; flex-direction: column; } } /* Shared document layout; controls retain their public component styles. */ .lab-shell :is(a, summary, [role="tabpanel"]):focus-visible { outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); outline-offset: var(--openbitfun-focus-offset); } -.design-secondary-navigation { position: sticky; top: var(--_lab-topbar-height); z-index: calc(var(--openbitfun-layer-sticky) - 1); background: var(--openbitfun-color-surface-canvas); } -.lab-mobile-navigation .design-secondary-navigation { position: static; } .lab-text-link { display: inline-flex; align-items: center; gap: var(--openbitfun-space-2); min-height: var(--openbitfun-control-height-sm); color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-label-md-font-size); text-decoration: none; } .lab-text-link:hover { color: var(--openbitfun-color-content-primary); text-decoration: underline; } -.design-reference-disclosure { padding-block: var(--openbitfun-space-2); } +/* Level-2 reference blocks keep a real heading: `renderHeader` moves the trigger inside it, so the + block owns its heading while the component keeps its state. Geometry reuses the component's own + layout tokens, so this header stays in sync with the package instead of drifting from it. + Blocks are separated by space, not by a rule: the content column keeps its rules for things that + are actually tabular. */ +.design-reference-disclosure { padding-block: var(--openbitfun-space-2) var(--openbitfun-space-4); } +.design-reference-heading { margin: 0; font: inherit; } +.design-reference-trigger { + display: flex; + align-items: center; + inline-size: 100%; + min-block-size: var(--openbitfun-layout-disclosure-trigger-min-block-size); + gap: var(--openbitfun-layout-disclosure-trigger-gap); + padding-block: var(--openbitfun-layout-disclosure-trigger-padding-block); + padding-inline: var(--openbitfun-layout-disclosure-trigger-padding-inline); + border: 0; + border-radius: var(--_lab-radius-control); + background: transparent; + color: inherit; + font: inherit; + text-align: start; + cursor: pointer; +} +.design-reference-trigger:hover { color: var(--openbitfun-color-content-primary); } +.design-reference-trigger:focus-visible { outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); outline-offset: var(--openbitfun-focus-offset); } +.design-reference-indicator { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + inline-size: var(--openbitfun-layout-disclosure-indicator-size); + block-size: var(--openbitfun-layout-disclosure-indicator-size); + color: var(--openbitfun-color-content-muted); + transition: transform var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); +} +.design-reference-disclosure[data-open="true"] .design-reference-indicator { transform: rotate(90deg); } +.design-reference-label { + flex: 1 1 auto; + min-inline-size: 0; + font-family: var(--openbitfun-type-heading-section-font-family); + font-size: var(--openbitfun-type-heading-section-font-size); + font-weight: var(--openbitfun-type-heading-section-font-weight); + letter-spacing: var(--openbitfun-type-heading-section-letter-spacing); +} .design-reference-note { margin: 0; color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-body-sm-font-size); line-height: var(--openbitfun-type-body-sm-line-height); padding-block: var(--openbitfun-space-2); } +/* Pattern sections (Icon contexts, Menu nesting, native disclosure) are Lab structure, not component + previews, so they take the same level-2 block treatment. The text starts where the reference labels + start, which is one indicator column into the block. */ +.design-detail-layout .design-icon-documentation { + padding-block: 0 var(--openbitfun-space-6); +} +.design-detail-layout .design-icon-documentation > h2 { + margin: 0; + padding-block: var(--openbitfun-layout-disclosure-trigger-padding-block); + padding-inline: calc(var(--openbitfun-layout-disclosure-trigger-padding-inline) + var(--openbitfun-layout-disclosure-indicator-size) + var(--openbitfun-layout-disclosure-trigger-gap)); + font-family: var(--openbitfun-type-heading-section-font-family); + font-size: var(--openbitfun-type-heading-section-font-size); + font-weight: var(--openbitfun-type-heading-section-font-weight); + letter-spacing: var(--openbitfun-type-heading-section-letter-spacing); +} .design-reference-disclosure .component-code-panel { border: 0; background: var(--openbitfun-color-surface-subtle); } .design-reference-disclosure .component-code-heading { justify-content: flex-end; border-bottom: 0; } .design-component-tokens { display: grid; gap: var(--openbitfun-space-4); justify-items: start; } @@ -253,20 +476,19 @@ .colors-theme-value { display: flex; align-items: center; min-height: var(--openbitfun-control-height-md); color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-body-sm-font-size); } .colors-semantic-tools > [data-openbitfun-component="search-field"] { flex: 1; min-width: 12rem; } .colors-semantic-tools > [data-openbitfun-component="select"] { min-width: 10rem; } -.colors-expand-action { display: flex; justify-content: center; padding: var(--openbitfun-space-2); border-top: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); } +.colors-expand-action { display: flex; justify-content: center; padding: var(--openbitfun-space-4) var(--openbitfun-space-2); } .token-tools > * { min-width: 0; } .token-action-row { flex-wrap: wrap; } .token-value-control > [data-openbitfun-component="input"] { flex: 1; min-width: 0; } .guide-setup-layout { display: grid; grid-template-columns: minmax(0, .85fr) minmax(0, 1.15fr); align-items: start; gap: var(--openbitfun-space-8); } .guide-step-list { display: grid; gap: var(--openbitfun-space-6); list-style: none; padding: 0; margin: 0; } .guide-step-list li { display: flex; align-items: flex-start; gap: var(--openbitfun-space-3); } -.guide-step-list h2 { margin: 0 0 var(--openbitfun-space-2); font-family: var(--openbitfun-type-heading-section-font-family); font-size: var(--openbitfun-type-heading-section-font-size); font-weight: var(--openbitfun-type-heading-section-font-weight); line-height: var(--openbitfun-type-heading-section-line-height); } +.guide-step-list :is(h2, h3) { margin: 0 0 var(--openbitfun-space-2); font-family: var(--openbitfun-type-heading-section-font-family); font-size: var(--openbitfun-type-heading-section-font-size); font-weight: var(--openbitfun-type-heading-section-font-weight); line-height: var(--openbitfun-type-heading-section-line-height); } .guide-step-list p { margin: 0; color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-body-sm-font-size); line-height: var(--openbitfun-type-body-sm-line-height); } .guide-step-icon { flex: none; } .guide-next-action { margin-top: var(--openbitfun-space-6); display: flex; flex-wrap: wrap; gap: var(--openbitfun-space-3); } @media (max-width: 1100px) { .lab-topbar { gap: var(--openbitfun-space-3); } - .design-wordmark-suffix, .lab-search-shortcut { display: none; } .colors-page-heading { grid-template-columns: minmax(0, 1fr); } } @media (max-width: 860px) { @@ -276,7 +498,6 @@ .guide-setup-layout { grid-template-columns: minmax(0, 1fr); } } @media (max-width: 600px) { - .lab-search-trigger-label { display: none; } .lab-topbar-actions { gap: var(--openbitfun-space-1); } .lab-page { padding-block: var(--openbitfun-space-6) var(--openbitfun-space-8); } .colors-context-controls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; } @@ -294,14 +515,9 @@ .design-primary-navigation a, .design-secondary-navigation a { transition: none; } } /* Keep document offsets beside the shell that owns their geometry. */ -.lab-page.design-foundations { --_foundation-sticky-top: calc(var(--_lab-topbar-height) + var(--_lab-section-height) + var(--openbitfun-space-4)); } -.foundation-sidebar { top: var(--_foundation-sticky-top); max-height: calc(100dvh - var(--_foundation-sticky-top)); } +.lab-page.design-foundations { --_foundation-sticky-top: calc(var(--_lab-topbar-height) + var(--openbitfun-space-4)); } .foundation-chapter { scroll-margin-top: var(--_foundation-sticky-top); } -.subagent-ip-detail { top: calc(var(--_lab-topbar-height) + var(--_lab-section-height) + var(--openbitfun-space-4)); scroll-margin-top: calc(var(--_lab-topbar-height) + var(--_lab-section-height) + var(--openbitfun-space-4)); } -.brand-gallery :is(section, details) { scroll-margin-top: calc(var(--_lab-topbar-height) + var(--_lab-section-height) + var(--openbitfun-space-4)); } -@media (max-width: 860px) { - .lab-page.design-foundations { --_foundation-sticky-top: calc(var(--_lab-topbar-height) + var(--_lab-section-height) + var(--openbitfun-control-height-lg) + var(--openbitfun-space-6)); } - .foundation-sidebar { top: calc(var(--_lab-topbar-height) + var(--_lab-section-height)); max-height: none; } -} +.subagent-ip-detail { top: calc(var(--_lab-topbar-height) + var(--openbitfun-space-4)); scroll-margin-top: calc(var(--_lab-topbar-height) + var(--openbitfun-space-4)); } +.brand-gallery :is(section, details) { scroll-margin-top: calc(var(--_lab-topbar-height) + var(--openbitfun-space-4)); } .page-heading--split { max-width: none; } diff --git a/design-system/apps/design-lab/src/foundationNavigation.ts b/design-system/apps/design-lab/src/foundationNavigation.ts new file mode 100644 index 0000000000..1fc20fb33e --- /dev/null +++ b/design-system/apps/design-lab/src/foundationNavigation.ts @@ -0,0 +1,47 @@ +import type { MessageKey } from "./i18n"; + +/* The Foundations chapters. One list, two consumers: the page renders each entry as a section, and + the rail renders the same entries as the children of the Foundations route, so the tree in the + rail and the document can never drift apart. */ +export const foundationSections = [ + { id: "colors", label: "nav.colors", note: "design.colorNote" }, + { id: "type", label: "design.type", note: "design.typeNote" }, + { id: "space", label: "design.space", note: "design.spaceNote" }, + { id: "shape", label: "design.shape", note: "design.shapeNote" }, + { id: "motion", label: "design.motion", note: "design.motionNote" }, + { id: "accessibility", label: "design.accessibility", note: "design.accessibilityNote" }, +] as const satisfies readonly { id: string; label: MessageKey; note: MessageKey }[]; + +export type FoundationSectionId = typeof foundationSections[number]["id"]; + +/* The reader's position belongs to the page: it is the only part that measures scroll geometry. + It publishes the current chapter here so the rail's tree can mark it, and the shell never has to + know how the page measures itself. */ +let activeSection: FoundationSectionId = "colors"; +const listeners = new Set<() => void>(); + +export function getActiveFoundationSection() { + return activeSection; +} + +export function subscribeFoundationSection(listener: () => void) { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} + +export function setActiveFoundationSection(id: FoundationSectionId) { + if (id === activeSection) return; + activeSection = id; + for (const listener of listeners) listener(); +} + +/* Move the reader to a chapter the way a hash change would. Used for the initial anchor and for + clicking the chapter already in the address, where no hash change will follow. */ +export function focusFoundationSection(id: FoundationSectionId, behavior?: ScrollBehavior) { + const element = document.getElementById(`foundation-${id}`); + element?.querySelector("h2")?.focus({ preventScroll: true }); + element?.scrollIntoView({ + behavior: behavior ?? (window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth"), + block: "start", + }); +} diff --git a/design-system/apps/design-lab/src/i18n/brandMessages.ts b/design-system/apps/design-lab/src/i18n/brandMessages.ts index aadba3e7e3..9840179602 100644 --- a/design-system/apps/design-lab/src/i18n/brandMessages.ts +++ b/design-system/apps/design-lab/src/i18n/brandMessages.ts @@ -12,6 +12,7 @@ export const brandAssetsEn = { "brandAssets.backgroundFor": "{asset} preview background", "brandAssets.downloadLabel": "Download {asset} ({format})", "brandAssets.chooseMotion": "Motion style", + "brandAssets.characterMotion": "Characters", "brandAssets.breathe": "Breathe", "brandAssets.flow": "Flow", "brandAssets.particles": "Particles", @@ -33,6 +34,7 @@ export const brandAssetsZhCN = { "brandAssets.backgroundFor": "{asset}预览背景", "brandAssets.downloadLabel": "下载{asset}({format})", "brandAssets.chooseMotion": "动效样式", + "brandAssets.characterMotion": "角色", "brandAssets.breathe": "呼吸", "brandAssets.flow": "流光", "brandAssets.particles": "粒子", @@ -54,6 +56,7 @@ export const brandAssetsZhTW = { "brandAssets.backgroundFor": "{asset}預覽背景", "brandAssets.downloadLabel": "下載{asset}({format})", "brandAssets.chooseMotion": "動效樣式", + "brandAssets.characterMotion": "角色", "brandAssets.breathe": "呼吸", "brandAssets.flow": "流光", "brandAssets.particles": "粒子", diff --git a/design-system/apps/design-lab/src/i18n/componentMetadata.ts b/design-system/apps/design-lab/src/i18n/componentMetadata.ts index e9bad7fdd0..910845529d 100644 --- a/design-system/apps/design-lab/src/i18n/componentMetadata.ts +++ b/design-system/apps/design-lab/src/i18n/componentMetadata.ts @@ -15,6 +15,15 @@ const categoryKeys: Readonly> = { }; const descriptionKeys: Readonly> = { + MobileAvatar: "component.MobileAvatar.description", + MobileCheckbox: "component.MobileCheckbox.description", + MobileRadioGroup: "component.MobileRadioGroup.description", + MobileSelect: "component.MobileSelect.description", + MobileStepper: "component.MobileStepper.description", + MobileSwitch: "component.MobileSwitch.description", + MobileTabBar: "component.MobileTabBar.description", + MobileTag: "component.MobileTag.description", + MobileToolbar: "component.MobileToolbar.description", SplitView: "component.SplitView.description", ListModelsToolCard: "component.ListModelsToolCard.description", ControlHubToolCard: "component.ControlHubToolCard.description", diff --git a/design-system/apps/design-lab/src/i18n/designMessages.ts b/design-system/apps/design-lab/src/i18n/designMessages.ts index f3fb446b8c..6e1308235e 100644 --- a/design-system/apps/design-lab/src/i18n/designMessages.ts +++ b/design-system/apps/design-lab/src/i18n/designMessages.ts @@ -24,9 +24,54 @@ export const designEn = { "design.character.robot-20": "Oat", "design.components": "Components", + "design.componentIndex": "Component index", "design.foundations": "Foundations", "design.brand": "Brand resources", "design.intro": "Components, foundations, and brand assets for OpenBitFun.", + /* Brand line: the same English sentence in every locale, as on the reference product sites. + It states the design stance (neutral, connected, intelligent) and what it is the stance of, + so it is not translated. Short enough to hold two lines at every width. */ + "design.slogan": "Neutral, connected, intelligent AI Core Blocks.", + /* The line under the brand line, in the same English in every locale: it explains the sentence + above it (what the blocks are) and what this site is for, in the register the reference + products use for the same job. */ + "design.sloganSupport": "Tokens, components, and motion every OpenBitFun surface is assembled from. Neutral by default, connected across surfaces, live in this Lab.", + /* Homepage product picture. The showcase frame repeats the product's own welcome screen, so its + chrome labels stay English in every locale — the same rule as the brand line above. Sample data + inside that frame (session and repository names, machine, version, model) is not copy: it lives + in the page, unlabelled, the way it lives in the product. */ + "design.homePreview.search": "Search", + "design.homePreview.newSession": "New session", + "design.homePreview.assistant": "AI Assistant", + "design.homePreview.taskBoard": "Task Board", + "design.homePreview.miniApp": "Mini App", + "design.homePreview.extensions": "Extensions", + "design.homePreview.sessions": "Sessions", + "design.homePreview.showMore": "Show more", + "design.homePreview.welcome": "Welcome", + "design.homePreview.settings": "Settings", + "design.homePreview.greeting": "Good morning, coding partner", + "design.homePreview.support": "Let’s create something together.", + "design.homePreview.composerPlaceholder": "How can I help you…", + "design.homePreview.composerVoice": "Voice input", + "design.homePreview.contextMachine": "This computer", + "design.homePreview.contextWorkspace": "worktree", + "design.homePreview.contextAsk": "Ask", + "design.homePreview.mode": "Standard", + /* The page closes on a footer built like the product site's own: the line under the wordmark, + the columns, and the small print. Its copy is translated, unlike the picture above, because a + reader of a translated catalog still expects a footer in their language. */ + "design.homeFooter.pathsLabel": "OpenBitFun design system", + "design.homeFooter.tagline": "Built in the open.", + "design.homeFooter.taglineAccent": "Moving forward together.", + "design.homeFooter.copyright": "© {year} OpenBitFun", + "design.homeFooter.contactLabel": "Contact OpenBitFun", + "design.homeFooter.contact": "hello@openbitfun.com", + "design.homeFooter.openSource": "Open Source", + "design.homeFooter.license": "MIT", + /* The lockup is a way out of the Lab: it opens the product site in a new tab, so the label says + both what it is and that focus will move to another tab. */ + "design.homeFooter.siteLabel": "OpenBitFun website (opens in a new tab)", "design.componentsIntro": "Browse components and try their states and interactions.", "design.foundationsIntro": "Color, typography, spacing, shape, and motion.", "design.brandIntro": "Original artwork, characters, and brand motion.", @@ -118,9 +163,43 @@ export const designZhCN = { "design.character.robot-20": "燕麦", "design.components": "组件库", + "design.componentIndex": "组件索引", "design.foundations": "设计规范", "design.brand": "品牌资源", "design.intro": "OpenBitFun 的组件、设计规范与品牌资源。", + /* Brand line; kept English in every locale on purpose. See designEn above. */ + "design.slogan": "Neutral, connected, intelligent AI Core Blocks.", + /* Support line; kept English in every locale on purpose. See designEn above. */ + "design.sloganSupport": "Tokens, components, and motion every OpenBitFun surface is assembled from. Neutral by default, connected across surfaces, live in this Lab.", + /* Homepage product picture; kept English in every locale on purpose. See designEn above. */ + "design.homePreview.search": "Search", + "design.homePreview.newSession": "New session", + "design.homePreview.assistant": "AI Assistant", + "design.homePreview.taskBoard": "Task Board", + "design.homePreview.miniApp": "Mini App", + "design.homePreview.extensions": "Extensions", + "design.homePreview.sessions": "Sessions", + "design.homePreview.showMore": "Show more", + "design.homePreview.welcome": "Welcome", + "design.homePreview.settings": "Settings", + "design.homePreview.greeting": "Good morning, coding partner", + "design.homePreview.support": "Let’s create something together.", + "design.homePreview.composerPlaceholder": "How can I help you…", + "design.homePreview.composerVoice": "Voice input", + "design.homePreview.contextMachine": "This computer", + "design.homePreview.contextWorkspace": "worktree", + "design.homePreview.contextAsk": "Ask", + "design.homePreview.mode": "Standard", + /* Footer copy is translated; see designEn above. */ + "design.homeFooter.pathsLabel": "OpenBitFun 设计系统", + "design.homeFooter.tagline": "开放共建,", + "design.homeFooter.taglineAccent": "一起向前。", + "design.homeFooter.copyright": "© {year} OpenBitFun", + "design.homeFooter.contactLabel": "联系 OpenBitFun", + "design.homeFooter.contact": "hello@openbitfun.com", + "design.homeFooter.openSource": "开放源码", + "design.homeFooter.license": "MIT", + "design.homeFooter.siteLabel": "访问 OpenBitFun 官网(在新标签页打开)", "design.componentsIntro": "查看组件,体验状态与交互。", "design.foundationsIntro": "颜色、排版、间距、形态与动效。", "design.brandIntro": "原始标识、角色形象与品牌动效。", @@ -213,9 +292,43 @@ export const designZhTW = { "design.character.robot-20": "燕麥", "design.components": "元件庫", + "design.componentIndex": "元件索引", "design.foundations": "設計規範", "design.brand": "品牌資源", "design.intro": "OpenBitFun 的元件、設計規範與品牌資源。", + /* Brand line; kept English in every locale on purpose. See designEn above. */ + "design.slogan": "Neutral, connected, intelligent AI Core Blocks.", + /* Support line; kept English in every locale on purpose. See designEn above. */ + "design.sloganSupport": "Tokens, components, and motion every OpenBitFun surface is assembled from. Neutral by default, connected across surfaces, live in this Lab.", + /* Homepage product picture; kept English in every locale on purpose. See designEn above. */ + "design.homePreview.search": "Search", + "design.homePreview.newSession": "New session", + "design.homePreview.assistant": "AI Assistant", + "design.homePreview.taskBoard": "Task Board", + "design.homePreview.miniApp": "Mini App", + "design.homePreview.extensions": "Extensions", + "design.homePreview.sessions": "Sessions", + "design.homePreview.showMore": "Show more", + "design.homePreview.welcome": "Welcome", + "design.homePreview.settings": "Settings", + "design.homePreview.greeting": "Good morning, coding partner", + "design.homePreview.support": "Let’s create something together.", + "design.homePreview.composerPlaceholder": "How can I help you…", + "design.homePreview.composerVoice": "Voice input", + "design.homePreview.contextMachine": "This computer", + "design.homePreview.contextWorkspace": "worktree", + "design.homePreview.contextAsk": "Ask", + "design.homePreview.mode": "Standard", + /* Footer copy is translated; see designEn above. */ + "design.homeFooter.pathsLabel": "OpenBitFun 設計系統", + "design.homeFooter.tagline": "開放共建,", + "design.homeFooter.taglineAccent": "一起向前。", + "design.homeFooter.copyright": "© {year} OpenBitFun", + "design.homeFooter.contactLabel": "聯絡 OpenBitFun", + "design.homeFooter.contact": "hello@openbitfun.com", + "design.homeFooter.openSource": "開放原始碼", + "design.homeFooter.license": "MIT", + "design.homeFooter.siteLabel": "前往 OpenBitFun 官網(另開分頁)", "design.componentsIntro": "查看元件,體驗狀態與互動。", "design.foundationsIntro": "色彩、排版、間距、形態與動效。", "design.brandIntro": "原始標誌、角色形象與品牌動效。", diff --git a/design-system/apps/design-lab/src/i18n/messages.ts b/design-system/apps/design-lab/src/i18n/messages.ts index e143ff2acc..479aba17e9 100644 --- a/design-system/apps/design-lab/src/i18n/messages.ts +++ b/design-system/apps/design-lab/src/i18n/messages.ts @@ -139,6 +139,15 @@ export const enUSMessages = { "component.MobileStatus.description": "A consistent mobile loading, empty, or error state with optional recovery action.", "component.MobileTextField.description": "A mobile text field with touch geometry and optional content slots.", "component.MobileTextarea.description": "A touch-friendly multiline field for expanded mobile composition and forms.", + "component.MobileAvatar.description": "A mobile identity avatar that renders initials, supplied artwork, or an image with an optional status ring.", + "component.MobileCheckbox.description": "A touch-sized checkbox row for multi-select mobile forms and settings lists.", + "component.MobileRadioGroup.description": "A flush mobile radio group where the group, not each row, carries the surface.", + "component.MobileSelect.description": "A mobile select field that opens the shared choice sheet instead of a native picker.", + "component.MobileStepper.description": "A bounded numeric stepper with decrease and increase controls at touch size.", + "component.MobileSwitch.description": "A touch-sized switch row for settings that take effect immediately.", + "component.MobileTabBar.description": "A safe-area-aware bottom tab bar whose selection is a filled pill, never an underline.", + "component.MobileTag.description": "A tappable mobile tag whose selection is a filled pill rather than an outline.", + "component.MobileToolbar.description": "A contextual mobile toolbar with independent leading, content, and trailing regions.", "component.ActionItem.description": "A native button row with independent leading, shortcut, and sibling action areas.", "component.ActivityItem.description": "A compact activity row with independent identity, description, metadata, and sibling action areas.", "component.AgentControlToolCard.description": "A compact agent capsule with lifecycle status that opens its conversation in the side pane.", @@ -459,6 +468,11 @@ export const enUSMessages = { "components.preview.composerDevice": "This computer", "components.preview.composerWorkspace": "OpenBitFun", "components.preview.composerMode": "Ask", + "components.preview.mobileSelectLabel": "Model", + "components.preview.mobileStepperLabel": "Retries", + "components.preview.mobileDecrease": "Decrease", + "components.preview.mobileIncrease": "Increase", + "components.preview.mobileToolbarSelection": "2 selected", "components.preview.composerStandard": "Standard", "components.preview.composerModel": "deepseek-v4-pro", "components.preview.session": "Session", @@ -947,6 +961,15 @@ export const zhCNMessages = { "component.MobileStatus.description": "统一呈现加载、空状态和错误恢复操作的移动端状态组件。", "component.MobileTextField.description": "具备触控几何与可选内容插槽的移动端文本框。", "component.MobileTextarea.description": "用于展开态输入和表单的触控友好多行文本框。", + "component.MobileAvatar.description": "移动端身份头像,支持首字母、图形或图片,并可附带状态圆环。", + "component.MobileCheckbox.description": "用于多选表单与设置列表的触控尺寸复选行。", + "component.MobileRadioGroup.description": "整组承担表面的移动端单选组,选项之间无缝排列。", + "component.MobileSelect.description": "打开统一选择弹层、而非系统选择器的移动端选择字段。", + "component.MobileStepper.description": "带上下限的数值步进器,减少与增加按钮均为触控尺寸。", + "component.MobileSwitch.description": "用于即时生效设置的触控尺寸开关行。", + "component.MobileTabBar.description": "适配安全区域的底部标签栏,选中态为色块填充而非下划线。", + "component.MobileTag.description": "可点按的移动端标签,选中态为填充胶囊而非描边。", + "component.MobileToolbar.description": "带前置、内容与后置区域的移动端上下文工具栏。", "component.ActionItem.description": "使用原生按钮语义,并拆分前置、快捷键与同级尾部操作区域的行项目。", "component.ActivityItem.description": "紧凑的活动行,可独立配置标识、描述、统计信息和同级尾部操作。", "component.AgentControlToolCard.description": "紧凑的子 Agent 胶囊,呈现任务和状态,点击在侧栏打开会话。", @@ -1267,6 +1290,11 @@ export const zhCNMessages = { "components.preview.composerDevice": "此电脑", "components.preview.composerWorkspace": "OpenBitFun", "components.preview.composerMode": "询问", + "components.preview.mobileSelectLabel": "模型", + "components.preview.mobileStepperLabel": "重试次数", + "components.preview.mobileDecrease": "减少", + "components.preview.mobileIncrease": "增加", + "components.preview.mobileToolbarSelection": "已选 2 项", "components.preview.composerStandard": "标准", "components.preview.composerModel": "deepseek-v4-pro", "components.preview.session": "会话", @@ -1801,6 +1829,15 @@ export const zhTWMessages = { "component.MobileStatus.description": "統一呈現載入、空狀態和錯誤恢復操作的行動端狀態元件。", "component.MobileTextField.description": "具備觸控幾何與可選內容插槽的移動端文字欄位。", "component.MobileTextarea.description": "用於展開態輸入和表單的觸控友好多行文字欄位。", + "component.MobileAvatar.description": "行動裝置身分頭像,支援縮寫字母、圖形或圖片,並可附帶狀態圓環。", + "component.MobileCheckbox.description": "用於多選表單與設定清單的觸控尺寸核取列。", + "component.MobileRadioGroup.description": "整組承擔表面的行動單選群組,選項之間無縫排列。", + "component.MobileSelect.description": "開啟統一選擇彈層、而非系統選擇器的行動選擇欄位。", + "component.MobileStepper.description": "具上下限的數值步進器,減少與增加按鈕皆為觸控尺寸。", + "component.MobileSwitch.description": "用於即時生效設定的觸控尺寸開關列。", + "component.MobileTabBar.description": "適配安全區域的底部標籤列,選中態為色塊填滿而非底線。", + "component.MobileTag.description": "可點按的行動標籤,選中態為填滿膠囊而非描邊。", + "component.MobileToolbar.description": "具前置、內容與後置區域的行動情境工具列。", "component.ActionItem.description": "使用原生按鈕語意,並拆分前置、快捷鍵與同級尾端操作區域的列項目。", "component.ActivityItem.description": "緊湊的活動列,可獨立設定標識、描述、統計資訊和同級尾端操作。", "component.AgentControlToolCard.description": "精簡的子 Agent 膠囊,呈現任務與狀態,點擊在側欄開啟對話。", @@ -2024,6 +2061,11 @@ export const zhTWMessages = { "components.preview.composerDevice": "這台電腦", "components.preview.composerWorkspace": "OpenBitFun", "components.preview.composerMode": "詢問", + "components.preview.mobileSelectLabel": "模型", + "components.preview.mobileStepperLabel": "重試次數", + "components.preview.mobileDecrease": "減少", + "components.preview.mobileIncrease": "增加", + "components.preview.mobileToolbarSelection": "已選 2 項", "components.preview.composerStandard": "標準", "components.preview.composerModel": "deepseek-v4-pro", "components.preview.session": "工作階段", diff --git a/design-system/apps/design-lab/src/i18n/previewMessages.ts b/design-system/apps/design-lab/src/i18n/previewMessages.ts index fdd313a376..ffa1d2e33d 100644 --- a/design-system/apps/design-lab/src/i18n/previewMessages.ts +++ b/design-system/apps/design-lab/src/i18n/previewMessages.ts @@ -24,6 +24,7 @@ export const previewEn = { "preview.state.accent": "Accent", "preview.state.neutral": "Neutral", "preview.state.danger": "Danger", "preview.state.image": "Image", "preview.state.fallback": "Fallback", "preview.state.grouped": "Group", "preview.state.indeterminate": "Indeterminate", "preview.state.checked": "Checked", + "preview.state.on": "On", "preview.state.off": "Off", "preview.state.unselected": "Unselected", "preview.state.indicator": "Indicator", "preview.state.with-label": "With label", "preview.state.with-title": "With title", "preview.state.with-actions": "With actions", "preview.state.matrix": "Matrix", "preview.state.bars": "Bars", "preview.state.reduced-motion": "Reduced motion", @@ -65,6 +66,7 @@ export const previewZhCN: Record = { "preview.state.accent": "强调", "preview.state.neutral": "中性", "preview.state.danger": "危险", "preview.state.image": "图片", "preview.state.fallback": "缺省头像", "preview.state.grouped": "头像组", "preview.state.indeterminate": "部分选中", "preview.state.checked": "选中", + "preview.state.on": "开", "preview.state.off": "关", "preview.state.unselected": "未选中", "preview.state.indicator": "指示器", "preview.state.with-label": "附带文字", "preview.state.with-title": "附带标题", "preview.state.with-actions": "附带操作", "preview.state.matrix": "点阵", "preview.state.bars": "条形", "preview.state.reduced-motion": "减少动态效果", @@ -102,6 +104,7 @@ export const previewZhTW: Record = { "preview.scrimHint": "點擊遮罩可將其關閉。", "preview.sent": "預覽訊息已加入對話。", "preview.state.accent": "強調", "preview.state.danger": "危險", "preview.state.image": "圖片", "preview.state.fallback": "預設頭像", "preview.state.grouped": "頭像群組", "preview.state.indeterminate": "部分選取", "preview.state.checked": "選取", + "preview.state.on": "開", "preview.state.off": "關", "preview.state.unselected": "未選取", "preview.state.indicator": "指示器", "preview.state.with-label": "附帶文字", "preview.state.with-title": "附帶標題", "preview.state.with-actions": "附帶操作", "preview.state.matrix": "點陣", "preview.state.bars": "條形", "preview.state.reduced-motion": "減少動態效果", "preview.state.counter-rotate": "雙六邊形對轉", diff --git a/design-system/apps/design-lab/src/pages/BrandPage.css b/design-system/apps/design-lab/src/pages/BrandPage.css index 0e7342dbf6..ad96ea690d 100644 --- a/design-system/apps/design-lab/src/pages/BrandPage.css +++ b/design-system/apps/design-lab/src/pages/BrandPage.css @@ -78,6 +78,14 @@ background: var(--openbitfun-color-surface-panel); } +/* A dark-scheme tile has to paint its own surface. The dark `surface-subtle` is a translucent white + that assumes a dark app background, so on the light Lab page the tile stayed white while its text + flipped to the dark-scheme foreground: the title was white-on-white. Same solid stage the voice + motion preview uses. */ +.brand-gallery-tile[data-color-scheme="dark"] { + background: var(--openbitfun-color-content-on-light); +} + .brand-gallery-tile-options { position: absolute; top: var(--openbitfun-space-4); @@ -103,9 +111,10 @@ cursor: pointer; } +/* Selection is a filled block, never a ring: the swatch keeps its own colour, the button + behind it carries the selection surface. */ .brand-gallery-backgrounds button[aria-pressed="true"] { - border-width: var(--openbitfun-border-width-strong); - border-color: var(--openbitfun-color-content-primary); + background: var(--openbitfun-color-selection-surface); } .brand-gallery-backgrounds button:hover { @@ -160,16 +169,16 @@ padding: var(--openbitfun-space-3) var(--openbitfun-space-5) var(--openbitfun-space-5); } -.brand-gallery-tile h2, -.brand-gallery-wordmark h2 { +/* One level-2 heading spec for the whole page. The asset titles, the wordmark band and the section + headings (标识 / 动效 / 角色) all take the panel heading, so no title on this page drifts by a + size tier or a weight, and the ramp stays monotonic: h1 24 / h2 18 / h3 13. */ +.brand-gallery :is(.brand-gallery-tile h2, .brand-gallery-wordmark h2, .brand-gallery-section-heading h2) { + color: var(--openbitfun-color-content-primary); font-family: var(--openbitfun-type-heading-panel-font-family); font-size: var(--openbitfun-type-heading-panel-font-size); font-weight: var(--openbitfun-type-heading-panel-font-weight); line-height: var(--openbitfun-type-heading-panel-line-height); -} - -.brand-gallery-tile--silver h2 { - font-size: var(--openbitfun-type-heading-section-font-size); + letter-spacing: var(--openbitfun-type-heading-panel-letter-spacing); } .brand-gallery-actions { @@ -232,29 +241,30 @@ margin-bottom: var(--openbitfun-space-5); } -.brand-gallery-section-heading h2 { - font-family: var(--openbitfun-type-heading-section-font-family); - font-size: var(--openbitfun-type-heading-section-font-size); - font-weight: var(--openbitfun-type-heading-section-font-weight); - line-height: var(--openbitfun-type-heading-section-line-height); -} - +/* The banner is a plate the mark is drawn on: the frame crops the texture layer, and the live + graphic plus the controls stay above it. */ .brand-gallery-motion { + position: relative; display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: var(--openbitfun-space-3); min-height: 400px; padding: var(--openbitfun-space-5); + overflow: hidden; border-radius: var(--openbitfun-radius-xl); color: var(--openbitfun-color-content-primary); background: var(--openbitfun-color-surface-subtle); } -.brand-gallery-motion[data-voice] { +/* The dark option has to paint its own plate — the solid surface the voice stage already uses. The + dark `surface-subtle` is a translucent white that assumes a dark app background, so on the light + Lab page the "dark" banner stayed white and the white silk mark vanished into it. */ +.brand-gallery-motion[data-color-scheme="dark"] { background: var(--openbitfun-color-content-on-light); } .brand-gallery-motion-art { + position: relative; display: grid; place-items: center; min-width: 0; @@ -263,6 +273,35 @@ .brand-gallery-motion-art > * { max-width: 100%; } +/* The live graphic and the controls sit above the texture layer, which is out of flow. */ +.brand-gallery-motion-art > :not(.brand-gallery-motion-texture), +.brand-gallery-motion-controls { + position: relative; + z-index: 1; +} + +/* The banner's own texture: the mark again, wider than the frame and left unlit, so the silk + reaches both edges and gets cropped by them instead of stopping inside the banner. The texture + takes its size from the frame rather than the component's `size` prop, because that prop writes + an inline size which would win over the frame. */ +.brand-gallery-motion .brand-gallery-motion-texture { + position: absolute; + top: 50%; + left: 50%; + width: 132%; + max-width: none; + translate: -50% -50%; + opacity: 0.3; + pointer-events: none; +} + +.brand-gallery-motion-texture > * { + width: 100%; + height: auto; + max-width: none; + aspect-ratio: 1; +} + .brand-gallery-voice { width: min(280px, 100%); aspect-ratio: 1; diff --git a/design-system/apps/design-lab/src/pages/BrandPage.tsx b/design-system/apps/design-lab/src/pages/BrandPage.tsx index 840ed6a54f..24690d5a74 100644 --- a/design-system/apps/design-lab/src/pages/BrandPage.tsx +++ b/design-system/apps/design-lab/src/pages/BrandPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Code2, Pause, Play } from "lucide-react"; import { SegmentedControl, Icon, IconButton, ThemeRoot } from "@openbitfun/ui"; import { @@ -6,7 +6,9 @@ import { OpenBitFunBrandMotion, OpenBitFunMark, OpenBitFunSolidMark, + SubagentHatch, VoiceParticleLogo, + type SubagentHatchPhase, } from "@openbitfun/ui/brand"; import { useI18n, type MessageKey } from "../i18n"; import { SUBAGENT_AVATAR_CATALOG } from "../assets/subagentAvatars"; @@ -62,11 +64,19 @@ export const brandResources: readonly BrandResource[] = [ }, ]; +/* The banner shows the family's own motion first: the hatch is the one animation the characters + have, and the banner is where a reader meets the brand. The three mark motions stay one click + away, because they are the brand's own vectors rather than product artwork. */ const motionScenes = [ + { name: "SubagentHatch", label: "brandAssets.characterMotion" }, { name: "OpenBitFunMark", label: "brandAssets.breathe" }, { name: "OpenBitFunBrandMotion", label: "brandAssets.flow" }, { name: "VoiceParticleLogo", label: "brandAssets.particles" }, ] as const; + +/* The banner leads with the lilac character: the family keeps all twenty. */ +const bannerCharacter = SUBAGENT_AVATAR_CATALOG.find(avatar => avatar.id === "robot-03") ?? SUBAGENT_AVATAR_CATALOG[0]!; +const bannerCharacterSize = 256; type Background = "light" | "dark"; type OpenComponent = (name: string) => void; @@ -134,10 +144,31 @@ function IdentityTile({ resource, onOpenComponent }: { resource: BrandResource; function BrandMotionShowcase({ onOpenComponent }: { onOpenComponent: OpenComponent }) { const { t } = useI18n(); - const [selected, setSelected] = useState<(typeof motionScenes)[number]>(motionScenes[1]); + const [selected, setSelected] = useState<(typeof motionScenes)[number]>(motionScenes[0]); const [playing, setPlaying] = useState(true); const [background, setBackground] = useState("dark"); + const [hatch, setHatch] = useState("incubating"); const voice = selected.name === "VoiceParticleLogo"; + const character = selected.name === "SubagentHatch"; + /* The hatch is a one-shot reveal, so the banner runs it as a loop: the egg incubates, the + character comes out, and the loop starts again. Pausing settles the character instead of + freezing it mid-hatch, which is the state a reader wants to look at. */ + useEffect(() => { + if (!character || !playing) { + setHatch("ready"); + return; + } + let timer = 0; + let incubating = true; + setHatch("incubating"); + const step = () => { + incubating = !incubating; + setHatch(incubating ? "incubating" : "ready"); + timer = window.setTimeout(step, incubating ? 1800 : 3600); + }; + timer = window.setTimeout(step, 1800); + return () => window.clearTimeout(timer); + }, [character, playing]); return (
@@ -147,9 +178,26 @@ function BrandMotionShowcase({ onOpenComponent }: { onOpenComponent: OpenCompone options={motionScenes.map(scene => ({ value: scene.name, label: t(scene.label) }))} />
+ {/* The banner's own texture: the same mark drawn far larger than the frame and left + unlit, so the silk runs off both edges instead of stopping inside the banner. It + carries no label and never animates — the live graphic stays the one thing named + here. Both marks take their size from the frame (see the stylesheet); passing the + size prop would put an inline size on the mark and win over the frame. */} + {!voice && !character && ( + + )} + {character && ( + + + + )} {selected.name === "OpenBitFunMark" && } {selected.name === "OpenBitFunBrandMotion" && } {voice &&
} diff --git a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx index e041290432..07b56130cf 100644 --- a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx +++ b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx @@ -1,5 +1,5 @@ import { CapsulePreview } from "../preview/CapsulePreview"; -import { Fragment, useEffect, useMemo, useState, type CSSProperties } from "react"; +import { Fragment, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react"; import { ComponentOverview } from "../preview/ComponentOverview"; import { getComponentPresentation, getInitialPreviewState, hasComponentOverview } from "../preview/componentPresentation"; import { previewEn } from "../i18n/previewMessages"; @@ -65,6 +65,7 @@ import { NavigationPanelSection, NavigationPanelSeparator, NumberInput, + OverflowText, PageHeader, Radio, ScrollArea, @@ -103,13 +104,15 @@ import { } from "@openbitfun/ui"; import { MobileActionSheet, + MobileAvatar, MobileBadge, MobileBanner, MobileButton, MobileCard, + MobileCheckbox, MobileChoiceSheet, - MobileConfirmSheet, MobileComposer, + MobileConfirmSheet, MobileDisclosure, MobileFileButton, MobileFloatingActions, @@ -118,15 +121,23 @@ import { MobileListRow, MobileMessage, MobilePageHeader, + MobileRadioGroup, MobileScrim, MobileSection, MobileSegmentedControl, + MobileSelect, MobileSheet, MobileStatus, + MobileStepper, + MobileSwitch, + MobileTabBar, + MobileTag, MobileTextField, MobileTextarea, -} from "@openbitfun/ui/mobile"; -import type { ComponentMeta } from "@openbitfun/ui/registry"; + MobileToolbar, +} +from "@openbitfun/ui/mobile"; +import { componentRegistry, type ComponentMeta } from "@openbitfun/ui/registry"; import previewImage from "../assets/design-system-hero.webp"; import { IconCompositionPreview } from "../preview/IconCompositionPreview"; import { RollingTextPreview } from "../preview/RollingTextPreview"; @@ -163,6 +174,41 @@ type FieldOrientation = "horizontal" | "vertical"; type PageHeaderAlign = "center" | "start"; type PageHeaderSize = "display" | "lg" | "md" | "sm"; +/** Registry order, so a detail heading can carry the same category index as the library index. */ +const categoryOrder = Array.from(new Set(componentRegistry.map(item => item.category))); + +/** + * A level-2 reference block that keeps a real heading. + * + * `Disclosure` renders its `summary` inside the trigger `` is valid phrasing content, while + * `` is not. The block keeps its document structure, and the component + * still owns open state, ids, and ARIA wiring. + * + * `summary` stays because the component requires it; `renderHeader` replaces the visible header. + */ +function ReferenceDisclosure({ label, children }: { label: string; children: ReactNode }) { + return ( + ( +

+ +

+ )} + > + {children} +
+ ); +} + const buttonVariants = ["outline", "fill", "secondary", "primary", "text"] as const; const iconButtonVariants = ["annotation", "quiet", "glass", "outline", "fill", "primary"] as const; const iconButtonSizes = ["xs", "standard", "sm", "md", "lg"] as const; @@ -470,6 +516,15 @@ export function ComponentDetailPage({ if (component.name === "MobileLink") return `import { MobileLink } from "@openbitfun/ui/mobile";\n\n\n ${t("nav.docs")}\n`; if (component.name === "MobileTextField") return `import { MobileTextField } from "@openbitfun/ui/mobile";\n\n`; if (component.name === "MobileListRow") return `import { MobileListRow } from "@openbitfun/ui/mobile";\n\n`; + if (component.name === "MobileAvatar") return `import { MobileAvatar } from "@openbitfun/ui/mobile";\n\n`; + if (component.name === "MobileCheckbox") return `import { MobileCheckbox } from "@openbitfun/ui/mobile";\n\n`; + if (component.name === "MobileRadioGroup") return `import { MobileRadioGroup } from "@openbitfun/ui/mobile";\n\n`; + if (component.name === "MobileSelect") return `import { MobileSelect } from "@openbitfun/ui/mobile";\n\n`; + if (component.name === "MobileStepper") return `import { MobileStepper } from "@openbitfun/ui/mobile";\n\n`; + if (component.name === "MobileSwitch") return `import { MobileSwitch } from "@openbitfun/ui/mobile";\n\n`; + if (component.name === "MobileTabBar") return `import { MobileTabBar } from "@openbitfun/ui/mobile";\n\n`; + if (component.name === "MobileTag") return `import { MobileTag } from "@openbitfun/ui/mobile";\n\n\n Design\n`; + if (component.name === "MobileToolbar") return `import { MobileToolbar } from "@openbitfun/ui/mobile";\n\n}\n trailing={}\n>\n ${t("components.preview.mobileToolbarSelection")}\n`; if (component.name === "MobileSheet") return `import { MobileSheet } from "@openbitfun/ui/mobile";\n\n}\n onOpenChange={() => setOpen(false)}\n open={open}\n title="${t("components.preview.modalTitle")}"\n>\n \n`; if (component.name === "Textarea") return `import { Textarea } from "@openbitfun/ui";\n\n`; if (component.name === "Alert") return `import { Alert } from "@openbitfun/ui";\n\n`; @@ -1002,6 +1057,153 @@ export function ComponentDetailPage({ ); } + if (component.name === "MobileAvatar") { + const avatarStatus = state === "online" || state === "away" || state === "busy" || state === "offline" ? state : undefined; + return ( + + {state === "image" || state === "initials" ? undefined : + ); + } + + if (component.name === "MobileCheckbox") { + return ( + + ); + } + + if (component.name === "MobileRadioGroup") { + return ( + + ); + } + + if (component.name === "MobileSelect") { + return ( + + ); + } + + if (component.name === "MobileStepper") { + return ( + + ); + } + + if (component.name === "MobileSwitch") { + return ( + + ); + } + + if (component.name === "MobileTabBar") { + return ( +