From b7058d4b310a48b19c3f72484cc10d667cb75e19 Mon Sep 17 00:00:00 2001 From: Cinnnnnnndy Date: Tue, 13 Oct 2026 17:27:40 +0800 Subject: [PATCH 1/6] feat(ui): Add the mobile component set and retune brand motion Add MobileAvatar, MobileCheckbox, MobileRadioGroup, MobileSelect, MobileStepper, MobileSwitch, MobileTabBar, MobileTag, and MobileToolbar, each with its contract, styles and catalog entry, so the mobile surface reaches the same coverage as the desktop kit. Export them from the mobile entry point, extend the registry types, and cover the catalog with the mobile, registry and consistency tests. Retune the brand flow animation: shorter cycle, per-strand stagger, and a comet-shaped highlight whose opacity ramps along the strand instead of cutting a hole in the ribbon. Co-authored-by: OpenBitFun <318544290+bitfun-ai@users.noreply.github.com> --- .../ui/src/brand/OpenBitFunBrandMotion.tsx | 52 ++++- .../packages/ui/src/brand/brand.module.css | 17 +- design-system/packages/ui/src/mobile.ts | 50 +++++ .../mobile/MobileAvatar/MobileAvatar.meta.ts | 36 ++++ .../MobileAvatar/MobileAvatar.module.css | 22 ++ .../src/mobile/MobileAvatar/MobileAvatar.tsx | 68 +++++++ .../ui/src/mobile/MobileAvatar/index.ts | 7 + .../MobileCheckbox/MobileCheckbox.meta.ts | 42 ++++ .../MobileCheckbox/MobileCheckbox.module.css | 30 +++ .../mobile/MobileCheckbox/MobileCheckbox.tsx | 104 ++++++++++ .../ui/src/mobile/MobileCheckbox/index.ts | 5 + .../MobileDisclosure/MobileDisclosure.meta.ts | 2 +- .../MobileDisclosure.module.css | 4 + .../MobileRadioGroup/MobileRadioGroup.meta.ts | 39 ++++ .../MobileRadioGroup.module.css | 39 ++++ .../MobileRadioGroup/MobileRadioGroup.tsx | 102 ++++++++++ .../ui/src/mobile/MobileRadioGroup/index.ts | 6 + .../MobileSegmentedControl.meta.ts | 2 +- .../MobileSegmentedControl.module.css | 8 +- .../mobile/MobileSelect/MobileSelect.meta.ts | 43 ++++ .../MobileSelect/MobileSelect.module.css | 27 +++ .../src/mobile/MobileSelect/MobileSelect.tsx | 124 ++++++++++++ .../ui/src/mobile/MobileSelect/index.ts | 6 + .../MobileStepper/MobileStepper.meta.ts | 44 ++++ .../MobileStepper/MobileStepper.module.css | 35 ++++ .../mobile/MobileStepper/MobileStepper.tsx | 117 +++++++++++ .../ui/src/mobile/MobileStepper/index.ts | 1 + .../mobile/MobileSwitch/MobileSwitch.meta.ts | 38 ++++ .../MobileSwitch/MobileSwitch.module.css | 29 +++ .../src/mobile/MobileSwitch/MobileSwitch.tsx | 84 ++++++++ .../ui/src/mobile/MobileSwitch/index.ts | 5 + .../mobile/MobileTabBar/MobileTabBar.meta.ts | 34 ++++ .../MobileTabBar/MobileTabBar.module.css | 31 +++ .../src/mobile/MobileTabBar/MobileTabBar.tsx | 82 ++++++++ .../ui/src/mobile/MobileTabBar/index.ts | 6 + .../ui/src/mobile/MobileTag/MobileTag.meta.ts | 38 ++++ .../src/mobile/MobileTag/MobileTag.module.css | 21 ++ .../ui/src/mobile/MobileTag/MobileTag.tsx | 55 +++++ .../packages/ui/src/mobile/MobileTag/index.ts | 6 + .../MobileToolbar/MobileToolbar.meta.ts | 25 +++ .../MobileToolbar/MobileToolbar.module.css | 11 + .../mobile/MobileToolbar/MobileToolbar.tsx | 52 +++++ .../ui/src/mobile/MobileToolbar/index.ts | 5 + design-system/packages/ui/src/registry.ts | 47 ++++- .../packages/ui/src/registry.types.ts | 7 + .../packages/ui/tests/consistency.test.mjs | 191 ++++++++++++++++++ .../packages/ui/tests/mobile.test.mjs | 92 +++++++++ .../packages/ui/tests/registry.test.mjs | 9 + 48 files changed, 1889 insertions(+), 11 deletions(-) create mode 100644 design-system/packages/ui/src/mobile/MobileAvatar/MobileAvatar.meta.ts create mode 100644 design-system/packages/ui/src/mobile/MobileAvatar/MobileAvatar.module.css create mode 100644 design-system/packages/ui/src/mobile/MobileAvatar/MobileAvatar.tsx create mode 100644 design-system/packages/ui/src/mobile/MobileAvatar/index.ts create mode 100644 design-system/packages/ui/src/mobile/MobileCheckbox/MobileCheckbox.meta.ts create mode 100644 design-system/packages/ui/src/mobile/MobileCheckbox/MobileCheckbox.module.css create mode 100644 design-system/packages/ui/src/mobile/MobileCheckbox/MobileCheckbox.tsx create mode 100644 design-system/packages/ui/src/mobile/MobileCheckbox/index.ts create mode 100644 design-system/packages/ui/src/mobile/MobileRadioGroup/MobileRadioGroup.meta.ts create mode 100644 design-system/packages/ui/src/mobile/MobileRadioGroup/MobileRadioGroup.module.css create mode 100644 design-system/packages/ui/src/mobile/MobileRadioGroup/MobileRadioGroup.tsx create mode 100644 design-system/packages/ui/src/mobile/MobileRadioGroup/index.ts create mode 100644 design-system/packages/ui/src/mobile/MobileSelect/MobileSelect.meta.ts create mode 100644 design-system/packages/ui/src/mobile/MobileSelect/MobileSelect.module.css create mode 100644 design-system/packages/ui/src/mobile/MobileSelect/MobileSelect.tsx create mode 100644 design-system/packages/ui/src/mobile/MobileSelect/index.ts create mode 100644 design-system/packages/ui/src/mobile/MobileStepper/MobileStepper.meta.ts create mode 100644 design-system/packages/ui/src/mobile/MobileStepper/MobileStepper.module.css create mode 100644 design-system/packages/ui/src/mobile/MobileStepper/MobileStepper.tsx create mode 100644 design-system/packages/ui/src/mobile/MobileStepper/index.ts create mode 100644 design-system/packages/ui/src/mobile/MobileSwitch/MobileSwitch.meta.ts create mode 100644 design-system/packages/ui/src/mobile/MobileSwitch/MobileSwitch.module.css create mode 100644 design-system/packages/ui/src/mobile/MobileSwitch/MobileSwitch.tsx create mode 100644 design-system/packages/ui/src/mobile/MobileSwitch/index.ts create mode 100644 design-system/packages/ui/src/mobile/MobileTabBar/MobileTabBar.meta.ts create mode 100644 design-system/packages/ui/src/mobile/MobileTabBar/MobileTabBar.module.css create mode 100644 design-system/packages/ui/src/mobile/MobileTabBar/MobileTabBar.tsx create mode 100644 design-system/packages/ui/src/mobile/MobileTabBar/index.ts create mode 100644 design-system/packages/ui/src/mobile/MobileTag/MobileTag.meta.ts create mode 100644 design-system/packages/ui/src/mobile/MobileTag/MobileTag.module.css create mode 100644 design-system/packages/ui/src/mobile/MobileTag/MobileTag.tsx create mode 100644 design-system/packages/ui/src/mobile/MobileTag/index.ts create mode 100644 design-system/packages/ui/src/mobile/MobileToolbar/MobileToolbar.meta.ts create mode 100644 design-system/packages/ui/src/mobile/MobileToolbar/MobileToolbar.module.css create mode 100644 design-system/packages/ui/src/mobile/MobileToolbar/MobileToolbar.tsx create mode 100644 design-system/packages/ui/src/mobile/MobileToolbar/index.ts create mode 100644 design-system/packages/ui/tests/consistency.test.mjs diff --git a/design-system/packages/ui/src/brand/OpenBitFunBrandMotion.tsx b/design-system/packages/ui/src/brand/OpenBitFunBrandMotion.tsx index b3bbf61e06..b9fcc3d558 100644 --- a/design-system/packages/ui/src/brand/OpenBitFunBrandMotion.tsx +++ b/design-system/packages/ui/src/brand/OpenBitFunBrandMotion.tsx @@ -5,7 +5,9 @@ import markSource from "./assets/openbitfun-mark.svg?raw"; import styles from "./brand.module.css"; const CONTOUR_COUNT = 15; -const FLOW_CYCLE_MS = 18000; +const FLOW_CYCLE_MS = 10000; +const FLOW_STRAND_CYCLE_MS = 7600; +const FLOW_STRAND_STAGGER_MS = 380; const COUNTER_ROTATE_CYCLE_MS = 1000; const COUNTER_ROTATE_TURN_MS = 420; const CONSTRUCTION_TIMING = { @@ -19,12 +21,32 @@ const CONSTRUCTION_TIMING = { strokeFade: 80, }; const CONSTRUCTION_EASING = "cubic-bezier(0.35, 0, 0.65, 1)"; +// A comet rather than a dashes trail: six nested arcs whose opacities ramp from a barely-there +// tail to a brighter head, so the highlight fades in and out along the strand instead of +// chopping a hole in it. Two constraints come from the geometry: the tail stays long because +// the strands are barely more than a pixel wide, and the head stops short of opaque because a +// fully blown-out hairline reads as a break in the ribbon rather than as light on metal. const FLOW_LAYERS = [ - { length: 34, opacity: 0.12 }, - { length: 26, opacity: 0.14 }, - { length: 18, opacity: 0.36 }, + { length: 34, opacity: 0.05 }, + { length: 28, opacity: 0.09 }, + { length: 22, opacity: 0.15 }, + { length: 16, opacity: 0.24 }, + { length: 11, opacity: 0.38 }, + { length: 6, opacity: 0.6 }, ]; +/** The edge contours stay darker than the middle of the ribbon. */ +function strandOpacity(index: number): number { + return index === 0 || index === CONTOUR_COUNT - 1 ? 0.58 : 0.34; +} + +/** Light travels across the ribbon: the middle of the form lifts well above its resting + * weight and swells slightly, which is what makes the sweep read as gloss on a body rather + * than as a row of lines fading in and out. */ +const STRAND_LIGHT_PEAK = 2.6; +const STRAND_REST_WIDTH = 1.15; +const STRAND_LIT_WIDTH = 1.75; + type Point = [number, number]; function createContour(index: number): string { @@ -166,13 +188,30 @@ export function OpenBitFunBrandMotion({ active = true, size = 192, label, varian ], { duration: COUNTER_ROTATE_CYCLE_MS, iterations: Infinity, easing: "linear" }); }); } - return Array.from(svg.querySelectorAll('[data-brand-flow]')).map((strand, index) => { + // Two motions, deliberately out of step: a bright wave travels across the ribbon + // (strand to strand) while the highlights travel along it (contour to contour). + const waves = Array.from(svg.querySelectorAll('[data-brand-strand]')).map((strand, index) => { + const base = strandOpacity(index); + return strand.animate([ + { opacity: base, strokeWidth: `${STRAND_REST_WIDTH}` }, + { opacity: Math.min(1, base * STRAND_LIGHT_PEAK), strokeWidth: `${STRAND_LIT_WIDTH}` }, + { opacity: base, strokeWidth: `${STRAND_REST_WIDTH}` }, + ], { + duration: FLOW_STRAND_CYCLE_MS, + iterations: Infinity, + easing: 'ease-in-out', + // Offset each contour's beat so the ribbon brightens inward to outward. + delay: -index * FLOW_STRAND_STAGGER_MS, + }); + }); + const sweeps = Array.from(svg.querySelectorAll('[data-brand-flow]')).map((strand, index) => { const start = -index * 3; return strand.animate([ { strokeDashoffset: String(start) }, { strokeDashoffset: String(start - 100) }, ], { duration: FLOW_CYCLE_MS, iterations: Infinity, easing: 'linear' }); }); + return [...waves, ...sweeps]; }); }, [active, variant]); @@ -212,11 +251,12 @@ export function OpenBitFunBrandMotion({ active = true, size = 192, label, varian )) : contours.map((path, index) => ( - + {FLOW_LAYERS.map(layer => ( :where(svg, img) { inline-size: 100%; block-size: 100%; } + .initials { display: grid; inline-size: 100%; block-size: 100%; place-items: center; letter-spacing: var(--openbitfun-letter-spacing-subtle); } + + /* The dot keeps a ring of the surrounding surface so it separates from the artwork it overlaps. */ + .status { position: absolute; inset-block-end: 0; inset-inline-end: 0; inline-size: var(--_avatar-dot); block-size: var(--_avatar-dot); border-radius: var(--openbitfun-radius-pill); background: var(--openbitfun-color-content-muted); box-shadow: 0 0 0 2px var(--openbitfun-color-surface-canvas); } + .status[data-status="online"] { background: var(--openbitfun-color-status-success-emphasis); } + .status[data-status="away"] { background: var(--openbitfun-color-status-warning-emphasis); } + .status[data-status="busy"] { background: var(--openbitfun-color-status-danger-emphasis); } + .status[data-status="offline"] { background: var(--openbitfun-color-content-disabled); } + + @media (forced-colors: active) { .status { border: var(--openbitfun-border-width-default) solid CanvasText; } } +} diff --git a/design-system/packages/ui/src/mobile/MobileAvatar/MobileAvatar.tsx b/design-system/packages/ui/src/mobile/MobileAvatar/MobileAvatar.tsx new file mode 100644 index 0000000000..ba6981d924 --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileAvatar/MobileAvatar.tsx @@ -0,0 +1,68 @@ +import { type HTMLAttributes, type ReactNode } from "react"; +import { classNames } from "../../internal/classNames"; +import styles from "./MobileAvatar.module.css"; + +export type MobileAvatarShape = "circle" | "rounded"; +export type MobileAvatarSize = "xs" | "sm" | "md" | "lg"; +export type MobileAvatarStatus = "online" | "away" | "busy" | "offline"; + +export interface MobileAvatarProps extends HTMLAttributes { + alt?: string; + children?: ReactNode; + name?: string; + shape?: MobileAvatarShape; + size?: MobileAvatarSize; + src?: string; + status?: MobileAvatarStatus; + statusLabel?: string; +} + +/** Two letters is all a fallback can carry before it stops being readable at avatar scale. */ +function initialsOf(name: string) { + const parts = name.split(/[\s\-_]+/).filter(Boolean); + return parts.slice(0, 2).map(part => part[0]?.toUpperCase() ?? "").join(""); +} + +/** A mobile identity mark: character artwork, an image, or initials. The status dot carries its own + label when the caller supplies one, because a colour alone is not a status. */ +export function MobileAvatar({ + alt, + children, + className, + name, + shape = "circle", + size = "md", + src, + status, + statusLabel, + ...props +}: MobileAvatarProps) { + const label = alt || name; + return ( + + {children !== undefined && children !== null + ? + : src !== undefined && src !== null + ? + : } + {status !== undefined && status !== null && ( + + )} + + ); +} diff --git a/design-system/packages/ui/src/mobile/MobileAvatar/index.ts b/design-system/packages/ui/src/mobile/MobileAvatar/index.ts new file mode 100644 index 0000000000..65259aa70e --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileAvatar/index.ts @@ -0,0 +1,7 @@ +export { + MobileAvatar, + type MobileAvatarProps, + type MobileAvatarShape, + type MobileAvatarSize, + type MobileAvatarStatus, +} from "./MobileAvatar"; diff --git a/design-system/packages/ui/src/mobile/MobileCheckbox/MobileCheckbox.meta.ts b/design-system/packages/ui/src/mobile/MobileCheckbox/MobileCheckbox.meta.ts new file mode 100644 index 0000000000..1e78773943 --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileCheckbox/MobileCheckbox.meta.ts @@ -0,0 +1,42 @@ +import type { ComponentMeta } from "../../registry.types"; + +export const mobileCheckboxMeta = { + category: "mobile", + description: "A touch-sized check row with an indeterminate state for partial selections.", + maturity: "stable", + name: "MobileCheckbox", + props: [ + { name: "label", type: "ReactNode" }, + { name: "description", type: "ReactNode" }, + { name: "checked", type: "boolean" }, + { defaultValue: "false", name: "defaultChecked", type: "boolean" }, + { defaultValue: "false", name: "indeterminate", type: "boolean" }, + { name: "onCheckedChange", type: "(checked: boolean) => void" }, + { defaultValue: "false", name: "invalid", type: "boolean" }, + { defaultValue: "plain", name: "appearance", type: '"plain" | "surface"' }, + ], + states: ["default", "checked", "indeterminate", "hover", "active", "focus-visible", "invalid", "disabled"], + tokens: [ + "color.action.neutral.surface.hover", + "color.action.neutral.surface.pressed", + "color.border.subtle", + "color.content.disabled", + "color.content.muted", + "color.content.primary", + "color.control.highlight.background", + "color.control.switch.thumb", + "color.field.background", + "color.field.border", + "color.focus.ring", + "color.status.danger.border", + "radius.md", + "radius.sm", + "shadow.xs", + "space.1", + "space.2", + "space.3", + "type.body.md.fontSize", + "type.body.xs.fontSize", + "type.label.lg.fontWeight", + ], +} as const satisfies ComponentMeta; diff --git a/design-system/packages/ui/src/mobile/MobileCheckbox/MobileCheckbox.module.css b/design-system/packages/ui/src/mobile/MobileCheckbox/MobileCheckbox.module.css new file mode 100644 index 0000000000..40641aad8e --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileCheckbox/MobileCheckbox.module.css @@ -0,0 +1,30 @@ +@layer openbitfun.components { + .root { --_box-size: 20px; display: flex; box-sizing: border-box; inline-size: 100%; min-inline-size: 0; min-block-size: 52px; align-items: center; gap: var(--openbitfun-space-3); margin: 0; padding: var(--openbitfun-space-2) var(--openbitfun-space-3); border: var(--openbitfun-border-width-default) solid transparent; border-radius: var(--openbitfun-radius-md); color: var(--openbitfun-color-content-primary); background: transparent; cursor: pointer; touch-action: manipulation; -webkit-tap-highlight-color: transparent; transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } + .root[data-appearance="surface"] { min-block-size: 64px; border-color: var(--openbitfun-color-border-subtle); background: var(--openbitfun-color-surface-panel); box-shadow: var(--openbitfun-shadow-xs); } + .root[data-disabled="true"] { color: var(--openbitfun-color-content-disabled); cursor: not-allowed; } + + @media (hover: hover) and (pointer: fine) { + .root:is(:hover, [data-openbitfun-preview-state="hover"]):not([data-disabled="true"]) { background: var(--openbitfun-color-action-neutral-surface-hover); } + } + .root:is(:active, [data-openbitfun-preview-state="active"]):not([data-disabled="true"]) { background: var(--openbitfun-color-action-neutral-surface-pressed); } + .root:is(:has(.input:focus-visible), [data-openbitfun-preview-state="focus-visible"]) { outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); outline-offset: var(--openbitfun-focus-offset); } + + /* The box is the web Checkbox at touch scale: same tokens, 20px instead of 14px, so it can carry + the row's weight without a second visual language. */ + .control { position: relative; display: inline-flex; inline-size: var(--_box-size); block-size: var(--_box-size); flex: 0 0 auto; } + .input { position: absolute; z-index: var(--openbitfun-layer-content); inset: 0; inline-size: 100%; block-size: 100%; margin: 0; opacity: 0; cursor: inherit; } + .box { display: inline-flex; inline-size: 100%; block-size: 100%; align-items: center; justify-content: center; border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-field-border); border-radius: var(--openbitfun-radius-sm); color: var(--openbitfun-color-control-switch-thumb); background: var(--openbitfun-color-field-background); transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), border-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } + .root[data-checked="true"] .box, .root[data-indeterminate="true"] .box { border-color: var(--openbitfun-color-control-highlight-background); background: var(--openbitfun-color-control-highlight-background); } + .root[data-invalid="true"] .box { border-color: var(--openbitfun-color-status-danger-border); } + .root[data-disabled="true"] .box { color: var(--openbitfun-color-content-disabled); background: var(--openbitfun-color-field-background); } + .icon { inline-size: 14px; block-size: 14px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 2.5; opacity: 0; } + .root[data-checked="true"] .icon, .root[data-indeterminate="true"] .icon { opacity: 1; } + + .copy { display: flex; box-sizing: border-box; min-inline-size: 0; flex: 1 1 auto; flex-direction: column; gap: var(--openbitfun-space-1); } + .label { color: var(--openbitfun-color-content-primary); font-size: var(--openbitfun-type-body-md-font-size); font-weight: var(--openbitfun-type-label-lg-font-weight); } + .description { color: var(--openbitfun-color-content-muted); font-size: var(--openbitfun-type-body-xs-font-size); } + .root[data-disabled="true"] .label, .root[data-disabled="true"] .description { color: inherit; } + + @media (prefers-reduced-motion: reduce) { .root, .box { transition: none; } } + @media (forced-colors: active) { .root[data-appearance="surface"], .box { border-color: ButtonText; } .root[data-checked="true"] .box, .root[data-indeterminate="true"] .box { border-color: Highlight; color: HighlightText; background: Highlight; } .root:has(.input:focus-visible), .root[data-openbitfun-preview-state="focus-visible"] { outline-color: Highlight; } } +} diff --git a/design-system/packages/ui/src/mobile/MobileCheckbox/MobileCheckbox.tsx b/design-system/packages/ui/src/mobile/MobileCheckbox/MobileCheckbox.tsx new file mode 100644 index 0000000000..b933ee22bd --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileCheckbox/MobileCheckbox.tsx @@ -0,0 +1,104 @@ +import { Check, Minus } from "lucide-react"; +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, + type ChangeEvent, + type InputHTMLAttributes, + type ReactNode, +} from "react"; +import { classNames } from "../../internal/classNames"; +import styles from "./MobileCheckbox.module.css"; + +export type MobileCheckboxAppearance = "plain" | "surface"; + +export interface MobileCheckboxProps + extends Omit, "children" | "size" | "type"> { + appearance?: MobileCheckboxAppearance; + checked?: boolean; + defaultChecked?: boolean; + description?: ReactNode; + indeterminate?: boolean; + invalid?: boolean; + label: ReactNode; + onCheckedChange?: (checked: boolean) => void; +} + +/** A check row. A checkbox in a mobile list is a row, not a box beside a caption: the whole row is + the target, so the box keeps the web control's geometry and the row supplies the touch size. */ +export const MobileCheckbox = forwardRef( + function MobileCheckbox({ + appearance = "plain", + checked, + className, + defaultChecked, + description, + disabled, + indeterminate = false, + invalid = false, + label, + onChange, + onCheckedChange, + ...props + }, ref) { + const [uncontrolled, setUncontrolled] = useState(defaultChecked ?? false); + const inputRef = useRef(null); + const controlled = checked !== undefined; + const resolved = controlled ? checked : uncontrolled; + /* Focus and press states belong to the hidden input, but the row is what changes. The Lab drives + the row through a root attribute, so lift that hook off the input onto the label. */ + const previewState = (props as Record)["data-openbitfun-preview-state"]; + + // `indeterminate` has no HTML attribute: it only exists as a DOM property, and it also + // decides which glyph the box draws, which is why it is mirrored into a data attribute. + useEffect(() => { + if (inputRef.current) inputRef.current.indeterminate = indeterminate; + }, [indeterminate]); + + useImperativeHandle(ref, () => inputRef.current!, []); + + function handleChange(event: ChangeEvent) { + if (!controlled) setUncontrolled(event.target.checked); + onChange?.(event); + onCheckedChange?.(event.target.checked); + } + + return ( + + ); + }, +); diff --git a/design-system/packages/ui/src/mobile/MobileCheckbox/index.ts b/design-system/packages/ui/src/mobile/MobileCheckbox/index.ts new file mode 100644 index 0000000000..05f320c235 --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileCheckbox/index.ts @@ -0,0 +1,5 @@ +export { + MobileCheckbox, + type MobileCheckboxAppearance, + type MobileCheckboxProps, +} from "./MobileCheckbox"; diff --git a/design-system/packages/ui/src/mobile/MobileDisclosure/MobileDisclosure.meta.ts b/design-system/packages/ui/src/mobile/MobileDisclosure/MobileDisclosure.meta.ts index a4e6964489..4d4ba7759d 100644 --- a/design-system/packages/ui/src/mobile/MobileDisclosure/MobileDisclosure.meta.ts +++ b/design-system/packages/ui/src/mobile/MobileDisclosure/MobileDisclosure.meta.ts @@ -1,2 +1,2 @@ import type { ComponentMeta } from "../../registry.types"; -export const mobileDisclosureMeta = { category: "mobile", description: "A compact disclosure row for thinking, tool groups, and advanced options.", maturity: "stable", name: "MobileDisclosure", props: [{ name: "open", type: "boolean" }, { name: "onToggle", type: "() => void" }, { name: "title", type: "ReactNode" }, { name: "metadata", type: "ReactNode" }], states: ["closed", "open", "focus-visible", "disabled"], tokens: ["color.content.primary", "color.content.muted", "color.focus.ring", "space.2", "space.3"] } as const satisfies ComponentMeta; +export const mobileDisclosureMeta = { category: "mobile", description: "A compact disclosure row for thinking, tool groups, and advanced options.", maturity: "stable", name: "MobileDisclosure", props: [{ name: "open", type: "boolean" }, { name: "onToggle", type: "() => void" }, { name: "title", type: "ReactNode" }, { name: "metadata", type: "ReactNode" }], states: ["closed", "open", "hover", "focus-visible", "disabled"], tokens: ["color.content.primary", "color.content.muted", "color.focus.ring", "space.2", "space.3"] } as const satisfies ComponentMeta; diff --git a/design-system/packages/ui/src/mobile/MobileDisclosure/MobileDisclosure.module.css b/design-system/packages/ui/src/mobile/MobileDisclosure/MobileDisclosure.module.css index b5121b5204..7748d2265c 100644 --- a/design-system/packages/ui/src/mobile/MobileDisclosure/MobileDisclosure.module.css +++ b/design-system/packages/ui/src/mobile/MobileDisclosure/MobileDisclosure.module.css @@ -1,6 +1,10 @@ @layer openbitfun.components { .root { box-sizing: border-box; min-inline-size: 0; color: var(--openbitfun-color-content-primary); } .trigger { display: flex; inline-size: 100%; min-block-size: 44px; align-items: center; gap: var(--openbitfun-space-2); margin: 0; padding: var(--openbitfun-space-2) 0; border: 0; color: inherit; background: transparent; cursor: pointer; text-align: start; font: inherit; } + @media (hover: hover) and (pointer: fine) { + .trigger:not(:disabled):hover, + .root[data-openbitfun-preview-state="hover"] .trigger:not(:disabled) { color: var(--openbitfun-color-content-primary); background: var(--openbitfun-color-action-neutral-surface-hover); box-shadow: 0 0 0 var(--openbitfun-space-2) var(--openbitfun-color-action-neutral-surface-hover); } + } .trigger:focus-visible { outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); outline-offset: var(--openbitfun-focus-offset); border-radius: var(--openbitfun-radius-sm); } .trigger:disabled { color: var(--openbitfun-color-content-disabled); cursor: not-allowed; } .leading { display: grid; inline-size: 20px; block-size: 20px; flex: 0 0 20px; place-items: center; } diff --git a/design-system/packages/ui/src/mobile/MobileRadioGroup/MobileRadioGroup.meta.ts b/design-system/packages/ui/src/mobile/MobileRadioGroup/MobileRadioGroup.meta.ts new file mode 100644 index 0000000000..8c4e8fbfba --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileRadioGroup/MobileRadioGroup.meta.ts @@ -0,0 +1,39 @@ +import type { ComponentMeta } from "../../registry.types"; + +export const mobileRadioGroupMeta = { + category: "mobile", + description: "A touch-sized single-choice list whose options share one input name for native keyboard navigation.", + maturity: "stable", + name: "MobileRadioGroup", + props: [ + { name: "options", type: "readonly MobileRadioOption[]" }, + { name: "value", type: "string" }, + { name: "defaultValue", type: "string" }, + { name: "onValueChange", type: "(value: string) => void" }, + { name: "name", type: "string" }, + { defaultValue: "plain", name: "appearance", type: '"plain" | "surface"' }, + ], + states: ["default", "selected", "hover", "active", "focus-visible", "disabled"], + tokens: [ + "color.action.neutral.surface.hover", + "color.action.neutral.surface.pressed", + "color.border.subtle", + "color.content.disabled", + "color.content.inverse", + "color.content.muted", + "color.content.primary", + "color.content.secondary", + "color.control.highlight.background", + "color.field.background", + "color.field.border", + "color.focus.ring", + "radius.md", + "shadow.xs", + "space.1", + "space.2", + "space.3", + "type.body.md.fontSize", + "type.body.xs.fontSize", + "type.label.lg.fontWeight", + ], +} as const satisfies ComponentMeta; diff --git a/design-system/packages/ui/src/mobile/MobileRadioGroup/MobileRadioGroup.module.css b/design-system/packages/ui/src/mobile/MobileRadioGroup/MobileRadioGroup.module.css new file mode 100644 index 0000000000..f3208ed408 --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileRadioGroup/MobileRadioGroup.module.css @@ -0,0 +1,39 @@ +@layer openbitfun.components { + /* The group is the surface: options are flush rows inside it, so a surface group reads as one + card instead of a stack of separate controls. */ + .root { display: flex; box-sizing: border-box; inline-size: 100%; min-inline-size: 0; flex-direction: column; overflow: hidden; gap: 0; border: var(--openbitfun-border-width-default) solid transparent; border-radius: var(--openbitfun-radius-md); background: transparent; } + .root[data-appearance="surface"] { border-color: var(--openbitfun-color-border-subtle); background: var(--openbitfun-color-surface-panel); box-shadow: var(--openbitfun-shadow-xs); } + .root[data-appearance="surface"] .option + .option { border-block-start: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); } + + .option { display: flex; box-sizing: border-box; inline-size: 100%; min-inline-size: 0; min-block-size: 52px; align-items: center; gap: var(--openbitfun-space-3); padding: var(--openbitfun-space-2) var(--openbitfun-space-3); color: var(--openbitfun-color-content-primary); cursor: pointer; touch-action: manipulation; -webkit-tap-highlight-color: transparent; transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } + .root[data-appearance="surface"] .option { min-block-size: 64px; } + .option[data-disabled="true"] { color: var(--openbitfun-color-content-disabled); cursor: not-allowed; } + + @media (hover: hover) and (pointer: fine) { + .option:is(:hover, [data-openbitfun-preview-state="hover"]):not([data-disabled="true"]), + .root[data-openbitfun-preview-state="hover"] .option:not([data-disabled="true"]) { background: var(--openbitfun-color-action-neutral-surface-hover); } + } + .option:is(:active, [data-openbitfun-preview-state="active"]):not([data-disabled="true"]), + .root[data-openbitfun-preview-state="active"] .option:not([data-disabled="true"]) { background: var(--openbitfun-color-action-neutral-surface-pressed); } + .option:has(.input:focus-visible), + .root[data-openbitfun-preview-state="focus-visible"] .option[data-checked="true"] { outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); outline-offset: calc(var(--openbitfun-focus-offset) * -1); } + + .control { position: relative; display: inline-flex; inline-size: 20px; block-size: 20px; flex: 0 0 auto; } + .input { position: absolute; z-index: var(--openbitfun-layer-content); inset: 0; inline-size: 100%; block-size: 100%; margin: 0; opacity: 0; cursor: inherit; } + .circle { display: grid; inline-size: 100%; block-size: 100%; place-items: center; border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-field-border); border-radius: 50%; background: var(--openbitfun-color-field-background); transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), border-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } + /* The selected mark is a filled core, not a ring: the row already states the choice, so the + control only has to confirm it. */ + .dot { inline-size: 60%; block-size: 60%; border-radius: 50%; background: var(--openbitfun-color-content-inverse); opacity: 0; transition: opacity var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } + .option[data-checked="true"] .circle { border-color: var(--openbitfun-color-control-highlight-background); background: var(--openbitfun-color-control-highlight-background); } + .option[data-checked="true"] .dot { opacity: 1; } + + .copy { display: flex; box-sizing: border-box; min-inline-size: 0; flex: 1 1 auto; flex-direction: column; gap: var(--openbitfun-space-1); } + .label { font-size: var(--openbitfun-type-body-md-font-size); font-weight: var(--openbitfun-type-label-lg-font-weight); } + .description { color: var(--openbitfun-color-content-muted); font-size: var(--openbitfun-type-body-xs-font-size); } + .option[data-disabled="true"] .label, .option[data-disabled="true"] .description { color: inherit; } + .leading { display: grid; inline-size: 20px; block-size: 20px; flex: 0 0 20px; place-items: center; color: var(--openbitfun-color-content-secondary); } + .leading > :where(svg, img) { inline-size: 100%; block-size: 100%; } + + @media (prefers-reduced-motion: reduce) { .option, .circle, .dot { transition: none; } } + @media (forced-colors: active) { .root[data-appearance="surface"], .circle { border-color: ButtonText; } .option[data-checked="true"] .circle { border-color: Highlight; background: Highlight; } .option:has(.input:focus-visible), .root[data-openbitfun-preview-state="focus-visible"] .option[data-checked="true"] { outline-color: Highlight; } } +} diff --git a/design-system/packages/ui/src/mobile/MobileRadioGroup/MobileRadioGroup.tsx b/design-system/packages/ui/src/mobile/MobileRadioGroup/MobileRadioGroup.tsx new file mode 100644 index 0000000000..e5cc5c7dcf --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileRadioGroup/MobileRadioGroup.tsx @@ -0,0 +1,102 @@ +import { + forwardRef, + useState, + type ChangeEvent, + type HTMLAttributes, + type ReactNode, +} from "react"; +import { classNames } from "../../internal/classNames"; +import styles from "./MobileRadioGroup.module.css"; + +export type MobileRadioGroupAppearance = "plain" | "surface"; + +export interface MobileRadioOption { + description?: ReactNode; + disabled?: boolean; + label: ReactNode; + leading?: ReactNode; + value: string; +} + +export interface MobileRadioGroupProps + extends Omit, "onChange"> { + appearance?: MobileRadioGroupAppearance; + defaultValue?: string; + name: string; + onValueChange?: (value: string) => void; + options: readonly MobileRadioOption[]; + value?: string; +} + +/** A single-choice list. The options share one input name, so the platform keeps arrow-key + navigation; the group is a radiogroup rather than a menu of buttons. */ +export const MobileRadioGroup = forwardRef( + function MobileRadioGroup({ + appearance = "plain", + className, + defaultValue, + name, + onValueChange, + options, + value, + ...props + }, ref) { + const [uncontrolled, setUncontrolled] = useState(defaultValue); + const controlled = value !== undefined; + const selected = controlled ? value : uncontrolled; + + function handleChange(event: ChangeEvent) { + if (!controlled) setUncontrolled(event.target.value); + onValueChange?.(event.target.value); + } + + return ( +
+ {options.map(option => { + const checked = selected === option.value; + const disabled = option.disabled ?? false; + return ( + + ); + })} +
+ ); + }, +); diff --git a/design-system/packages/ui/src/mobile/MobileRadioGroup/index.ts b/design-system/packages/ui/src/mobile/MobileRadioGroup/index.ts new file mode 100644 index 0000000000..81422cf506 --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileRadioGroup/index.ts @@ -0,0 +1,6 @@ +export { + MobileRadioGroup, + type MobileRadioGroupAppearance, + type MobileRadioGroupProps, + type MobileRadioOption, +} from "./MobileRadioGroup"; diff --git a/design-system/packages/ui/src/mobile/MobileSegmentedControl/MobileSegmentedControl.meta.ts b/design-system/packages/ui/src/mobile/MobileSegmentedControl/MobileSegmentedControl.meta.ts index f73dad238f..b9b92cfc44 100644 --- a/design-system/packages/ui/src/mobile/MobileSegmentedControl/MobileSegmentedControl.meta.ts +++ b/design-system/packages/ui/src/mobile/MobileSegmentedControl/MobileSegmentedControl.meta.ts @@ -1,2 +1,2 @@ import type { ComponentMeta } from "../../registry.types"; -export const mobileSegmentedControlMeta = { category: "mobile", description: "A touch-first segmented selector for compact mode and filter choices.", maturity: "stable", name: "MobileSegmentedControl", props: [{ name: "value", type: "string" }, { name: "options", type: "readonly MobileSegmentedControlOption[]" }, { name: "onChange", type: "(value: string) => void" }], states: ["default", "selected", "focus-visible", "disabled"], tokens: ["color.action.neutral.surface", "color.content.primary", "color.surface.panel", "radius.pill", "shadow.xs", "space.1"] } as const satisfies ComponentMeta; +export const mobileSegmentedControlMeta = { category: "mobile", description: "A touch-first segmented selector for compact mode and filter choices.", maturity: "stable", name: "MobileSegmentedControl", props: [{ name: "value", type: "string" }, { name: "options", type: "readonly MobileSegmentedControlOption[]" }, { name: "onChange", type: "(value: string) => void" }], states: ["selected", "unselected", "hover", "active", "focus-visible", "disabled"], tokens: ["color.action.neutral.surface", "color.content.primary", "color.surface.panel", "radius.pill", "shadow.xs", "space.1"] } as const satisfies ComponentMeta; diff --git a/design-system/packages/ui/src/mobile/MobileSegmentedControl/MobileSegmentedControl.module.css b/design-system/packages/ui/src/mobile/MobileSegmentedControl/MobileSegmentedControl.module.css index 667cb8d3be..81c3a94d8d 100644 --- a/design-system/packages/ui/src/mobile/MobileSegmentedControl/MobileSegmentedControl.module.css +++ b/design-system/packages/ui/src/mobile/MobileSegmentedControl/MobileSegmentedControl.module.css @@ -2,7 +2,13 @@ .root { display: inline-grid; box-sizing: border-box; min-block-size: 44px; grid-auto-columns: minmax(72px, 1fr); grid-auto-flow: column; gap: var(--openbitfun-space-1); padding: var(--openbitfun-space-1); border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); border-radius: var(--openbitfun-radius-pill); background: var(--openbitfun-color-surface-panel); box-shadow: var(--openbitfun-shadow-xs); } .option { min-block-size: 36px; margin: 0; padding: 0 var(--openbitfun-space-4); border: 0; border-radius: var(--openbitfun-radius-pill); color: var(--openbitfun-color-content-secondary); background: transparent; cursor: pointer; touch-action: manipulation; font: inherit; font-size: var(--openbitfun-type-label-md-font-size); } .option[data-selected="true"] { color: var(--openbitfun-color-content-primary); background: var(--openbitfun-color-action-neutral-surface); box-shadow: var(--openbitfun-shadow-xs); } - .option:active:not(:disabled) { background: var(--openbitfun-color-action-neutral-surface-pressed); } + @media (hover: hover) and (pointer: fine) { + .option:not([data-selected="true"]):hover:not(:disabled), + .root[data-openbitfun-preview-state="hover"] .option:not([data-selected="true"]):not(:disabled) { background: var(--openbitfun-color-action-neutral-surface-hover); } + } + .option:active:not(:disabled), + .root[data-openbitfun-preview-state="active"] .option:not([data-selected="true"]):not(:disabled) { background: var(--openbitfun-color-action-neutral-surface-pressed); } + .root[data-openbitfun-preview-state="focus-visible"] .option[data-selected="true"] { outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); outline-offset: calc(var(--openbitfun-focus-offset) * -1); } .option:focus-visible { outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); outline-offset: calc(var(--openbitfun-focus-offset) * -1); } .option:disabled { color: var(--openbitfun-color-content-disabled); cursor: not-allowed; } @media (prefers-reduced-motion: reduce) { .option { transition: none; } } diff --git a/design-system/packages/ui/src/mobile/MobileSelect/MobileSelect.meta.ts b/design-system/packages/ui/src/mobile/MobileSelect/MobileSelect.meta.ts new file mode 100644 index 0000000000..65f5d9a091 --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileSelect/MobileSelect.meta.ts @@ -0,0 +1,43 @@ +import type { ComponentMeta } from "../../registry.types"; + +export const mobileSelectMeta = { + category: "mobile", + description: "A touch-sized choice field that opens the mobile choice sheet instead of a desktop popup list.", + maturity: "stable", + name: "MobileSelect", + props: [ + { name: "options", type: "readonly MobileSelectOption[]" }, + { name: "value", type: "string" }, + { name: "defaultValue", type: "string" }, + { name: "open", type: "boolean" }, { defaultValue: "false", name: "defaultOpen", type: "boolean" }, { name: "onOpenChange", type: "(open: boolean) => void" }, { name: "onValueChange", type: "(value: string) => void" }, + { name: "sheetTitle", type: "ReactNode" }, + { name: "label", type: "ReactNode" }, + { name: "description", type: "ReactNode" }, + { name: "placeholder", type: "ReactNode" }, + { defaultValue: "false", name: "invalid", type: "boolean" }, + { defaultValue: "soft", name: "appearance", type: '"soft" | "surface"' }, + ], + states: ["default", "hover", "active", "focus-visible", "open", "invalid", "disabled"], + tokens: [ + "color.action.neutral.surface.hover", + "color.action.neutral.surface.pressed", + "color.content.disabled", + "color.content.muted", + "color.content.primary", + "color.content.secondary", + "color.field.background", + "color.field.border", + "color.focus.ring", + "color.status.danger.border", + "color.surface.tertiary", + "radius.md", + "shadow.xs", + "space.1", + "space.2", + "space.4", + "type.body.md.fontSize", + "type.body.xs.fontSize", + "type.label.md.fontSize", + "type.label.md.fontWeight", + ], +} as const satisfies ComponentMeta; diff --git a/design-system/packages/ui/src/mobile/MobileSelect/MobileSelect.module.css b/design-system/packages/ui/src/mobile/MobileSelect/MobileSelect.module.css new file mode 100644 index 0000000000..3ec9f64b75 --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileSelect/MobileSelect.module.css @@ -0,0 +1,27 @@ +@layer openbitfun.components { + .root { display: flex; box-sizing: border-box; inline-size: 100%; min-inline-size: 0; flex-direction: column; gap: var(--openbitfun-space-2); } + + .copy { display: flex; flex-direction: column; gap: var(--openbitfun-space-1); } + .label { color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-label-md-font-size); font-weight: var(--openbitfun-type-label-md-font-weight); } + .description { color: var(--openbitfun-color-content-muted); font-size: var(--openbitfun-type-body-xs-font-size); } + + /* The field is the MobileTextField surface: a select and a text field sit in the same column, so + they must read as the same control until one of them is opened. */ + .field { display: flex; box-sizing: border-box; inline-size: 100%; min-inline-size: 0; min-block-size: 44px; align-items: center; gap: var(--openbitfun-space-2); margin: 0; padding: 0 var(--openbitfun-space-4); border: var(--openbitfun-border-width-default) solid transparent; border-radius: var(--openbitfun-radius-md); color: var(--openbitfun-color-content-primary); background: var(--openbitfun-color-surface-tertiary); font: inherit; font-size: var(--openbitfun-type-body-md-font-size); text-align: start; cursor: pointer; touch-action: manipulation; -webkit-tap-highlight-color: transparent; transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), box-shadow var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } + .root[data-appearance="surface"] .field { border-color: var(--openbitfun-color-field-border); background: var(--openbitfun-color-field-background); box-shadow: var(--openbitfun-shadow-xs); } + .root[data-invalid="true"] .field { border-color: var(--openbitfun-color-status-danger-border); } + .root[data-disabled="true"] .field { color: var(--openbitfun-color-content-disabled); cursor: not-allowed; } + + @media (hover: hover) and (pointer: fine) { + .field:is(:hover, [data-openbitfun-preview-state="hover"]):not(:disabled) { background: var(--openbitfun-color-action-neutral-surface-hover); } + } + .field:is(:active, [data-openbitfun-preview-state="active"]):not(:disabled) { background: var(--openbitfun-color-action-neutral-surface-pressed); } + .field:is(:focus-visible, [data-openbitfun-preview-state="focus-visible"]) { outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); outline-offset: var(--openbitfun-focus-offset); } + + .value { min-inline-size: 0; flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .value[data-placeholder="true"] { color: var(--openbitfun-color-content-muted); } + .chevron { inline-size: 18px; block-size: 18px; flex: 0 0 18px; color: var(--openbitfun-color-content-secondary); } + + @media (prefers-reduced-motion: reduce) { .field { transition: none; } } + @media (forced-colors: active) { .root[data-appearance="surface"] .field { border-color: ButtonText; } .field:focus-visible { outline-color: Highlight; } } +} diff --git a/design-system/packages/ui/src/mobile/MobileSelect/MobileSelect.tsx b/design-system/packages/ui/src/mobile/MobileSelect/MobileSelect.tsx new file mode 100644 index 0000000000..2e40ecafbf --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileSelect/MobileSelect.tsx @@ -0,0 +1,124 @@ +import { ChevronDown } from "lucide-react"; +import { useState, type ButtonHTMLAttributes, type ReactNode } from "react"; +import { classNames } from "../../internal/classNames"; +import { MobileChoiceSheet } from "../MobileChoiceSheet"; +import styles from "./MobileSelect.module.css"; + +export type MobileSelectAppearance = "soft" | "surface"; + +export interface MobileSelectOption { + description?: ReactNode; + disabled?: boolean; + label: ReactNode; + leading?: ReactNode; + value: string; +} + +export interface MobileSelectProps + extends Omit, "children" | "disabled" | "onChange" | "value"> { + appearance?: MobileSelectAppearance; + cancelLabel?: ReactNode; + defaultOpen?: boolean; + defaultValue?: string; + description?: ReactNode; + disabled?: boolean; + emptyContent?: ReactNode; + invalid?: boolean; + label?: ReactNode; + onOpenChange?: (open: boolean) => void; + onValueChange?: (value: string) => void; + open?: boolean; + options: readonly MobileSelectOption[]; + placeholder?: ReactNode; + sheetTitle: ReactNode; + value?: string; +} + +/** A choice field. Mobile has no room for a popup listbox, so the field opens the choice sheet the + rest of the mobile set already uses; the selected option is read from the field's own text. */ +export function MobileSelect({ + appearance = "soft", + cancelLabel, + className, + defaultValue, + defaultOpen = false, + description, + disabled = false, + emptyContent, + invalid = false, + label, + onOpenChange, + onValueChange, + open: controlledOpen, + options, + placeholder, + sheetTitle, + value, + ...props +}: MobileSelectProps) { + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen && !disabled); + const [uncontrolled, setUncontrolled] = useState(defaultValue); + const controlled = value !== undefined; + const selected = controlled ? value : uncontrolled; + const selectedOption = options.find(option => option.value === selected); + /* The sheet is the component's open state, so it follows the same controlled trio as the web + Select: `open` wins, `defaultOpen` seeds the uncontrolled case, and both report changes. */ + const open = disabled ? false : controlledOpen ?? uncontrolledOpen; + + function setSheetOpen(next: boolean) { + if (controlledOpen === undefined) setUncontrolledOpen(next); + onOpenChange?.(next); + } + + function select(next: string) { + if (!controlled) setUncontrolled(next); + onValueChange?.(next); + setSheetOpen(false); + } + + return ( +
+ {label !== undefined && label !== null && ( + + {label} + {description !== undefined && description !== null && ( + {description} + )} + + )} + + { if (!next) setSheetOpen(false); }} + onSelect={select} + open={open} + options={options} + selectedValue={selected} + title={sheetTitle} + /> +
+ ); +} diff --git a/design-system/packages/ui/src/mobile/MobileSelect/index.ts b/design-system/packages/ui/src/mobile/MobileSelect/index.ts new file mode 100644 index 0000000000..077cfd8b3e --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileSelect/index.ts @@ -0,0 +1,6 @@ +export { + MobileSelect, + type MobileSelectAppearance, + type MobileSelectOption, + type MobileSelectProps, +} from "./MobileSelect"; diff --git a/design-system/packages/ui/src/mobile/MobileStepper/MobileStepper.meta.ts b/design-system/packages/ui/src/mobile/MobileStepper/MobileStepper.meta.ts new file mode 100644 index 0000000000..85fb680138 --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileStepper/MobileStepper.meta.ts @@ -0,0 +1,44 @@ +import type { ComponentMeta } from "../../registry.types"; + +export const mobileStepperMeta = { + category: "mobile", + description: "A touch-sized numeric row whose bounds disable the decrement and increment buttons.", + maturity: "stable", + name: "MobileStepper", + props: [ + { name: "label", type: "ReactNode" }, + { name: "description", type: "ReactNode" }, + { name: "value", type: "number" }, + { name: "onValueChange", type: "(value: number) => void" }, + { name: "min", type: "number" }, + { name: "max", type: "number" }, + { defaultValue: "1", name: "step", type: "number" }, + { name: "unit", type: "ReactNode" }, + { defaultValue: "false", name: "invalid", type: "boolean" }, + { name: "decrementLabel", type: "string" }, + { name: "incrementLabel", type: "string" }, + ], + states: ["default", "hover", "active", "focus-within", "invalid", "disabled"], + tokens: [ + "color.action.neutral.surface.hover", + "color.action.neutral.surface.pressed", + "color.content.disabled", + "color.content.muted", + "color.content.primary", + "color.content.secondary", + "color.field.background", + "color.field.border", + "color.field.borderFocus", + "color.focus.ring", + "color.status.danger.border", + "color.status.danger.content", + "radius.md", + "space.1", + "space.2", + "space.3", + "type.body.md.fontSize", + "type.body.sm.fontSize", + "type.body.xs.fontSize", + "type.label.lg.fontWeight", + ], +} as const satisfies ComponentMeta; diff --git a/design-system/packages/ui/src/mobile/MobileStepper/MobileStepper.module.css b/design-system/packages/ui/src/mobile/MobileStepper/MobileStepper.module.css new file mode 100644 index 0000000000..85212e37c0 --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileStepper/MobileStepper.module.css @@ -0,0 +1,35 @@ +@layer openbitfun.components { + /* A control row, like the switch: the label explains the value, the value carries the buttons. */ + .root { display: flex; box-sizing: border-box; inline-size: 100%; min-inline-size: 0; min-block-size: 52px; align-items: center; gap: var(--openbitfun-space-3); padding-block: var(--openbitfun-space-2); } + .root[data-disabled="true"] { color: var(--openbitfun-color-content-disabled); } + + .copy { display: flex; box-sizing: border-box; min-inline-size: 0; flex: 1 1 auto; flex-direction: column; gap: var(--openbitfun-space-1); } + .label { color: var(--openbitfun-color-content-primary); font-size: var(--openbitfun-type-body-md-font-size); font-weight: var(--openbitfun-type-label-lg-font-weight); } + .description { color: var(--openbitfun-color-content-muted); font-size: var(--openbitfun-type-body-xs-font-size); } + .root[data-disabled="true"] .label, .root[data-disabled="true"] .description { color: inherit; } + + .control { display: inline-flex; min-inline-size: 0; flex: 0 0 auto; align-items: center; gap: var(--openbitfun-space-1); border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-field-border); border-radius: var(--openbitfun-radius-md); background: var(--openbitfun-color-field-background); } + .root[data-invalid="true"] .control { border-color: var(--openbitfun-color-status-danger-border); } + .root:not([data-disabled="true"]) .control:focus-within, + .root[data-openbitfun-preview-state="focus-within"] .control { border-color: var(--openbitfun-color-field-border-focus); box-shadow: 0 0 0 var(--openbitfun-focus-width) var(--openbitfun-color-focus-ring); } + + .button { display: grid; inline-size: 44px; block-size: 44px; flex: 0 0 44px; margin: 0; padding: 0; border: 0; border-radius: var(--openbitfun-radius-md); place-items: center; color: var(--openbitfun-color-content-secondary); background: transparent; cursor: pointer; touch-action: manipulation; -webkit-tap-highlight-color: transparent; transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } + .button > svg { inline-size: 18px; block-size: 18px; } + @media (hover: hover) and (pointer: fine) { + .button:is(:hover, [data-openbitfun-preview-state="hover"]):not(:disabled), + .root[data-openbitfun-preview-state="hover"] .button:not(:disabled) { background: var(--openbitfun-color-action-neutral-surface-hover); } + } + .button:is(:active, [data-openbitfun-preview-state="active"]):not(:disabled), + .root[data-openbitfun-preview-state="active"] .button:not(:disabled) { background: var(--openbitfun-color-action-neutral-surface-pressed); } + .button:focus-visible { outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); outline-offset: calc(var(--openbitfun-focus-offset) * -1); } + .button:disabled { color: var(--openbitfun-color-content-disabled); cursor: not-allowed; } + + .value { display: inline-flex; min-inline-size: 0; align-items: baseline; gap: var(--openbitfun-space-1); } + .input { box-sizing: border-box; inline-size: 4ch; min-block-size: 44px; margin: 0; padding: 0; border: 0; outline: 0; color: inherit; background: transparent; font: inherit; font-size: var(--openbitfun-type-body-md-font-size); font-variant-numeric: tabular-nums; text-align: center; } + .input:disabled { color: var(--openbitfun-color-content-disabled); } + .unit { color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-body-sm-font-size); } + .root[data-invalid="true"] .input { color: var(--openbitfun-color-status-danger-content); } + + @media (prefers-reduced-motion: reduce) { .button { transition: none; } } + @media (forced-colors: active) { .control { border-color: ButtonText; } .button:focus-visible { outline-color: Highlight; } } +} diff --git a/design-system/packages/ui/src/mobile/MobileStepper/MobileStepper.tsx b/design-system/packages/ui/src/mobile/MobileStepper/MobileStepper.tsx new file mode 100644 index 0000000000..f27b9948cf --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileStepper/MobileStepper.tsx @@ -0,0 +1,117 @@ +import { Minus, Plus } from "lucide-react"; +import { useState, type ChangeEvent, type HTMLAttributes, type ReactNode } from "react"; +import { classNames } from "../../internal/classNames"; +import styles from "./MobileStepper.module.css"; + +export interface MobileStepperProps + extends Omit, "onChange"> { + decrementLabel: string; + description?: ReactNode; + disabled?: boolean; + incrementLabel: string; + invalid?: boolean; + label?: ReactNode; + max?: number; + min?: number; + onValueChange?: (value: number) => void; + step?: number; + unit?: ReactNode; + value?: number; +} + +/** The mobile form of a number input: a value that is usually nudged, so the two ends are buttons + and the middle stays typable. Bounds disable the buttons rather than rejecting the tap. */ +export function MobileStepper({ + className, + decrementLabel, + description, + disabled = false, + incrementLabel, + invalid = false, + label, + max, + min, + onValueChange, + step = 1, + unit, + value, + ...props +}: MobileStepperProps) { + const [uncontrolled, setUncontrolled] = useState(min ?? 0); + const controlled = value !== undefined; + const current = controlled ? value : uncontrolled; + + function commit(next: number) { + const bounded = Math.min(max ?? Number.POSITIVE_INFINITY, Math.max(min ?? Number.NEGATIVE_INFINITY, next)); + if (!controlled) setUncontrolled(bounded); + onValueChange?.(bounded); + } + + function handleInput(event: ChangeEvent) { + const parsed = Number(event.target.value); + if (Number.isFinite(parsed)) commit(parsed); + } + + const atMin = min !== undefined && current <= min; + const atMax = max !== undefined && current >= max; + + return ( +
+ {(label !== undefined && label !== null) && ( + + {label} + {description !== undefined && description !== null && ( + {description} + )} + + )} + + + + + {unit !== undefined && unit !== null && ( + {unit} + )} + + + +
+ ); +} diff --git a/design-system/packages/ui/src/mobile/MobileStepper/index.ts b/design-system/packages/ui/src/mobile/MobileStepper/index.ts new file mode 100644 index 0000000000..a3ef374473 --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileStepper/index.ts @@ -0,0 +1 @@ +export { MobileStepper, type MobileStepperProps } from "./MobileStepper"; diff --git a/design-system/packages/ui/src/mobile/MobileSwitch/MobileSwitch.meta.ts b/design-system/packages/ui/src/mobile/MobileSwitch/MobileSwitch.meta.ts new file mode 100644 index 0000000000..03b8f55eb4 --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileSwitch/MobileSwitch.meta.ts @@ -0,0 +1,38 @@ +import type { ComponentMeta } from "../../registry.types"; + +export const mobileSwitchMeta = { + category: "mobile", + description: "A touch-sized settings row that pairs a label and its explanation with the product switch.", + maturity: "stable", + name: "MobileSwitch", + props: [ + { name: "label", type: "ReactNode" }, + { name: "description", type: "ReactNode" }, + { name: "checked", type: "boolean" }, + { defaultValue: "false", name: "defaultChecked", type: "boolean" }, + { name: "onCheckedChange", type: "(checked: boolean) => void" }, + { defaultValue: "plain", name: "appearance", type: '"plain" | "surface"' }, + ], + states: ["off", "on", "hover", "active", "focus-visible", "disabled"], + tokens: [ + "color.action.neutral.surface.hover", + "color.action.neutral.surface.pressed", + "color.border.subtle", + "color.content.disabled", + "color.content.muted", + "color.content.primary", + "color.control.switch.thumb", + "color.control.switch.track", + "color.control.switch.track.checked", + "color.focus.ring", + "radius.md", + "radius.pill", + "shadow.xs", + "space.1", + "space.2", + "space.3", + "type.body.md.fontSize", + "type.body.xs.fontSize", + "type.label.lg.fontWeight", + ], +} as const satisfies ComponentMeta; diff --git a/design-system/packages/ui/src/mobile/MobileSwitch/MobileSwitch.module.css b/design-system/packages/ui/src/mobile/MobileSwitch/MobileSwitch.module.css new file mode 100644 index 0000000000..4d59b70000 --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileSwitch/MobileSwitch.module.css @@ -0,0 +1,29 @@ +@layer openbitfun.components { + /* Touch geometry lives on the row, not on the switch: a 32px track inside a 52px row is the + mobile pattern — the label is the target, the track is the state. */ + .root { display: flex; box-sizing: border-box; inline-size: 100%; min-inline-size: 0; min-block-size: 52px; align-items: center; gap: var(--openbitfun-space-3); margin: 0; padding: var(--openbitfun-space-2) var(--openbitfun-space-3); border: var(--openbitfun-border-width-default) solid transparent; border-radius: var(--openbitfun-radius-md); color: var(--openbitfun-color-content-primary); background: transparent; cursor: pointer; touch-action: manipulation; -webkit-tap-highlight-color: transparent; transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } + .root[data-appearance="surface"] { min-block-size: 64px; border-color: var(--openbitfun-color-border-subtle); background: var(--openbitfun-color-surface-panel); box-shadow: var(--openbitfun-shadow-xs); } + .root[data-disabled="true"] { color: var(--openbitfun-color-content-disabled); cursor: not-allowed; } + + @media (hover: hover) and (pointer: fine) { + .root:is(:hover, [data-openbitfun-preview-state="hover"]):not([data-disabled="true"]) { background: var(--openbitfun-color-action-neutral-surface-hover); } + } + .root:is(:active, [data-openbitfun-preview-state="active"]):not([data-disabled="true"]) { background: var(--openbitfun-color-action-neutral-surface-pressed); } + .root:is(:has(.input:focus-visible), [data-openbitfun-preview-state="focus-visible"]) { outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); outline-offset: var(--openbitfun-focus-offset); } + + .copy { display: flex; box-sizing: border-box; min-inline-size: 0; flex: 1 1 auto; flex-direction: column; gap: var(--openbitfun-space-1); } + .label { color: var(--openbitfun-color-content-primary); font-size: var(--openbitfun-type-body-md-font-size); font-weight: var(--openbitfun-type-label-lg-font-weight); } + .description { color: var(--openbitfun-color-content-muted); font-size: var(--openbitfun-type-body-xs-font-size); } + .root[data-disabled="true"] .label, .root[data-disabled="true"] .description { color: inherit; } + + .control { position: relative; display: inline-flex; inline-size: var(--openbitfun-control-switch-track-width); block-size: var(--openbitfun-control-switch-track-height); flex: 0 0 auto; } + .input { position: absolute; z-index: var(--openbitfun-layer-content); inset: 0; inline-size: 100%; block-size: 100%; margin: 0; opacity: 0; cursor: inherit; } + .track { position: relative; display: block; inline-size: 100%; block-size: 100%; overflow: hidden; border-radius: var(--openbitfun-radius-pill); background: var(--openbitfun-color-control-switch-track); pointer-events: none; transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } + .thumb { position: absolute; inset-block-start: var(--openbitfun-control-switch-thumb-inset); inset-inline-start: var(--openbitfun-control-switch-thumb-inset); inline-size: var(--openbitfun-control-switch-thumb-size); block-size: var(--openbitfun-control-switch-thumb-size); border-radius: var(--openbitfun-radius-pill); background: var(--openbitfun-color-control-switch-thumb); transition: transform var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } + .root[data-checked="true"] .track { background: var(--openbitfun-color-control-switch-track-checked); } + .root[data-checked="true"] .thumb { transform: translateX(var(--openbitfun-control-switch-thumb-travel)); } + .root:dir(rtl)[data-checked="true"] .thumb { transform: translateX(var(--openbitfun-control-switch-thumb-travel-reverse)); } + + @media (prefers-reduced-motion: reduce) { .root, .track, .thumb { transition: none; } } + @media (forced-colors: active) { .root[data-appearance="surface"] { border-color: ButtonText; } .track { border: var(--openbitfun-border-width-default) solid ButtonText; } .root:has(.input:focus-visible), .root[data-openbitfun-preview-state="focus-visible"] { outline-color: Highlight; } } +} diff --git a/design-system/packages/ui/src/mobile/MobileSwitch/MobileSwitch.tsx b/design-system/packages/ui/src/mobile/MobileSwitch/MobileSwitch.tsx new file mode 100644 index 0000000000..c91f9702bd --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileSwitch/MobileSwitch.tsx @@ -0,0 +1,84 @@ +import { + forwardRef, + useState, + type ChangeEvent, + type InputHTMLAttributes, + type ReactNode, +} from "react"; +import { classNames } from "../../internal/classNames"; +import styles from "./MobileSwitch.module.css"; + +export type MobileSwitchAppearance = "plain" | "surface"; + +export interface MobileSwitchProps + extends Omit, "children" | "size" | "type"> { + appearance?: MobileSwitchAppearance; + checked?: boolean; + defaultChecked?: boolean; + description?: ReactNode; + label: ReactNode; + onCheckedChange?: (checked: boolean) => void; +} + +/** A settings row: the label and its explanation are the tap target, the switch is the control. + The switch itself is the web Switch's track and thumb, so both entries read as one product. */ +export const MobileSwitch = forwardRef( + function MobileSwitch({ + appearance = "plain", + checked, + className, + defaultChecked, + description, + disabled, + label, + onChange, + onCheckedChange, + ...props + }, ref) { + const [uncontrolled, setUncontrolled] = useState(defaultChecked ?? false); + const controlled = checked !== undefined; + const resolved = controlled ? checked : uncontrolled; + /* Focus and press states belong to the hidden input, but the row is what changes. The Lab drives + the row through a root attribute, so lift that hook off the input onto the label. */ + const previewState = (props as Record)["data-openbitfun-preview-state"]; + + function handleChange(event: ChangeEvent) { + if (!controlled) setUncontrolled(event.target.checked); + onChange?.(event); + onCheckedChange?.(event.target.checked); + } + + return ( + + ); + }, +); diff --git a/design-system/packages/ui/src/mobile/MobileSwitch/index.ts b/design-system/packages/ui/src/mobile/MobileSwitch/index.ts new file mode 100644 index 0000000000..8c54ca54e6 --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileSwitch/index.ts @@ -0,0 +1,5 @@ +export { + MobileSwitch, + type MobileSwitchAppearance, + type MobileSwitchProps, +} from "./MobileSwitch"; diff --git a/design-system/packages/ui/src/mobile/MobileTabBar/MobileTabBar.meta.ts b/design-system/packages/ui/src/mobile/MobileTabBar/MobileTabBar.meta.ts new file mode 100644 index 0000000000..c02d83c7e1 --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileTabBar/MobileTabBar.meta.ts @@ -0,0 +1,34 @@ +import type { ComponentMeta } from "../../registry.types"; + +export const mobileTabBarMeta = { + category: "mobile", + description: "The bottom destination bar: equal-width items, a filled selection pill, and safe-area padding.", + maturity: "stable", + name: "MobileTabBar", + props: [ + { name: "items", type: "readonly MobileTabBarItem[]" }, + { name: "value", type: "string" }, + { name: "defaultValue", type: "string" }, + { name: "onValueChange", type: "(value: string) => void" }, + { name: "label", type: "string" }, + { defaultValue: "surface", name: "appearance", type: '"surface" | "plain"' }, + { defaultValue: "true", name: "safeArea", type: "boolean" }, + ], + states: ["default", "selected", "hover", "active", "focus-visible", "disabled", "safe-area"], + tokens: [ + "color.action.neutral.surface.hover", + "color.action.neutral.surface.pressed", + "color.content.disabled", + "color.content.primary", + "color.content.secondary", + "color.focus.ring", + "color.selection.surface", + "color.surface.panel", + "radius.md", + "radius.pill", + "space.1", + "space.2", + "type.label.md.fontWeight", + "type.label.sm.fontSize", + ], +} as const satisfies ComponentMeta; diff --git a/design-system/packages/ui/src/mobile/MobileTabBar/MobileTabBar.module.css b/design-system/packages/ui/src/mobile/MobileTabBar/MobileTabBar.module.css new file mode 100644 index 0000000000..8b72499488 --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileTabBar/MobileTabBar.module.css @@ -0,0 +1,31 @@ +@layer openbitfun.components { + .root { display: flex; box-sizing: border-box; inline-size: 100%; min-block-size: 56px; align-items: stretch; gap: 0; padding-inline: var(--openbitfun-space-1); background: var(--openbitfun-color-surface-panel); } + .root[data-appearance="plain"] { background: transparent; } + /* The bar owns the bottom edge of the screen, so it owns the home indicator's space too. */ + .root[data-safe-area="true"] { padding-block-end: env(safe-area-inset-bottom, 0px); } + + .item { display: flex; box-sizing: border-box; min-inline-size: 0; min-block-size: 52px; flex: 1 1 0; flex-direction: column; align-items: center; justify-content: center; gap: var(--openbitfun-space-1); margin: 0; padding: var(--openbitfun-space-2) var(--openbitfun-space-1); border: 0; border-radius: var(--openbitfun-radius-md); color: var(--openbitfun-color-content-secondary); background: transparent; cursor: pointer; touch-action: manipulation; -webkit-tap-highlight-color: transparent; font: inherit; transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } + /* Selection is a filled pill behind the icon: no underline, no border. */ + .item[data-selected="true"] { color: var(--openbitfun-color-content-primary); } + .item[data-selected="true"] .icon { background: var(--openbitfun-color-selection-surface); } + + @media (hover: hover) and (pointer: fine) { + .item:is(:hover, [data-openbitfun-preview-state="hover"]):not(:disabled), + .root[data-openbitfun-preview-state="hover"] .item:not(:disabled) { color: var(--openbitfun-color-content-primary); } + .item:is(:hover, [data-openbitfun-preview-state="hover"]):not(:disabled) .icon, + .root[data-openbitfun-preview-state="hover"] .item:not(:disabled) .icon { background: var(--openbitfun-color-action-neutral-surface-hover); } + } + .item:is(:active, [data-openbitfun-preview-state="active"]):not(:disabled) .icon, + .root[data-openbitfun-preview-state="active"] .item:not(:disabled) .icon { background: var(--openbitfun-color-action-neutral-surface-pressed); } + .item:is(:focus-visible, [data-openbitfun-preview-state="focus-visible"]), + .root[data-openbitfun-preview-state="focus-visible"] .item[data-selected="true"] { outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); outline-offset: calc(var(--openbitfun-focus-offset) * -1); } + .item:disabled { color: var(--openbitfun-color-content-disabled); cursor: not-allowed; } + + .icon { position: relative; display: grid; box-sizing: border-box; inline-size: 64px; block-size: 32px; place-items: center; border-radius: var(--openbitfun-radius-pill); transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } + .icon > :where(svg, img) { inline-size: 20px; block-size: 20px; } + .badge { position: absolute; inset-block-start: 0; inset-inline-start: 50%; margin-inline-start: 4px; } + .label { display: block; max-inline-size: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--openbitfun-type-label-sm-font-size); font-weight: var(--openbitfun-type-label-md-font-weight); } + + @media (prefers-reduced-motion: reduce) { .item, .icon { transition: none; } } + @media (forced-colors: active) { .item:focus-visible { outline-color: Highlight; } .item[data-selected="true"] .icon { border: var(--openbitfun-border-width-default) solid Highlight; } } +} diff --git a/design-system/packages/ui/src/mobile/MobileTabBar/MobileTabBar.tsx b/design-system/packages/ui/src/mobile/MobileTabBar/MobileTabBar.tsx new file mode 100644 index 0000000000..4545195ca1 --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileTabBar/MobileTabBar.tsx @@ -0,0 +1,82 @@ +import { useState, type HTMLAttributes, type ReactNode } from "react"; +import { classNames } from "../../internal/classNames"; +import styles from "./MobileTabBar.module.css"; + +export type MobileTabBarAppearance = "surface" | "plain"; + +export interface MobileTabBarItem { + badge?: ReactNode; + disabled?: boolean; + icon?: ReactNode; + label: ReactNode; + value: string; +} + +export interface MobileTabBarProps + extends Omit, "onChange"> { + appearance?: MobileTabBarAppearance; + defaultValue?: string; + items: readonly MobileTabBarItem[]; + label: string; + onValueChange?: (value: string) => void; + safeArea?: boolean; + value?: string; +} + +/** Destinations, not tabs: the bar swaps the screen, so each item is a link-like button marked with + aria-current rather than a tab in a tablist. */ +export function MobileTabBar({ + appearance = "surface", + className, + defaultValue, + items, + label, + onValueChange, + safeArea = true, + value, + ...props +}: MobileTabBarProps) { + const [uncontrolled, setUncontrolled] = useState(defaultValue); + const controlled = value !== undefined; + const current = controlled ? value : uncontrolled; + + function select(next: string) { + if (!controlled) setUncontrolled(next); + onValueChange?.(next); + } + + return ( + + ); +} diff --git a/design-system/packages/ui/src/mobile/MobileTabBar/index.ts b/design-system/packages/ui/src/mobile/MobileTabBar/index.ts new file mode 100644 index 0000000000..13d30e2ab7 --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileTabBar/index.ts @@ -0,0 +1,6 @@ +export { + MobileTabBar, + type MobileTabBarAppearance, + type MobileTabBarItem, + type MobileTabBarProps, +} from "./MobileTabBar"; diff --git a/design-system/packages/ui/src/mobile/MobileTag/MobileTag.meta.ts b/design-system/packages/ui/src/mobile/MobileTag/MobileTag.meta.ts new file mode 100644 index 0000000000..015502d550 --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileTag/MobileTag.meta.ts @@ -0,0 +1,38 @@ +import type { ComponentMeta } from "../../registry.types"; + +export const mobileTagMeta = { + category: "mobile", + description: "A touch-sized labelled pill for filters and tags, with leading and trailing slots and a filled selected state.", + maturity: "stable", + name: "MobileTag", + props: [ + { name: "children", type: "ReactNode" }, + { name: "leading", type: "ReactNode" }, + { name: "trailing", type: "ReactNode" }, + { defaultValue: "false", name: "selected", type: "boolean" }, + { defaultValue: "soft", name: "appearance", type: '"soft" | "accent"' }, + { defaultValue: "md", name: "size", type: '"sm" | "md"' }, + ], + states: ["default", "selected", "hover", "active", "focus-visible", "disabled"], + tokens: [ + "color.accent.default", + "color.accent.surface", + "color.action.neutral.surface", + "color.action.neutral.surface.hover", + "color.action.neutral.surface.pressed", + "color.action.primary.content", + "color.content.disabled", + "color.content.primary", + "color.content.secondary", + "color.focus.ring", + "color.selection.surface", + "font.weight.semibold", + "radius.pill", + "space.2", + "space.3", + "space.4", + "type.label.md.fontSize", + "type.label.md.fontWeight", + "type.label.sm.fontSize", + ], +} as const satisfies ComponentMeta; diff --git a/design-system/packages/ui/src/mobile/MobileTag/MobileTag.module.css b/design-system/packages/ui/src/mobile/MobileTag/MobileTag.module.css new file mode 100644 index 0000000000..a0dcc476c2 --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileTag/MobileTag.module.css @@ -0,0 +1,21 @@ +@layer openbitfun.components { + .root { box-sizing: border-box; display: inline-flex; min-block-size: 44px; align-items: center; justify-content: center; gap: var(--openbitfun-space-2); margin: 0; padding: 0 var(--openbitfun-space-4); border: 0; border-radius: var(--openbitfun-radius-pill); color: var(--openbitfun-color-content-secondary); background: var(--openbitfun-color-action-neutral-surface); cursor: pointer; touch-action: manipulation; -webkit-tap-highlight-color: transparent; font: inherit; font-size: var(--openbitfun-type-label-md-font-size); font-weight: var(--openbitfun-type-label-md-font-weight); transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } + .root[data-size="sm"] { min-block-size: 36px; padding-inline: var(--openbitfun-space-3); font-size: var(--openbitfun-type-label-sm-font-size); } + .root[data-appearance="accent"] { color: var(--openbitfun-color-accent-default); background: var(--openbitfun-color-accent-surface); } + /* Selection is a filled block, the same signal the rest of the mobile set uses for state. */ + .root[data-selected="true"] { color: var(--openbitfun-color-content-primary); background: var(--openbitfun-color-selection-surface); font-weight: var(--openbitfun-font-weight-semibold); } + .root[data-selected="true"][data-appearance="accent"] { color: var(--openbitfun-color-action-primary-content); background: var(--openbitfun-color-accent-default); } + + @media (hover: hover) and (pointer: fine) { + .root:is(:hover, [data-openbitfun-preview-state="hover"]):not(:disabled) { background: var(--openbitfun-color-action-neutral-surface-hover); } + } + .root:is(:active, [data-openbitfun-preview-state="active"]):not(:disabled) { background: var(--openbitfun-color-action-neutral-surface-pressed); } + .root:is(:focus-visible, [data-openbitfun-preview-state="focus-visible"]) { outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); outline-offset: var(--openbitfun-focus-offset); } + .root:disabled { color: var(--openbitfun-color-content-disabled); cursor: not-allowed; } + + .label { display: block; min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .leading, .trailing { display: grid; inline-size: 16px; block-size: 16px; flex: 0 0 16px; place-items: center; } + .leading > :where(svg, img), .trailing > :where(svg, img) { inline-size: 100%; block-size: 100%; } + + @media (prefers-reduced-motion: reduce) { .root { transition: none; } } +} diff --git a/design-system/packages/ui/src/mobile/MobileTag/MobileTag.tsx b/design-system/packages/ui/src/mobile/MobileTag/MobileTag.tsx new file mode 100644 index 0000000000..041b4c5a2b --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileTag/MobileTag.tsx @@ -0,0 +1,55 @@ +import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react"; +import { classNames } from "../../internal/classNames"; +import styles from "./MobileTag.module.css"; + +export type MobileTagAppearance = "soft" | "accent"; +export type MobileTagSize = "sm" | "md"; + +export interface MobileTagProps + extends Omit, "children"> { + appearance?: MobileTagAppearance; + children: ReactNode; + leading?: ReactNode; + selected?: boolean; + size?: MobileTagSize; + trailing?: ReactNode; +} + +/** The mobile form of the web Capsule: a labelled pill that may carry a leading mark and a trailing + slot. Selection is a fill, never a stroke — a bordered chip would make a filter row read as a + row of inputs. */ +export const MobileTag = forwardRef( + function MobileTag({ + appearance = "soft", + children, + className, + leading, + selected = false, + size = "md", + trailing, + type = "button", + ...props + }, ref) { + return ( + + ); + }, +); diff --git a/design-system/packages/ui/src/mobile/MobileTag/index.ts b/design-system/packages/ui/src/mobile/MobileTag/index.ts new file mode 100644 index 0000000000..0dc683aaca --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileTag/index.ts @@ -0,0 +1,6 @@ +export { + MobileTag, + type MobileTagAppearance, + type MobileTagProps, + type MobileTagSize, +} from "./MobileTag"; diff --git a/design-system/packages/ui/src/mobile/MobileToolbar/MobileToolbar.meta.ts b/design-system/packages/ui/src/mobile/MobileToolbar/MobileToolbar.meta.ts new file mode 100644 index 0000000000..e75f978290 --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileToolbar/MobileToolbar.meta.ts @@ -0,0 +1,25 @@ +import type { ComponentMeta } from "../../registry.types"; + +export const mobileToolbarMeta = { + category: "mobile", + description: "A bottom action bar with leading, middle, and trailing slots that keeps clear of the home indicator.", + maturity: "stable", + name: "MobileToolbar", + props: [ + { name: "leading", type: "ReactNode" }, + { name: "children", type: "ReactNode" }, + { name: "trailing", type: "ReactNode" }, + { name: "label", type: "string" }, + { defaultValue: "surface", name: "appearance", type: '"surface" | "plain"' }, + { defaultValue: "true", name: "safeArea", type: "boolean" }, + { defaultValue: "false", name: "sticky", type: "boolean" }, + ], + states: ["default", "plain", "sticky", "safe-area"], + tokens: [ + "color.surface.panel", + "shadow.sm", + "space.2", + "space.3", + "space.4", + ], +} as const satisfies ComponentMeta; diff --git a/design-system/packages/ui/src/mobile/MobileToolbar/MobileToolbar.module.css b/design-system/packages/ui/src/mobile/MobileToolbar/MobileToolbar.module.css new file mode 100644 index 0000000000..126745a44f --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileToolbar/MobileToolbar.module.css @@ -0,0 +1,11 @@ +@layer openbitfun.components { + .root { display: flex; box-sizing: border-box; inline-size: 100%; min-block-size: 56px; align-items: center; justify-content: space-between; gap: var(--openbitfun-space-3); padding: var(--openbitfun-space-2) var(--openbitfun-space-4); background: var(--openbitfun-color-surface-panel); box-shadow: var(--openbitfun-shadow-sm); } + .root[data-appearance="plain"] { background: transparent; box-shadow: none; } + .root[data-safe-area="true"] { padding-block-end: calc(var(--openbitfun-space-2) + env(safe-area-inset-bottom, 0px)); } + .root[data-sticky="true"] { position: sticky; inset-block-end: 0; z-index: var(--openbitfun-layer-sticky); } + + .leading, .trailing { display: inline-flex; min-inline-size: 0; flex: 0 0 auto; align-items: center; gap: var(--openbitfun-space-2); } + .content { display: inline-flex; min-inline-size: 0; flex: 1 1 auto; align-items: center; justify-content: center; gap: var(--openbitfun-space-2); } + + @media (forced-colors: active) { .root { border-block-start: var(--openbitfun-border-width-default) solid CanvasText; } } +} diff --git a/design-system/packages/ui/src/mobile/MobileToolbar/MobileToolbar.tsx b/design-system/packages/ui/src/mobile/MobileToolbar/MobileToolbar.tsx new file mode 100644 index 0000000000..fa73d6070b --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileToolbar/MobileToolbar.tsx @@ -0,0 +1,52 @@ +import { type HTMLAttributes, type ReactNode } from "react"; +import { classNames } from "../../internal/classNames"; +import styles from "./MobileToolbar.module.css"; + +export type MobileToolbarAppearance = "surface" | "plain"; + +export interface MobileToolbarProps extends HTMLAttributes { + appearance?: MobileToolbarAppearance; + children?: ReactNode; + label?: string; + leading?: ReactNode; + safeArea?: boolean; + sticky?: boolean; + trailing?: ReactNode; +} + +/** The action bar under a mobile screen. It separates from the content by fill and elevation rather + than a rule, the same way the rest of the mobile surfaces do. */ +export function MobileToolbar({ + appearance = "surface", + children, + className, + label, + leading, + safeArea = true, + sticky = false, + trailing, + ...props +}: MobileToolbarProps) { + return ( +
+ {leading !== undefined && leading !== null && ( + {leading} + )} + {children !== undefined && children !== null && ( + {children} + )} + {trailing !== undefined && trailing !== null && ( + {trailing} + )} +
+ ); +} diff --git a/design-system/packages/ui/src/mobile/MobileToolbar/index.ts b/design-system/packages/ui/src/mobile/MobileToolbar/index.ts new file mode 100644 index 0000000000..df7d915828 --- /dev/null +++ b/design-system/packages/ui/src/mobile/MobileToolbar/index.ts @@ -0,0 +1,5 @@ +export { + MobileToolbar, + type MobileToolbarAppearance, + type MobileToolbarProps, +} from "./MobileToolbar"; diff --git a/design-system/packages/ui/src/registry.ts b/design-system/packages/ui/src/registry.ts index abda186802..98afc696d7 100644 --- a/design-system/packages/ui/src/registry.ts +++ b/design-system/packages/ui/src/registry.ts @@ -47,10 +47,12 @@ import { voiceCallPanelMeta } from "./components/VoiceCallPanel/VoiceCallPanel.m import { voiceParticleLogoMeta } from "./brand/VoiceParticleLogo/VoiceParticleLogo.meta"; import { mobileIconButtonMeta } from "./mobile/MobileIconButton/MobileIconButton.meta"; import { mobileActionSheetMeta } from "./mobile/MobileActionSheet/MobileActionSheet.meta"; +import { mobileAvatarMeta } from "./mobile/MobileAvatar/MobileAvatar.meta"; import { mobileBadgeMeta } from "./mobile/MobileBadge/MobileBadge.meta"; import { mobileBannerMeta } from "./mobile/MobileBanner/MobileBanner.meta"; import { mobileButtonMeta } from "./mobile/MobileButton/MobileButton.meta"; import { mobileCardMeta } from "./mobile/MobileCard/MobileCard.meta"; +import { mobileCheckboxMeta } from "./mobile/MobileCheckbox/MobileCheckbox.meta"; import { mobileChoiceSheetMeta } from "./mobile/MobileChoiceSheet/MobileChoiceSheet.meta"; import { mobileConfirmSheetMeta } from "./mobile/MobileConfirmSheet/MobileConfirmSheet.meta"; import { mobileComposerMeta } from "./mobile/MobileComposer/MobileComposer.meta"; @@ -61,13 +63,20 @@ import { mobileListRowMeta } from "./mobile/MobileListRow/MobileListRow.meta"; import { mobileLinkMeta } from "./mobile/MobileLink/MobileLink.meta"; import { mobileMessageMeta } from "./mobile/MobileMessage/MobileMessage.meta"; import { mobilePageHeaderMeta } from "./mobile/MobilePageHeader/MobilePageHeader.meta"; +import { mobileRadioGroupMeta } from "./mobile/MobileRadioGroup/MobileRadioGroup.meta"; import { mobileSectionMeta } from "./mobile/MobileSection/MobileSection.meta"; import { mobileScrimMeta } from "./mobile/MobileScrim/MobileScrim.meta"; import { mobileSegmentedControlMeta } from "./mobile/MobileSegmentedControl/MobileSegmentedControl.meta"; +import { mobileSelectMeta } from "./mobile/MobileSelect/MobileSelect.meta"; import { mobileSheetMeta } from "./mobile/MobileSheet/MobileSheet.meta"; import { mobileStatusMeta } from "./mobile/MobileStatus/MobileStatus.meta"; +import { mobileStepperMeta } from "./mobile/MobileStepper/MobileStepper.meta"; +import { mobileSwitchMeta } from "./mobile/MobileSwitch/MobileSwitch.meta"; +import { mobileTabBarMeta } from "./mobile/MobileTabBar/MobileTabBar.meta"; +import { mobileTagMeta } from "./mobile/MobileTag/MobileTag.meta"; import { mobileTextFieldMeta } from "./mobile/MobileTextField/MobileTextField.meta"; import { mobileTextareaMeta } from "./mobile/MobileTextarea/MobileTextarea.meta"; +import { mobileToolbarMeta } from "./mobile/MobileToolbar/MobileToolbar.meta"; import { askUserMeta } from "./flow-chat/ask-user/AskUser.meta"; import { chatComposerMeta } from "./flow-chat/composer/ChatComposer.meta"; import { thinkingBlockMeta, flowGroupMeta, exploreGroupMeta, contextLoadGroupMeta, flowChatRuntimeStatusMeta } from "./flow-chat/conversation/ConversationBlocks.meta"; @@ -108,11 +117,12 @@ import { webSearchToolCardMeta, } from "./flow-chat/tool-cards/StandardToolCards.meta"; import { openBitFunSolidMarkMeta, openBitFunAppIconMeta, openBitFunMarkMeta, openBitFunBrandMotionMeta, subagentHatchMeta } from "./brand/brand.meta"; -import type { ComponentMeta } from "./registry.types"; +import type { ComponentMeta, ComponentPlatform } from "./registry.types"; export type { ComponentMaturity, ComponentMeta, + ComponentPlatform, ComponentPropMeta, } from "./registry.types"; @@ -169,10 +179,12 @@ export const componentRegistry = [ loadingStateMeta, menuMeta, mobileActionSheetMeta, + mobileAvatarMeta, mobileBadgeMeta, mobileBannerMeta, mobileButtonMeta, mobileCardMeta, + mobileCheckboxMeta, mobileChoiceSheetMeta, mobileConfirmSheetMeta, mobileComposerMeta, @@ -184,13 +196,20 @@ export const componentRegistry = [ mobileListRowMeta, mobileMessageMeta, mobilePageHeaderMeta, + mobileRadioGroupMeta, mobileScrimMeta, mobileSectionMeta, mobileSegmentedControlMeta, + mobileSelectMeta, mobileSheetMeta, mobileStatusMeta, + mobileStepperMeta, + mobileSwitchMeta, + mobileTabBarMeta, + mobileTagMeta, mobileTextFieldMeta, mobileTextareaMeta, + mobileToolbarMeta, dialogMeta, multiSelectMeta, navigationPanelMeta, @@ -235,3 +254,29 @@ export const componentRegistry = [ webFetchToolCardMeta, webSearchToolCardMeta, ] as const satisfies readonly ComponentMeta[]; + +const categoryPlatform: Readonly> = { + action: ["web"], + brand: ["web"], + feedback: ["web"], + "flow-chat": ["web"], + form: ["web"], + mobile: ["mobile"], + navigation: ["web"], + primitive: ["web"], +}; + +/* The surfaces a component is authored for. Entries stay derived until a component is really + shared, which keeps a new platform from forcing a rewrite of every existing entry. */ +export function componentPlatforms( + component: Pick, +): readonly ComponentPlatform[] { + return component.platforms ?? categoryPlatform[component.category]; +} + +export function isComponentForPlatform( + component: Pick, + platform: ComponentPlatform, +): boolean { + return componentPlatforms(component).includes(platform); +} diff --git a/design-system/packages/ui/src/registry.types.ts b/design-system/packages/ui/src/registry.types.ts index 62e67ec892..f3df97220b 100644 --- a/design-system/packages/ui/src/registry.types.ts +++ b/design-system/packages/ui/src/registry.types.ts @@ -1,5 +1,9 @@ export type ComponentMaturity = "experimental" | "beta" | "stable" | "deprecated"; +/* Platform is orthogonal to category: a surface ("where it runs") never becomes a category + ("what it is"), so adding tablet, desktop or watch never renames an existing entry. */ +export type ComponentPlatform = "web" | "mobile" | "tablet" | "desktop" | "watch"; + export interface ComponentPropMeta { defaultValue?: string; name: string; @@ -11,6 +15,9 @@ export interface ComponentMeta { description: string; maturity: ComponentMaturity; name: string; + /* Defaults to a single platform derived from `category`, so an entry only states this + explicitly when it is authored for several surfaces at once. */ + platforms?: readonly ComponentPlatform[]; props: readonly ComponentPropMeta[]; states: readonly string[]; tokens: readonly string[]; diff --git a/design-system/packages/ui/tests/consistency.test.mjs b/design-system/packages/ui/tests/consistency.test.mjs new file mode 100644 index 0000000000..3fbbb434c9 --- /dev/null +++ b/design-system/packages/ui/tests/consistency.test.mjs @@ -0,0 +1,191 @@ +// Cross-platform contract, enforced instead of described. +// +// The design system ships one visual language per surface (web today, mobile today, tablet and +// watch later) and a shared token layer underneath. Two things must therefore stay true as the +// surfaces multiply: +// +// 1. Vocabulary — one word per idea. A platform may skip a state it cannot express on its +// input model (a touch device has no hover), but it may not rename the idea. `off/on` and +// `checked`, or `focus-visible` and `focus-within` on the same element, are the drift this +// test exists to stop. +// 2. Platform — a surface is declared, never implied by the component name. `category` says +// what a component is; `platforms` says where it runs, so a third surface never renames an +// existing entry. +// +// Divergence that is deliberate is listed in the registers below with a reason, so the next +// reader can tell a decision from an accident. +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { componentPlatforms, componentRegistry } from "../dist/registry.js"; + +const KNOWN_PLATFORMS = ["web", "mobile", "tablet", "desktop", "watch"]; + +/* States that describe how a control behaves, not what it contains. Content variants ("with-title", + "media", "artwork") stay free-form. */ +const INTERACTION_STATES = new Set([ + "active", "checked", "collapsed", "disabled", "expanded", "focus-visible", "focus-within", "hover", + "indeterminate", "invalid", "loading", "off", "on", "open", "pressed", "read-only", "selected", + "unselected", +]); + +/* Pairs that name the same idea and must not both appear on one component's counterpart pair. */ +const SYNONYM_PAIRS = [ + ["checked", "on"], + ["checked", "off"], + ["selected", "checked"], +]; + +/* Register 1 — a web interaction state a mobile counterpart does not declare. + Reason is required: platform anatomy is allowed to excuse a state, silence is not. */ +const MOBILE_STATE_GAPS = { + MobileButton: "web Button declares no interaction state for the press ring the touch surface draws itself", + MobileComposer: "the mobile composer is an expandable shell whose validation lives in the sheet, not in the row", + MobileTextarea: "touch text fields draw focus on the wrapper and never show hover", + MobileSelect: "the mobile select has no hover on touch; its field is a button, so pressed feedback replaces it", +}; + +/* Register 2 — a shared axis the two surfaces express differently: either the same name with a + second meaning, or an axis only one surface has. */ +const PROP_PARITY_GAPS = { + Checkbox: "appearance means the control skin (custom|native); the mobile row uses the same name for its container surface", + IconButton: "the mobile button exposes selected and writes aria-pressed itself; the web button is stateless and leaves the toggle to the caller", +}; + +async function source(relative) { + return readFile(new URL(`../src/${relative}`, import.meta.url), "utf8"); +} + +/* Compound entries (Sheet lives with Dialog) have no file of their own. */ +async function optionalSource(relative) { + try { + return await source(relative); + } catch { + return ""; + } +} + +function propsOf(tsx) { + /* Interfaces are written both multiline and inline, so read to the closing brace at depth zero + instead of assuming a line break before it. */ + const start = tsx.search(/export interface \w+Props/); + if (start < 0) return []; + const body = tsx.slice(tsx.indexOf("{", start) + 1); + let depth = 0; + let end = body.length; + for (let index = 0; index < body.length; index += 1) { + const char = body[index]; + if (char === "{" || char === "<") depth += 1; + else if (char === ">" || char === "}") { + if (char === "}" && depth === 0) { end = index; break; } + depth -= 1; + } + } + const members = body.slice(0, end); + return [...new Set([...members.matchAll(/(?:^|[,;{\s])([A-Za-z][A-Za-z0-9]*)\??:/g)].map(match => match[1]))]; +} + +test("every component declares the surfaces it is authored for", () => { + for (const component of componentRegistry) { + const platforms = componentPlatforms(component); + assert.ok(platforms.length > 0, `${component.name} declares no platform`); + for (const platform of platforms) { + assert.ok(KNOWN_PLATFORMS.includes(platform), `${component.name} declares unknown platform ${platform}`); + } + } + assert.ok(componentPlatforms({ category: "mobile" }).includes("mobile")); + assert.ok(componentPlatforms({ category: "form", platforms: ["web", "mobile"] }).includes("mobile")); +}); + +test("both surfaces keep one word per interaction state", async () => { + const mobileEntry = await source("mobile.ts"); + const names = [...mobileEntry.matchAll(/^\s*(Mobile[A-Za-z]+),$/gm)].map(match => match[1]); + assert.ok(names.length > 0); + + for (const name of names) { + const mobile = componentRegistry.find(component => component.name === name); + assert.ok(mobile, `${name} is exported but not registered`); + + for (const state of mobile.states) { + if (!INTERACTION_STATES.has(state)) continue; + const counterpart = SYNONYM_PAIRS.find(pair => pair.includes(state)); + if (!counterpart) continue; + const web = componentRegistry.find(component => component.name === name.replace(/^Mobile/, "")); + if (!web) continue; + assert.ok( + !counterpart.every(word => mobile.states.includes(word) || web.states.includes(word)), + `${name} and ${web.name} name one interaction twice: ${counterpart.join(" / ")}`, + ); + } + } +}); + +test("a web interaction state reaches its mobile counterpart or is registered as a gap", async () => { + const mobileEntry = await source("mobile.ts"); + const names = [...mobileEntry.matchAll(/^\s*(Mobile[A-Za-z]+),$/gm)].map(match => match[1]); + + for (const name of names) { + const web = componentRegistry.find(component => component.name === name.replace(/^Mobile/, "")); + const mobile = componentRegistry.find(component => component.name === name); + if (!web || !mobile) continue; + + const missing = web.states.filter((state) => { + if (!INTERACTION_STATES.has(state)) return false; + if (mobile.states.includes(state)) return false; + /* A wrapper that draws the focus ring reports focus-within; the input inside still gets + focus-visible from the browser, so the pair names the same moment on different elements. */ + if (state === "focus-visible" && mobile.states.includes("focus-within")) return false; + /* Selection words differ per control family by design: a switch has off/on, a tab has + selected, a checkbox has checked. */ + if (["off", "on"].includes(state)) return false; + return true; + }); + + if (missing.length === 0) continue; + assert.ok( + MOBILE_STATE_GAPS[name], + `${name} is missing ${missing.join(", ")} from ${web.name}: add the state or register the gap`, + ); + } +}); + +test("a shared prop name keeps one meaning across surfaces", async () => { + const mobileEntry = await source("mobile.ts"); + const names = [...mobileEntry.matchAll(/^\s*(Mobile[A-Za-z]+),$/gm)].map(match => match[1]); + /* Native attributes (`disabled`) are shared by construction, so only the system axes are read. */ + const shared = ["value", "checked", "selected", "onValueChange", "onCheckedChange", "size", "tone", "variant", "appearance", "invalid"]; + + for (const name of names) { + const web = componentRegistry.find(component => component.name === name.replace(/^Mobile/, "")); + if (!web || PROP_PARITY_GAPS[web.name]) continue; + const [webSource, mobileSource] = await Promise.all([ + optionalSource(`components/${web.name}/${web.name}.tsx`), + source(`mobile/${name}/${name}.tsx`), + ]); + const webProps = new Set([...propsOf(webSource), ...web.props.map(prop => prop.name)]); + const mobileProps = propsOf(mobileSource); + assert.ok(mobileProps.length > 0, `${name} exposes no props`); + for (const prop of shared) { + if (!mobileProps.includes(prop)) continue; + assert.ok( + webProps.has(prop) || prop === "appearance", + `${name}.${prop} has no counterpart on ${web.name}: name the axis both surfaces know`, + ); + } + } +}); + +test("mobile components reuse the shared token layer instead of private colour", async () => { + const mobileEntry = await source("mobile.ts"); + const names = [...mobileEntry.matchAll(/^\s*(Mobile[A-Za-z]+),$/gm)].map(match => match[1]); + + for (const name of names) { + const styles = await source(`mobile/${name}/${name}.module.css`); + assert.match(styles, /@layer openbitfun\.components/, `${name} styles must stay in the shared layer`); + assert.doesNotMatch(styles, /#[0-9a-f]{3,8}\b/i, `${name} must not hardcode a colour`); + assert.doesNotMatch(styles, /rgba?\(/, `${name} must not hardcode a colour`); + for (const match of styles.matchAll(/var\((--openbitfun-[a-z0-9-]+)/g)) { + assert.match(match[1], /^--openbitfun-/, `${name} uses a non-token custom property`); + } + } +}); diff --git a/design-system/packages/ui/tests/mobile.test.mjs b/design-system/packages/ui/tests/mobile.test.mjs index c5515a71a5..931fdaf423 100644 --- a/design-system/packages/ui/tests/mobile.test.mjs +++ b/design-system/packages/ui/tests/mobile.test.mjs @@ -5,10 +5,12 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { MobileActionSheet, + MobileAvatar, MobileBadge, MobileBanner, MobileButton, MobileCard, + MobileCheckbox, MobileChoiceSheet, MobileConfirmSheet, MobileComposer, @@ -20,13 +22,20 @@ import { MobileListRow, MobileMessage, MobilePageHeader, + MobileRadioGroup, MobileScrim, MobileSection, MobileSegmentedControl, + MobileSelect, MobileSheet, MobileStatus, + MobileStepper, + MobileSwitch, + MobileTabBar, + MobileTag, MobileTextField, MobileTextarea, + MobileToolbar, } from "../dist/mobile.js"; test("mobile entry exposes touch controls without product state", () => { @@ -187,3 +196,86 @@ test("mobile stylesheet is isolated and uses semantic tokens", async () => { assert.match(styles, /--openbitfun-shadow-base/); assert.doesNotMatch(styles, /#[0-9a-f]{3,8}\b/i); }); + +test("mobile entry exposes the second round of touch controls", () => { + const markup = [ + createElement(MobileSwitch, { defaultChecked: true, description: "/workspace", label: "Streaming" }), + createElement(MobileCheckbox, { indeterminate: true, label: "Include archived", name: "archived" }), + createElement(MobileRadioGroup, { + name: "density", + options: [{ label: "Comfortable", value: "comfortable" }, { label: "Compact", value: "compact" }], + value: "compact", + }), + createElement(MobileTag, { selected: true }, "TypeScript"), + createElement(MobileSelect, { + label: "Model", + options: [{ label: "Sonnet", value: "sonnet" }], + sheetTitle: "Model", + value: "sonnet", + }), + createElement(MobileStepper, { + decrementLabel: "Decrease", + incrementLabel: "Increase", + label: "Retries", + max: 5, + min: 0, + value: 3, + }), + createElement(MobileAvatar, { name: "Cindy", status: "online" }), + createElement(MobileTabBar, { + items: [{ label: "Chat", value: "chat" }, { label: "Files", value: "files" }], + label: "Sections", + value: "chat", + }), + createElement(MobileToolbar, { label: "Selection" }, "2 selected"), + ].map(example => renderToStaticMarkup(example)); + + for (const name of ["switch", "checkbox", "radio-group", "tag", "select", "stepper", "avatar", "tab-bar", "toolbar"]) { + assert.ok(markup.some(html => html.includes(`data-openbitfun-component="mobile-${name}"`)), `missing mobile-${name}`); + } +}); + +test("mobile touch controls keep native input semantics", () => { + const switchMarkup = renderToStaticMarkup(createElement(MobileSwitch, { + defaultChecked: true, + label: "Streaming", + })); + const checkboxMarkup = renderToStaticMarkup(createElement(MobileCheckbox, { + label: "Include archived", + })); + const radioMarkup = renderToStaticMarkup(createElement(MobileRadioGroup, { + name: "density", + options: [{ label: "Comfortable", value: "comfortable" }], + value: "comfortable", + })); + + assert.match(switchMarkup, /]*type="checkbox"/); + assert.match(switchMarkup, /]*role="switch"/); + assert.match(switchMarkup, /checked=""/); + assert.match(checkboxMarkup, /]*type="checkbox"/); + assert.match(radioMarkup, /]*type="radio"/); + assert.match(radioMarkup, /]*name="density"/); +}); + +test("mobile tag, tab bar and stepper expose filled selection state", () => { + const tag = renderToStaticMarkup(createElement(MobileTag, { selected: true }, "TypeScript")); + const tabBar = renderToStaticMarkup(createElement(MobileTabBar, { + items: [{ label: "Chat", value: "chat" }], + label: "Sections", + value: "chat", + })); + const stepper = renderToStaticMarkup(createElement(MobileStepper, { + decrementLabel: "Decrease", + incrementLabel: "Increase", + max: 1, + min: 0, + value: 1, + })); + + assert.match(tag, /aria-pressed="true"/); + assert.match(tag, /data-selected="true"/); + assert.match(tabBar, /role="tablist"|aria-current="page"/); + assert.match(stepper, /role="spinbutton"/); + assert.match(stepper, /aria-valuenow="1"/); + assert.match(stepper, /aria-valuemax="1"/); +}); diff --git a/design-system/packages/ui/tests/registry.test.mjs b/design-system/packages/ui/tests/registry.test.mjs index e7539b8c92..c9d57572e7 100644 --- a/design-system/packages/ui/tests/registry.test.mjs +++ b/design-system/packages/ui/tests/registry.test.mjs @@ -65,10 +65,12 @@ test("registry exposes only the formal stable components", () => { "LoadingState", "Menu", "MobileActionSheet", + "MobileAvatar", "MobileBadge", "MobileBanner", "MobileButton", "MobileCard", + "MobileCheckbox", "MobileChoiceSheet", "MobileConfirmSheet", "MobileComposer", @@ -80,13 +82,20 @@ test("registry exposes only the formal stable components", () => { "MobileListRow", "MobileMessage", "MobilePageHeader", + "MobileRadioGroup", "MobileScrim", "MobileSection", "MobileSegmentedControl", + "MobileSelect", "MobileSheet", "MobileStatus", + "MobileStepper", + "MobileSwitch", + "MobileTabBar", + "MobileTag", "MobileTextField", "MobileTextarea", + "MobileToolbar", "Dialog", "MultiSelect", "NavigationPanel", From 5d27431cdcdf57398fdba0217c97ee8e03ed53ef Mon Sep 17 00:00:00 2001 From: Cinnnnnnndy Date: Tue, 13 Oct 2026 17:27:48 +0800 Subject: [PATCH 2/6] feat(design-lab): Cover every surface and unify the Lab's chrome type Rebuild the homepage showcase as a single frame that morphs between the desktop window, a phone, a watch and glasses, assembled from the design system's own mobile parts, and play the character hatch in the hero instead of the static brand mark. Fill in the mobile gallery, the brand motion banner, the foundations chapters and the shared guide contract, lift the rail directory plus the page and foundation navigation into shared modules, and self-host the Lab's IBM Plex Mono. Put every piece of chrome typography on one published type role: one leading per size, one tracking level per label, one code role for snippet blocks, and the wordmark recipe in a single set of variables. Tighten the closing band and size the showcase stage to the first screen so the whole morph is visible without scrolling. Co-authored-by: OpenBitFun <318544290+bitfun-ai@users.noreply.github.com> --- design-system/apps/design-lab/src/App.tsx | 44 +- .../fonts/ibm-plex-mono-latin-400.woff2 | Bin 0 -> 14708 bytes .../fonts/ibm-plex-mono-latin-500.woff2 | Bin 0 -> 14888 bytes .../fonts/ibm-plex-mono-latin-600.woff2 | Bin 0 -> 15620 bytes .../design-lab/src/componentNavigation.ts | 14 + .../src/components/GuideContract.tsx | 73 +++ .../src/components/LabComponentIndex.tsx | 77 +++ .../src/components/LabNavigation.tsx | 55 +- .../apps/design-lab/src/design-layout.css | 410 +++++++++---- .../design-lab/src/foundationNavigation.ts | 47 ++ .../apps/design-lab/src/i18n/brandMessages.ts | 3 + .../design-lab/src/i18n/componentMetadata.ts | 9 + .../design-lab/src/i18n/designMessages.ts | 113 ++++ .../apps/design-lab/src/i18n/messages.ts | 42 ++ .../design-lab/src/i18n/previewMessages.ts | 3 + .../apps/design-lab/src/pages/BrandPage.css | 71 ++- .../apps/design-lab/src/pages/BrandPage.tsx | 54 +- .../src/pages/ComponentDetailPage.tsx | 233 +++++++- .../design-lab/src/pages/ComponentsPage.tsx | 203 ++++--- .../src/pages/FoundationSpecimens.css | 8 +- .../design-lab/src/pages/FoundationsPage.css | 53 +- .../design-lab/src/pages/FoundationsPage.tsx | 208 +++---- .../src/pages/GettingStartedPage.tsx | 55 +- .../design-lab/src/pages/OverviewPage.css | 549 ++++++++++++++---- .../design-lab/src/pages/OverviewPage.tsx | 315 ++++++++-- .../design-lab/src/pages/SubagentIpPage.css | 4 +- .../src/preview/ComponentShowcase.css | 2 +- .../src/preview/LiveComponentExamples.tsx | 2 +- .../src/preview/MultiSurfaceMorph.css | 222 +++++++ .../src/preview/MultiSurfaceMorph.tsx | 191 ++++++ .../src/preview/SubagentMotionPreview.css | 6 +- .../src/preview/componentPresentation.ts | 4 + design-system/apps/design-lab/src/styles.css | 123 ++-- design-system/apps/design-lab/vite.config.ts | 4 + .../vite/component-detail-contract.test.mjs | 12 +- .../vite/component-preview-render.test.mjs | 10 +- .../design-lab/vite/icon-consumers.test.mjs | 24 +- 37 files changed, 2575 insertions(+), 668 deletions(-) create mode 100644 design-system/apps/design-lab/src/assets/fonts/ibm-plex-mono-latin-400.woff2 create mode 100644 design-system/apps/design-lab/src/assets/fonts/ibm-plex-mono-latin-500.woff2 create mode 100644 design-system/apps/design-lab/src/assets/fonts/ibm-plex-mono-latin-600.woff2 create mode 100644 design-system/apps/design-lab/src/componentNavigation.ts create mode 100644 design-system/apps/design-lab/src/components/GuideContract.tsx create mode 100644 design-system/apps/design-lab/src/components/LabComponentIndex.tsx create mode 100644 design-system/apps/design-lab/src/foundationNavigation.ts create mode 100644 design-system/apps/design-lab/src/preview/MultiSurfaceMorph.css create mode 100644 design-system/apps/design-lab/src/preview/MultiSurfaceMorph.tsx 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" && u?K%G5`TK0we>7U<4oqgE|L^X$*rj z8;_19%I%W^bO(SBuPiMn*f#0ijzrPkb7(X&f{g=!B742;|Gy8w1P?Xm&z;E?bn2ER0SB@N_|ohnR#tmztr{aO7{OhZ>6~^sZ*r{s<4EH4Dw+^f;SyWMD_#BD1JaCUG$UB8U?kpU?cMV6-Uq|O<_fGpiN*+6o|bB1jrJ2rAzlx?#_2c zgagkjBm=2TmlU1Tnn7(K5WM|?iUDakwSHWmB<1+|}P?0FC<>z&ptG-EseJQc5 zFBw?2b#}sz4`c{~Q(|%m&viCct)AK3lD)!xYEuGG z&U7YI6WD%wR{d6~RQ^6aleH;nb8x9zKC&cxVxk+-0+iUo#S7RHwwMeY;_1g)UR)B% zPD;dT!vEYoEZTIUck@nZ)IvxM{X))62l}sFHFvkN8H85e-vo`Q@nyesFCci_p^Z#w zRAl{{|B2Jur{F?pJT){`Wcrn5lnf!f?d9`Ux5YtA1Y{))X@4Mqy8%dq zAnU9M8_1AT$fQE%6tY|)I}~#AzoG!t26*_935S4oEXnXn=ltc2FFl9$Aa9W)`^aDb zigz(=DbWhZa{htLOR-1Mdg5bQa_HEPNOj0m&?O1Py20#1OS5+!pW$A(yCt!ab8 z4Cb(a<=0vf1twYHF{=m^(?UVM#{*CkK(rtSh|cj^u;Ena3&jnK#;OdP7n{d;cd?NS z?AnFxd0B3=p9&2U2bg^ZDO{}Z3|_?n zM;NSeLajE+aWgD|@B_V&6c-^ukTj!VDHAsIg60q)A($Uf*c;Q-Nc*++E2fL0lk*WE zEbKVKz)$F{fDA#9$d)a84@i8O!*$>Oy`rmro{BXHox|hk-b4J^0umfr3^6!wAR>J= zzLm_IJu#@kd&fv2Oqxqtq(??%lDZ^BI4r~vN6xpCBQjQTZ=5g-gV=Hd9VqZk1lkNO zM>u^_Fk%Qlz+`ZB5Y-WbCM8mYd=Dg_L7aJ-LKG*f{Yab`q6Q+^GNnt695LVIr4I!1 zmJ!DwnxZL0RmoU-Pu9LB4m?dE>h6#*XH1_qb;{(S#0%$-#gQdLntnJS)ghr&{t2!Vo~#c?vkiUkKO zC}M6RL5_f^BLerEK8zLkKzcp}fqCp-MXcu>QELL0mSbT8SoR~Z9T)(BIqQOa%1O{; zrc?g^JtzelY>Yv#0a6M72FT$+0y5xXI0QV5k)8!QG4UaQdzf*+6faGkCjU>_hBr;5 zbLn1skWC?!IAoMZjcKZ;Yo^V@5@9pO;tn$moWT-$l3w$($p12`KfJhZmmo zk%}c^@Qm=K22I)&8@g+*UoqlH1kenM`=HncD5^pCjBZhP*KNT6|Hs7qf8*+Kz}3@L z;#JL6^i{%@J6GmiiC(F?TzlCC0n7l_13Q32YCwzvKn$_YA7W5&jsJ6ths6m}p-UE~ zK#7u+tLBfz2gOf-Mh%)IaF;Ayib~$Nrbv}7O;xhRYS*bvw;Z|HniPrF%0q#_W~g<= zRUBW))9nA4&x*x(~_QnM;&v@adl3*;H-1b zyXcHn`t|5FsLy~Q!zPRxGj7(DX){L5TV}zc6_(UnZoM_uT4#gRO!ZOUxvLaeq-gTG zMV4A@iDe!$NyTWZ&2Y>i9iFd`IhI<9y9=qiV~*z7tvMEHj)R(0iJLxgce=?m7_>FC zEv{aSc)8YyVgXQj=3{NhhV_FRVo;uU?5xyd&!k7P_9V4bAJ8Rd@DXK^x!n04+r&VfCmCJcY{VUf8^_5(%)e4Nwg=9oIyYunl zq84-W04ugC;Fmdmn$1*u{&~*dvs}O6vUA^aI61V2uLf%x=xNplWszC$jz(Nq?9X3- z@Kg`p;7zrs)_9e{4Q+$wEvt4^5~v=n!2-bvzNX?G4-k(-8bxMwG3<&b&xV79l}IXWnsD^wo8Pq2W(e zpWDvx+tFE9NUg+-d5N)=)oVwysbamL#0t-HA3=TWWd}hEbf`ZWZT2D_tGA)N@^K zCj0|SfA@2)wO#0(FV5zSMHkw+n`$8o$I(td3PPXWD{Yhtgc7+ddRJ?i^^&%!c8u1s zUh#OIQEFyU7*V*A9!e&_=n8kfWS&i=<~BCgcRX;Op%<)|B-|79EUY5fy}P5pVM6mT z-CJVZ6@a>R18A*;H(`m8hH6-5Z|rV{2ly{h0-1wnq3LRGd0-@$|QmF9w6QWOca#XK$*$R2o$b( z#x-+q%Ww|pG6#m%D^F&s6#gx_*4zlT0B<^x*szT{hBz0FmBh<3aziW}VDR11z9vOJslu3liO3HDTed(Dvz>z8Lu1usSSUT9^ae*W+@j~GV_DVSn4gj#$|1L z9Rq`+I!+nm7Lq3uNfLuajHWb{(o&63Ajd{he^#5SG)3o(R()AB*AaX{ib+etjbTg2 zE!o?$n9BGh<}x*dI}R@F?B{nhtvLWH1B?hrl_|>ehZAQWFm`uM0IGa*Z^>2odiU>% zI1#eZk;Ik^S-K@rWkc7sD|4SbkZjvvQD}@pEXr1!aQiS5yt$43C2pQDnf2+mjo6fS zHx9;F15^$eAyIh4$P1(-u%-a=6wy9$EO8xLiWQ!X3YLTGj^mE6)GE z@>}`C!-)nG2DrO}OHBfIS8vX-lQ!cgGQIplTs4X(`|x6i&4opB(dt*mp%@Wj@PMYO z<1055mTO-v#KPW@_W8+lyZCTOWr;nKOr&fe4%&r+WzkZ6xz(?EYGAl+9S710tCd)_ zIkU|((;Zc`U|=}=w$^1t$>4AU5MF*1IDSsz&Zf`pirG%U+;m{=U;Dw-{9py@*Z+9v zQt3zRTyRvFmauHN>6k!stI?WVoBBZo?pHzbr$YWzx__^IUiM4*Y3_Lkkw>i| zn1QjHS9d9Yw7J?l%p$sZN36yPe+=eN!l$p<;_Gnu;Sv$w%o)pRq6o>CTzgAh6g!LP z`Obnf4}h(1cm9QiVDUz`q$dF`hvqORVM}p4b#JJw9V(i`puoVm+}gwzmTi6Zlo^v# zk))x^6xBh!yuQYktbpbLB_db8lfheI2K&+ywOm)Oc!T6*q1=f^U0?GP8PV^pO`7x-Ay>nD=PLeZO2tb?yBXI!`w|BVMXmAC zKBonLI_`>DY^f`BgcAXhGic|gU$JNj3qtLJVOazlBq};E=QtN^i`=$87}Blu4`(nz z2t(AJZ8|`Cbb)~u)T=>4ZR%m6UenI>mBS{qFUSk-`tKXc7r_I#-*A~VOtQ3)-@dZm z3>`2TgOhV~y826JjEUUdO5Ya!G7Q;48Z@EQ61noKbbV148b?23cimZs{(eMC?*E(u zWIZU@``OyjOYX4cPtH;nj#_1|^Y?tT{tst|%bt|%o!&Ql02l9>+M63TwC`M5z#@3J z>aB>gCg>NSs{u(BbpATLD9|V(B&w!svBp}?g~t(_5Ai_{S;@%wzdVY@mcv_Qh2|;U z?So7MEsi&ad2`6&0EXj87=c7f0K8TToWZ4-P>WYwuy=)?AVY}3no~xPN+__ZCZ@iI z0okA2$hxRs9ImpycyyP+E(?1vB_~k41U3P!q&%xoQVAxMC0vyKQpo;3tBlZ!6GAU& z3e5LvG^{&7#Nw9p@SR`sXl0>I&!lfYdvACSjv}yjTJ67@S!=FF$y%YRxv07n>#Q=? z`XBrmz8!?B-!$o@YBuMJe%F=)2_h6l__F{tuwkoef64FFE9+m9F9}&rLm%boD?GC`o}6 z#C(3IN%972Frwu)(%kc`!zplhAmflqWas8wi+$QYrYRYNqbw&h=SETrw6f?eKw!ta zyNp~Qov*Z?0sC6Xnd|5q4w+s56tb*W5(1R*0>8B7@(mhDf2Qy1)8` zem4~b_if6Iq4|T=4c9qP2Tut9xhG>s>wJ!J%-y>72RB2*-~6yph``7qScc}wMr-n* zvDko};-o~e$P zG3HO|5Cv&0+O1Z0CDa(s9tLTuk-FyRK!2%&`+&(AkQt*BqsCMYf~d7Uh=beq?i_qv0@J zL7%eA$m`#=b^EC@g{)(GZ|j#OGsTig7<-lYr9_b-lH_ZO`s-*AqcC%Bk3yYZ9aA&6 zW0rmC{)xjBnN(YUZ+7Pvh~IPtMmXn#bhAuf><#}QelJb)7E4IG3RsKuSYygpDXh-cur8C<(p>K(Wu$56MDXk!&Q|sCVBj=vrJ)jH4roCbOZ*1MKe~1xFi&Vx3|iczNSxX3{79 z@m8+fIoCRGpjoG8e%zZBnu(&Ddf+dGZV3&ob6WD*@}mj36U4Bwopb`LW6&@}Of@0y60O$Q3OHj+Pw-;aiV_qb4!js)T<$#$rzZuxl!g zh8ghGT1)iX$%JlZpF4p>SafdTd!`lPV~Yx>uK-^@e&69f%rSHu7n>mFYK z547@-?soA|g)zWTEA329*7sZ9X1I{UKdDJf+P9sy47RbBW#lgTuu4HxX&Lm~i1?`^ zk33A&(dlh6D8xFUbuTyv1)>%@j{gAu(9efi{ef2_-9o5Z7u?nG=P1l=xn2A%yk*{T zM`0iqW|DuL5coIPpvFQ(V zLS;h4b+I}y_EV@P;?XEg|H1az>=q{4LIH7f^p71lzzNOa8uHOOUziAk-6@j5Mm%|;Cb}&&zTXo1!D(^63|Ru9@li|l4@Qeo<@l%!F~7skBezaF zvG-`t*leCDpOv%6L>R13xr&FJ0}aihRDbby7HD(p%&UQ?CC<(x$}|-;4hhqQ)hu%$ zV83ubaPWb*Jsv|8VoebnitVcNq*{y9K=|idurjsQuCdn7=cmj<>&9Kt8i*IUP zQvP0D7405}X4)*GbL9`?SJjS1;~!Qy#+!Quu~obEDl%_$VR$-d2bOm#~JfE;tlm-Htez>C@U0RCKF6M=DXE~GI={7~i8D@(IA#U|Sd8dp{3zcdDh#A7zh@|0`wrF$z%_r|AO!z{Om$9kZx@zB;T zP1v)fbPc>>O<+mc^XM&K*fr>13imDb4wl`9x~69QI#L-u25pA>H%E_E{5p~mqQA8* zq4ZjA+5Sh_ZmlH#5uaXo1j$t-)9d={bhwOWCJ(^?8$5TFhEOoYW|h zNkMMlmovd4eI|?T$k|BHF^L8f`Z)Y-X#G+en`nIz#ELn_%*f)`zudT5k1en|0ZqKx z4miB#NblmHmHl<^kM)N7#bp<5sQ z(3-5+`3h|`V+tw*HHBeEk{e;9i5%>n8DsZu=8~f3&=)X%C=|YM9%?X)?8fX zb5X+i=XOah?sKiKMXNVjclNm%Hd!R7v)>*20*xS;Lw>=MrY`f*-=64$H~UQe&G*v` zFekZolnZNM6%`oWAdXT~yvYZP=)n}JULseTbl0rt74jx)J_eLI9Lk?>QJUi>wNKJx zmZ1Afv4t=wZL^haaUZn%<+TN!TE8~H28Y~(?#;IHbGWl-FNv{(^3Cq84lu#3?k%=5 zbogZPygc>w*z0OW=Ns?oKv6GdZYF8aBkz)yz`jhQ8i=4%?F0=(YTB2uCDQ+PqBW$= z%!BGr9gM!0h(`qGFcnT6W)dDDUiL8@pQ=rHZ)Dec=YQ+y-AG8u;_TD&%uP@afi*^FJSf&G={dFA!Dv$;8b^TQ?=|CRtZ&)#-sH# z8uR$5rzRs)F+hAO-cS%bl~;_*!WHwTV#mJ-G4wGX=4TYm&bG0s2CNd57ng@3OMa@^ z{+*xmgya8iPOeV70H$4_&gIUI@xYxMoEzXVp4q*7s|!BLgMV04BwoN4J9%ac#_1sa z_~FXtMVTLAv}T=ZH&uGzJ?4qrM4pO%7{^%8DzkgTJ_riiY)}~TL1C+qeT$`F-D2Ba z`&iS-cYoSUCU5@fF4>FJa9Y^x7S0b9@O_^|Wv#o*+HYFix9glPOLtCl$9l&z8?XCF zY;pgd^wqcjPro8(!F>I4%56*Un1yd*C==DDZ*dboIA%P$+80U`xAF|tI=QkV6Wfw% zznE5Ph$^VEgGbUK#89l4E@n5HAPYiu^#XX|s(sc1O_c&Z0CN#*&iB^yc9=8Qe5wG} zyY1U!#_A(dMBL|3_x7p#B+h>;1FE*X`TqG3hXKXA_PzC1U5Iy?zHF$<1;sRn%w~PR zTWBYjH3l>lVhWo8JHhNTj>(1Kmxx?er*Jjcamq4K-q28P`BVG8uNDd+0=-zP)h`aM z*mcd!nVI=aO{yN6F{5UvEU7_Vr-i$zt5@vUhZrpO=>T}$Zj(ad2`vdpJc_pC z-w6i%!fu`t%*Ex2t`~-H%DyqYAUKpV6P~7YFErqfgMU~CZ4E2PKv6Oc9Wgk!WdTeM z-f`ZK6T9lwQ-2(_7RvN?or95~JMfv|I4KkVtoz~}#2i;NC(#3M(eoyi#XJxZl^V#a za715oWkqbQx)y|2OFcghgRHDqH|Pe^c04KA+)xu&tM2XQEz~Ffc)L4cL!V~sX=TfB1VzW9!&VWV)?4VNjT+_CF;FyAA-$uaX zS|ZI^+}R zB%1rfcTt&oseUh2mFlfiRuCy!*a#sT|GX!M@%_Hwk}Elx%hFQbd;dr>P?}slH2s|a zInnz0X}|JkxudWgHAAT zvvOH<%Yh<#q0!&y4+gqhr0+G+%mE7v`Jpru%lJ$ks~$#!y!7{}#*8YpDkDlY zzE3aIa&Ry&z+l>}ogL!0`DSjAlkti9v1@(pQUPLL?X=w8Yl{tgHUx_IKB1 zVn9@hR_Z~0$geTw17*_MH1NRll#@d@SL%Px?2=qqyMA1ui(^j1&fhi~HvYBK@D?{v zJAH7*Qhoo~-1+wfB2kaJGN-xDZ4%I!Qg>|ISz(3NnqrsM#hf??T;0Tp>s0-8NIMia zorNr&&M7{ksM(Y^+kM>b49#9mnYDATh#2aAvz1~Zo#)us2QPh6{M1_t=JXxdw~w83 zQp-~04am*(`8t|M2yuONp>b&C?nq5zSs4#KvU|ge-J)TxkUK2e9gUp-(xqD3mG=b= zyeNNRLh$)j372rTBk)-7#(4+K{Fa0U@&5+2pr(pndd!?An_GWA1s#h@A&?5Xyzb)o zM>w@k$t@IWw^Uj$2J#E2_FQ;A6Nl}(u`T{IUuNd9$#-AFve9&&U9OkhW<0&wN1{&0 z#!4J)!8BI2K^^jXpf|_}kGCwyqdaA)SWhYa*~t~@A15Xxew_YGJ#S)T#n-{Yz9tEX z_40o$J-lN^NvH^sxf_N0ah+k?DUoauomp(R%a8J32gudJ^?yG(&L&_JbK`Qw1tdO+ z@J)JL)@@YLqLumKLh*m}g26NJmnTSUP}FVQJ**hI?s;6qEz+uH?1s(U)x@ag_uQL2 zn~cR5pkXK#CihUcDQV)0U~7Ck;)6d4oSS?Bpz{`1D*=;|JY6_e9(Y9yYzVrBmy_5d zTmh&%fK{WBXf^iR9TsR=dT2QeJA8L3!^J>_=)6Z_ZeUw!_S1VrUe;7^E3!b+e2*Grd*%1#BmmG&sg z{iZ)LIepWQao4Yq-sH~RaxxEG%3f-~ugglwT8Fn|H*oRTe6s}FWhRowzv*EsCLxvo zGi}+W_L<)=p?a1kZ|l$b@cq0s&nq(n{Ua&|^&{H;7&tQjh*zN}T1bCURk0IwYRRSD z*U2&}`Khj*+glfU|7oGV%gtHcJa9`^*BRMckQvFy^RdS?6x>Poiyh(o%(!UXJv=$w zvHsTnBKIxAvtxXB=fA=DftQs6F`}xRVlgU`$9V3bW zMl^8(RWYZZvTs8}Ty;8SSVhl!)6Y5<3oMZO@Bvz0%<*Efo?-vNz`rR;1RmSL?_Y`F z7eWxy4*!vZaHF6d`R%Dlo#*S(j&3j z47&u79j*nbrG$cZ#09CPgn|JC45cfzlu|*!T)I+A3FWMNxB8;n1Zc|mti?l9BYvd6 z9;W%R{zjPQC;IF98~U4JnxE=#g=zgWey+cvzoowurs+k&8Tr$Flm9ZCImE5Zf8qZ9 zdJF>z{yTleR4niHtKhl*>a7^wN-X+)guLjR=uF9Qxot5IOSk57>`5<#(;KfSkupur z;I_<+Z<^f9WE(KC&8aJN)I4iSReVM2kGM)Z?=SEOEqpCYRD7%Ps`q$mSaUMuL zkWT-)8}O*z_%Gd2)yz@d%*oQvx!W@(UgBW9j-5PWxEn#oHkFQqJg%yPG$A_%9(ivT z_==iGF38ik_*!c&X8g$gfKj9?fgiaaz=l#L|Ac*CCPInvh{4@n9u!LPvBQtt57?e0 zNV8CCDuZbMvL|~qC&s%cqbhLXy}Njn%12RpP?V%<8^)cYBMlfFlSNZ%G(BzF0GS#Ep)EG&?N|SQ>4l9g7MY?v z&ly=E)?+k53;rEj*5NlPOs?uoMXNN?KEf1DTGr3KqP`jnWR7t$H zVDt^)0}UReApIa{mC>8gB{OD%K~JD7y%j2m#j5dyCCNglstVM&sz8!6zf2}c73BT6 zn)y(?LgmRSqCLt`G>)=U0P0DpoGbELz$KP!E0FV`nH9u){1S|3LHr9Pq^v)qc1>$w z@~hU^rmDI}WRLZU%zv(jo~ZaTmak*MJDm`HbaMJHDt_VQV<-4T$rn!0wo9^$w-LEnssgB{hX5igxr?vd512rK=LF1z?r8r^`xb`>a|4*Hwl%0PPhz!-7hir zy*_E7r62e~ba18@B6Vm-#=v=9x>|V>_bT|HR!)bIc@~uB&GDj*9RNA~4k=PBm)xRG z?7*+^&jljLdBU!)MvE}fNM-+NVPd+L^S!oV#t}s9&@d|c3mUWro|$Y#WDKjF5LQTe zsge%~iDE4DV0~zCja@o{f>30`T)2QNlxYfp&WQD6ao>|g$Q#E2g8K+y$nW`llKYey zPW9U`5^m%>@eEQo;#r&7(q%A+-&N#IHnmoX9k4$fIXKQnW)q_gGqY^8^ujj+5(FJ1 z8@Z2_QTG`0{mMxHV7`m->VZ-8H!bE0?Y{l;d+8%?7hRZ`ncE3$&D}}AR z0>C49LE@%fw;-*G_x)alSasB7GF@asyFuwAv$Fow*3^T?AD}q4aIQ57Xy`NywG)rR20c%G>0jKeCX620H z4Pptu8$*n;bXqF`^slR?kr+F`$c^LR>*gHWB}_Q5S_=b@4;VRRl*RpDn4es5AJ?KD znAj%FC0iP<1SUeHp zNi)o%VXB@ju)mu+K5nhMYO zE+_!ZVN{H{d*e2(vK4uIdlE;m4+Pzw*pDV;>xqAxP%F10nfc13jACOco!(Rs8a8{6VEbe*Q?>WDA?GI(xH7|Z3Wk3p=*RTKb)TAX`mLyPHuY3 zW9cjc5^kkf_1RJHOdZ`FV}Z5wBpoE3p6jUufa%I`jCa;6**C`3sSbT3rG^M*WvQq z|HW+1HG`^2 z24a+U%ol~w)jW}o73ys$lO=fsfwGq@9B2`sqR*frUf05x=v813<5Um9oPEWIuG~4f zb|kcJT#?xtgGuVcIEN?mq}NLm9=Jt^TXmyDH#&syPO_%pSe)~O62^@V0V!AqM+^WG z@m3MLiIX_yoio6CS0)*KghqVhL~EyA>ZK)W<7A`t7E@_mS+uP(P2IR#kOpYkHLP1^ zf^je?NzWNwJr7{^{37N!Hjz%Og)L{!X>NVOLOj(h^eV>{2#h@pYphQHq#Li1+;o@2zPRy;4;r6(wq z`%!fEOM_a@Hq)w0EOGx>Iw*V3>+Fyud&)g!aIbvAlM}gPx9*bi@O)P6J(e<#TJuT& zOVRL9@0W}};ftry-H^OmyD%cfo2`w0{J)R90{GT%;gcd6KOqhykPkd5D>*rUaI0y* zh*h*Y>3b(V?$w3fxpy(^7Y?9$-t+^96;>NiELst%$x@ajc+3f63IGSxeybpe?O;}~ z5E2x%&)&p_ej-X9YCxHM@zM6Fsm`BH*PST?G2zV*nV42oTTs1sB=KLyp}>yE7t@3jgKdfks=yB3ZML2}va;3J^Xi ze;~;m;+yic->_IKnIRo>qID;tz|%ueuo6zUBwSGKa-r(ouub_!v)DixMQ%V!#zjRHecxvaUAs*l)qYau)^_n{HkS_={hP zinZ7i{L^^b5HA=0iwwO%{7dF&toJcBLjrW>%=x1EpAESU1;A2*Kx;WpTTaLhxb0xo z%!Ny^x^K71p8UoOp9oCSe9CNK#N0`+#meWLxuMpL>;aen&Sw*Drva8_`aJP=JU3{j z3KE-$dM7|y2yWo%f;I@KbG=&XDZfa)#`zdhs&Cu9%8S*~PfVlt0txzo)~z+7sf3i3 zUqIR>U7XoTvc#?lJl}>~J*#VD&!X0Q7bI+TLXDUBLEMfCM|ZK~wF9t|i<6j!Vj(#E zopOTllxoKMW5~vCzPlQmL`FSzaJ)K=Sk$a!J<3%C)DGu&Bu#PP#GJd=$WwXnQcS%F=E)>*yyP&>PzJ<807pXt(!(v=ou8@FY$!`jOg3Kpj%7zoX()b8Qkb1z7O zHNDjm*FqwNxBNBb~sUVV}Q>_A~r75M{=uJFoGpu8vLR!*y#{zmzPj($a zGDT!P0gG9W5LR;>;ZBOLlNz&t2PL4rzE)?}@yFA-0p{{ahJjZ2d3djGbz07d^Hix0 zaVbJuA9g)8v%i*ch}Uo^s_EL`7s#B4bjTUg-+Ueh40CgX-&W?OR_hTfo({fpgMLn7 zO2@_~E?&dMrjo&~7%XP(y1Hu&ee`yXk6gz!@b-sv z&X}=;?M-*5pa*c{-)?D0IfYMy~4cmMC z{#P`vtat4g%%L*R{j>uY+ ze;sU)AqZpW;!gu+easBNGQO4yC1er|Z&-4=a;@64Ytu`R8o`Qn#iGTbwS;HHJmMZv zYRSmlwrAVR>fI!&RPRpX`ph}bF?sZaI-eo7SHy+oj$SvMU|A&Bp z%HMec`48aIlD$J*`M8aB|MTDWEh>e9HW)yFe_Z-@s%DaZ4EhY2pNhv*5Xla&rxXsf z1jOQ~(x_YmSHwZ|3De`CqA8<=qaxyi7mQIRA&Zm?6e~@4z@VlSn+|sJgOQP-q=!o( zijGHQiB^ncDW5tUEXu?J~L?wCfab3^?B(ARdpm zm~s&iKs!kA_u&xGDhm#k9003vmlZ_GvkFGcjS9}*T!q0umlNJAXAMX#flU&5iV1SX*i|ZYDaV=4m$Chk}F?(0r&Cp<|<4@moB|A@S**3CzT@mR;cox`|K zfzH$j{`NS@-S77I^jV5m-qa7{?=Nj|%fY52ZU2oo);vwd-#UF?m;1CI7tZz%$nNN9 z`|NCb@6KbCt!?LtdQupF*2jMF=QeHUKGo-`HUixs5H%6;L=e;u;Y0)?URCeo9rq3g zj$Clb71un)hNpRkXL)XH=j&^))AwmTI3Yp%W+dYzBrN(uVPOGWnX`5sBt{Vbzy|n; z3`NEGyDG5`TK0we>7U<4oqgBk~kX$*s4 z8;g)7Y@0^(;5-1)W%qZIqX;$*All&dD1wax0vS0<_W$3Glf#Od!4D&*z_JoluvXSW zsEk5)EqB|2(WYJDMSUVJ(l{cz$2~7|j<9~@xk3Ech{cHv5`@CB^sGC(EaM3cv*rEh z3jh35(rOK4*aI8S*z~r%^4DiP<40)eBVL~We!o7~KCg$t5%vHoB(jMepNf#taX`XE z*bl}8sP7S-IkS7&fG=P2(&34GAzUq`PAR8fWPr9An}C7abRs#B5EKvuiHM_=mQVz3 z6z%CEU41TpbJe=YpRVg`U0=1|zKDmvZ$IY+T$V^2rRhrR3L2L!f7#8y(rp01EP?;G z*1z4NuHvqS>FH@6!hS@blPs|g2=l)%3wrtZkZ}8CfP~9Ceo43=;o_Im-d)*Fc`M~m zf&zFtAOTpmT(0Sy8PCKMT&hGRoK}Wc`V-ts0L;5zJ842FypD$gk*-!LiBeC*CTH^H z%(gF+O<5|pEL}E4vaQ?);m2(0u%&B|3n3z&s*Cpl&bL$rFwkHTj;z3)Tg1A21Q#lH zEpU`z8m?gE|LQa6|G&-3RQJ6qNz=x%TGIy7sc91gS&fF$T}};2QvK?;{*=C+{y-bG zZ6Ym0m%g+snpuR{oH=qt6eJFbf*^3X;r^dZv)!>gJ~;&ZdLZs|ZK~1Vv9!CR5qK?I zU_$vomP{xg5$URgS4XH2VnEK1A zmLSm!;sQlG|DMNwieI|McVgeja0HDcAY4#mRAk*yiyrMsZ2VnQTQ^hcLjefF9|i8HUo(CPv<4U#U#NnvN9vEogKYxgrJ`edh&~D0LK7i9BP3rlzD9sldLc#&5Oay-YgG_KNvQ5@Y*fi zn$StW+(22vq{-6!n6wwKT$g7}5)EAXmW$b&;+rN46u>?#aW>!JSnkGAY~TdW)RM+U-d0F2oSBC}Y4X9s(sy_9FI%mxGKD-5 zCd^l$T2gSN%#LFd$dk?&E&BjWCSwID)KvG5Nf9ZFW6~sv63Um!XLJ6Rz+NGf=&*tn zq||Gfzai^hFtLmkq}&`9^G!G2aQ(GuP^DZM3k?ZL9Gh=ODN3K(*L_Tpd+!Q=ZJodD z4}abYd&Zf_6f0Q43RbX!6|5iyf*_~U%rj-lmWu&hh*!--Q7H2aOj$4n;)1#`f)gwi z_|`UoPd%<+$H&4D6qux;xHtvrb;yQ2`yIg(3; zA;49=dRS^oL8wwr)Sq*?A9UB1;gT-|Y|9=3zpx12} z^);Y+WG}D~1q$eZ*(d~N8fpSs3ruZMl)U5q=vhLRX4k<^qG<=~q zAIOLDHa?1%`7GYfZ(KyOPFu8S@pzk$+YP4R39=Lls?uj|#MG#LHZ}#?QO)LFygwg& zv=X2BjEQe_0LFrL542@~))Ht?G)Nsx1_k*4AHo06Mn%Brc+@tk9L0_jMs7cn;R-a9Weyyokg@m>1W}#tJ#SAdn zBvS;bMZ{F-WbhtPGqs|+;-j9KMj395w(9)_9W?Wru2_?|(3Lq8UA^{h*I=su_bq&0 zK6_Per$Iw@+hLbI_B!N%gFbh}un~vtcht#ajyrv#*Qtx=&R@86c7p}xn&(6Fec&S> zTkKN{EwbEF%Y5b&1J+n+m33C@v({FdY_`QV8*O)HeQ3H=#;|iJ<5bE8X#nt3xRqHT zB4DFJMMy#i5CD@~21Ck&rUPiIf~Fg2abT#u4E3DhJJ9bsL3{f09>mF44xxM0cVKVs zp+#@qWIADgAz#u?w;P+Sbt3)m;Oa$*PPD(UjDtoVtl=lu=f^Lce4g@5LPMi%LM=}XH`1Z!S_695ed*M{|k5Qyu z)aiPq{@p|ESjTQ%0Bg1KC62?+%QHz_*824`tgM#?P8n2>>Mv5;*=lRnSdh+nslaBd ze;Y5QldX;~d~VSEC4po>PKQ?q8^8ssleIt^dBBN7PZvu=p#()_CBardXwtGXowWU9 zHT`;>BJSQw1Vm}*Sg4xYG(hG9Y6HkBFslVH{}=Q=gZd5ZX#lwfDQ-O#2AOn6C8U+8 zpT@w`GR1Zx@`&axm%PU$o`BQYEy?x#SO>sWEZYm)YPGK>5j=2ZoU_tmge|X7f>%#i zm00tIT2DP%0d-2tA!|zrpH%ATb(OHII>7hT6iQp2#HU;L`zt-Twk~%{JE!J(>U4j? zaG&PN$6g5aqMsEskH3!?VVqDA4H*oi{Lu~BL4On0T}x^uE|u5;)5*wF!w=m|*Y zJa7)xe#4;4}lo zE2ng>w3D-O5_|g0x-`-3>1^R-@cA znuo!AmKpo!{RZ#*L@`W4L`lNYllGn(Ny;21w98U3l0!Ju;aL^XUn4&fQliPdnFYZS z{$Iz$K;TuPLY9FcuI`6gX5}2&4jT(_RVoWyf$NYM zCv9)&=s#g-Z2I@U7yS)gaJLR_EPuM5Eh!}nqZkS{}FfVo2#hJ5*55{!m{wnTI$+g0m}__GBBSddbEhVDTa~E zZ_rv}wYygg!Q%RG!joo0=M0&<>e$HYJhrm&fjgDnrb~N#MeQ;b90`I3Veww5b9ZDX zqwxRs0m1bq(Mp0@nuRw(3f%)|?}RxEW4{DkM7s z#do=f{LW}Vl4QlPDkeMZ;Z}O`mgG56%1Up}#qnB|&?qNU)!ooAmZ-oB+9>b8G@v|M z`B(-}%&^yTL8uc3Dj?x*WnN5w3J(2@NNc6IO;rm$4_e&=j`d+MT%4+hDmBU#5XwPV z*EUQg7Mk66`MBV<)v4J-ExM-?kCF0d%6qoMe05B_L3gvkyO*vowb^TcE2Z!Ej~Q@U zG?$MJYo0_2fp$`0)>d9xwLFq@BVcPSh9~iMe_TnAp<6ciIhqZior{ASpWF(wCa|Gp z6ZP!Q;ZG@D#GXw0*ix_q+An(?RN5@2N8*DV0$t2|>zk)ok7+d9mu6h;vGa?s(i$h< z%@(kdQSyYwS1Iyu&P%<~6X`&Jhs;-b8a!n>?|gLMbUAe#2rupZdIuwQ;VSxH8U{ew8uucS}JDE3J_8Z z($U&>=sJTCYOQMYdcey^gdpm(bU2?L+d+l(+t)+gEdKkEky(agbaHwS3Kq>}laDs3 z7t*6}TMXu=Frgg?2O(E9ZQVH3{wEZ5pMH9A`DESsrDkR&&Ka3~s8C|` zA40K_!%3Kyz{;UCMMj5A66G_pQFYdI&hHTkxCar5WX1p99fVr_{q7%5xUS+^RA}3qJx$Az|)vDX@cWu%;;ut+98H$-;Z<_buZG1X82c0iiPdPVmG_ zG~-jFF@TrKeykkC%3dt*25vtF5bIYqQ0w%(cMGBWfF!L4S=jDniCL@=E)u35+op`V zv4i-}tC*Oh_FDSEZ%%2Q(na+O4}-oIAU;qV$Y`sMwiWRN%7QHW_6-ss+nP%w>&c(x zD0b{=I}(UuVzEm%E!)gdUslJOjk@yQ$antlwB*A!wbOgA`Az;il{~3`z4_kc2Gtr? z0WY)ATj_dL_fH#Cu4g5w4?OJiDuWPiD+xmamoPQpFQ;q=nEX1#Z)D*y3c0Uj(ASa_ z94|)0iU79)P<-~UDVUJ|hq63j!ab7Gsg=K~>`%hMZNZPi%f*$1bx_j8pT9`b>AlUu zVCew?YbQ@3T_*(8emu6iwICi^pi07kbEKJfhwHnrB{>*J)q{An7l2C#W<8x60lrrr zHR~yfIleNW%io$ObX9E0l8DuCcea~Uqy6`#p@V<-f^BB&vP%!`WGh5xcW&UWYSy)I;`ZDy1z;XiA~s-y+z)jB5+WZW_S$Qd4pp! zg>R38N{(6&V*?J&F$7wKan>yrGY&Li%T5pFU@#y#!7^kiz$m3ZEuZeC=6U`kD>?VY zYX}LeGILCTizeH;R5^P4daNbxD!Ibi!${vj3=X@iE36KYIkWzE75z_TMFR`llMHFR zlV+Ewn3kZZ@NnFoNHIJuJUX2bF;B4tSRDidly82AOWn*f_W>11SpEs z#T`Lu-4NwKqQ7j?mW090e9q5~)n+zAj2S?f60sf~*Q_#=qsGtdvYWupA&m=W*oWxI z9@F#>ho?Rmjm{XGg{_l=Q-Wk>?S{3uFSuaENGVIGMMvUYVp@H%g`Y5G)KvSrbNlK) ztPFthv7d*T+VEir(z{F4@M}-J@7@07_oi+?`TbfG)3pEl{YoEi+X{8OsdAxkM`GnO zbBT(x&~8!oZ~{L`V@KD0x{E1qnjE8)J8n}Q$|Z}W@V?5B-ayEk#i&_Tv68=3u9o?1 z>=^FPi--<(C=d4Lp!)7+utmLe$szoc#Rg8i)pzT)E`hb5Lt{_R><=V4+R;~KcWP-r zq;}iOnX>HEnvkwL&33zQ1UjeB`TeD@!N9)FeM9-CNytQHUb0SUn|*U z2$z|GW_x?Au)d|_rbERG)XK`LTEc-8wU?^pYm2Esw95!5kDoi>gEj*^ajTBzbhAT&#{kWk`{!_7N%kv4}`h8}J z%A~uJy^mh$PWSnqC5}U!WuGjV{>7zNqM7^N8#RX#F^g}IH|R%wFz7#RECPqMklk15 zv@2flS6Rg}maDWe92R_r!;r{b?W%Ke*okj(gA@LaWi~Qk;_xA2G%=W5PUMjpOeVW& zvB{y*nI8$Rt=TA-+_kpTwT$;19V zZACD558tYh!rfE}Uqz;79SEQQr8M*FFG0^d9X+KnQyrahU0|ll6MEw7-%ewnUysz@ z<8K6XN|S`P#@~wT!cVU`c;A2DJr5+l)HfRQBp@RRaH9Urp_Ep`9nCdpMb-yk;!uh% zCne0;lWyJ90PIOG*%L6P^y@xO*MHpK%RCPG{NJhCDBlL<*|gEFL+?(ePqQ}j04Ns# zn!8V>G{39ms5o+;zn9f@{hzkdTgu%~RPfs>#XzxtTj{IwyRPwuA7Y)h%frimg&<66 z9?t%F+3+tFby1I8YGgsKn(ZbA*2ptWD9U`}5gp~`bB~H9y=)Zn>)841I)2m5gc3G; z+Tk5LrX8NmwO}IleQ{#9_`!6%MVqYbs?;Ve_~{SC-ErbRd&Gj#mJWZ>KW)$8;Iuvc zUt9>N zcm~&sjVRW~iCv=W({W~1va*Fe#*yZr%2g(@a*q3l>YA=KC9k>3ktFRk4ZB9av7NDLBqqpF1DT=QPL{{uWVYt6X)rU*9u!h`W9Jo#`(qKQ5XU zYZ^(06cPRr+vqRT7St@LNgMs8JvK700iN0jpFR?6-HS>`SB-pfC7nl<0n=2n6+zr= zuLsx9WMdkcz~=ED0n4dFfIJ!PtXyOu##VNf*Sbd_@w%3(s_H!%UxH|#RaeQVsGH?^ zw{x0KYLHM(HepFQvd>oA8D9|_uFrC9Z;0y}NBCfHi?q($iP>_A-wqoS_= zO_Bl&_9;7zx~YL8vBn@3G1=b&Fr+qrA&VTFT9b5j@RWM}DLY-FQ1Q47f-1wD8P4+5 zSl9%IN*KY%V!=={bT2}U$oCKLV1V_>cXax@Bv{Ybkz5XBB{`OIirGwr$)*V`H#|Hu zJTX`y`yM(uLlqshwhrJ7SD}_<>`k+;X@Qg$%cGY>IKxW~i_&ldcN?bK=b2|hftjWA zx^Glpa7{H#GERdSrW`Gv zaeaB`EI?RM28pLsq_6Ur17i*8la@?$#h~lUWchwlL*|NOp*wHVr23kc7JZGBc54R|y?r6=et|lt4wx>0GDf=hwmKu7U5mruYT0R6WF0vF z2Sh&NhkWwCpB4UH+flM~t>V8e8|W+a*e)CDxsLygMa3QN1L&KOMMSIEq?0>avu=^59T74+~u-sPAjJ=cfDde-?glwXBmR zPV7Rwsfd}U=VTcG+E%)mc|k=8OK_f)N&i`Md2$RPfN2 zXA{AX2=w({sCPY`@CZEd*_IGob;NGWd24z!TmBVoyg)d1Y5#@2zpdTB^%ky+l+DY{ znGv0nayN)dZ-+e_8J_jgq?~JWP+u1E`-$s$b)A-|ptI=;;_>UC30=OD5!e|G3^2+! z!D#b2`_H@?kr}!Fd{P^!4P%-cjPmnn>VrNjqL^KQtrF|{UsJi;DMZ9S`WzR+=;W|O zMo&ghzK-_dSoKT zdgpu>;xV$7O-!eUHPL2e^)hLhQYNrjZAU<@ms~GJp&#Y+bHj=Qc7NRC zDUVwPYEG(}v;~GCPogOEns*)Jf34R&FQ>i7JtzNKuy;5p zFzt~OpNn@hcGyy}Nm;?={;qc<{=*T<(&Vz)>@~Es+!-+&mRpOJzsSh zra%r3+z%k%Zx~VPNEO)e8`5%G#?LDTRH}h$)fFn0DQCsx>Ob5q1fh^9P+{7A!R)&( zt-Sf!e(&pLktObb>UR9xU^$p(InbEv?##%+P!1rM@!*m324@UeWa<(;`p$nG3Wf9E z9W=f~rq11|J=q$#fFL7~J%7%oMoK&NA zOlw1UZ1o4t%`@Q}vcX;V%BggeGKN0?v!06n$;zs5tJJA=+f7`S@pAk!%gAjv!e-4G z*|`(n-Lp7Gq(O7X%aI-wYRrxd{S*63_e=Ye487T*5gwF!Id?P$BXX~)>yrW%Gn-K3 zwI0t=6|jibS3aE3-Y6m2REsn;u2?HolQBcbyc?mgRNN1-OSe+%<1tu<1Co#z8+9n4 z==T>fl%Bj#k?*OGSESdvYt_&SZjnhn<;CJ}HlXz6Q_6gALm~#NcUQF2e+*uT!%$0^ zW9Q!#`nG7i`&aDuP2Stuy{R>IQ)|cG*2zBQYn!jS=EFpQ)#3~~`~o50P9_TX=n10% zrHpwK3>G~tu)8KEW$$w}3@4qW+GW8^Tpi~VxpuJ#Jg63+&GZ0M&tOyN+2r$i^Jpu@ z1)B=QD}RxXdn5hU>a5h*My=wm*TQhq$Ver48;S|+_-OuPWX^}^8dfGF~X>X(nM@f=mSKdG?=f@5G!R8Tu7(}$i*@ne`1+H8Cq7MSnm~K zEyYW=zJZJhNG5HT8T5Lk-l&&lX>;Pfw~F^_EUnaNT^w!V&C?0H=g5I0O8lia7iVV4 zCIHv}ea~yTGs}O6*6dY~n{=~uP2@E4=ywI;NmS}2aY31YrlMAZ3#?9C>74%l`Eg_S zlievHRY4)(>}i$#lB=PY(g@ifTQKz>>2gQhQHj!|U+5j0?=D|;Bb;7e5v>0((WCJy z?0`^?k~vCzIo@Sgzu)=cyHQ&I?c=LO8=1u%X&xSK^{v0T%(v{(dLIna=nf>yKTy=Y z1y_QdWz7v!;@R$H$_?0GKIE>~X5d4#AxnC6^-Fkg~*lcMH_FzZ#f%*_odf+HrR zp{FAK6&j3sg~mVV(xGo?1qzj3&OyH26R|r*Cw&+y>GgOp{1%3Hj{!X4DlI%F`O@zx zl)tGZPKj2krZEQ3H4zCJC@@-2EKqeZ;zo73tBmwUqrS{l-5?|l6{-t|Na-Gl{_LX} z=^00}f7mMyZ!Ev%qOYqL*mIqd--5da6P{cBNbvY22=wLtTTaERV>sz;dhXLtaL-n~(Z!=8ty=2Gx^LL&Y^R$BH(WbvU}k|`{)wwOlfKLZADCNquvZq@eH z>bX~gcUgRv%Fy{4xO!PVEzV#5EpI;qi+h9uBfmrE?y0?%dF-xqQ(6mTGmgx9&gJU0 zwMB8I&Lkr@^DZ{mXo{THwa)2n6y`EE&!9dq;R-4Pbp>~HgZZLUJJia)e-xcrHYFpj z)C%aN{#W2Fd_46lnd~bnp1%eBs-Hx^s;;TfRQaU#+r~RJ18RV;>smJGPdXAIiA{FjhkD3@6gZge% zfxV~Q$6mvDI9w@|yU!1!DF^MlIPlY`PlMwG{eBAkKMT?n zgCd|>t}KvoTYKKCy;jgy`Xf0m^hTw{xhmI-jVc$e%2m(RcqMmgSOCt|`izoVX5Vau z+GQY8v-Wm07Fw3NZuYp_271Kr?|Jr5)|D;I{^g$=zj1@f{>i!q%!WfM@zJbE!`>jU8@`&Y6HbSj$PaqGZ=fY}u#PXa8(GE}*oZ%76VWaD# zOU6Xp!EL=k9q5ig>{vD$;`_-M9-URSSDkXwlfl^GMj-Q`ELl=0YZ1hMzr_?#vs3@g$^eOlf)DZ#6#?c4G=O7FZI2X0G z4)I&RGkQD+@o%|IWwxa0{B(!N8;ICV-4NH(J)-RIz3`sz*n4GPJ0*F+FVYKpFMj7m zykl?4!*eKLkGFC`sXvbV=q%a?H zh#(8ZvW#R_lC9ejV;KZ?n&}Rr=t;~j??wv}Q9TY++4_W~3^?oqgxo4;g!X?_Wh+W3 z5naCqfG>89$mG{l+<%hFhhp)X0RoJF;b1D=>SYjb6jxh zct)S5j6$KDItUP4A%M!N^{UuTdX91x4gj%#8HT_v@hb4%TMGr2LjW+=uTIIdrb)Nw!W!Zy%dUca1^&erw5BqU~2pkjp$68)Q zupBgMYKjzcSmh{u;r7qsGGTkzC~A0g$yh@8f-&RaDAZvv$R%|I(?J+#pzeZt*|21$ zxA0or(=czBqiM&|SDho@jwugH1@pA|O0az=}@{GMcodYO8J&!lLftxGrax`O=HO)=UszP0_F|;p6 zEVyNxa)}gtEy<$Q9QKwDL>#N+?F7@Ah7ll8k;o0E$hU)MBkL z3z9pYreWE7<1JtyW)r5oRDyu8X3YATAnA__E9I zC_~}L3~yzI4M#YO39W;yE*-$oco3~yPNe}09H%L_v#;Q}X*kRr8S31pP7UDhX**LH zeu1gZqT>q|r0o(W+_PF(2LnGgQ7CCdqp(-3u2>GZj|-xHnfN-~Q9H_nddxE2~n+ObI1rP|>E15EE*I~6I4jxbw1Ckk~hQtMH#H3f+SjMcI@Nr{;B>>1| zp$)PCQ}r?;hkz=C<|@<+$YLWUIE2wCaN{Z3XU>gKFZ#k`Mn92N)~lT+jdiBp%?)f7Zh@5*)6z59mHJetFgIGb78 z^5*XHGA&%X5UAh}7WKuDFFV7{7Mu+(k6@S_n-2oCQ4+QvTUy)D63bw`+FOpWubT;v zYR8ipP@nfPA?+OW=q>^%iR;{Fx}-pJ8O@58?M`O!!Al@1q+*ns@t6X@G?jV7)eo%P9qt%KgpZFt={P7h{|^@<3?srJNEXu5m6rwI=3DcTd&X=_gf6O255 zMw&P_9M^_)E9#~m%yI$Z9yP9v&LkAOSw2VXzt{V@HmQ9rG;GrezsRYN-qnua_H<7# zu5AKexYStGq;hj;(Aww}HMA+#@A0F3jc^e=*g{Z;lY{M4M;K0&q=2Cd;y{DM!bI-p zzzy&oRAA(%P*+0S-m3uUg;Nq49o%|C<){nzH6F`DK z8tHsAqg50CgR`NUYU$RC(Wu>wu2ysNCZkxO02gyInm%Z}-_p$VrpuXMvyD$u!c zOJ~Q44BW9w1;nrlu2=z>7`TjBQUKsp%kuJBMDt7zqDVS-=aJ6CE9M%W;yP}h*mW~B zRLmFuqw&_5r^#S4qZD8)n1dK^v(Dp{!sKIfm1ebovf*w5E~F<`IG=`7;caiZ<7km% z#1J2q#(wS>i2Fb209IjDn=rh~ASS%-#MW3&6oqna(@*dASFiJ`d9=wTgpGT=^`4%&^!UtK zRO6XG)4tv4YA4c*)2b^(B+omiZ$wO5Y5)6j*IoJX);+^FcsNfFV6JVS{g$_!Cyyhl zZE*(Qof8Jq_ulxK)YemW1g55CWNX!x7rW(5fAOL&5853lt&f#;Xik#YEqYso*UTol^*~#kP*FWZ` zI|mhOZx|3XxzNHaNNtfnG4EKpT3GMxy?}vjwP*@ZC>Krepg;qL4*wCyTk$i63r2V+a{quZ6P*q}S0KGASYGKGDrv ze?Jz+NQ;uCJD<;U ziEp*&&l&i)hcsKI8`6XCy*6Vdx>C!8$`Js=0)q%62^<(|5{P7JuI+n~2JIAs0OtY= zBllaFeFD&r8qD#A>)FQfU$Qecr$@$}l@Z`My$AxlkM8bB7=>a-Wmqm?pkFO%m#qp= z%FjS?bXh=GKqnz)K+J~_4`g9H^lJsUBUE>T8C5Qk#0|%}iAS!;bt`Om&$=me z+s}uXRJl#)EH(-TsjnD*eXhS<{|Cc}RQCsHhz@)9KGna*KZIb2e8OHe%`4W^!D508 za1PK-{wm3BM5MjVKy6YU9(TBN7v@s8x7RsA;^FcvBHp!y9U@JTh6H=;d`VgAfvwaD z0CT{(#fE#0@`PVJR<8e5tX!s(8>w|vURGgWTq}?)l6noO8dn&H>f`s;5BzZNyr(sv zA26cP+M`-BZRfT}koGk!t>gqw1KPqy8M*3Op(2 zu};!Hfh$0*-ib+~^u@8ZAA0TD%_cn^`{P{4Za^uIMeM}f&FNm+RDzDSk8`={(S6Ed-?D9{(ZAm)05WjA8^HJuO zF5J4q0t5?W!99m|i#T)uFSM=~Q2=A=c!M>^erOu)gP0Dt{P^JhfNfl+)8i7rl0CgJ+&ggf5=@LZjM3#upWW79>Cwvl~l_>uxo(1idz@B)yn$KIDPR~mVu8Z^vBmJ+)@8He-!%9D7M;O!=yE7)>>ipz zTb%u!OC#>rw+oUsI?&ka?oW}4umVQJF#A3nPWEK?dVAR2?-itRMdRE;rp`bHcM(*@ zoro=6-Jk&=EoD1T%9B4dQ>hQWk&boPWC-TLUTF{wm|YthK*>=plt9Hyp6+opnW1vr zPf3Cp=FX&qn*{8rU6DR*!-dpw zx@93Se&+!2o`oecEL(;iPtMUdWS6wkbt!<=6BTw!7#Ih*utMi*nHadIB?L!nkF5YS zi?qh5q9vFGdm4HTU|bSDuRteg6TI>zxWbK=>Q*f?19#wP)Dmc0sJk`PtkZA+T#RY6 z0e4OL5$7qxFVB=q%vxyrg{_=_J=3xB^-L%!j0@LOD<GKQs zeSW{-njzoq&3C;8mv}x1qyCuF|&<#&5y%hB>+j+9RAdI%}==xa^U4p zj1q^rB_EGXyHn8PHsN=(CSb58|DjO`LP3aI0IGkBsL)W~kzoG!O&C6GzSa=&V*wwUz zS3uS5cmMaSUH?_`8qW{IbNv_GKa(IcE}wUlVjYCQWXQ9De6H4Ti_CvQUKTWmug~=( zzQ2_K!A)?sDm6z7{ru`5^DpIXi=Ait9bQ{8TLhkO3CsZ_1WWo?4$c<+?)?U_hZqKQ zHW79G+3W%a6@Gz$?(7@h(rNry!I3@>>ANlOWjjL=l0fcOZ2 zFD-+WQ;+xc9-;JsuGBV+@;tdwyPs^b%V=PbH8HDc(boz4jhL#(0Ppb`4&W;&G^mFQgE*l&bG?m%u>nC`M?~WgyRxwOUsyl} z2%Q***_er5z0X0#7*N2RSHQe8R}bKZZc?Fy1u}5J&@pllHI=$bs9#l$2*KS=s1&Ya z%2qirE;t@IG9ZbwyLr1gVd_?-WCRB8WqQy|9adu{7GeXoV5@GFF@$Zpi(jnGl!qI} zVh8##fZ6yRgE|@Y+kSleGFt(y8lfv1;0%Uh5f0RAi>;s?L#t8%+6Te^{sEaE7sP1? z3IQg9Lcc*FFkWjZu%`uZ)!lp>#n`q@n5^8U(sZ8Z%C}W~)!Swwq?9K4Hh8RK>?BDf zuL##eOq+U19cERQ9&!`kU0t-PRjeulD>DrnrzX{U*tBTYf~m4M!J;{@0B2H51yonuCQ7cp$DY}qIA3kks!B)7s|pb z)T9Z4%-SWnno%|Dvdmd8AEC8TDg(V}#KoWU!2S>@VPIrpW&yCWv2$=LRmR25!^_7n zpj=Q$SiVS6qQ!_6CtiX?Nsv`#mQ+(*bGo}Oc)qh^JhEp@4SjhZxQmatZfZ$1m@0Fef>UuCPRAKI6Bl9u7vW-T$0gBXOlFk4AzLac(iN{M3o=f@8Cb+Z zbY@6NHnx0H{cl)k8I5&zR7(y2IUg%kkL8JfMYUf>&nHds>i;2!i6R2aFrypTP-uVL zpO9<(SG|d{lK%Voh=OOfFJ87keiLN3UQHdy>4vDr%{{ZgFPdO?0<5tYG9qiTmkC*+ zLsv(ZB$OXdVEme2nF*qW@nuxz>JU_hM1rsioWPnPJhn;YOA%u_E6Sm=`^`G_vzi6t_U3Rvx(>?BUX}QFC7wI+&f_c-{CZMJe3S zmo+2#%0}N@6fM}sEbV2XrC#$L_27;0EAcwL2CI^XW z41-u3>zhZ!+ozP^ZdDa+AZ)3Eacl~39)zL>chbl}9UYt(WdHyF=Oi662ICHZUo%q^ zLQ*>;GbAaM8ItT-5-Qt+L9*tITzeeLWl>r}wF^y-Kf_K_FWA<=IgGZz^E)Q+G}Cw) z)=R7f^%1Z2-V5frIC!c&4+Y$+Dm2XS%+s+B1Kp z_iX)Wm($&7IfsK3sJhEzfAaT9md@JvfIMkCJpv3F2ImHzNQ8FNxu!tpv9Wa~QHqf~ z|KB~o4LDhA1vqfP5>BqbtDi$+N3s!|0{9Prfq8}+PfhRv4cm6+w+e+;qV31V&U?_t zQnXRjVXVSE!s(#0$+r14DHc^D-y$!;7?cO=XV&1<8Pd$aCWN@Uved%-uenLHQV5yx zIvv_=SZb6y_phfqwSPDNOSLlP`=3pYpMFO5~X;J>&S?|jSNOitPywKOqw?o;7L9~pa|dqUHPSmlX zYWIOgY+-te;jOz1#kuU;4MNXBJT0*t&w(7yDz@P`4zb?OHV^E|ksPS}jBoCrvWkN^_|qH03|K!}y>0_RoE9Sa%47hW z0MdeDpl>7N;`YckNnBJ5zUZEm^c+J`{d;DiZu0o_{Lh{IDtR8}II`c-$93?B~Kpx+*~lN>G9ll%NC% zTZk5cuN);>j5svV(8iiW!sQ!V0RfsIs#U)ef#M_#cYz|o*CG&)G_g(2K1b{vBc|6_(m>i(+{~rM~2CXjxz5qSqL5d|Ctk~L`EC(q&Y&clPF*{pM*l~_M z4qRW3_-yqHMbV-f*07G$5;1qy=`8g`&2!9x++blE-DFW}*6kMYI7ct7`evJ@ zp`8xPJXf++$+f=J-B^>0VaNk%ZM?-6Jp$yl-UtLi3O=K z5Ul~GgD@w`j>f<_CU{XKKa^=p%Y^#f#RPTH8_2UrekL~I7?wyB3hb8-YiaUZ`=fP;!(1! zvvqaaz-8ax&gJ7qUVom&5A6%?hX7M(Ww1H22o%Yh3Y#?sx8+QZ#8VD*k314nxvdv? z5Y|z|B=#&RiThvVjz3vgYl0r}75#QP+DVVE{CP`aVE? z3xKK>_`mg||Kt9L1N{33{(YrE0DL^Ks3`!bi0qO#uYh3S0zk z12blUtWj99>MZJ58ZA%uA|9i!1{=Ya}jN=ro~IfKCUxP{0)f zu6zO9ELgIj2l3(4XA#%!y@kf*dPtqP3}WP^i~f4RCF6WrL+Tlvu~ZMll3Bn-6aIQ2 zmyD;MAw6~$mAh5&ef&UHsE;@D^&0IN%0sY4Kf1mO7JmkccN<>Km+Z>VB$hbNS_xNF zMEmQ3@)46FeJxy48l7V{vPSstfWu_TKDCLY@^E=!`I$wjh%<@$3P-t|Pdjtdl`uC| zc}2wuJsl;>f%6;e;zg!LdXbSu9>_6E5={hDyzXNmZt^pWY?R}GTS-1DJ>2XR7a?|{ zSr6VCCp~uPanb5xvIwGM0v~XO3df0->aPd!l6}SHX{iF*^W_hSB*L&@QjN?383mLT zAPd336u{^&fX0WQyaU+$ABaIc4ktM&g_u|y9bq*S^}{&iq{(5JDzRCO%%`y<1MZ*!jQO65Z$47mRG%SwJ2?PNo;4d zlWPsi7Hzd%{ECz|G7eJR#6jt#*GeZ^NUg;*iFB%~fAw{g2oo5=X&}VJ5lXC-inQ%3 zW28uo7E7LsAW*_+t=e-**R6Isty{g7N16hf=mExT-J(*|NSbnfd8t~I&Ksn+Q4l4S zS*6Rl-$-s5xZ{KL<=j_g(!e7e`$EZQjNVxfi1rPR)Jl4?VDuFiz;g(pf` z9w@D4y_R&GXeZY?(Q_`(%e^+pB1LJXqN0aNuF)&y%t+?h-@T`DT~hh%g58WsgqxPG z113kWM4oIzAlaWU2;>;+HF&#@@X}$!UilNbK$_L#rx9y>Y?uiH4yQA4Ss0Q*j6L3P z002HekC4csqxGcmA->8YgRFgC62z1*fswL5Q(#i-LG!1~ZqMJ-H$qmK8Bp(0vw(({ z#2Zgo351y7EU!-9<2^p*yF}fQ%Gj(Err+4l&lSg zYh?TTnO(%%VPb*PuDKhAVr^CF$dMQ}W(U1BS{vBXHb$(%tC34R+r`8f-iP}&*0C&i z+6w6wf&YZ9-7uFFKDRh0&L7>w2ElEQ(+B*6&N$F2Vk)k9zz#PRGj~#t2noTf1Dby>4I&4Qd1>eT<{%PB0rExxLRAD!uVX9ZId_j!LTQ zmV{ky--(*LoWpmf%C~n7k4!Mv=SyDy%uw1#YnO-iM&EX}IzDEgZ$sws(~hyZltzo5 zA$}VSTvk%bwf1>|gdUGW{X@o>^XwR_N`W$9K){kEPFhlkxwsg|5GB3}Df4H1BQAh* z4ko|D!85ggF1e^=aMTUr3OJC2BiL3OuSy6p$C161d44F^Qw39mf~fEDs>_p5x$Y9- znd8x^N>*s#M-ssU$^K2)rVrjrPdWq*j0-z0V(}+wT3`v?l-)4(PF`kfo*qKMUN&)+ z4zQ}U@@TG{A7z_!U!ZNsuvt@|J|vqZ1w4~49q&`;IeRW(JF`>hPiD!vpmNWD|5@dJ zimOxVEMCl^xP6L9%MSAcGcOzFmw@{ueRj<$=+%era**sYg^lOesoBxS$}g;ymO3y{ zAp0zES805TT)AXcet#;s*U;sQC-%Xq#Zbr&u~!(O*Tcpe|)oXL`3y3#N96udj>igr%DyLQ6f z>gP&&=GxJd-jU-|ox#;C5hZw<`NdSi^Mnj@OjD{nC=EERi9zwSl|>v!a)~oIwMTVRqUgEfV6`+Gj4_d3vKrg6nl;*jVI*O9gq+?d^=Q#Oc@)f6ol$m7 zv&M-jX73eEJQ^K;6``#BVW zePrl&W4OfkDT08;`*?SEFYk#aGNnx?pOJ_#7(pwl`?lb^u+jtEm>FSfr1leiF2_A6 z-fYKQ(mCNtk#^%9eThV_88LfuY!ldFXqPurz6i_elkd^8>~+XX%{@}Gd5frLOJ&~% zjc$Ry&oV`|0k$(qfu#0VypxlW{#Kt862!cijuEoD96c|;#-2{Z&xadf8LmHe=IzxJ z@3lv6z$CZSYfN2-bF&F|DVIXUj;UamBSEauVF^)&{DSRACU>YLK@_Fg-2~*`W2GG9 z8Cw%Ez}a!u=<5R3A`Y_xzr#Ck%i0s}ol408V#WpKb?)54NTXnj&^M@4%T=Gx0dHa^ zj8CA2oc!NF-tlycy{Mi9zX0G0wWHT)&)nN6+$sL96SVI|Wf8C>?WvQV<4HHyUiY<= zz5W*<*qZ~UyXnRgM#4y41FO+etI+2+%ln(tCcbQlpY`6HbN0p=@lu0GA6G_M(-XaF zrF=WPUPl=YyYt#+i8PZRR$O2bg4={$%Qf5`6kejrFG%4AtJc#A7XSNZ%Q7IJ6e2Dq z{nB%Vc>8pFABojGaYQO+(*5^eyoXUweV1>avyA@r1xZe(?wg}vXR^a|Lf1&RTw16Z zOQ~53$o06!GDgG`157w{N`qv`#D_$ILtN{u(yQ#=W-tb?WwVH&1%sR(SV#1>X<6?p zIT1fLA*uuFFO<{oW!INxO;2#=;io)jf|~4&qmJ2~>VNpdDBj-v%P5)`Mq%TBow>83 z+J4d%qrIIojV>SiRcNec<7K7XMC)~#m1On2z&)U5JYc`(N&JMc?ZmOEOvv%QOB`Ym zJA3vpT$PyfxVmW@d7vY#4!TBQV;LG*2_z9y-l$i(84F`@(slI25$Keq@ z6T=6k(kj)ia|X&#s(;06WNss-cB#WTZNd|JcT0~m_%M@ZcN@64EqA$S1Fy>(H5`+9 zO=rKymqVsQ6HwK;OiQn@Eddv+sCIGO3DEjmyYL1$e(5A$^GK^Vq@^74+2Lm6nq3cc zrQDmqA)*fItW+PWFeL>Tc1cr#^*m3RY;U%lpUV>O+#qpc!s*~s?kD=94d!9FNtT&w z{17a+RuvxSoXr>qdoKHN&!w-juloOg*F%q6x_d+n^yZaL{JLLg;L1x~@Lrg@{L~au zSE!meb%nnxRQAYqS-Kp>1PVWNZFzT(m(rAEu!B42Y|M)CRfWk=dL3Hdv+VJT%ywdX z?esXkl5#g=#G0^0z>v+#+)c^j?iA1NYb@DqKIS*bviKw0_0N$$dbgm<4AZ@Ezj|2R-JB+Z*49$ zzO*ZE6#Sv<%IlCX6xAdf^IeZL zf4vrh{%F;PQlP^ulGDu?o)!_-uZS%zk<0h)0G7oQ;?a_v=&c!vl{4dRG)FAg$qHq1 zU61~=xXhmD2^bcI?qd78q@G($|JrXqQ5UPgvZ(~M0T)y%3D{6h*!ctnbz1_0`(Az$ zxkgefmTP6q`k7VY&2@TRV=6|r=+&ho0xPEz)CO!oslo+ShEjq=T#~QUVXLInXsS3( zuVU+o&1UmkQAz1#p++YLew`$a)$~|HyQN3MM~cuh`C7us@YMxZHJhA*|F^0H?qvmQ zjTISd7b0nS?}}dFc_LvO;0dolLPKfSIAI+At{hYZUs2taSxh$b3dwYP9w#ArNd3LS(W4Ci+r3Qw2_2~ktxnQIi!_v-;SaDftz+tpNG z#puh0e&^a1^~u|EVN)HlCB(~`HH8=rEURw8o}?`bOAC@3kxHv^X=a_MoJO5~R=n(n z!r=3V?Zf#uMrN{r*h|M6ic90+8LO{oZ*3<@JTEHM=maN6!XvE|uy+BUZq@x#pIlo(Wi+#hm|{Su4@ymG0j z3h=$zVWuOEIxB*e@>!**j*I2gi9$~c*#ZI!%OVIo?=>pG{k<1Y9Pho<&vzi!5%fno zv60-9El9mIQaZ0x8qp(LPUc2Bu}28{bq<8iJoaM6l9Az~OU|x%aj(v-uPB{gqOUMJ zzqEPA#~OsAv@MfQ9{GA5FD4~Hsw;!wqE!k3m#YP*(htGCjT*`MKdh+!{o!D^F z$wH(?6e(?Cq*$N)?JkyR`I7Fc>Bf$^>uHmIOUL_glZqy{O^(2>Uv~vAwP1TQ_7@9n zJ#cArdBK)odSO@Y$}c(o(~|hN4Jru7T=np_3TdLNZNu%sDZ&?<2ZJF=a|jv)=e2-| zq-C`uN#bqOwT|zso&oG;Y-sD8OVi13+Q;{ermLu-f%QSQx(v0n*o#^QSQ^l;=)QdH z7_Dx36#wUS&dlg66-JCk;TOY(7Im)iq}o_pD5Ty;?Xu*Km|!YU$(jml^3EDvZdF;8 zq-a*~!Zt5tn}mFF?mf>&Jq6S}>g%l7ZmiR!4(6>}lIDzt{ax}?EqHM0VnwUN-B3#9 z%ir**f3C2At25kDOQRyda@FL0l>69ui64Y>@=?0*w|mST-SoUF?6T0wsLvVx6kdMDu79y z@s;TOIm>rwKYQL5jGNEL`Lyb4yoyr**?8smO6$lC$#dserCI_K{U@bY&y1$!uH5Hi zf)&$<8Y3>GvN4vJn@W6>XTJ%!%pP{>`8gZUHDGXbTV5U7$ra#zT2&=ZS}XrBnv?z* z6ZKE?r|+ZbL%%!zh_`(6P3w;)6Ai4Kj?xIQ7P_Ip^D(GU?NpG_+N-`?iNu$C6|E&J z)XqZC#|0h(9c$4Ds!)os>{XzY{Wv!Eu}aE%6&)v9Pfw$55Q)~&(&!sIDI?8Ecs^Ft zJd~#Kmj8$gIVQSWw&nqtm5$DanJ98e@aI2TBZYX+KP{J%Kl1UPy``*E2^hC+n8ft? ztY=LckI0^gjiM*dK3roe%FrivZ(HdO0DE0BR4DJwS8LDwQHlj{yGO4zH@lm;`Uq-! zeH(U%y0Vh2RFx5A8e}QIy;=_3_lg*!tKA1WvohlAK_I-;YSWX(7eT_d9%)tuU2DM$ zn^mKzO!PW*CJK`(OO&O?*Zs6exCkoyGxl)|`d7>!OO@$oZdx+`3PxEj@~3{G5dcAw zgIQTy^^<;!~)#KLcC=$rk=dxbUGKOGadi&4zaW(Y;cQU85z0!7uR#qJL3 z#L@OCgy>ne);P1r7f)fusSReozW5&df9kZH_tIe8eiRbb&rOUuKNN)ije#M5F8u!L zIM+jpk>@aG20c%Y)1U>euI=`jrF?lBqms%R9FEb}NE!;@gnWn5%AbF3(JS6+29|tJ zo9G@DS}@DRZMO0~@q2EsqBg%>RjryKz{-6uzS~w&hAnIM-WnyU*qw5Zt$evRR`-hh z_a5H!QwD#Nzcvw!{c<0j3qSmiXkgZ4Mr^y!?`xN5Op*hle-6XBbf5f;|Du>Q|CfZ1 zFg_cWB`(`Q#e78lGMgm6=r?8j=GKtCAF{ExjqKT*sg+;GDv!`Achux9cn7x0xhk zzXQhR2J<#)>?{5xvLkrZ!|yGSW2cONLm5J2ZEAnMo=X0q-wCaeVNm1P`gK|y>QM@T zaqgqpAE4(~m}d99X$>w!%xGPIzZqQEmx>||hsevgiL5+l^lQu4Q9dnG)xdChne*)h z%q|8mtGJ`9z@B5!J`X7)D7E+!o}4#BfK@MfbB9`5m>wrJplgPvs{92MHazw>i zpFMto#eX}B2Mk{bY=b5tzSCTI3My%BNl3N>nxssJOJngS-3x+@_?ra;Z(XE1xh@b< z1IszTIb2Uo;46JKnZ8<^x2`3S77aL>37u1?z2KQk$Xma<^7fe_Meuar=}_yDrv7bB zplwY{k2JN0l)q@+pW6=>=hzBd{Fv(4>~b_IFwT6QzC(!<+2xdHEO1_lq4Go zow~*g?^+)g;P`G4E#cU25I5oTz!*IBmC_1x05XqYVp9ewY|~H5^f%#TQ|Y)k|F?>_ zKL|!`z{528&+SwTuI%6OBXstvw3N|XfmCf(TK&rAH-VQ%>&r0*=f6k?r5qMhTJbTB zp$sdQSLUmAxN3z2Stiifa7?M4-xZuGFW*`r{k()vcd!rNetzqlVqy1oky@jYsb-z1 z3|?LnddHiusae%XY9UwK_~x@EvOaUK(q`lp66zXKRS=}6|9{$l3@aGwHU8O@HQUFQ zvFDr_O^^&q2e0lGSw=d`J70{@z@8wt(8E&K-GMK3 zzg!=nuuWa!vF^(WbW1dIqUrjy0HY1#^e(Ga80y-*nyyS7@JjcFacGa7* z6Q^T^zqdhWWiElh-tE!mx>Ud2b%|9=sM(*Mc2r(GqMD&5g+yPy-g z$vc<V?;941@l$`%2(D?Lc)pf zmK~J9ktcEhIhUc3YZ-cA%GFQQM?f*a5R<^1=J8Fs7J5i+Fw60k{1*#a^o%0=ZdXei zkv_!cTQp;cft+M$9}>PDnw+!KSH7S@tBcRbE#P7mEu=CEsklvFBn$~qq@U&TpGhde zP{J3RaMW6h$_cxHEKueE4D?J;)&O*E?%^!Z>_fTDoXrMh9qPCi4WU;Bw*XIskr6@1 zWfcvui}Rx_L_=W&6hKuN0EGt-H=_zEXn=lmJ*8)SoNm*l4*GL zik1Fc_cXm;m;zcN%go&I_mo&myt2-=%@fpWs_aG??g{!=V37T|RGvnM&y$+Yfz-l}!7zM5?-wne-Incw z++BzNURnmYyBa5cpqhK-?8=9>uK)=vGd;ZhZQ#5|zTf7Z(3&zmZn-?T&e8G1MT1Fb z|7o)`ZhM3Mpgnj`zol#ci!{YlgO>Xn*Bk7H>}u&Rc=|6VA^|ut#P%+I8VWx0E~nw9 zul%g`6<_w5>f>!Vyl2kyNSKaCf}{ZzLNGIExpl@49YwiK#L5JYhrcwLhjG#jLy9UI{z6o^DP3l0IEP2 zy@RD9t8~dX&?T0iY`)UW6AufecQ=|rYwUIWP;7@!i-yhDKUCp=!js`Q;eYur{W_p& z5dJ4TnbAIMY-zkYFi7FaYydQ#nTO#*suqJ3o@5L4CU#>~tFl3aCo>tSl7rc>gm{vi zUMC!cC1Eyf7B&YHL7xX1?+K)igfvQ`V>WCy?jAVo9(E78>o9N>?}|D0Q4(XzKFm%x zODsdI8t2?twYEx#LoIhJ)FX*SIkflJ;(c2)^}$q+c4QxVtRwr#qaO2kNA|HNI^s|E zsmDCwmmRs!zIcEA|Mx1YztyCAjWSl zc~kvO0vFP(+wq!xb)sGnuOR3t%^dmgdU2j0+Y@okFy4iT_3j{|2tS`3;>n;-GY zky%<;gKL$$tn#U@9*qdd1yybkCkUV{LY0g55<*VeFH_Z7c!{WT zDHekk%)PDH-|&dk@U%5l{{oWc@?ub&cJ2wO;d~7tKW~Q`&es4b>5y!?XKA5AYB-^U z5Dy0-H9SCDB2W&a(^l7{>l@@Z6Tys|rZUzv4CQkun;$XRp^UzLYHSFtwzkJcXHYvY z&w3rJ^mn+{QdjOZaQ~+)#W`=!7jyy7{|2vr{SAf>0nFzIbby_d0_Xrc8(s=}D{J)i zj53`mWsaC;jK}YHV#|het1y|beJc7C z+Mo6Y^Xglbzo$4!qC8$jzpYHEhGZU{Aymh7-iCTu9K#xR@)M9PNE^$SBa z)ATjkj#U!ZFO*fIvCgG(?9?#+KUPqcn;C(z71;(2R9g zp6nw&{LIaMvg=B$@0vxEzh@|w0Z8&Xr8dZVNlBJ?!2oK>BP6us)RtF>Kmh249j!@x z;Y<14z5adot+4k_VxEfe!Xl9f-QZ0+xv*W(!9Jp(ef^DnV=CI@H-fEwZ&bEF`T4)C z=UaH(aWDJJx13E2=|03nQ%j@=3C|RVV-h5eDHfynR6lTTEIVzd>B)==JQgaGuU=A` zrN&<)SOV~SMuuHd$nyZ>sm^T#c7TlJKsP`)vzqujGZ;Xvw1>dxOew}afC$1sR@fpZ z?2ub?wZ(0vA74nXwIH|ic6w{fGYHvWCmFU~Dm^?sFp!?5b1!*AWw@lU8s@C@K{A$h zBDt<(a(_?kyO)OpdpwdJ(t4q0e(`456CSz(CBpj10E4`@} z7J>kPNzsBuvDsmg3KvLom9DW~PbBUM)9aM_$A%5dJVvzO)>;S=51fymX$tMcnR^*9 zf!M!jF!QB&ABM<62~XWnL1uq4sy`5G#r}O=hEFgZ{2E##dF+(|g^9U;5ALXv)E1V4 zCY|VY10ms=*vjZNVabA{7-*b20kP=zFjEBUuMPepuLK>E6)sW2$G8i}rDkTLpOUD~S7+L$&y}_8223ado6GSrAr6~F54Z}P z1MN1sGmIxdpk_vvDOiY!ce$hUtt?2na_O3EGGzDEs=A?NbqVFt`B_QXXDoni+JkKC zKBj7x@vSNAFGQb}fit;kCd03sc)C*@M%W&!>cO#q69w%-hGg2oL~7Kj^=(eGikt9GV}dEL95aa)VFAmD zV6IP)Sn#=Ov?pBNmKK`gLMSk)6f1l7C8`%`T`{CnqGssU2VXWmRQIRXYSlLClQ_k$ ze%G_0p{W6xnNPPOzVlphv(*?M*yAX5FBh_+XYfT1b!%&z?_zBTt$5$82uM))vS1USxh1 zJ{lE}x!v#ub?oN--ey@WO{>>tvcXa#ZqN%XKhOt_2wdfHARHJVE`k5c+(Q?s1keyy6+3p%fOwX!b%vKaPrF&(L+I7B!> z$JiR0+mXwa(<^qcO>T6q3fEVOfPGO4_U*cI{W#d@9lJ|zcp%-mhs4eG-N%Zg_d7jw zbYexi{|IS&Ex`kQBsI%c?6wDzK>n%NeauSG@)?LL!Efam00{lQOI%-B0l?axt?IggQJ?iKas_ow0PyDi z_fj8oR$B&Dp$c4d&h}zm*^^umjj?NB1C@-)3-H+G*W<6{=GiLb^d zKJC8yz%*+)xZ0YwB}C8bW?;>VWN@gb^4H5j>Co%>hj-G;B@a;O#Ff8nKQKYU86N|_ zUue!f2rs?qEf-mCT199PDSzr5m*qttc zjmsPB>}9SkFjcqk(@1AKdD3(*y%D(+Z3u0vy#wq;<(uHvjWurhEI4xO>{RQ;Ne}pq zk-TmZVKOSpl9Ij)BmV5KLH5LO3rvDj063s;O0Xc2&`6%y%=OGxuGhLk8D%lduGEHB)PMniNc7z1&3HUk20& z0#<2K1_dk7v5#r~=>d-CL)3`u5HjXR<)2hx;G&z|@O>AK#6zFzq=L49yhM*vB)H}vO4O*IA7$KRQ?rOq-9u?s_}3|U7-(0S zB7|_r{V9uz(Vnz0I|bw>bkM=^aPzEXUItudnehZ9@QyKCPA`d4+&HYTdFaSJ@6VfE z=mTF=C`y@pz2FAS0$8KzCKJNgEk;-b8=reQbP)GLvh#MQbB291-5eQ*6=l{A_c}<- zBdAna-vYuG=EMzmoGGO|7?ax2c-n{J=u~vLM-S> z#33<_DA$F{?h9&b^D6am(8vT^i^PCXdILU#;cC7dy=_Z2|DW}-OOgu{j?jQi17mL3X2PF#XN!00_ zJ%h{8c7pV3<8Z3$Lf(19ZaA&ey zYb1)9__i2pM}(pxt`3Y-!sEgP1ziB+*b91-YYkOPl|*W` zkRqMuY>A_qt4b*PWOm_U6aZ#SZMnvFMTIN-vrX@8_RQkXN%RwBJ{NULR3f}V2&)A) zF^$N!_$nCD_4OtZQaFVcsGD;hGuDVZUZ}*qb%8!`_s& z(5DjNJEm(>(4AGoKS5gXlP%=_?h^JEPVhCeRi?s4alZU=CG4%g;11u~w(Ly>s1lsI zZ1Mf!Jny4&$A8EuBgfre%a*@GgV@0b006BjJ(!$7jA-0Om(52ji;oaFQTrWz6bEbi_Vp$jrHcIo&S30c-{n8VzUn*;rp4d}!9*vVCgnID18E4B zKLmOJiEyvv_^@5AM(QL9>}Rx(j`B`k`;ebXLM^CKD+p+7YyC(Ln1wd_d+kZ}>)hIArehP6sx?w0ARrzyGj~8itRL?e|!H20s_U|@e_B}SEa{(1BuCR0C+z~8UgtD2|4z^eEYYkb0*M+00`I@ zk3Hq|PUU^0l!}vNw3J&`41Jv#gCSi-L_@$Ppp2b4f-bg83{Hr`h$s!NJV@ojWyv!_ zq2q|77mBSbKe-8bxs)blnU@Kf>P7Ps3X@9I7Pzf@Y_^LQ zc+xI4IyK0&d(i>9(q{g9w&_$SP=R#0vLwqhMKhgQ^0aBy#$Pd=77dcMX{J?1r_h$Zwp}dMF?4CKEwlojOFph{fcD(>Ipp6wu)PRKlP*K1EZK78%9F3a6orZu zD^aS9TDc09s#GJWkgqowO=gSLW_LIN5P}gD+pnI~xKn>qHYppjA1D3a&4D8)Vs0#o zgmcbIX6ZO*=3FpL47=oli|)GTx*LuqG@KZlHh7lATr%IR#;_?KK=IC zYiXGv4Q6ObG$eku*)Q~XuH^1m>40mNl_@M|YO4lKnl)}i( zHLG>M4;;=M03jGbF`OVNnqfI!5GA=$f>8z5Fn|AU6?}IzHK;%=;`zY{(iMPF4?p%n z-&}b~6~g?b5AN4=i~s#mv!6{L6E!#K%6@yNVwnRAi?goyH;0PcTiTgAWG-zNhp4GaWvE-+GHU=WuA$pr=iBje30AjLuvMHN$A2_;ojKqZw` zQMC$dGQ-<+Z%W|2f&o@U2?Z6X%11@`6ROu8?~oHTH9g%rRz36xBj()+FEonvAc^R9CkY_W7IeB7T5dfUtotY}Kuj96{o^s+6! z>3#!i|3F-LA7N3$06+U$uM|-L`2KrVCJLeQJ@Q}V%89i~P-6qT$MT4IE#l0+iSo`$ z{le#7HFtt7%X{w56bm`FY}XEVi;|C%GQC=Aoxmx%*DZ{jBPbl(Fv{!`8gX1ibMUpK z-~Y;2c4jAEOZnKVPV}M^>&9Pnvh1toABKZ%;1DIu+<^#svD&FS^~;Gz6MoxJrr1eb eR3cDM)T4T~^dNioH}c94d6i%Fz)#w|`vL&JyAfCb literal 0 HcmV?d00001 diff --git a/design-system/apps/design-lab/src/componentNavigation.ts b/design-system/apps/design-lab/src/componentNavigation.ts new file mode 100644 index 0000000000..c4b1fbe257 --- /dev/null +++ b/design-system/apps/design-lab/src/componentNavigation.ts @@ -0,0 +1,14 @@ +import { componentRegistry, type ComponentMeta } from "@openbitfun/ui/registry"; + +/* Which directory route belongs to which component category. The shell passes it to the page and + the rail reads it back, so both agree on the route without duplicating the mapping. */ +export function libraryCategory(page: string): ComponentMeta["category"] | undefined { + return page === "mobile" ? "mobile" : page === "flow-chat" ? "flow-chat" : undefined; +} + +/* The component a directory route opens with. Shared so the rail can mark the same row the page is + showing when the route names no component of its own. */ +export function defaultComponent(category?: ComponentMeta["category"]): ComponentMeta | undefined { + return componentRegistry.find(item => category ? item.category === category : item.name === "Button") + ?? componentRegistry[0]; +} 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 ( +
-