diff --git a/docs/design-system-adoption.md b/docs/design-system-adoption.md index 8d1d341ee..15ae014da 100644 --- a/docs/design-system-adoption.md +++ b/docs/design-system-adoption.md @@ -1,83 +1,67 @@ # Shared design system adoption -Buzz keeps Base UI for interaction behavior and uses its own public palette, -Inter and JetBrains Mono. No private fonts, packages, artwork or business examples -are required. The visual direction follows Block UI: clear semantic color roles, -pill actions, consistent fields and shared states. +Buzz uses shared semantic colors, Inter and JetBrains Mono, and Base UI interaction +primitives. The visual direction follows Block UI’s controls and states. Public +fonts, packages, and generic examples keep the system independent of private assets. -## From the audit to the app +Use this map to find the shared owner before changing a product surface. -| Audit area | Shared owner | App adoption | +## Shared owners and app uses + +| Area | Shared owner | App uses | | --- | --- | --- | -| Colors and type | Semantic surface, text, border and affordance roles | Host aliases forward to shared roles; feature CSS uses role names; inline links have paired text and hover roles. | -| Action sizes and states | Button and IconButton | Retry, refresh, delete, recovery, composer send and picker triggers. Buttons use 32/40/52px minimum sizes and allow labels to wrap. | -| Forms and choices | Field, Input, Textarea, RadioGroup, Checkbox | Profile, community setup, appearance, plugin import and workflow editing. | -| Search | SearchField | Channels, pages, members and GIFs preserve their query, ref and keyboard handlers. | -| Navigation | NavigationItem | Settings, channel rows, shell destinations, Home and community choices. Route destinations remain buttons, not tabs. | -| Tabs | Tabs | Emoji/GIF uses associated panels and Base UI keyboard activation. Workflow mode retains its existing externally owned editor view. | -| Modals | Dialog and AlertDialog | Page search, community setup and workflow confirmations. Pending work prevents dismissal; focus returns to the opener. | -| Panels and headers | Panel and PanelHeader | Settings, channels and companion cards use shared paint. Grids, scrolling, docks and subscriptions stay with the feature. | -| Feedback | Toast | Agent-start, live-update, sidebar preferences and Settings feedback use a source-owned stack. Recovery stays available; form errors, blocked pages and lasting paused-state context remain inline. | -| Hints | Tooltip | Control titles and agent activity use keyboard-accessible, dismissible hints. Accessible names stay on the controls. | -| Sessions and activity | NavigationItem, Button, IconButton, Panel and PanelHeader | Session history, agent choice, child-channel navigation and activity actions retain unread, admission, draft and focus behavior. Base UI owns their menus. | -| Media stages | surface-inverse with text-inverse | Preserve existing stage values and measure their text pairing explicitly; images and video pixels stay renderer-owned. | +| Colors and type | Surface, text, border, affordance, and complete type roles | Host aliases forward to shared roles. Feature CSS uses role names; inline links pair text and hover roles. | +| Actions | Button and IconButton | Retry, refresh, delete, recovery, composer send, and picker triggers. Standard sizes are 32/40/52px minimums. Labels stay on one line; surrounding layouts reflow whole controls or scroll. | +| Forms and choices | Field, Input, Textarea, RadioGroup, Checkbox | Profile, community setup, appearance, plugin import, and workflow editing. | +| Search | SearchField | Channels, pages, members, and GIFs retain their query, refs, and keyboard handlers. | +| Navigation | NavigationItem | Settings, channel rows, shell destinations, Home, and community choices. Route destinations use button semantics, not tab semantics. | +| Tabs | Tabs | Emoji/GIF connects tabs to panels with Base UI keyboard activation. Workflow mode keeps its feature-owned editor view. | +| Modals | Dialog and AlertDialog | Page search, community setup, and workflow confirmations. Pending work prevents dismissal; focus restoration follows the shared contract. | +| Panels and headers | Panel and PanelHeader | Settings, channels, and companion cards share appearance. Features own grids, scrolling, docks, and subscriptions. | +| Feedback | Toast | Agent-start, live-update, sidebar preferences, and Settings recovery use a source-owned stack. Form errors, blocked pages, and lasting paused-state context stay inline. | +| Hints | Tooltip | Control hints and agent activity support keyboard access and dismissal. Controls keep their own accessible names. | +| Sessions and activity | NavigationItem, Button, IconButton, Panel, PanelHeader | Session history, agent choice, child-channel navigation, and activity actions retain unread, admission, draft, and focus behavior. Base UI owns their menus. | +| Media stages | `surface-inverse` and `text-inverse` | Preserve the stage’s existing values and measure text pairings. Renderers own image and video pixels. | + +## What stays with the feature -## Deliberate local ownership +Shared appearance does not transfer ownership of product data or interaction. -- The rich message editor keeps its caret, IME, selection and completion logic. - Completion rows retain `aria-activedescendant` while using shared colors and type. -- GIF and image tiles retain native media-selection buttons and image geometry. - Search, retry, playback and zoom actions use shared controls. The image zoom - range retains native range behavior and reads semantic colors; there is no - separate shared Slider. Media modal focus, drag regions, playback and timecode - ownership stay with the renderer. -- Emoji Mart keeps its shadow-root adapter and native search behavior. Its search - field mirrors SearchField's 40px minimum size, 12px control corners, 14px body - type, inset fill, metadata placeholder and perimeter focus border. Keyboard - focus and reduced motion are immediate. It does not own another appearance - preference. -- Native disclosures remain for persisted channel groups and diagnostic content. - They are disclosures, not application menus; their content and state remain local. -- Avatars, previews, links, mentions and thread summaries retain their identity - and navigation ownership. Composer mentions use inert shared InlineChip rendering; - editing or deleting the mention removes its explicit mention intent. Host-owned - recipient avatars beside the mention tool also allow clearing that intent without - changing authored text; Sessions can still route to the selected or sole agent. - Shared appearance does not move their data. -- Panel marks its surface separately from interactive components. Native product - and plugin content inside it can still receive host defaults. -- Legacy utility names remain available through the host bridge for existing - callers and plugins. They are aliases, not another palette. Use the semantic - names and shared components for new work. +- **Rich editor:** caret, IME, selection, and completion logic stay with the editor. Completion rows keep `aria-activedescendant` while using shared colors and type. +- **Media:** GIF and image tiles retain native selection buttons and geometry. Search, retry, playback, and zoom actions use shared controls. Image zoom uses a native range with semantic colors; there is no shared Slider. The renderer owns modal focus, drag regions, playback, and timecodes. +- **Emoji Mart:** the shadow-root adapter retains native search behavior. Search mirrors the shared 40px minimum field, 12px corners, 14px body type, inset fill, metadata placeholder, and perimeter stroke. Keyboard focus and reduced motion are immediate. The widget uses the host appearance preference. +- **Disclosures:** persisted channel groups and diagnostic content retain native disclosure semantics and local state. +- **Identity and navigation:** avatars, previews, links, mentions, and thread summaries keep their existing owners. Composer mentions use inert InlineChip rendering. Editing or deleting a mention removes its explicit intent; host-owned recipient avatars can clear that intent without changing authored text. Sessions may still route to the selected or sole agent. +- **Host defaults:** Panel marks its surface separately from interactive controls, so native product and plugin content inside it can still receive host defaults. +- **Compatibility:** legacy utilities forward to shared roles for existing callers and plugins. Use semantic names and shared components in new work. ## Form adoption -Agent import uses shared Input and Select styling without local element overrides. -Do not add container selectors that repaint shared inputs, textareas or selects. +Agent import uses shared Input and Select styling. Do not add feature selectors +that repaint shared inputs, textareas, or selects. -Environment variable-name errors and link-lab URL errors belong to Field. Workflow -validation retains one owner (`editor-model.ts`); its issue includes the field and -step location when available. Form mode attaches name, message, delay and timeout -errors to the corresponding control, revealing step options when a timeout needs -attention. YAML mode attaches draft validation to the YAML field and restores its -helper description after correction. Unsupported Form-mode conversion is a -separate notice: valid advanced YAML is still valid and saveable. +Field owns environment variable-name and link-lab URL errors. Workflow validation +stays in `editor-model.ts`, with field and step locations on each issue when known. +Form mode connects name, message, delay, and timeout errors to their controls and +reveals step options when a timeout needs attention. YAML mode shows validation +on the YAML field and restores its helper text after correction. -Keep request failures, permissions and whole-workflow problems as feature-level -notices. They must not mark an unrelated input invalid. Styling and message -placement do not change save gates, secret handling, or relay validation. +Unsupported conversion to Form mode is a separate notice: valid advanced YAML +remains valid and saveable. Keep request failures, permissions, and whole-workflow +problems in feature-level notices instead of marking an unrelated field invalid. +Shared styling does not change save gates, secret handling, or relay validation. -## Checking a migration +## Check a migration -Check pointer and keyboard behavior, loading and failures, both color modes, -narrow layouts and enlarged text. A rendered app check matters: a component can -look right in the viewer while its stylesheet is missing from the host. +Exercise pointer and keyboard paths, loading, failure, and recovery. Inspect both +themes, narrow layouts, and enlarged text in the actual app; a viewer example +cannot confirm that the host loads the right stylesheet. -Browser journeys retain community joining, workflow save/recovery, draft and -sidebar persistence, media insertion and focus checks. Visual assertions should -track the shared treatment. The media journey now checks tab/panel associations, -keyboard activation and search clearing instead of the retired picker-specific -stretch animation. No browser journey is removed by this migration. +Retain browser coverage for community joining, workflow save/recovery, draft and +sidebar persistence, media insertion, and focus. Media coverage includes tab/panel +associations, keyboard activation, and search clearing. Moving to shared styles is +not a reason to remove a browser journey or weaken its behavioral assertions. -Before review/integration, run the contribution workflow’s full batch checks. -Draft PRs and a running preview are not claims of native or full-suite validation. +Follow [the contribution workflow](contributing.md#interactive-product-iteration) +for iteration and completed-batch checks. Record deferred checks. A draft PR or +running preview does not establish native behavior or full validation. diff --git a/docs/design-system.md b/docs/design-system.md index ee4ab7806..3d10b3402 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -1,24 +1,26 @@ # Design system and appearance -The shared components live in `src/shared/design-system` and appear at -`/tests/fixtures/design-system.html`. Buzz follows Block UI’s approach to semantic -roles, controls and states, using Base UI for behavior and public fonts and assets. -The app imports the same form and overlay styles as the component viewer. -See [the adoption map](design-system-adoption.md) for ownership and retained adapters. - -The **host** owns appearance, including startup and recovery. A plugin must not be -required to render the shell correctly. Pages still own their layout and behavior; -this is shared styling, not a second component registry or a parallel `core/` tree. - -## First release contract - -Settings → Appearance offers **Light**, **Dark**, and **System**, defaulting to System. System -follows the computer's color scheme as it changes. The choice -is device-local (`buzz-appearance.v1` in browser-origin localStorage), not a community -profile or relay event. There is no theme marketplace or appearance sync -between devices. Another same-origin window observes saved changes without rebuilding -pages or relay services. Failed storage reads open safely in System; failed saves apply -for this session and expose a retry in Appearance. Invalid stored values use System. +Build new Buzz UI with the components and roles in `src/shared/design-system`. Explore +them in the standalone viewer at `/tests/fixtures/design-system.html`. The app and +viewer share form and overlay styles, Base UI behavior, and public fonts and assets. +[The adoption map](design-system-adoption.md) identifies shared owners and the behavior +that stays with each feature. + +The **host** owns appearance, startup, and recovery so the shell renders without a +plugin. Pages own their layout and product behavior. Keep these responsibilities in +their existing owners; do not introduce a second component registry or parallel `core/` +tree. + +## Appearance behavior + +In Settings → Appearance, choose **Light**, **Dark**, or **System**. The default, +System, follows the computer’s color scheme as it changes. The choice is stored on this +device under `buzz-appearance.v1` in browser-origin localStorage. It does not sync +through community profiles or relay events, and no theme marketplace is available. + +Other same-origin windows observe saved changes without rebuilding pages or relay +services. Failed reads and invalid values fall back to System. Failed saves keep the +selection for the current session and show a retry in Appearance. `public/appearance-init.js` runs as a parser-blocking same-origin script in the HTML head, before the React bundle. It sets `data-color-mode` on ``; CSS supplies the @@ -28,9 +30,10 @@ storage key/parser is intentionally tiny and checked against the service in test browser theme-color metadata. `app/services.ts` owns its lifetime, including disposal. The built-in palettes do not fetch anything or wait for plugin/relay startup. -Native window decorations and pre-WebView launch color are **not** controlled by a -CSS attribute. An attended packaged-app check is still needed before claiming native -chrome/relaunch parity; no broad Tauri capability or CSP expansion was added here. +Native window decorations and the color before WebView startup have separate owners. +Verify them in an attended packaged-app check before claiming native chrome or relaunch +parity. The appearance implementation does not broaden Tauri capabilities or CSP +permissions. ## Tokens and shared controls @@ -60,18 +63,18 @@ The standalone design viewer imports the real shared controls without app startu identity or relay services. Check the actual app as well as specimens, in both themes and at narrow, intermediate and wide widths with enlarged text. -## Future theme contributions (design boundary, not implemented API) +## Future theme contributions -A theme's identity is separate from its `light | dark` color mode. Future first-party -or plugin definitions can contribute named, versioned semantic values; the host owns -validation, explicit user selection, applying/removing overrides and a built-in -fallback when a contribution disappears. Do not make feature components branch on +There is no theme contribution API yet. A future theme would have an identity separate +from its `light | dark` mode and contribute named, versioned semantic values. The host +would own validation, explicit selection, applying and removing overrides, and fallback +when a contribution disappears. Do not make feature components branch on specific theme names. Do not advertise internal source imports as a versioned SDK. Add a supported contribution API only with its first real external consumer and unload/rollback tests. Trusted same-process plugins can already execute arbitrary code; these authoring conventions are not a sandbox or CSS security boundary. -## Evidence and remaining validation +## Appearance checks `shared/theme/service.test.ts` checks parser/bootstrap agreement, denied storage, save/retry/restore, events and disposal. `tokens.test.ts` checks complete paint roles @@ -81,8 +84,8 @@ accessibility audit (transparency, images and focus placement need inspection). `tests/browser/appearance.spec.mjs` exercises the production frontend with fixture relay data in Chromium and WebKit: Settings controls, reload, denied save/retry, cross-window updates preserving a live draft/DOM/scroll anchor, responsive screenshots, -and the dark document while the entire application JS bundle is withheld. Existing -Settings journeys also cover the added navigation stop. No live relay writes occur. +and the dark document while the entire application JS bundle is withheld. Settings +journeys also cover Appearance navigation. These tests do not write to a live relay. Emoji Mart uses the root mode and an in-place attribute observer, disposed with the picker. Browser regressions cover host Settings changes through an open widget, @@ -92,14 +95,15 @@ script, Settings writes/retry, host lifetime and widget initial/update/disposal Run the design guards, unit tests and relevant browser journeys for a changed component. Native window chrome and relaunch still need an attended packaged-app check; browser checks do not establish native acceptance or third-party plugin -styling. Keep full batch validation separate from an interactive preview. +styling. Follow the contribution workflow’s iteration and validation gates; record an +interactive preview separately from a validated batch. ## Interface size and shortcuts The host owns a separate device-local `buzz-font-scale.v1` preference (80–200%, -10% steps; default/reset 100%). Color-mode storage is unchanged. Settings → +10% steps; default/reset 100%). This preference is independent of color-mode storage. Settings → Appearance supplies visible decrease/increase/reset controls and save-failure retry. The bootstrap and appearance service apply `--buzz-text-scale`; invalid persisted values fall back to 100%, and same-origin storage events re-read the latest choice. @@ -107,7 +111,8 @@ values fall back to 100%, and same-origin storage events re-read the latest choi Command+, opens Settings on Apple platforms. Command+= / Command++ enlarge the interface, Command+- reduces it and Command+0 resets. Other platforms use Control. Zoom works while typing and in dialogs without changing browser/WebView zoom; Settings does -not navigate behind an open modal. The [shortcut service](plugin-architecture.md#in-app-keyboard-shortcuts) +not navigate behind an open modal. The [shortcut +service](plugin-architecture.md#in-app-keyboard-shortcuts) also serves plugins and owns event dispatch/lifetime rules. The preference scales the root rem size, so shared typography, controls, icons and @@ -128,18 +133,17 @@ dispatcher, grouped by owner, with each owner's deliberate numeric order, per-row Change/Reset and Reset all. Buzz's host rows use a functional sequence (navigation, interface sizing, search/settings, then development-only actions); plugins choose the order of their own actions. Equal orders use stable registry identity -and then title as tie-breakers. It -is built from existing components (`Input`, `Button`, `NavigationSection`, +and then title as tie-breakers. The page uses existing components (`Input`, `Button`, +`NavigationSection`, the shared PreferenceRow layout) and `formatBinding`, which renders chords as glyphs in Control, Option, Shift, Command order on Apple platforms (⇧⌘K) and as words -elsewhere (Ctrl+Shift+K), with a plain-words accessible label. A row whose chord -another listed shortcut also answers to carries a plain "Also used by …" line in -subtle text, no colour. Two pieces are -provisional and await a design pass: the key-combo `` chip +elsewhere (Ctrl+Shift+K), with a plain-words accessible label. When another listed +shortcut uses the same chord, the row shows “Also used by …” in subtle text. Two +feature-owned pieces still need a design pass: the key-combo `` chip (`src/features/shortcuts/KeyCombo.tsx`) and the inline key-capture control -(`src/features/shortcuts/KeyCaptureControl.tsx`). Both are deliberately -black-and-white: capture composes the shared Input with feature-owned sizing -and keyboard handling; notices retain explicit alert text in neutral roles. +(`src/features/shortcuts/KeyCaptureControl.tsx`). Both use neutral colors. Capture +composes Input with feature-owned sizing and keyboard handling; notices retain explicit +alert text. The keycaps use standard text, surface, border and radius tokens. Both live outside `src/shared/design-system/ui/`, and are marked with a `DESIGN PASS PENDING` file comment and `data-design-pass="pending"` on their root so they are greppable. @@ -148,7 +152,8 @@ leave capture without saving, with an accessible instruction explaining the exit Rows wrap their actions before the title collapses. `tests/browser/shortcuts.spec.mjs` covers real key dispatch to Settings and actual -message/composer text, icon/control/spacing geometry, draft/node preservation, reset/limits/reload, modal/editor/ +message/composer text, icon/control/spacing geometry, draft/node preservation, +reset/limits/reload, modal/editor/ Shadow DOM guards, the independent example's disable/re-enable path, and rebinding that example's shortcut from Settings → Shortcuts (host conflict refused, new chord fires, old chord does not, persists across reload, reset restores). These @@ -157,8 +162,8 @@ attended desktop shortcut try remains necessary for native acceptance. ## Incremental system integration -The host entry `src/shared/styles/globals.css` now loads the shared system's -palette, typography roles, materials and component styles with one Tailwind reset. +The host entry `src/shared/styles/globals.css` loads the shared palette, typography, +materials, and components with one Tailwind reset. It does not import the viewer's global entry, preference owner, docking vendor CSS or workspace experiments. Panel styling remains defined only by the shared system. @@ -169,7 +174,8 @@ forward to the same shared roles; they are not the vocabulary for new work. remain available outside shared-control boundaries. Shared primitives carry `data-buzz-ui`, including portal popup roots. Legacy -native-element selectors exclude that boundary and its descendants (without native CSS scope); shared typography starts there. +native-element selectors exclude that boundary and its descendants (without native CSS +scope); shared typography starts there. For new custom compositions use the same boundary and named type roles, not old native-element styling. Inline chips deliberately inherit their sentence's type. Do not nest legacy UI inside a migrated boundary without explicitly migrating it. @@ -183,14 +189,18 @@ preference and full-document typography; its settings do not change the host. BentoWorkspace still uses the viewer preference helper and is not ready for app adoption; no workspace experiment is imported by app startup. -The host mounts the shared input-modality hook once, including cleanup. Shared -keyboard focus remains visible; pointer focus does not acquire a ring. +The host mounts and cleans up the shared input-modality hook once. Its keyboard-focus +recipes remain visible while pointer focus stays quiet. The standalone viewer loads a +separate global outline suppression; see [Temporary focus +appearance](../src/shared/design-system/DESIGN.md#temporary-focus-appearance). Preserve +this explicit distinction rather than adding local focus overrides. The actual-app Appearance/shortcuts journeys cover startup, preferences, focus, and the temporary font/color compatibility contracts. Remove compatibility checks as their legacy consumers disappear; no separate legacy viewer or test suite is -needed. Browser checks do not establish native or packaged acceptance. Broad scan -remains an agreed integration-batch gate. +needed. Browser checks do not establish native or packaged acceptance. Run `just scan` +only when explicitly requested or needed to reproduce a broad integration failure, as +the contribution workflow requires. ## Baseline ownership and exceptions @@ -204,9 +214,9 @@ These are static guardrails, not a substitute for browser checks. Anchored emoji, mention, completion, account and diagnostics surfaces use `popover-surface` for their border, fill, elevation and layer. Their placement, scrolling and specialized keyboard/editor interactions remain feature-owned. -Transient picker/typeahead highlights use `affordance-subtle-hover` directly, -not a popover-wide override of persistent selection, so they stay visible on the -raised dark surface. Compact completion/emoji layouts may select shared radius +Completion highlights use `affordance-popover-selected` so they remain visible on the +raised dark surface. The shared popover recipe maps selection to this same role. Compact +completion/emoji layouts may select shared radius tokens to fit their inner geometry. Shared Button/IconButton `title` props render a shared Tooltip; content titles (full names, timestamps and media descriptions) remain native. @@ -218,16 +228,15 @@ renderer-owned. The design viewer's layout experiments are not bundled app UI. Plugin examples consume public CSS roles and host fallbacks. External plugins cannot be guaranteed to follow this system; no new plugin API is introduced here. -The browser adoption regression changes semantic fill, type and spacing values -and checks the actual Settings button, inline chips and production CSS inside -message-history containers and anchored popups. It exists because DOM emulation -cannot establish CSS layer ownership. +The browser adoption test changes semantic fill, type, and spacing values, then checks a +Settings button, inline chips, message-history containers, and anchored popups. This +verifies production CSS ownership across the real DOM and stylesheet layers. ## Message specimens -`just design` includes **Product patterns → Messages**, a catalogue of current -message content, attachments, delivery feedback, thread summaries, and membership -activity. Examples render the production message components against local sample +`just design` includes **Patterns → Messages**, a catalog of message content, +attachments, delivery feedback, thread summaries, and membership activity. Examples +render the production message components against local sample data; filters, narrow preview, and reset help compare states without a relay. Product specimens live in `tests/fixtures/message-gallery` and run in a separate @@ -238,8 +247,8 @@ available viewport height. The gallery scrolls inside its isolated document so fullscreen media and its Close control stay visible. Theme changes reload sample state. -This is a visual inventory, not live delivery or plugin validation. Composer, -presence, unread tracking, and timeline pagination remain outside this first pass. +Use this gallery to compare rendered message states. It does not validate live delivery, +plugins, composer behavior, presence, unread tracking, or timeline pagination. ## Content headers and settings fills @@ -248,16 +257,16 @@ Use `Header` for a settings page's title and introductory subtitle, and actions and heading level; the default levels are h2 and h3. Keep heading IDs on the title for labelled regions. `PanelHeader` still owns workspace chrome, and `DialogTitle`/`DialogDescription` retain dialog labelling semantics; do not replace -those with a generic heading. The initial adoption covers Profile, Plugins, -Appearance, Shortcuts and Notifications; other pages can adopt these when touched. +those with a generic heading. Profile, Plugins, Appearance, Shortcuts, and Notifications +use these headers. Adopt them on other pages when those pages are updated. Light-mode inset fields and quiet fills use neutral-2 (#f5f5f6). Panel and floating hover use that same stop; subtle-button hover is #f1f1f2. In dark mode, panels, popovers, subtle controls, and hover fills have separate steps. See the shared DESIGN.md quiet surface stack. -Authored CSS dimensions now use rem across shell, channel, message, picker, and -settings layouts. Physical strokes and browser/media measurement coordinates +Authored dimensions use rem across shell, channel, message, picker, and settings +layouts. Physical strokes and browser/media measurement coordinates remain pixels. The host interface-size preference scales the root; the timeline measures its rem-sized leading region for the virtualizer’s pixel start margin. @@ -293,8 +302,8 @@ its text and icon; the caller must also disable its control. Icons are decorativ and must not contain interactive content. Keep plugin management actions and errors outside the primary toggle row. Pages retain ownership of grouping and dividers. -The PreferenceRow catalog page includes interactive switch, checkbox, button, -icon, subtitle, disabled, and required-status examples. +Use the PreferenceRow page in the viewer to inspect switches, checkboxes, buttons, +icons, subtitles, disabled states, and required-status text. Standard Button sizes use the 14px `text-label-sm` role while retaining their existing minimum heights. The extra-small capsule keeps its caption role. diff --git a/scripts/design-system/check-contrast.mjs b/scripts/design-system/check-contrast.mjs index ca24e5b6a..9e9045300 100644 --- a/scripts/design-system/check-contrast.mjs +++ b/scripts/design-system/check-contrast.mjs @@ -114,7 +114,7 @@ const TEXT_ROLES = [ "--purple-12", // accent text: links, active nav, chip labels "--red-12", // error text: failed session start, rejected form "--amber-12", // warning text in delivery notices and dialogs - "--green-12", // completion text in the foundation alignment proposal + "--green-12", // success text ]; /** diff --git a/src/shared/design-system/AGENTS.md b/src/shared/design-system/AGENTS.md index 0d5211c49..a97bd0aeb 100644 --- a/src/shared/design-system/AGENTS.md +++ b/src/shared/design-system/AGENTS.md @@ -1,18 +1,36 @@ -# Design system handoff - -This is the app's design system. New UI and surfaces moving off the existing styles should use it. -This initial port does not migrate existing surfaces; that is a boundary of the PR, not a prohibition on adoption. -Read DESIGN.md and MAINTAINING_DESIGN_SYSTEM.md before editing. -Use semantic color roles and complete type roles; keep Base UI behavior and Phosphor icons through ../icons. -Block UI is the visual target. Palette steps belong in shared token definitions, not new component recipes. -Preserve keyboard focus behavior and test light/dark and narrow/intermediate/wide views. Focus outlines are temporarily hidden globally; follow DESIGN.md “Temporary focus appearance” and do not add local replacements. -Components live in ui/, values in styles/, documentation metadata in tokens/ and ui/registry.ts. -The standalone viewer lives in tests/fixtures/design-system and imports the real shared components. -Do not import features, plugins, native adapters, or app startup into this system or viewer. -When wiring it into the app, use the shared components and tokens rather than the viewer's documentation furniture. -For row lists in dialogs and panels, follow DESIGN.md § Align row content, not state backgrounds: offset the list composition, keep shared row padding, use consistent icon slots, and preserve narrow-screen gutters and keyboard focus outlines. -Integrate global styles deliberately through the host entry point instead of layering two resets, and keep the host appearance owner. -The theme helper is viewer-only; app surfaces read appearance through the host. -Run the root design:typecheck, design:check, design:test, and design:build scripts. - -Public-key display text uses `src/shared/identity/public-key.ts`, not hand-written slicing or a new visual component. See DESIGN.md “Public identity text”. Never format secret keys with it. +# Agent guide + +Use this system for new Buzz UI and for existing surfaces as they migrate. +Before editing, read [the design guide](DESIGN.md) and +[Maintaining the design system](MAINTAINING_DESIGN_SYSTEM.md). + +## Keep decisions with their owner + +- Shared components live in `ui/`, values and recipes in `styles/`, and documentation metadata in `tokens/` and `ui/registry.ts`. +- Use semantic color roles and complete type roles. Block UI is the visual reference; Base UI owns interaction behavior. Import Phosphor icons through `icons/`. +- Keep palette steps in shared token definitions. Product code uses roles and shared components, not viewer-specific layouts or styles. +- Keep features, plugins, native adapters, and app startup out of the system and core viewer. The viewer at `tests/fixtures/design-system` renders real shared components. +- Integrate global styles through the host entry point with one reset. The host owns appearance; `theme/useColorScheme.ts` belongs to the standalone viewer. + +## Preserve interaction contracts + +Keep keyboard focus, Tab order, selection, dismissal, and focus restoration intact. +The shared global stylesheet temporarily hides focus outlines; follow +[Temporary focus appearance](DESIGN.md#temporary-focus-appearance). Do not add local +replacement rings. Preserve the underlying keyboard-focus recipes and their space. + +For rows in dialogs and panels, follow +[Align row content, not state backgrounds](DESIGN.md#align-row-content-not-state-backgrounds): +offset the list wrapper, retain shared row padding, align icon slots, and preserve +narrow-screen gutters and space for keyboard focus. + +Public-key labels use `src/shared/identity/public-key.ts`. Do not slice keys by hand +or add a visual component for this formatting. Follow +[Public identity text](DESIGN.md#public-identity-text), and never pass secret keys. + +## Check the change + +Inspect light and dark mode at narrow, intermediate, and wide widths. Exercise the +changed interaction with a keyboard and pointer. Run the root `design:typecheck`, +`design:check`, `design:test`, and `design:build` scripts for the completed change, +following the root contribution workflow’s iteration and validation gates. diff --git a/src/shared/design-system/DESIGN.md b/src/shared/design-system/DESIGN.md index 54847c534..c43f34a7d 100644 --- a/src/shared/design-system/DESIGN.md +++ b/src/shared/design-system/DESIGN.md @@ -1,4 +1,4 @@ -# DESIGN.md +# Design guide ## Direction @@ -53,7 +53,8 @@ This guide describes how to use the system: surface relationships, hierarchy, identity, interaction and composition. The token registry documents the available values and their purpose. -Run `pnpm design:dev` and open `/tests/fixtures/design-system.html` to see the system rendered from the tokens themselves. +Run `pnpm design:dev` and open `/tests/fixtures/design-system.html` to see the system +rendered from the tokens themselves. ## Identity shapes @@ -63,8 +64,9 @@ not density or emphasis. The caller supplies identity type from domain data, never a name or picture heuristic. `size="fill"` fills the owning layout’s available space. Shape clips the artwork, never the interactive focus target. Avatar-only controls use `IconButton variant="avatar"` so the surrounding backdrop -shows through their cutouts at rest, hover, press, and while a menu is open. The -button retains its unmasked keyboard focus ring. +shows through their cutouts at rest, hover, press, and while a menu is open. The focus +target stays unmasked. Its keyboard-ring recipe remains subject to the temporary focus +appearance policy below. Circular and squircle avatars can add `statusBadge="online" | "away" | "offline"`. The dot uses a semantic green, yellow, or grey role with light and dark values. Its inset cutout and dot scale with the existing avatar size; the dot is separate from the @@ -79,8 +81,8 @@ Away uses an unoutlined Amber 10 fill at the designer's explicit request. 1.4.11 3:1 non-text contrast target on supported neutral surfaces. The contrast guard reports exact accepted light-mode role/color/surface pairs; other pairs remain enforced. This exception is not an accessibility pass. Dark Away meets -3:1 on these opaque surfaces. Offline stays unoutlined. Badge footprints, -Bézier artwork cutouts, presence behavior and accessible names are unchanged. +3:1 on these opaque surfaces. Offline stays unoutlined. Keep badge footprints, Bézier +artwork cutouts, presence behavior, and accessible names with the shared Avatar owner. For a separate trailing action attached to a row, use `IconButton shape="row-end"` in a stretched flex slot. It keeps the size-selected width, fills the row height, @@ -104,8 +106,8 @@ whether a caller supplied a secret: callers must use public-identity fields only Keep the full key for routing, persistence, identity comparisons, and explicit key copy actions. Short labels are recognition aids, not proof of identity. Full-key -inspection/export surfaces remain explicit exceptions. Existing surfaces are not -migrated automatically; new abbreviated public-key displays should reuse this rule. +inspection/export surfaces remain explicit exceptions. Reuse this formatter for new +abbreviated public-key displays; migrate existing surfaces deliberately. Identity display names use the active naming policy, not a separate composer rule. The default policy compares trimmed resolved names case-sensitively (`Honey` and @@ -130,37 +132,72 @@ Removal is immediate and reduced motion disables the reveal animation. ## Posture -Buzz is a place where people build together and bring their agents into the room. Everyday surfaces stay quiet, crisp, and highly functional; character shows up in identity, guidance, transitions, and ceremony rather than in the chrome of ordinary work. Colour is signal, not decoration. When in doubt, the interface gets out of the way of the conversation. +Buzz is a place for people and their agents to build together. Keep everyday +surfaces quiet and functional so the conversation stays central. Use character +in identity, guidance, transitions, and ceremony. Use color to convey meaning. ## Surface and depth -- **Panels sit on the backdrop; the backdrop is a gradient.** The app shell uses one `Panel joined` around navigation and content. Nested Panels keep their opaque fill and clipping but lose independent borders, rounding, and shadows; layout owners separate adjacent regions with `border-standard` hairlines. Standalone Panels retain their own outer surface. -- **A region is separated by a soft fill, not by an outline.** Reach for `bg-inset` before reaching for a border. A bordered box announces its own edges; a filled one lets the content sit in a place. Grouping is the common case, so the quiet treatment is the default one. -- **Use border-standard for quiet separators, border-prominent for controls and border-focus for keyboard focus.** Error and warning boundaries have their own roles. Measure real surfaces in both themes. -- **No page-wide gradient behind documentation or dense reading.** The gradient is the product's backdrop for chrome and panels. Behind a column of prose it fights the text and makes contrast position-dependent — such surfaces sit on `bg-panel`. -- **Shadows stay at the threshold of perception.** If a shadow is obvious, it is too strong. The two elevation values are the whole vocabulary. -- **Floating controls share one outer material.** Menus, selects, popovers, and preview cards use the opaque `floating-surface`: floating fill, primary boundary, panel radius, and graduated lift. Each component still owns its content padding and interaction behavior; sharing the container does not imply that a preview behaves like a menu. -- **Floating rows need their own hover contrast.** Menu, select, and popover activity rows use `affordance-floating-hover` (neutral 2 in light mode, neutral 5 in dark). Supporting text becomes standard text on highlight so it stays readable. Selection marks remain independent of hover. Small action menus and compact account popovers use 10px `radius-row` outer corners with a 4px list inset and 8px inner rows (80% of the outer radius). Their hover uses `affordance-subtle-hover` with immediate feedback. Default/wide menus, content popovers, pickers, dialogs, and alert dialogs retain 24px `radius-panel` outer corners. Choose compact explicitly for short action lists, never automatically from viewport width. -- **Elevation is carried by shadow in light mode and by lightness in dark mode.** On a near-black background there is nothing darker for a shadow to cast, so a floating surface becomes a step lighter instead. Never reach for a stronger shadow to make something float in dark mode. -- **On a translucent surface, elevation reads as less translucency, not as a lighter colour.** A glass container with a fully opaque child looks layered; the same container with a merely brighter child looks unchanged. -- **Light comes from one direction, and every glass surface agrees on it.** A glass rim is bright along the lit edge and dimmer on the opposite one; that is what makes it read as a material rather than an outline. Two surfaces lit from different directions in the same view look like a mistake. -- **A glass rim is not an outline.** If a surface needs a visible boundary rather than a material edge, it wants a border role, not glass. -- **Glass needs something behind it worth seeing.** Translucency over a flat fill is wasted cost; use it where the gradient, an image, or content actually shows through. -- **Only panels and chrome should sit directly on the gradient as a default.** Text and hairlines on a gradient have position-dependent contrast. Good practice rather than a hard rule — a rotated label pill on the backdrop is fine. -- **A translucent surface has no contrast guarantee, and this one is measured.** `check-contrast` pairs each text role with the *opaque* surface roles, so glass is invisible to it — the surface a person actually reads against is the fill composited over whatever gradient happens to be behind it, which varies by position on screen. Sampled from a rendered dark-mode screenshot, primary glass over Night garden runs from `#162e28` in its quiet regions to `#1e4a3c` where the green glow reaches through. On the darker end everything clears; on the brighter end **`text-secondary` measures Lc 58 and `text-tertiary` Lc 43**, against targets of 60 and 45. Marginal, and only in a region the glow reaches — but real, and no guard can see it. Three ways out, none obviously right: make the gradients' bright stops dimmer where panels sit, raise the glass fill a ramp step under a bright backdrop, or keep meta text off glass. **Deliberately unresolved** — it needs the real product content on screen, not a token edit. -- **A redundant fill on glass is not free — it compounds.** Two identical translucent layers are not one layer: `glass-2` over `glass-2` composites to **0.77 alpha**, a value no token holds. Four panels each set the same fill as the container they exactly covered, so panels meant to be the most translucent surface in the system read as nearly solid. Before giving a region a glass fill, check whether its parent already is glass; if the region covers it, it needs no fill of its own. -- **A component that can sit on either the gradient or a panel says so, with a variant.** `Tabs` takes `chrome` (a glass pill for the app backdrop) or `panel` (an underline for a plain surface); `IconButton` has the same axis as its `chrome` variant. The failure that earned it: the chrome container is `glass-2`, which over a white panel composites to pure white, and its selected pill is `neutral-1` — also pure white. Container and selection became one colour with only a shadow between them, and no guard could see it because the component had no way to state which background it expected. **The fix was never to retint `--bg-chrome-selected`** — that moves the collision rather than removing it. **One component with a variant, not two components:** behaviour, keyboard model, accessibility, props, and the Base UI parts underneath are identical, so a sibling component would duplicate all of it to change how selection is drawn, and the two would drift exactly as the four hand-assembled chrome surfaces did. When adding a component that could appear in both places, give it the axis and put both on its specimen page — the chrome-only specimen is why this defect survived until it appeared on a real screen. - -Underlined panel tabs keep their labels at intrinsic width and scroll their own -Base UI tablist when the content column is narrow. Keyboard navigation reveals -the focused tab; the shared panel grid must use a shrinkable column so tab labels -do not widen the content below them. +Use the surface that matches the content and its position in the interface. + +- **Panels:** the shell uses one `Panel joined` around navigation and content on the gradient backdrop. Nested Panels keep their opaque fill and clipping but lose independent borders, corners, and shadows. The layout owner separates adjacent regions with `border-standard` hairlines. Standalone Panels keep their own outer treatment. +- **Recessed regions:** use `bg-inset` for a region pushed into its surrounding surface. Use quiet fills where they communicate separation; reserve outlines for boundaries that need to be identified. +- **Reading surfaces:** documentation and dense prose sit on `bg-panel`. Keep page-wide gradients behind product chrome and panels, where their changing contrast will not interfere with reading. +- **Borders:** use `border-standard` for quiet separators, `border-prominent` for controls, and `border-focus` for keyboard-focus recipes. Errors and warnings have their own boundary roles. Measure the actual pairing in both themes. +- **Shadows:** use the two shared elevation values and keep them subtle. Light mode relies on shadow; dark mode also raises the fill’s lightness. Do not strengthen a shadow to separate a dark floating surface. + +### Floating surfaces + +Menus, Select, Popover, and PreviewCard share `floating-surface`: an opaque fill, +primary boundary, panel radius, and lift. Each component owns its content spacing +and interaction. Shared material does not make these controls interchangeable. + +Default and wide menus, content popovers, pickers, dialogs, and alert dialogs use +24px `radius-panel` corners. Short action menus and compact account popovers opt +into 10px `radius-row` corners, a 4px list inset, and 8px inner rows. Choose compact +for the content, never automatically because the viewport is narrow. + +Floating rows use `affordance-floating-hover` (neutral 2 light / neutral 5 dark). +Supporting text becomes standard text on highlight. Keep persistent selection +independent of hover. Compact menus use `affordance-subtle-hover` with immediate +feedback and retain their separate selected fill. + +### Glass + +Use glass where a gradient, image, or content is visible behind it. Apply the +complete material so fill, blur, rim, and optional shadow stay together. + +- More opaque glass reads as a higher layer. A brighter fill alone does not communicate the same depth. +- Keep the rim’s light direction consistent across surfaces. Use a border role when you need a visible boundary rather than a material edge. +- Avoid adding a glass fill when the region already covers an identical glass parent. Two `glass-2` layers composite to 0.77 alpha and become more opaque than either intended layer. +- Panels and chrome normally sit directly on the gradient. Text placed there needs inspection because its contrast changes with position; small decorative labels may be deliberate exceptions. + +**Unresolved contrast:** opaque token checks cannot validate glass over a variable +backdrop. A rendered dark-mode sample of primary glass over Night garden ranged +from `#162e28` to `#1e4a3c`. In the brighter area, `text-secondary` measured Lc 58 +and `text-tertiary` Lc 43, below their 60 and 45 targets. Review real product +content before choosing a fix: dim the backdrop, increase glass opacity, or keep +metadata off that glass surface. These measurements do not establish a general +contrast guarantee. + +### Match controls to their surface + +Use `Tabs variant="chrome"` for a glass pill on the backdrop and `variant="panel"` +for an underline on a plain surface. IconButton also provides a `chrome` variant. +A chrome selection can blend into a white panel; choose the appropriate variant +instead of retinting `--bg-chrome-selected` for one caller. Keep shared behavior +in one component and show both surface variants in its examples. + +Panel tabs keep labels at intrinsic width and scroll within the Base UI tablist +when space is limited. Keyboard navigation reveals the focused tab. The surrounding +panel grid must have a shrinkable column so tabs do not widen other content. ## Temporary focus appearance -Focus outlines are currently hidden globally at the designer's request while -forms are being polished. The centralized override in styles/globals.css takes -precedence over the keyboard-ring recipes documented below. Keep focusability, +The shared global stylesheet currently hides focus outlines at the designer’s request +while forms are being polished. The override in `styles/globals.css` takes precedence +over the keyboard-ring recipes in this guide. This is a known visible-focus +accessibility exception, not an accessibility pass. Keep focusability, Tab order, input modality, selection, and focus restoration intact. Do not add local replacement rings or disable keyboard interaction. Shared text fields now use a border flush with the field perimeter: surface-inset fill and a 1px @@ -169,7 +206,8 @@ field-scoped --border variable selects transparent, active, or error color for the reserved 1px border, so state changes do not shift the layout. Keyboard focus and reduced motion change immediately. Composite fields own one stroke around the input and actions; error strokes retain priority. Placeholders use -text-metadata, one step quieter than supporting text, in both themes. This supersedes the older keyboard-only +text-metadata, one step quieter than supporting text, in both themes. This supersedes +the older keyboard-only and no-container-ring recipes for these fields. Existing component recipes remain so this temporary visual decision can be reversed in one place. @@ -229,21 +267,21 @@ is called complete; token math alone cannot validate the CSS cascade. Button and IconButton share prominent, subtle, ghost, inverted, destructive, outline and link emphasis. Inverted is for an inverse surface; link keeps its -background clear and underlines on interaction. Its -32 / 40 / 52px sizes are sm / md / lg at the default scale, with minimum +background clear and underlines on interaction. Their 32 / 40 / 52px sizes are sm / md / +lg at the default scale, with minimum heights that accommodate larger text. Text buttons use `--radius-capsule` (1.625rem / 26px): capsule-shaped through the default 52px large size, clamped naturally on shorter controls, and bounded on taller ones. Reuse this role for similarly sized actions; `--radius-pill` remains the fully round role for circles and pills of any height. Fields retain `--radius-control`. -Button labels stay on one line and do not shrink in flex layouts, following -shadcn's `whitespace-nowrap shrink-0` behavior without changing Buzz's sizing, -emphasis, or Base UI interactions. Parents must reflow whole controls or provide +Button labels stay on one line and do not shrink in flex layouts (`whitespace-nowrap +shrink-0`). Parents must reflow whole controls or provide scrolling when space is limited. Composite reply summaries may reflow whole avatar/count groups without wrapping individual labels. Small buttons use 16px side padding and 16px icons; medium and large use 24px side padding and 24px icons. -Labels use the complete text-label-sm / text-label roles, with an 8px icon gap. +Standard Button labels use the complete `text-label-sm` role with an 8px icon gap; the +extra-small capsule uses `text-caption`. IconButton defaults to round and uses the same sm/md/lg sizes. Existing names remain compatibility aliases: primary/solid → prominent, quiet → subtle, @@ -256,8 +294,8 @@ colors while blocking activation; never swap in a differently sized loading labe Pointer hover uses shared state timing; expanded triggers retain pressed emphasis. Keep keyboard-only focus and reduced-motion behavior owned by the system. -IconButton also offers `xs` (28px with 16px icons) for dense composer formatting -options, preserving the original toolbar layout. Mode toggles remain `sm`. +IconButton also offers `xs` (20px with 12px icons) for dense formatting actions. Mode +toggles remain `sm`. IconButton defaults to round across all sizes and variants. Use `shape="control"` only when a rectangular control shape is explicitly needed. Disabled ghost icons @@ -296,7 +334,7 @@ reversible transform transition. Keyboard navigation and reduced motion switch the orientation immediately. Loading indicators keep their separate behavior. Fields share a 40px minimum size at the default scale, the control radius, -text-body (now 14px / 20px across Buzz), and the 16px control inset. Derive vertical padding from the control +text-body (14px / 20px), and the 16px control inset. Derive vertical padding from the control size, text line height, and boundary; do not force a fixed height that clips larger text or wrapped Select values. Textarea uses the control inset on all four sides (16px at the default scale), with manual vertical resizing and a code variant. @@ -309,21 +347,24 @@ variant, SearchField, and Combobox.Control already own their label and supportin text; do not add a second Field around them. Use description/error for connected help and validation. An error replaces the secondary description until it clears; keep the accessible description synchronized with the visible message. Validation -strokes belong to the outer field, never its auxiliary buttons. Keep feature-owned asynchronous status connected through +strokes belong to the outer field, never its auxiliary buttons. Keep feature-owned +asynchronous status connected through aria-describedby. Forms own 16px between adjacent fields and the 32px section gap between named groups. InputGroup shares the inline frame for SearchField and Combobox. Icons use an 8px gap, and trailing actions retain a stable slot. SearchField uses the same -12px control radius as other fields, with no separate navigator shape. Focus belongs to the input or action, +12px control radius as other fields, with no separate navigator shape. Focus belongs to +the input or action, while the frame owns the active perimeter stroke. Read-only values can be read and copied; disabled actions cannot change a value. Search clear restores input focus. Features still own filtering, custom values, and async recovery. SearchField, Combobox.Control, and code Textarea default to no autocorrection, capitalization, or spellcheck. Callers can override these defaults explicitly. -Ordinary Input and prose Textarea retain platform defaults. See the -[exact-text input audit](../../../docs/input-correction-audit.md) for remaining fields. +Ordinary Input and prose Textarea retain platform defaults. Check the defaults in +`ui/SearchField.tsx`, `ui/Combobox.tsx`, and `ui/Textarea.tsx` when adding an exact-text +field. The Forms page in Just Design documents states, usage, and a form-in-dialog example. Review it with both themes, narrow widths, and enlarged text before @@ -356,25 +397,32 @@ and let the containing item own state and padding. Use the small shared avatar for identity choices, retaining human/agent shapes. PopoverPopup uses 16px content padding, or `padding="list"` when its rows own their -spacing. Use `size="compact"` with list padding for short account/action surfaces: 14rem width and 10px corners. `MenuPopup size="compact"` uses the same corner, inset, row and hover treatment for short action lists. Content and wide popovers retain 24px corners. Name it with PopoverTitle or aria-label; PopoverDescription connects +spacing. Use `size="compact"` with list padding for short account/action surfaces: 14rem +width and 10px corners. `MenuPopup size="compact"` uses the same corner, inset, row and +hover treatment for short action lists. Content and wide popovers retain 24px corners. +Name it with PopoverTitle or aria-label; PopoverDescription connects supporting copy. Hover opening is optional and remains configured by its feature. -Use `padding="none"` for an embedded picker that owns its internal spacing, such as emoji/GIF content. +Use `padding="none"` for an embedded picker that owns its internal spacing, such as +emoji/GIF content. Menus and popovers use a quicker version of the form dropdown motion: 75ms entry and 60ms exit (half the state/fast duration tokens), a 2px offset and blur-to-sharp opacity fade. Movement uses easing-settle; opacity and filter use easing-state. The offset follows the actual placement side toward the trigger, including collision flips and nested menus. This extends the designer-requested blur exception to these anchored surfaces. -Keyboard navigation and reduced motion remove transitions, movement, and blur. The Just Design Menu, Popover and ChoiceRow pages +Keyboard navigation and reduced motion remove transitions, movement, and blur. The Just +Design Menu, Popover and ChoiceRow pages show these contracts and their compositions. ## Compositions PanelHeader owns one consistent header frame: leading `navigation`, title/icon, and trailing `actions`. Use a toolbar IconButton with ArrowLeft for a local back -action and X for closing the panel. The default 2.5rem (40px at the default root size) minimum height aligns conversation, +action and X for closing the panel. The default 2.5rem (40px at the default root size) +minimum height aligns conversation, thread, profile, tabbed workspace, and Todos headers. The compact variant shares -this height. Headers use 0.25rem inline padding (matching the centered 2rem controls’ block inset), 1rem identity icons, and +this height. Headers use 0.25rem inline padding (matching the centered 2rem controls’ +block inset), 1rem identity icons, and 0.25rem gaps between action buttons without reducing their hit areas. Navigation tabs use 1rem icons or fill avatars and 0.5rem leading padding. Header spacing, icons, and controls scale with rem; separators remain @@ -461,9 +509,12 @@ remain above the stack. Content updates do not restart expiry; timeout changes d Tabs with content use renderPanel, which lets Base UI connect each tab and panel. Route navigation uses NavigationItem with aria-current instead. Tabs can also compose NavigationItem through the `navigation` variant: these retain tab -semantics, use 12rem widths with ellipsis and a subtle selected fill, accept avatars/icons, and place a sibling close -button over reserved trailing space. Navigation tab strips scroll horizontally with a thin native scrollbar. The main -channel header uses the same control with a single non-closable tab with `showSelection={false}` (no selection or hover fill); channel +semantics, use 12rem widths with ellipsis and a subtle selected fill, accept +avatars/icons, and place a sibling close +button over reserved trailing space. Navigation tab strips scroll horizontally with a +thin native scrollbar. The main +channel header uses the same control with a single non-closable tab with +`showSelection={false}` (no selection or hover fill); channel actions remain in the header action slot. The settings launcher uses `data-highlight-expanded="false"` to preserve disclosure semantics without a sticky pressed treatment; the selected tab owns the open-state indicator. @@ -491,11 +542,13 @@ the hovered row’s trailing action unobstructed. ## Menu row corners -Every shared menu item uses `--radius-pill` on all four corners. First, middle and -last rows keep the same fully rounded highlight, so moving between them does not -change its shape. Direct items, grouped choices and submenu triggers share this -recipe. Do not add positional or feature-local radius overrides, derive a special -menu inset radius, or change the global row radius to correct a menu. +Default and wide menus use `--radius-pill` for every row. First, middle, and last +items keep the same highlight shape, including grouped choices and submenu +triggers. Compact menus use their shared 8px inner corners within the 10px outer +surface. + +Use these shared recipes. Do not add positional or feature-local radius overrides, +derive a new inset radius, or change the global row radius for one menu. ## Align row content, not state backgrounds @@ -523,33 +576,33 @@ should remain legible without a state background to explain it. ## State -- **Design default, hover, pressed, focus, selected, disabled and loading states where they apply.** Pressed changes fill without moving the control. Loading keeps the label footprint and prevents repeated activation; CSS alone cannot enforce it. -- **Hover means one step more contrast, in whichever direction that surface needs.** A light row darkens, a dark chip lightens. Direction lives in the value. -- **Selected is a persistent statement, not a stronger hover.** It should be legible without a cursor present. -- **A selected item in a toggle group is not interactive.** Clicking it does nothing, so it gets no hover. -- **Disabled communicates unavailability, not quietness.** It is not a fourth level of the emphasis ramp. -- **Never hide the only way out of a state.** Before adding a visibility rule, ask what happens when the state it assumes is wrong, and whether the person can still recover. +Define default, hover, pressed, focus, selected, disabled, and loading states where +they apply. Keep the same control recognizable across those states. + +- Pressed changes the fill without moving the control. +- Loading preserves the label’s space and blocks repeated activation in behavior, not just CSS. +- Hover adds contrast appropriate to the surface: a light row darkens, while a dark chip may lighten. +- Selection remains visible after the pointer leaves. A selected toggle-group item does nothing when selected again and has no hover treatment. +- Disabled means unavailable. Do not use it as a quieter text-emphasis level. +- Keep a way to recover visible even when a state assumption or visibility rule is wrong. ## Emphasis -- **Three levels of text: normal, lesser, really lesser.** If a fourth seems necessary, the thing wants a different size, weight, or position instead of a fourth colour. -- **Two text colours do most of the work.** Treat the third level as genuinely for metadata. -- **Borders describe their job:** a quiet edge, a control boundary or focus. -- **Weight and size carry hierarchy before colour does.** Reaching for a louder colour to fix hierarchy usually means the size relationship is wrong. +Use three text levels: primary content, supporting text, and metadata. Primary +and supporting text should carry most of the interface. If you need more hierarchy, +first adjust size, weight, position, or grouping rather than adding a fourth color. + +Name borders for their purpose: quiet separation, control boundaries, or focus. ## Type -Two layers, and the same rule as colour: only roles are used when building a -screen. Layer 1 is the raw ramps (`--type-size-*`, `--type-leading-*`, -`--type-tracking-*`, `--type-weight-*`); layer 2 is the roles, which register in -Tailwind's `--text-*` namespace and become utilities like `text-body`. The -authoring lives in `src/shared/design-system/styles/typography.css`. +Use complete type roles when building a screen. The raw ramps (`--type-size-*`, +`--type-leading-*`, `--type-tracking-*`, `--type-weight-*`) supply values; roles combine +them into Tailwind utilities such as `text-body`. Both live in `styles/typography.css`. -**Size roles and colour roles never collide**, because they live in different -namespaces: colour registers as `--color-*` and is named for emphasis -(`text-primary`), size registers as `--text-*` and is named for an editorial job -(`text-body`). So `text-primary text-body` is one colour plus one setting, and no -name ever means both. +Color and type roles use separate namespaces. Color registers as `--color-*`; type +registers as `--text-*`. For example, `text-standard text-body` combines a color with a +complete type setting. The active sizes are 12, 14, 16, 18, 20, 24, 28, 32, 36, 44, 56, 72 and 96px at 100% interface size. Sans roles use Inter and mono roles use JetBrains Mono. @@ -565,43 +618,49 @@ Values scale with the host interface-size preference. `--type-xsmall-size` points to the existing 12px step, keeping the semantic independent from caption even though their sizes currently match. `text-mono-lg` and `text-mono-sm` are compatibility aliases for this same setting, not extra sizes. -- Caption uses 12/16 and 0.0133em tracking. Default reading text is 16/24. +- Caption uses 12/16 and 0.0133em tracking. Buzz’s `text-body` uses 14/20; `text-label` uses 16/24 and `text-body-lg` uses 20/28. - Preserve text preferences and browser zoom. Author values in scaled rem and keep layout geometry independent of text scaling. Typography provenance: the ramp and role settings derive from the pinned -[Block UI typography specification](https://github.com/squareup/design-blockinterface/blob/eff766161ba8aaee3258ca107f0d904dd542c708/blockUI/docs/type.resolution.draft.json). +[Block UI typography +specification](https://github.com/squareup/design-blockinterface/blob/eff766161ba8aaee3258ca107f0d904dd542c708/blockUI/docs/type.resolution.draft.json). The values documented above define this system, including the 12px xsmall role. ## Both modes -- **Design in both modes, not in light and then dark.** Dark is not a filter applied afterwards: elevation, glass, and accent text all behave differently there. -- **Accent text moves in opposite directions between modes.** Darker than its fill on a light background, lighter on a dark one. -- **A tint is a pale wash in light mode and a deep one in dark.** The name describes the job, not the lightness. -- **Check the pairing, not the swatch.** A colour is only right in the context of what sits on it and behind it. -- **Every dark value in this system is authored rather than observed.** The design exploration it came from is light-only. Treat anything that looks wrong in dark as a finding. +Design and inspect light and dark mode together. Elevation, glass, and accent text +need their own values in each mode. + +- Accent text is darker than its fill on light surfaces and lighter on dark surfaces. +- Tints are pale in light mode and deep in dark mode; their names describe their purpose. +- Check text with its actual fill and backdrop, including interactive states. +- The original design exploration was light-only. Buzz’s dark values are authored choices and should be revised when rendered evidence shows a problem. ## Density and rhythm -- **Scrollbars share one native treatment.** Use `scrollbar-width: thin` and - `scrollbar-color: var(--scrollbar-thumb) transparent`. The thumb is gray in - both modes. Load the shared scrollbar recipe into vendor shadow roots too; - let the browser own scrolling and scrollbar visibility. +Choose the content structure first: a list, reading column, gallery, settings +group, or workspace. Use generous space by default, with tighter relationships +inside a group than between groups. -- **Dense data renders as rows with dividers, edge to edge.** Wrapping every list item in its own card is the most common way a functional surface becomes a marketing page. -- **Content that separates itself needs no divider, and no container.** A divider is for uniform rows where the eye needs a line to track along. When each entry already carries a visible difference — a colour swatch, a type specimen, an avatar — the content is the separator, and adding a rule or a card on top is redundant structure. Space alone is enough. -- **Never judge a value against a surface it will not be used on.** A swatch on a grey fill, or a type specimen in a tinted box, is being evaluated in a context the product will never reproduce. Samples sit on the page. The one exception is a value that needs a backdrop to exist at all — translucency needs something behind it, and a white surface swatch needs a hairline or it renders as nothing. -- **Cards are for widgets, galleries, and settings groups.** A card is a bordered, padded region on the page, not a different depth. -- **A card carries no default fill.** It sits on `bg-panel` and is grouped by a hairline or by spacing. Fill on a card is reserved for `bg-hover`, and only where the card is actually clickable — so a filled card always means *you are pointing at this*, never merely *this is a box*. This is why there is no `bg-card` role: a card with no default fill needs no name. It also rules out the cards-in-cards look, where a filled card inside a filled panel reads as a stack of empty text fields. Reaching for `bg-inset` here is the specific mistake — `inset` means pushed in, like an input or a code block, which is the opposite gesture from grouping. -- **Pick the frame before the content.** Decide what the surface is — a list, a reading column, a workspace — before filling it. -- **Whitespace is generous by default.** Crowding reads as a different product. +- Dense data uses edge-to-edge rows and dividers where a line helps track the row. +- Distinct swatches, type samples, and avatars often need only space between them. Avoid adding a card or divider when the content already separates itself. +- Show samples on the surface where they will be used. Glass needs a backdrop; a white swatch on white needs a quiet boundary. +- Cards group widgets, gallery items, or settings. They sit on `bg-panel` with spacing or a hairline and have no default fill. Use `bg-hover` only for an interactive card’s hover state. There is no `bg-card` role; `bg-inset` is for recessed content such as inputs or code blocks. +- Use native thin scrollbars: `scrollbar-width: thin` and `scrollbar-color: var(--scrollbar-thumb) transparent`. The thumb is gray in both modes. Load the shared recipe into vendor shadow roots and let the browser control scrolling and visibility. ## Motion -- **Direct manipulation follows the pointer exactly, with no easing.** Smoothing during a drag or resize reads as lag. Spring physics belongs to what happens after release. -- **A drag gesture must not select text in whatever it passes over.** -- **Never animate blur.** Re-blurring a large surface every frame is expensive enough to feel. Animate opacity instead. -- **Motion explains a change; it does not decorate one.** If removing an animation loses no information, remove it. +Use motion to explain a change in state, position, or structure. Remove animation +that adds no useful information. + +Direct manipulation follows the pointer without easing. Apply settling motion +after release, and prevent a drag from selecting text it passes over. + +Keep blur fixed during general surface transitions; animate opacity instead. +The form dropdown, menu, popover, and tooltip sections document narrow, +designer-requested blur exceptions. Respect each component’s keyboard and +reduced-motion treatment. ## Colour structure @@ -612,7 +671,7 @@ screens consume those roles so one shared edit can change every caller. |---|---|---| | **Palette** | `--purple-9`, `--neutral-4` | Shared token definitions; each hue has authored light and dark steps. | | **Roles** | `--surface-panel`, `--text-danger`, `--affordance-subtle-hover` | Component recipes and product screens. | -| **Components** | Button, TextField, Dialog | Product features that need the same appearance and behavior. | +| **Components** | Button, Input, Dialog | Product features that need the same appearance and behavior. | Choose a role by its job, even when it uses the same palette step in both modes. Add roles for real uses and their required states; document the intended surfaces @@ -654,14 +713,9 @@ separate WCAG 3:1 non-text ratio; decorative separators and hover fills do not. - **Use both measurements.** The ratio establishes the AA floor; APCA adds a polarity-aware readability check. A perceptual pass alone does not establish WCAG conformance, and a numeric pass does not replace rendered inspection. -- **Constrain the fill, never degrade the text.** If neither black nor white - carries a fill legibly, the fill is wrong — it is not a valid solid. Move the - fill's lightness and keep the hue; do not settle for the less-bad text. -- **A paired text token is derived, not authored.** `text-on-*` is a function of - its fill, so it is generated with the fill and never hand-set. Every hand-set - pairing in this system has been wrong at least once. -- **One implementation of the rule.** Desktop, mobile, and web must not each - compute their own pairing; they diverge and the same defect ships three times. +- **Adjust the fill to support readable text.** If neither black nor white is legible on a solid fill, change its lightness while preserving the hue. +- **Derive paired text from its fill.** Generate `text-on-*` with the fill instead of setting it independently. +- **Share the pairing logic.** Desktop, mobile, and web use one implementation. - **Size a text step against the worst surface it can land on**, including hover, pressed and selected fills. A step that only clears a panel at rest can fail when a menu row highlights. @@ -673,15 +727,9 @@ separate WCAG 3:1 non-text ratio; decorative separators and hover fills do not. that a control is unavailable. Never put information a person needs there. - **`pnpm design:check` enforces this.** Every text role is measured against every surface it can sit on, in both modes, parsed from `tokens.css` so the - check cannot drift from the tokens. Exceptions live in that script with a - stated reason, which keeps the list short and arguable. -- **A tint's hover is the hardest surface an identity has**, so a `text-*` role is - sized against that rather than against the neutral panel. Every failure the - audit found on a coloured surface was on a tint-hover, never at rest. -- **Hairline dividers are not held to a contrast target.** WCAG's 3:1 non-text - rule covers boundaries needed to identify a *control* or its state, not - grouping lines. Buzz's borders measure 1.2–1.8:1, which is where Radix and - Apple ship theirs; raising them would draw the box the fill already implies. + check cannot drift from the tokens. Keep exceptions in the owning guard with a stated reason. +- **Check identity text on tint hover.** Measure its `text-*` role against the highlighted tint as well as its resting surface. +- **Decorative dividers have no contrast target.** WCAG’s 3:1 non-text requirement applies to boundaries needed to identify a control or state. Keep quiet grouping lines distinct from required control boundaries. Error and warning boundary roles must reach 3:1 against surface-base, surface-panel, surface-inset and surface-popover in both themes. The contrast guard checks these role mappings and status dots separately from text and @@ -691,7 +739,7 @@ separate WCAG 3:1 non-text ratio; decorative separators and hover fills do not. Inline links and mentions use Blue 11 with Blue 3 hover in both modes. Blue 11 is tuned for the supported surface stack: #0b5fa8 in light mode and -#83c4ff in dark. The former pair-specific link exemptions have been removed. +#83c4ff in dark. These pairs have no link-specific contrast exemptions. The contrast guard checks APCA and WCAG AA (4.5:1) on opaque text pairs, including hover and pressed fills. Metadata uses neutral 9 (#5f5f5f) in light mode so small labels remain readable even on the pressed neutral 4 surface. @@ -711,7 +759,7 @@ Borders stay decorative unless needed to identify a control or state. ### Relative sizing Use rem for authored UI dimensions and spacing, semantic roles for text, and -unitless line-height. At the default 16px root the migration retains geometry. +unitless line-height. Dimensions are based on a 16px root at the default interface size. The host interface-size preference scales the root, so text, numeric icons and rem layout spacing grow together. Keep physical hairlines, optical offsets, and runtime geometry returned by the browser or media APIs in pixels. Sidebar resize limits @@ -720,23 +768,38 @@ the timeline measures its rem-sized leading region for Virtua's pixel start marg ## Writing -- **Every word earns its place.** Prefer the shortest phrasing that stays accurate. -- **Labels say what happens, not what the thing is called internally.** -- **Empty states say what this place is for and what to do next.** An empty state is a first impression, not an error. -- **Errors say what happened and what to do about it.** A message the person cannot act on is decoration. +Use direct, familiar language and remove words that do not help someone decide or act. + +- Labels describe the action or destination in the reader’s terms. +- Empty states explain what belongs there and provide a useful next step. +- Errors explain what happened and how to recover, beside the affected control. +- Keep names and capitalization consistent across the flow. ## Accessibility -- **Every interactive element has explicit assistive semantics, and one owner per label.** Two widgets claiming the same label produces duplicate screen-reader stops. -- **Contrast comes from the paired token, not from judgement.** Where a background is not neutral, its text is named for it. -- **Keyboard, pointer, and shortcut paths must not diverge.** When adding an input handler, enumerate the ways a person can reach it and check the ones that are not the mouse. -- **Focus rings are for keyboard navigation, not pointer navigation.** Gate every authored focus treatment with `html[data-keyboard-navigation]` and `:focus-visible`; the app-root input-modality owner supplies that attribute. Mouse, pen, and touch focus stays quiet, including programmatic focus during a drag. Keyboard focus remains clearly visible on the control itself. -- **Colour is never the only carrier of meaning by default.** Pair it with text, shape, or position. The solid avatar status badges in [Identity shapes](#identity-shapes) are an intentional product exception; preserve their solid fills and expose known status through the owning accessible label or description. +Give every interactive element an accessible name, with one owner for each label. +Use paired text roles on colored surfaces and verify the rendered contrast. + +Keep keyboard, pointer, and shortcut paths consistent. When adding an input handler, +identify and check every supported way to reach the action. + +The intended focus recipe requires both `html[data-keyboard-navigation]` and +`:focus-visible`. The app-root modality owner supplies the attribute; pointer +focus stays quiet, including programmatic focus during a drag. The shared global +outline suppression is a temporary exception: follow +[Temporary focus appearance](#temporary-focus-appearance) rather than adding a +local replacement. + +Pair color with text, shape, or position. Solid avatar status badges are an explicit +product exception, including the light Away badge’s accepted contrast shortfall. +Preserve their fills and expose known status through the owning accessible label +or description. See [Identity shapes](#identity-shapes). ## Responsiveness -- **Design for narrow, intermediate, and wide, not just wide.** Intermediate widths are where layouts usually break. -- **Text scales with the person's preference and with zoom.** Anything readable uses relative units; fixed pixel text freezes and breaks zoom. +Check narrow, intermediate, and wide layouts, including enlarged text and browser +zoom. Use relative units for readable content so the interface respects the +person’s size preference. Allow content to reflow before it clips. ## Growing the system @@ -748,27 +811,53 @@ the timeline measures its rem-sized leading region for Virtua's pixel start marg ## Components -- **Compose existing components freely. Never reimplement one.** -- **Focus is keyboard-only visual navigation.** Pointer focus stays quiet; keyboard navigation gives the focused control—not its container—a visible focus ring. Browsers can retain `:focus-visible` after programmatic focus too, so every component focus treatment must explicitly require `html[data-keyboard-navigation]`; do not rely on the base-layer reset to defeat a component-layer outline or shadow. Never add a `:focus-within` focus ring to a container: it duplicates the child control's signal and makes pointer focus noisy. -- **Base UI is the behavior layer.** Before writing an interactive shared component, inspect Base UI for the matching primitive. When one exists, wrap and compose it; Base UI owns focus, keyboard behavior, positioning, portals, and dismissal, while Buzz owns the visual language and product semantics. Reach for native elements only when Base UI has no matching primitive or the component is semantically static. -- **Need a variant that doesn't exist? Add it, mark it proposed.** If a variant almost fits but you would cancel several of its states, the base is wrong for the job and the system is missing a variant. -- **Never add a boolean prop for a visual difference.** Variants are enumerable, so an agent can read the list and pick; booleans multiply, and nobody designed most of the combinations. New props are for data and behaviour, not appearance. -- **Used by one feature? It lives in that feature's folder.** Used by two? Propose it as shared. The folder is the namespace. +Reuse and compose existing components before adding another. + +- **Behavior:** inspect Base UI before building an interactive shared component. Use its matching primitive for focus, keyboard behavior, positioning, portals, and dismissal. Buzz owns appearance and product semantics. Use native elements where Base UI has no matching primitive or the component is static. +- **Variants:** add a missing visual variant for a real use and mark it proposed. Do not cancel several existing states to force an unsuitable variant to fit. +- **Props:** use named variants for visual differences, never a new boolean appearance prop. Keep data and behavior props distinct from appearance choices. +- **Ownership:** keep a component with its first feature. A second real use can justify proposing it as shared. +- **Focus recipes:** require `html[data-keyboard-navigation]` and `:focus-visible` on the control. Do not rely on a base-layer reset to override component-layer styles or add a `:focus-within` ring around its container. Preserve these recipes while the temporary global outline suppression is active; shared fields follow the perimeter-stroke exception documented above. ## Using the system -- **Use an existing component before creating one, and an existing role before adding one.** -- **A new visual treatment that repeats belongs in the system, not in the feature.** -- **If a shared role fails in a real context, repair the role — never work around it locally.** A documentation specimen frame needed a border but `border-primary` was neutral-4 in both modes, which measured 1.08:1 on the dark page. The wrong response was the one we made first: name `neutral-6` directly and call documentation furniture a special case. The right response was to ask whether the one shared boundary role was wrong, measure it on every surface it reaches, and make it `neutral-4` light / `neutral-6` dark. The frame then returned to `border-primary`, and every product divider improved with it. **A local exception is evidence the shared decision is incomplete, not a licence to bypass it.** -- **Choose the job first.** Page → surface-base; card → surface-panel; popup → surface-popover; recessed region → surface-inset. Controls use affordance roles; labels use text roles; edges use border roles. -- **A role is useful because it names a purpose.** It does not need different palette steps in each theme to earn its name. -- **If a screen looks right but breaks these rules, the rules are probably wrong — say so.** This document is meant to be argued with, not worked around. +Choose a component and a role by their purpose: -## Icons +| Need | Shared role or owner | +| --- | --- | +| Page background | `surface-base` | +| Panel or card surface | `surface-panel` | +| Popup | `surface-popover` | +| Recessed region | `surface-inset` | +| Control states | Affordance roles | +| Labels and reading text | Text roles | +| Edges | Border roles | -Phosphor is the only general icon family. Import named icons from `icons/index.ts`, which re-exports individual upstream modules. Add exports as needed; no approval list. SVG-only widgets use individual assets through `icons/svg.ts`. Do not import the upstream packages elsewhere or reintroduce other icon libraries. All six native weights remain designer choices: no size-to-weight or selection-to-fill rules. For chat and conversation metaphors, prefer the rounded `ChatCircle` family (including `ChatsCircle`) over square or teardrop variants; choose the matching dots, text, or slash variant when the meaning requires it. Keep accessible names on controls and decorative artwork hidden from assistive technology. +When a shared role fails in context, measure its supported surfaces and repair the +shared decision. Do not substitute a palette step in one feature. For example, +`border-primary` uses neutral-4 in light mode and neutral-6 in dark so its quiet +boundary remains visible on both page surfaces. + +A role names a purpose even when it uses the same palette step in both modes. +Repeated visual treatments belong in the system. If a real design exposes an +unsuitable rule, explain the conflict and improve the rule rather than working +around it locally. + +## Icons -OneDrive is a designer-approved custom brand mark: its complete outline is recreated on Phosphor’s square canvas, uses the same current-color and sizing behavior, and stays in the shared icon gateway. It does not permit another general icon library. +Phosphor is the only general icon family. Import named icons from `icons/index.ts`, +which re-exports individual upstream modules. Add exports as needed; no approval list. +SVG-only widgets use individual assets through `icons/svg.ts`. Do not import the +upstream packages elsewhere or reintroduce other icon libraries. All six native weights +remain designer choices: no size-to-weight or selection-to-fill rules. For chat and +conversation metaphors, prefer the rounded `ChatCircle` family (including `ChatsCircle`) +over square or teardrop variants; choose the matching dots, text, or slash variant when +the meaning requires it. Keep accessible names on controls and decorative artwork hidden +from assistive technology. + +OneDrive is a designer-approved custom brand mark: its complete outline is recreated on +Phosphor’s square canvas, uses the same current-color and sizing behavior, and stays in +the shared icon gateway. It does not permit another general icon library. ### Picker search and choices diff --git a/src/shared/design-system/MAINTAINING_DESIGN_SYSTEM.md b/src/shared/design-system/MAINTAINING_DESIGN_SYSTEM.md index 23fd53b31..dda4f4742 100644 --- a/src/shared/design-system/MAINTAINING_DESIGN_SYSTEM.md +++ b/src/shared/design-system/MAINTAINING_DESIGN_SYSTEM.md @@ -1,61 +1,71 @@ # Maintaining the design system -## The point +The system records shared decisions so you can build a clear, consistent interface. +Use it as a starting point. When a real design needs something it cannot express, +improve the system rather than forcing the design into an unsuitable pattern. -The system helps Buzz stay clear and coherent as it grows. It should make the common choice easy, while leaving room for a design to be specific, surprising, or new. It is a shared memory of decisions that have been made—not a gate a designer needs to pass through. +## Start with a real use -When the system cannot express the right design, change the system. Do not distort the design to satisfy an old rule. +Build the screen or interaction in front of you. Reuse the existing colors, type, +surfaces, and components, then inspect them in context. -## Start with the work in front of you +A new use can reveal a missing state, an unsuitable token, or guidance that no +longer fits the product. Explain the need where you make the change. Avoid adding +options for situations no current surface needs. -Build the screen or interaction you are trying to make. Use the existing colors, type, surfaces, and components when they fit. Look at the result in context, not just in a token table. +## Choose colors by purpose -A repeated need is evidence. One-off work is evidence too: it may reveal that a ramp step is wrong, a component needs another supported state, or a rule no longer reflects the product. +Use semantic surface, text, border, and affordance roles. The palette supplies +light and dark values; components should not choose palette steps directly, +even when a role uses the same step in both modes. -## Color: use semantic roles +Add only the states the control needs, such as hover, pressed, or disabled. +Check the paired text and update the registry and viewer in the same change. +Legacy names support existing callers during migration; use semantic names for +new work. -Choose a name by what the color does: surface, text, border or affordance. -The shared palette supplies values to those roles in light and dark mode. -Components should not choose a palette step directly, even when both modes use -the same step. A role lets us adjust that job without editing every caller. +## Share what repeats -Keep roles grounded in real controls. Add the required hover, pressed or disabled -state beside the base role and check its paired text. Update the registry and -viewer in the same change. Legacy names exist only to support staged migration. +A shared component should provide the same appearance and interaction wherever +it is used. Build the first version with its feature. When a second real use +appears, decide which part belongs in the system: -## Components grow from real repetition +- Share generic controls and visual building blocks. +- Keep Buzz-specific behavior with the product capability that owns it. +- Share a complete arrangement only when another surface needs that arrangement. -A shared component is a promise: the same interaction and visual language will work the same way wherever it appears. +Prefer small components with clear responsibilities. Do not add a catalog of +unused variants or a collection of unrelated boolean options. -Build the first version where it is needed. Once another real use appears, decide what is truly shared: +## Review states in context -- a generic building block belongs in the design system; -- Buzz-specific behavior belongs with the product capability that owns it; -- a complete arrangement should only become reusable when another surface needs that exact arrangement. +Check default, hover, pressed, selected, disabled, and loading states where they +apply. Selection must remain clear after the pointer moves away. Preserve +keyboard operation and focus restoration alongside pointer behavior. -Do not build a catalogue in advance. Small, proven components are more flexible than a large component with a long list of switches. +The shared global stylesheet currently hides focus outlines by explicit design +decision. This is a known exception to visible keyboard focus, not an accessibility +pass. Follow [Temporary focus appearance](DESIGN.md#temporary-focus-appearance) +and do not add local replacement rings. -## Keep the important states visible +Inspect narrow, intermediate, and wide layouts in both themes. A specimen alone +cannot prove that a component works in its product context. -Every interactive piece should have a clear default, hover, pressed, selected state where it applies, and disabled state where it matters. Selection is a lasting statement, not just a stronger hover. Keyboard focus should be visible, and the same action should work with a pointer or keyboard. +## Use checks as evidence -Check narrow, medium, and wide layouts. Check light and dark mode together. A decision that works only in a component specimen is not finished. +Automated guards protect shared decisions such as contrast, type roles, and +paired theme values. Keep deliberate exceptions named and explained in the +owning guard. If exceptions reveal a repeated need, revisit the rule rather than +adding more local workarounds. -## What the guards are for +Passing checks establishes only what those checks measure. Combine them with +rendered inspection and the affected interaction before calling a change validated. -The automated checks protect decisions that are easy to accidentally undo: readable contrast, the shared type scale, and the color system’s light/dark structure. +## Leave the decision easy to find -They are guardrails, not judges. Each one has a named exception list with room to explain why a specific design needs to differ. Adding an exception is normal when it reflects a deliberate design choice. If the exceptions start pointing in one direction, improve or remove the rule instead of accumulating workarounds. +Update the token, component, or guidance that owns the decision. Show useful +states in the viewer, use the change in its real product context, and explain the +reason in plain language near the implementation. -A passing check means the system is internally consistent. Looking at the actual interface tells us whether the system is good. We need both. - -## A good change leaves a trace - -When you change the system, make the decision easy to find: - -- update the relevant token, component, or guidance; -- show the result on the design-system site where useful; -- use the new thing in real product work, not only in documentation; -- note the reason in plain language close to the decision. - -The goal is not to freeze Buzz into a style. It is to give every future designer and builder a clear starting point—and the confidence to improve it when the work asks for more. +Follow the root contribution workflow for iteration and validation. Keep proposed +work marked as proposed until it has the evidence needed for adoption. diff --git a/src/shared/design-system/README.md b/src/shared/design-system/README.md index dd342ce88..383fca807 100644 --- a/src/shared/design-system/README.md +++ b/src/shared/design-system/README.md @@ -1,30 +1,35 @@ # Buzz design system -The design system this app is moving to, imported from `block/buzz`'s `desktop-new` -worktree, including its in-progress layout playgrounds. It is the styling source -for new UI and for existing surfaces as they move onto the system. This initial -port adds the system and its viewer without migrating existing app surfaces. +Shared components, tokens, and styles for Buzz. Use them for new UI and when +migrating existing surfaces. The system originated in the `block/buzz` +`desktop-new` worktree; its layout playgrounds remain experimental. -## Ownership and locations +Read [the design guide](DESIGN.md) for product decisions, +[Maintaining the design system](MAINTAINING_DESIGN_SYSTEM.md) for how to evolve +them, and [the agent guide](AGENTS.md) before editing with an agent. -- `src/shared/design-system/`: shared components, tokens, styles, chip presentation helpers, registries and colocated tests. -- `tests/fixtures/design-system/`: standalone viewer, documentation pages and interactive specimens. It renders the real shared components, not copies. -- `scripts/design-system/`: scoped type, color, contrast and token-consumer checks. +## Where to work -Read `DESIGN.md` for design decisions and `MAINTAINING_DESIGN_SYSTEM.md` for -stewardship. Proposed work retains its status; historical examples in the design -guide are rationale, not a claim that those features were ported. +- `src/shared/design-system/`: shared components, tokens, styles, chip presentation helpers, registries, and colocated tests. +- `tests/fixtures/design-system/`: the standalone viewer and its interactive examples, using the real shared components. +- `scripts/design-system/`: type, color, contrast, and token-consumer guards. -## Run from the repository root +## Run the viewer + +From the repository root: ```sh -bin/pnpm install --frozen-lockfile -bin/pnpm design:dev +bin/just design ``` -Open http://localhost:1442/tests/fixtures/design-system.html. -The same fixture path works under the normal web dev server, at the -worktree-derived port `just web` prints. +Open [the local viewer](http://localhost:1442/tests/fixtures/design-system.html). +`bin/pnpm design:dev` starts the viewer directly after dependencies are installed. +The same fixture path works on the port printed by `just web`. + +## Check and build + +Use the root contribution workflow to choose checks for the current iteration. +The system provides these scoped commands: ```sh bin/pnpm design:typecheck @@ -32,35 +37,36 @@ bin/pnpm design:check bin/pnpm design:census bin/pnpm design:test bin/pnpm design:test:browser +bin/pnpm design:build ``` The browser command builds the host and viewer, then tests Chromium and WebKit. -`bin/pnpm design:build` produces `dist/design-system`; serve that directory and -open `tests/fixtures/design-system.html`. Hash links survive static-host reloads. -The build rejects source outside the system/viewer and copies no public directory, -native configuration or source maps. No publishing configuration is included. +`design:build` writes `dist/design-system`; serve that directory and open +`tests/fixtures/design-system.html`. Hash routes support static-host reloads. +The core build rejects application imports and bundles shared artwork explicitly. +It copies no public directory, native configuration, or source maps. Publishing is +not configured. -## What is intentionally absent +## Viewer boundaries -The core viewer bundle has no session shell, navigation controller, agent -setup/activity, composer, conversation feature, relay client, native adapter, -local lab or identity data. -Blank layout playgrounds and generic inline reference presentations are retained. +The core viewer runs without app startup, sessions, agent setup, conversation +features, relay services, native adapters, or live identity data. It includes shared +Composer examples, generic inline references, and blank layout playgrounds. -The Messages product-pattern page embeds a separately built local fixture using -production message renderers and sample data. It does not import product code into -the core viewer or start live services. See [message specimens](../../../docs/design-system.md#message-specimens). +**Patterns → Messages** embeds a separate local fixture with production +message renderers and sample data. It does not add product imports to the core +bundle or start live services. See [message +specimens](../../../docs/design-system.md#message-specimens). -## Compatibility boundary +## Host integration -The host owns the appearance lifecycle and now loads the shared palette, -typography roles, materials and component styles through its coordinated entry. -Existing UI keeps temporary legacy styling until deliberately migrated. Shared -primitives mark their own styling boundary, including portal roots; new product -compositions use those primitives and named roles, never viewer furniture. +The host owns appearance and loads the shared palette, type, materials, and +component styles through one coordinated entry point. Existing callers may still +use compatibility styles until they migrate. Shared primitives mark their styling +boundary, including portal roots. Product compositions use those primitives and +semantic roles. See [the host integration contract](../../../docs/design-system.md#incremental-system-integration) -for compatibility names, text scaling, keyboard focus and actual-app regression coverage. -The viewer stays an independent document and preference owner. Workspace experiments -remain excluded from host startup; BentoWorkspace still reads viewer preferences -and needs a host adapter before product adoption. +for compatibility names, text scaling, focus, and app regression coverage. +The viewer owns its separate document and preferences. Workspace experiments stay +out of host startup; BentoWorkspace needs a host preference adapter before adoption. diff --git a/src/shared/design-system/icons/BestieMark.tsx b/src/shared/design-system/icons/BestieMark.tsx index dffbf7c0c..169ccb8e7 100644 --- a/src/shared/design-system/icons/BestieMark.tsx +++ b/src/shared/design-system/icons/BestieMark.tsx @@ -1,4 +1,6 @@ import { forwardRef, type SVGProps } from "react"; +const bestieUrl = new URL("../../../../public/bestie.png", import.meta.url) + .href; /** Bestie's raster companion mark, framed as an icon so navigation rows and search can size it. */ export const BestieMarkArtwork = forwardRef< @@ -24,7 +26,7 @@ export const BestieMarkArtwork = forwardRef< {...props} > design, - path: "foundation-alignment", - component: FoundationAlignmentPage, - }), createRoute({ getParentRoute: () => design, path: "maintaining", diff --git a/tests/fixtures/design-system/styles.css b/tests/fixtures/design-system/styles.css index 84ab356db..6afb00e2e 100644 --- a/tests/fixtures/design-system/styles.css +++ b/tests/fixtures/design-system/styles.css @@ -1,7 +1,5 @@ @layer components { - /* Documentation stays quiet and paper-like. The navigation itself is a fixed - reading tool, not a product panel: Figma's reference is a 250px white rail - with a soft right shadow and no active-fill decoration. */ + /* A quiet navigation rail, a bounded reading column, and optional contents. */ .design-system-shell { display: flex; min-height: 100vh; @@ -20,7 +18,7 @@ overflow-y: auto; padding: 1.125rem 1.5rem; background: var(--bg-panel); - box-shadow: var(--shadow-float); + border-inline-end: 1px solid var(--border-primary); } .design-system-nav-title { @@ -59,7 +57,7 @@ } .design-system-nav-child { - padding-left: var(--space-4); + padding-inline-start: var(--space-4); } .design-system-nav-link { @@ -84,94 +82,175 @@ position: relative; min-width: 0; flex: 1; - padding: 2.5rem 4rem; + padding: calc(var(--space-16) + var(--space-6)) var(--space-8); } - /* This is a page-level appearance control, not part of the reading column. - Fix it to the viewport so it remains available through a long document. - Two space steps leave room for the browser's own scrollbar without trying to - restyle or replace that platform control. */ - .design-system-content > .buzz-button[data-icon-size="toolbar"] { - position: fixed; - z-index: 10; - top: var(--space-4); - right: calc(var(--space-4) + var(--space-4)); + .design-system-nav-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + } + + .design-system-nav-header > .buzz-button { + flex-shrink: 0; + } + .design-system-nav-toggle { + display: none; + } + + .design-system-skip { + position: absolute; + z-index: 20; + inset-block-start: var(--space-2); + inset-inline-start: var(--space-2); + padding: var(--space-3); + background: var(--bg-panel); + color: var(--text-primary); + transform: translateY(-200%); + } + + .design-system-skip:focus { + transform: none; + } + + .design-page-header { + display: flex; + flex-direction: column; + gap: var(--space-3); + } + + .design-page-header h1, + .design-section-heading h2 { + text-wrap: balance; + } + + .design-page-intro, + .design-section-description { + max-width: 70ch; + text-wrap: pretty; + } + + .design-section-heading { + display: flex; + flex-direction: column; + gap: var(--space-2); } + .design-section { + display: flex; + flex-direction: column; + gap: var(--space-4); + } + + .design-section-content { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--space-4); + } + + .design-section-content > :is(p, ul, ol) { + max-width: 70ch; + } + + /* Each stack owns its gaps. Sections never add a second outer margin. */ .design-system-reading-column { - max-width: 48rem; + display: flex; + flex-direction: column; + gap: var(--space-8); + width: 100%; + max-width: 44rem; + margin-inline: auto; } .design-doc { display: flex; - max-width: 44rem; + min-width: 0; + max-width: 70ch; flex-direction: column; + color: var(--text-secondary); + overflow-wrap: break-word; } - .design-doc-title { - margin-bottom: var(--space-6); + .design-doc > * + * { + margin-block-start: var(--space-4); } - .design-doc-heading { - margin: var(--space-6) 0 var(--space-2); + .design-doc > :where(.design-page-header) + p { + margin-block-start: var(--space-3); + } + + .design-doc > h2 { + margin-block-start: var(--space-8); + } + + .design-doc > h3 { + margin-block-start: var(--space-8); + } + + .design-doc > :is(h2, h3) + :is(p, ul, ol) { + margin-block-start: var(--space-2); } - .design-doc-paragraph { - margin: 0 0 var(--space-4); + .design-doc-heading { + text-wrap: balance; + scroll-margin-block-start: var(--space-6); } .design-doc-list { display: flex; flex-direction: column; gap: var(--space-2); - margin: 0 0 var(--space-4); - padding-left: 1.25rem; + padding-inline-start: 1.25rem; + list-style: disc; + } + + ol.design-doc-list { + list-style: decimal; + } + + .design-doc-table-scroll { + max-width: 100%; + overflow-x: auto; + } + + .design-doc-table-hint { + display: none; } - /* Dense reference rows: DESIGN.md § Density and rhythm asks for rows with - hairline dividers rather than a card per entry. Left-aligned because every - cell is a name or a phrase, never a quantity to compare. */ .design-doc-table { width: 100%; - margin: 0 0 var(--space-4); border-collapse: collapse; - text-align: left; + text-align: start; } .design-doc-table th, .design-doc-table td { - padding: var(--space-2) var(--space-3); + padding: var(--space-3); border-bottom: 1px solid var(--border-primary); - vertical-align: baseline; + vertical-align: top; } .design-doc-table th:first-child, .design-doc-table td:first-child { - padding-left: 0; + padding-inline-start: 0; } .design-doc-table th:last-child, .design-doc-table td:last-child { - padding-right: 0; + padding-inline-end: 0; } .design-doc-table tbody tr:last-child td { border-bottom: 0; } - /* A token is one word. `--neutral-4` broken across two lines reads as two - different names, so the line breaks between cells' tokens instead of inside - one; the column takes the width its longest token needs. */ + /* Reference identifiers keep their exact spelling inside a contained table. */ .design-doc-table code { white-space: nowrap; } - .design-doc-table th:nth-child(2), - .design-doc-table td:nth-child(2) { - white-space: normal; - width: 1%; - } - .design-doc code { border-radius: var(--radius-row); padding: 0.0625rem var(--space-1); @@ -180,11 +259,47 @@ font-family: var(--font-mono); } + .design-doc-code { + max-width: 100%; + overflow-x: auto; + padding: var(--space-4); + border-radius: var(--radius-control); + background: var(--neutral-2); + white-space: pre-wrap; + overflow-wrap: anywhere; + } + + .design-doc-code code { + padding: 0; + background: none; + } + .design-doc strong { color: var(--text-primary); font-weight: var(--type-weight-semibold); } + .design-doc a { + color: var(--purple-12); + text-decoration: underline; + text-underline-offset: 0.15em; + text-decoration-thickness: from-font; + } + + .color-palette-ramp, + .color-semantic-ramp, + .glass-ramp { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 6rem), 1fr)); + } + + .color-palette-ramp { + gap: var(--space-3) var(--space-1); + } + .color-semantic-ramp { + gap: var(--space-3); + } + /* Glass is meaningful only with a scene behind it. These documentation canvases deliberately use the first paired backdrop, so the same specimen reveals Sky field in light mode and Night garden in dark mode. */ @@ -192,24 +307,21 @@ background: var(--gradient-1); } - .component-page-heading { + .component-specimen-stack { display: flex; flex-direction: column; - gap: var(--space-2); - margin-bottom: var(--space-6); + gap: var(--space-8); } - .component-specimen-stack { - display: flex; - flex-direction: column; - gap: var(--space-6); + .component-specimen-stack .component-specimen-stack { + gap: var(--space-8); } .component-specimen-group { display: flex; min-width: 0; flex-direction: column; - gap: var(--space-2); + gap: var(--space-4); } .component-specimen-group > h2 { @@ -370,39 +482,40 @@ .component-overview-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-6); + grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); + gap: var(--space-4); } .component-overview-item { display: flex; min-width: 0; flex-direction: column; - gap: var(--space-3); - } - - .component-overview-preview { - height: 13rem; - overflow: hidden; border: 1px solid var(--border-primary); - border-radius: var(--radius-panel); - background: var(--bg-panel); + border-radius: var(--radius-control); } - .component-overview-preview > .component-specimen-stack { - width: 180%; - transform: scale(0.55); - transform-origin: top left; + .component-overview-link { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-5); + border-radius: inherit; + text-decoration: none; } - .component-overview-preview .component-specimen-frame { - min-height: 8rem; - border-color: transparent; + .component-overview-link:hover { + background: var(--affordance-panel-hover); + } + .component-overview-link p { + line-height: 1.5; } - .component-overview-item > a { - align-self: flex-start; - text-decoration: none; + .component-overview-item > nav { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + padding: 0 var(--space-5) var(--space-5); } @media (max-width: 63.999rem) { @@ -419,6 +532,30 @@ box-shadow: none; } + .design-system-nav-rule { + display: none; + } + .design-doc-table-hint { + display: block; + margin-block-end: var(--space-2); + } + + .design-system-nav-toggle { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--space-2); + padding: var(--space-3); + border-radius: var(--radius-control); + background: var(--neutral-2); + text-align: start; + } + + .design-system-nav-sections[data-open="false"] { + display: none; + } + .design-system-nav-sections { display: grid; grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); @@ -429,7 +566,6 @@ padding: var(--space-6) var(--space-4); } - .component-overview-grid, .custom-icon-inventory { grid-template-columns: 1fr; } @@ -456,7 +592,7 @@ flex-direction: column; height: 100%; } -.messages-page > .component-page-heading { +.messages-page > .design-page-header { flex-shrink: 0; } .messages-page > iframe { @@ -478,20 +614,15 @@ display: flex; flex-wrap: wrap; gap: var(--space-3); - margin-block: var(--space-6); + margin-block-start: var(--space-3); } .form-doc-preview { + display: flex; + flex-direction: column; + gap: var(--space-8); width: 100%; } -.form-doc-preview[data-narrow="true"] { - max-width: 22rem; -} -.form-doc-example { - display: grid; - gap: var(--space-3); - margin-bottom: var(--space-8); -} -.form-doc-example .component-specimen-frame { +.form-doc-preview .component-specimen-frame { display: block; } .form-doc-fields { @@ -509,14 +640,118 @@ .form-doc-input-action > input { flex: 1 1 10rem; } -.form-doc-guidance { - display: grid; - gap: var(--space-4); - margin-block: var(--space-8); -} .form-doc-code { overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; padding: var(--space-4); border-radius: var(--radius-control); background: var(--surface-inset); } + +@media (prefers-reduced-motion: reduce) { + .design-system-nav-link { + transition: none; + } +} + +/* Preview and code share a frame; the example keeps its own state and surface. */ +.design-example { + min-width: 0; + border: 1px solid var(--border-primary); + border-radius: var(--radius-control); +} +.form-doc-preview[data-narrow="true"] .design-example { + max-width: 22rem; +} +.design-example-toolbar { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding-inline: var(--space-3); + border-bottom: 1px solid var(--border-primary); +} +.design-example-toolbar > .buzz-tabs { + min-width: 0; + flex: 1; +} +.design-example > [role="tabpanel"] > :where(.component-specimen-frame) { + min-height: 12rem; + border: 0; + border-radius: 0 0 var(--radius-control) var(--radius-control); + padding: var(--space-8); +} +.design-example-code { + max-height: 32rem; + overflow: auto; + padding: var(--space-5); + border-radius: 0 0 var(--radius-control) var(--radius-control); + background: var(--surface-inset); + color: var(--text-primary); +} +.design-example-code pre { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.design-example-feedback { + margin: 0; +} +.design-example-feedback[data-visible="true"] { + padding: var(--space-3) var(--space-5); + border-top: 1px solid var(--border-primary); +} +@media (max-width: 40rem) { + .design-example > [role="tabpanel"] > :where(.component-specimen-frame) { + padding: var(--space-5); + } +} + +.design-system-contents { + display: none; +} + +@media (min-width: 80rem) { + .design-system-content[data-contents="true"] { + display: grid; + grid-template-columns: minmax(0, 44rem) 11rem; + align-items: start; + justify-content: center; + column-gap: var(--space-8); + } + + .design-system-contents { + position: sticky; + top: var(--space-8); + display: flex; + flex-direction: column; + gap: var(--space-3); + max-height: calc(100vh - var(--space-16)); + overflow-y: auto; + border-inline-start: 1px solid var(--border-primary); + padding-inline-start: var(--space-4); + } + + .design-system-contents-links { + display: flex; + flex-direction: column; + gap: var(--space-1); + } + + .design-system-contents-links button { + min-height: 1.75rem; + padding-block: var(--space-1); + text-align: start; + } + + .design-system-contents-links button:hover { + color: var(--text-primary); + } +} + +.design-section-heading h2, +.component-specimen-group > h2 { + scroll-margin-block-start: var(--space-8); +} diff --git a/tests/fixtures/design-system/ui/BaseUiBackingLine.tsx b/tests/fixtures/design-system/ui/BaseUiBackingLine.tsx index 41b7be599..64613cc16 100644 --- a/tests/fixtures/design-system/ui/BaseUiBackingLine.tsx +++ b/tests/fixtures/design-system/ui/BaseUiBackingLine.tsx @@ -1,13 +1,12 @@ import { Link } from "@tanstack/react-router"; import { baseUiBackingSentence } from "./baseUiBackingSentence"; -/** Inline inheritance note; links point to the actual owning library. */ +/** Supporting inheritance note; links point to the actual owning library. */ export function BaseUiBackingLine({ slug }: { slug: string }) { const segments = baseUiBackingSentence(slug); if (!segments.length) return null; return ( - <> - {" "} +

{segments.map((segment, index) => segment.kind === "text" ? ( // biome-ignore lint/suspicious/noArrayIndexKey: static sentence segments retain their order @@ -32,6 +31,6 @@ export function BaseUiBackingLine({ slug }: { slug: string }) { ), )} - +

); } diff --git a/tests/fixtures/design-system/ui/BaseUiPage.tsx b/tests/fixtures/design-system/ui/BaseUiPage.tsx index c38d581ee..094176207 100644 --- a/tests/fixtures/design-system/ui/BaseUiPage.tsx +++ b/tests/fixtures/design-system/ui/BaseUiPage.tsx @@ -140,12 +140,12 @@ export function BaseUiPage() { <>
@@ -183,8 +183,8 @@ export function BaseUiPage() {
@@ -194,7 +194,7 @@ export function BaseUiPage() {
-
Native elements only
+
Without Base UI
{native.length} of {rows.length}
@@ -209,8 +209,8 @@ export function BaseUiPage() {
{native.map(({ component }) => ( @@ -234,10 +234,10 @@ export function BaseUiPage() {
- A dash in the third column too means the component owns its keyboard, - focus, and assistive semantics outright — InlineChip is the case to - watch, because it switches between a control and an image role and has - to get both right without help. See{" "} + A dash means there is no Base UI part in that column. For components + with no direct or inherited part, check the behavior notes above. + InlineChip, for example, owns its native button and image semantics. + Consult{" "} the Base UI component index {" "} - for a part that could take that work over. + when adding shared interactions. ); diff --git a/tests/fixtures/design-system/ui/ButtonSpecimens.tsx b/tests/fixtures/design-system/ui/ButtonSpecimens.tsx index 46d2bb612..d1c220234 100644 --- a/tests/fixtures/design-system/ui/ButtonSpecimens.tsx +++ b/tests/fixtures/design-system/ui/ButtonSpecimens.tsx @@ -1,3 +1,5 @@ +import { ExamplePreview } from "./ExamplePreview"; +import { SectionHeading } from "./primitives"; import { useState } from "react"; import { ArrowRightIcon, @@ -45,12 +47,11 @@ function ButtonMatrix({ kind }: { kind: "text" | "icon" }) { aria-label={`${kind === "text" ? "Button" : "Icon button"} variants`} className="component-specimen-stack" > -
-

Emphasis and sizes

-

- Small · 32px, medium · 40px, large · 52px. Hover, press, or Tab - through the controls to inspect their states. -

+
+
{(["enabled", "disabled", "loading"] as const).map((value) => ( ` + : `
+ ))} @@ -122,20 +131,52 @@ export function ButtonSpecimen() { const [expanded, setExpanded] = useState(false); return (
-
+ Continue`} + > -
+
-

Loading and expansion

-

- Saving keeps the same width and focus. Finish the example with the - separate completion control. -

-
+ + +
+ + +
+ + +
`} + >
-
+
-

Single-line labels

-

- Labels stay on one line. Let the surrounding layout wrap whole - controls or scroll when space is limited, rather than squeezing the - label. -

-
+ + +
+ + + + +
+
+

+ Changes affect future starts and restarts. Running work is never + restarted automatically. +

+ +
+
+ +
+
`} + >
@@ -200,7 +265,7 @@ export function ButtonSpecimen() {
-
+
); @@ -209,14 +274,31 @@ export function ButtonSpecimen() { export function IconButtonSpecimen() { return (
-
+
+ -
+ + Row action +
`} + >
Row action
-
-
+ + } +/> +size="xs" · 20px`} + > } /> - size="xs" · 28px -
+ size="xs" · 20px +
-

Buzz treatments

-

- Tint supports quiet composer actions. Chrome uses the workspace glass - material. Round is the default; control corners remain an explicit - option. -

-
+ + +
`} + >
- +
-

- Avatar · Transparent cutouts -

-

- Hover, press, or open the menu: the backdrop stays visible through the - avatar cutout. Tab to the enabled control to inspect keyboard focus. -

-
+ + + + } + /> + } + /> + + View profile + + + + } + /> +
`} >
@@ -314,18 +449,30 @@ export function IconButtonSpecimen() { } />
- +
-

+

Chrome · Workspace glass material

-
+
`} >
- +
-

+

Media · Static dark glass over video

- +
); diff --git a/tests/fixtures/design-system/ui/ColorPage.tsx b/tests/fixtures/design-system/ui/ColorPage.tsx index fe7a366fe..d401d1eb6 100644 --- a/tests/fixtures/design-system/ui/ColorPage.tsx +++ b/tests/fixtures/design-system/ui/ColorPage.tsx @@ -81,18 +81,18 @@ export function ColorPage() { <>
{PALETTE.map((hue) => (
-

{hue.id}

+

{hue.id}

{hue.usedBy ? `drawn from by ${hue.usedBy}` : "unassigned"} @@ -103,7 +103,7 @@ export function ColorPage() { the values came from — step 4 as "component hover" — long after this product had made step 4 its one border weight. Unrendered documentation cannot be checked by looking. */} -
+
{hue.steps.map((step) => (
{(["light", "dark"] as const).map((mode) => (
-

{mode} mode

+

{mode} mode

{BACKDROP_TREATMENTS.filter( (treatment) => treatment.mode === mode, @@ -152,7 +152,7 @@ export function ColorPage() {
{BACKDROP_CHOICES.map((choice) => { @@ -186,23 +186,19 @@ export function ColorPage() {
{RAMPS.map((ramp) => (
-

{ramp.name}

-

+

{ramp.name}

+

{ramp.description}

6 - ? "grid-cols-4 sm:grid-cols-6" - : "grid-cols-3 sm:grid-cols-5" - } ${ramp.translucent ? "rounded-xl bg-app p-4" : ""}`} + className={`color-semantic-ramp ${ramp.translucent ? "rounded-xl bg-app p-4" : ""}`} > {ramp.steps.map((step) => ( (
-

{group.name}

-

+

{group.name}

+

{group.description}

@@ -244,7 +240,7 @@ export function ColorPage() {
{/* No swatch here to do the separating, so these entries keep a little structure — the token name leads and the spacing groups it with its @@ -253,7 +249,7 @@ export function ColorPage() { {EXCEPTIONS.map((exception) => (
{exception.name} -

+

{exception.why}

@@ -262,10 +258,9 @@ export function ColorPage() {
- Every dark value in this system is authored rather than observed — the - design exploration it was derived from is light-only. Toggle the mode in - the sidebar and treat anything that looks wrong as a finding, not a - given. + The original design exploration covered light mode. Dark values were + authored for Buzz. Use the appearance control in the navigation header + to compare both modes, and report combinations that are hard to read. ); diff --git a/tests/fixtures/design-system/ui/ColorTablePage.tsx b/tests/fixtures/design-system/ui/ColorTablePage.tsx index bb03c1bbe..eba0d3648 100644 --- a/tests/fixtures/design-system/ui/ColorTablePage.tsx +++ b/tests/fixtures/design-system/ui/ColorTablePage.tsx @@ -139,7 +139,7 @@ export function ColorTablePage() { <>
@@ -163,14 +163,14 @@ export function ColorTablePage() { by what the color does, then let the shared palette supply its light and dark values. See the{" "} - colour page + Color guide .
@@ -178,17 +178,16 @@ export function ColorTablePage() { ) : view === "choices" ? ( <> - Each numbered choice is a stable selection rather than a scene name: + Each numbered token pairs a light scene with a dark scene: gradient-1 resolves to Sky field in light mode and Night garden in dark. Product surfaces keep - using bg-app; an appearance - preference can select another numbered choice without knowing which - mode is active. + using bg-app; the token mapping + chooses the treatment for the active mode.
@@ -236,8 +235,7 @@ function TokenTable({ with the swatch for a fourth column. */ diff --git a/tests/fixtures/design-system/ui/ComponentDetailPage.tsx b/tests/fixtures/design-system/ui/ComponentDetailPage.tsx index d85f3e677..adf54a042 100644 --- a/tests/fixtures/design-system/ui/ComponentDetailPage.tsx +++ b/tests/fixtures/design-system/ui/ComponentDetailPage.tsx @@ -3,6 +3,7 @@ import { COMPONENTS } from "../../../../src/shared/design-system/ui/registry"; import { BaseUiBackingLine } from "./BaseUiBackingLine"; import { COMPONENT_SPECIMENS } from "./componentSpecimens"; import { MissingPage } from "./MissingPage"; +import { PageHeader } from "./primitives"; export function ComponentDetailPage({ slug }: { slug: string }) { const component = COMPONENTS.find((candidate) => candidate.slug === slug); @@ -11,27 +12,21 @@ export function ComponentDetailPage({ slug }: { slug: string }) { return ( <> -
-

{component.name}

-

- {component.purpose} - -

-
- {[ - "input", - "textarea", - "search-field", - "select", - "combobox", - "field", - ].includes(component.slug) && ( -

- - Forms: composition, states, and implementation guidance → - -

- )} + + + {[ + "input", + "textarea", + "search-field", + "select", + "combobox", + "field", + ].includes(component.slug) && ( +

+ Read the form composition guide → +

+ )} +
{Specimen ? : null} ); diff --git a/tests/fixtures/design-system/ui/ComponentsPage.tsx b/tests/fixtures/design-system/ui/ComponentsPage.tsx index 94f2eeadf..c05e8731e 100644 --- a/tests/fixtures/design-system/ui/ComponentsPage.tsx +++ b/tests/fixtures/design-system/ui/ComponentsPage.tsx @@ -1,6 +1,6 @@ import { Link } from "@tanstack/react-router"; import { COMPONENTS } from "../../../../src/shared/design-system/ui/registry"; -import { COMPONENT_SPECIMENS } from "./componentSpecimens"; +import { PageHeader } from "./primitives"; export function ComponentsPage() { const topLevelComponents = COMPONENTS.filter( @@ -10,26 +10,24 @@ export function ComponentsPage() { return ( <> -
-

Components

-
+
{topLevelComponents.map((component) => { - const Specimen = COMPONENT_SPECIMENS[component.slug]; const children = COMPONENTS.filter( (candidate) => candidate.parent === component.slug, ); return (
-
- {Specimen ? : null} -
- {component.name} +

{component.name}

+

{component.purpose}

{children.length > 0 ? (
); diff --git a/tests/fixtures/design-system/ui/DesignSystemLayout.tsx b/tests/fixtures/design-system/ui/DesignSystemLayout.tsx index b6e71510b..ae7af90a2 100644 --- a/tests/fixtures/design-system/ui/DesignSystemLayout.tsx +++ b/tests/fixtures/design-system/ui/DesignSystemLayout.tsx @@ -2,8 +2,19 @@ import { MoonIcon, SunIcon, } from "../../../../src/shared/design-system/icons/index"; -import { Link, Outlet } from "@tanstack/react-router"; -import { Fragment, type ReactNode } from "react"; +import { + Link, + Outlet, + useRouter, + useRouterState, +} from "@tanstack/react-router"; +import { + Fragment, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; import { useColorScheme } from "../../../../src/shared/design-system/theme/useColorScheme"; import { IconButton } from "../../../../src/shared/design-system/ui/IconButton"; @@ -40,28 +51,29 @@ const SECTIONS: NavSection[] = [ ["Floating surfaces", "/design/floating-surfaces"], ["Glass", "/design/glass"], ["Motion", "/design/motion"], - ["Base UI backing", "/design/components/base-ui"], ], }, { - heading: "System", + heading: "Patterns", items: [ ["Forms", "/design/forms"], - ["Foundation alignment", "/design/foundation-alignment"], - ["Maintaining the system", "/design/maintaining"], - ["DESIGN.md", "/design/design-guide"], - ["AGENTS.md", "/design/agents-guide"], + ["Messages", "/design/messages"], ], }, { - heading: "Components", + heading: "Guides", items: [ - ["Overview", "/design/components", componentNavItems("components")], + ["Design guide", "/design/design-guide"], + ["Agent guide", "/design/agents-guide"], + ["Maintaining the system", "/design/maintaining"], + ["Base UI backing", "/design/components/base-ui"], ], }, { - heading: "Product patterns", - items: [["Messages", "/design/messages"]], + heading: "Components", + items: [ + ["All components", "/design/components", componentNavItems("components")], + ], }, { heading: "Layout playgrounds", @@ -73,14 +85,17 @@ function NavLink({ to, exact, children, + onNavigate, }: { to: string; exact?: boolean; children: ReactNode; + onNavigate: () => void; }) { return ( void; + depth?: number; +}) { return (
{items.map(([label, to, children]) => ( @@ -101,13 +124,18 @@ function NavItems({ items, depth = 0 }: { items: NavItem[]; depth?: number }) {
0} > {label}
{children?.length ? ( - + ) : null} ))} @@ -115,45 +143,129 @@ function NavItems({ items, depth = 0 }: { items: NavItem[]; depth?: number }) { ); } +function routeLabel(items: NavItem[], path: string): string | undefined { + for (const [label, to, children] of items) { + if (to === path) return label; + const child = children && routeLabel(children, path); + if (child) return child; + } +} + /** `children` is for the not-found shell, which renders outside the route tree. */ export function DesignSystemLayout({ children }: { children?: ReactNode }) { const { scheme, toggle, persistenceError } = useColorScheme(); + const router = useRouter(); + const [navigationOpen, setNavigationOpen] = useState(false); + const main = useRef(null); + const [contents, setContents] = useState([]); + const focusAfterNavigation = useRef(false); + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }); + const currentPage = + routeLabel( + SECTIONS.flatMap((section) => section.items), + pathname, + ) ?? "Overview"; + const focusContent = () => { + main.current?.focus(); + main.current?.scrollIntoView(); + }; + const closeNavigation = () => { + if (navigationOpen) { + setNavigationOpen(false); + focusAfterNavigation.current = true; + } + }; + useLayoutEffect(() => { + if (!navigationOpen && focusAfterNavigation.current) { + focusAfterNavigation.current = false; + main.current?.focus(); + main.current?.scrollIntoView(); + } + }, [navigationOpen]); + + useLayoutEffect(() => { + const readContents = () => { + setContents( + Array.from( + main.current?.querySelectorAll( + ".design-section-heading h2, .component-specimen-group > h2, .design-doc > h2", + ) ?? [], + ), + ); + }; + readContents(); + // Location changes before the outlet commits its next page. Read headings + // at the router's render boundary so the rail always targets mounted content. + return router.subscribe("onRendered", readContents); + }, [router]); return (
+
+
- Colour tokens, the base token each resolves through, and the value it - paints + Color tokens, their source tokens, and their rendered values