Mercurial
changeset 254:2b6e732087ff
[ui] Add complete native component catalog
Co-authored-by: Copilot <[email protected]>
line wrap: on
line diff
--- a/.claude/skills/zenbu-design-system/SKILL.md Tue Aug 04 12:21:19 2026 -0700 +++ b/.claude/skills/zenbu-design-system/SKILL.md Tue Aug 04 15:12:09 2026 -0700 @@ -30,12 +30,35 @@ - Prefix custom elements and tokens with `zen-` / `--zen-`. - Keep selectors low-specificity so applications can override tokens. - Support light, dark, reduced-motion, desktop, and mobile behavior. +- Disclosure, overlay, and collapsing surfaces require smooth tokenized motion + plus a reduced-motion fallback. Native disclosure markers must be replaced by + first-party `zen-icon` geometry. +- Reset browser chrome for design-system form controls and restyle every state + with Zenbu tokens. Calendar and date-picker UI must use the first-party + keyboard calendar rather than exposing the browser's default date picker. +- Browser acceptance must exercise every catalog route at desktop and mobile + widths and reject horizontal page overflow. +- Preserve the warm, restrained MUJI-like visual character: paper neutrals, + natural material colors, strong typography, and deliberate color accents. +- Use semantic `--zen-color-*` aliases in component CSS. Use primitive ramps + or the ten data tokens only when a semantic role does not fit; never encode + meaning through color alone. +- Raw color literals are forbidden outside `src/styles/tokens.css`. Run + `//design_system/test:design_system_policy_test` whenever styles, components, + stories, or catalog markup changes. +- Render every interface icon with the first-party `zen-icon` component. Emoji, + text-glyph icons, ad hoc SVG, icon fonts, and external icon packages are + forbidden. +- Every exported JavaScript class, function, or constant requires Google-style + JSDoc with its public type tags. The policy test must fail when documentation + is missing. ## Bazel targets ```bash bazel run //design_system:dev bazel test //design_system/test:storybook_test +bazel test //design_system/test:design_system_policy_test bazel build //design_system:design_system_server_bundle ```
--- a/design_system/BUILD Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/BUILD Tue Aug 04 15:12:09 2026 -0700 @@ -28,6 +28,12 @@ srcs = glob(["src/**"]), ) +filegroup( + name = "policy_sources", + srcs = glob(["src/**"]), + visibility = ["//design_system/test:__pkg__"], +) + cc_binary( name = "design_system_server", srcs = ["main.c"],
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/COLORS.md Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,86 @@ +# Zenbu UI color + +The Zenbu palette is warm, quiet, and material-oriented. Paper neutrals carry +most surfaces; restrained natural colors provide emphasis without making the +interface feel synthetic or overly saturated. + +## Primitive ramps + +Each family has `50`, `100`, `200`, `300`, `400`, `500`, `600`, `700`, `800`, +`900`, and `950` steps: + +- `--zen-color-neutral-*`: warm paper, linen, charcoal, and ink; +- `--zen-color-red-*`: brick and iron oxide; +- `--zen-color-orange-*`: persimmon; +- `--zen-color-amber-*`: straw and ochre; +- `--zen-color-green-*`: matcha and moss; +- `--zen-color-teal-*`: aged copper and patina; +- `--zen-color-blue-*`: faded workwear and indigo; +- `--zen-color-violet-*`: muted plum; +- `--zen-color-rose-*`: clay and dusty rose; +- `--zen-color-brown-*`: kraft paper and wood. + +Use a primitive when an application needs a precise ramp step. Primitive values +do not change between light and dark themes. + +## Material aliases + +Material aliases make one-off composition readable: + +```css +var(--zen-color-paper) +var(--zen-color-washi) +var(--zen-color-linen) +var(--zen-color-sumi) +var(--zen-color-brick) +var(--zen-color-persimmon) +var(--zen-color-ochre) +var(--zen-color-moss) +var(--zen-color-patina) +var(--zen-color-indigo) +var(--zen-color-plum) +var(--zen-color-clay) +var(--zen-color-wood) +``` + +## Semantic aliases + +Components should prefer semantic tokens because these adapt to the current +theme: + +- surfaces: `canvas`, `surface`, `surface-raised`, `surface-muted`, + `surface-sunken`, and `surface-overlay`; +- text: `text`, `text-muted`, `text-subtle`, and `text-inverse`; +- borders: `border`, `border-muted`, and `border-strong`; +- action: `accent`, `accent-hover`, `accent-muted`, `focus`, `disabled`, and + `selection`; +- status: `info`, `success`, `warning`, `danger`, their `on-*` foregrounds, + and their `*-muted` surfaces. + +All names use the `--zen-color-` prefix. + +Component and catalog styles may not contain raw color literals. Add or reuse a +token in `tokens.css`, then reference it with `var()`. The Bazel target +`//design_system/test:design_system_policy_test` rejects hex, functional, or +named colors anywhere else in the design-system source. + +## Data colors + +`--zen-color-data-1` through `--zen-color-data-10` form a categorical palette. +They deliberately vary in both hue and lightness. Keep adjacent series in +numerical order and provide labels or patterns; color alone must never carry +meaning. + +## Usage + +```css +.repository-summary { + border: 1px solid var(--zen-color-border-muted); + background: var(--zen-color-surface-muted); + color: var(--zen-color-text); +} + +.repository-summary strong { + color: var(--zen-color-indigo); +} +```
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/COMPONENTS.md Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,125 @@ +# Zenbu UI component contracts + +Zenbu UI covers the 65 concepts in the shadcn/ui component catalog without +copying its React, Radix, or third-party runtime. Every Zenbu component uses +light DOM and keeps the corresponding native HTML control visible. + +## Inventory + +| shadcn/ui concept | Zenbu UI element | +| --- | --- | +| Accordion | `zen-accordion` | +| Alert | `zen-alert` | +| Alert Dialog | `zen-alert-dialog` | +| Aspect Ratio | `zen-aspect-ratio` | +| Attachment | `zen-attachment` | +| Avatar | `zen-avatar` | +| Badge | `zen-badge` | +| Breadcrumb | `zen-breadcrumb` | +| Bubble | `zen-bubble` | +| Button | `zen-button` | +| Button Group | `zen-button-group` | +| Calendar | `zen-calendar` | +| Card | `zen-card` | +| Carousel | `zen-carousel` | +| Chart | `zen-chart` | +| Checkbox | `zen-checkbox` | +| Collapsible | `zen-collapsible` | +| Combobox | `zen-combobox` | +| Command | `zen-command` | +| Context Menu | `zen-context-menu` | +| Data Table | `zen-data-table` | +| Date Picker | `zen-date-picker` | +| Dialog | `zen-dialog` | +| Direction | `zen-direction` | +| Drawer | `zen-drawer` | +| Dropdown Menu | `zen-dropdown-menu` | +| Empty | `zen-empty` | +| Field | `zen-field` | +| Form | `zen-form` | +| Hover Card | `zen-hover-card` | +| Input | `zen-input` | +| Input Group | `zen-input-group` | +| Input OTP | `zen-input-otp` | +| Item | `zen-item` | +| Kbd | `zen-kbd` | +| Label | `zen-label` | +| Marker | `zen-marker` | +| Menubar | `zen-menubar` | +| Message | `zen-message` | +| Message Scroller | `zen-message-scroller` | +| Native Select | `zen-native-select` | +| Navigation Menu | `zen-navigation-menu` | +| Pagination | `zen-pagination` | +| Popover | `zen-popover` | +| Progress | `zen-progress` | +| Radio Group | `zen-radio-group` | +| Resizable | `zen-resizable` | +| Scroll Area | `zen-scroll-area` | +| Select | `zen-select` | +| Separator | `zen-separator` | +| Sheet | `zen-sheet` | +| Sidebar | `zen-sidebar` | +| Skeleton | `zen-skeleton` | +| Slider | `zen-slider` | +| Sonner | `zen-notifications` | +| Spinner | `zen-spinner` | +| Switch | `zen-switch` | +| Table | `zen-table` | +| Tabs | `zen-tabs` | +| Textarea | `zen-textarea` | +| Toast (deprecated) | `zen-notifications` | +| Toggle | `zen-toggle` | +| Toggle Group | `zen-toggle-group` | +| Tooltip | `zen-tooltip` | +| Typography | `zen-typography` | + +`zen-stack` remains a Zenbu-specific layout primitive in addition to this +catalog. + +## Native HTML rules + +- Dialog, alert dialog, sheet, and drawer contain a real `dialog`. +- Accordion and collapsible contain `details` and `summary`. +- Input, textarea, select, checkbox, radio, switch, and slider contain their + corresponding native form controls. +- Calendar and date picker keep a native date value for form submission while + rendering the first-party keyboard month grid. The browser date-picker chrome + is not exposed. +- Table and data table contain a real `table`; pagination and breadcrumbs use + native links in a `nav`. +- Button-like components contain native `button` or `a` elements. +- Components do not own application data, network requests, routing, or + persistence. + +## Interactive hooks + +| Elements | Required light-DOM hooks | Emitted event | +| --- | --- | --- | +| `zen-dialog`, `zen-alert-dialog`, `zen-sheet`, `zen-drawer` | `[data-zen-trigger]`, `dialog`, optional `[data-zen-close]` | native `close` | +| `zen-popover`, `zen-hover-card`, `zen-tooltip` | `[data-zen-trigger]`, `[data-zen-content]` | native toggle/focus events | +| Menu elements | `[data-zen-trigger]`, `[role="menu"]`, `[role="menuitem"]` | native click | +| `zen-tabs` | `[role="tablist"]`, `[role="tab"]`, `[role="tabpanel"]` | `zen-change` with `{ value }` | +| `zen-toggle` | direct `button` | `zen-change` with `{ pressed }` | +| `zen-toggle-group` | direct buttons with `value` | `zen-change` with `{ values }` | +| `zen-sidebar` | `[data-zen-sidebar-trigger]`, `[data-zen-sidebar-panel]` | `zen-change` with `{ open }` | +| `zen-combobox` | `input`, `[role="listbox"]`, `[role="option"]` | native `input`/`change` and `zen-change` | +| `zen-command` | `input`, `[role="menu"]`, `[role="menuitem"]` | `zen-command` with `{ value }` | +| `zen-input-otp` | one native input, optional `[data-zen-otp-slots]` | `zen-complete` with `{ value }` | +| `zen-calendar`, `zen-date-picker` | native date input plus generated month grid | native `input`/`change` and `zen-change` | +| `zen-carousel` | `[data-zen-viewport]`, `[data-zen-prev]`, `[data-zen-next]` | `zen-change` with `{ index }` | +| `zen-resizable` | two `[data-zen-panel]` elements and `[data-zen-handle]` | `zen-resize` with `{ percent }` | +| `zen-data-table` | `button[data-zen-sort]` and matching cell `data-key` values | `zen-sort` | + +All custom events bubble but remain inside their current DOM tree unless a +component-specific document says otherwise. Application code should listen at +the nearest owning feature boundary. + +## Catalog + +Every inventory row has a route at `/components/<name>`, a rendered example, +and copyable HTML source in the Seobeo-backed catalog: + +```bash +bazel run //design_system:dev +```
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/ICONS.md Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,40 @@ +# Zenbu UI icons + +`zen-icon` is the only icon renderer used by Zenbu UI. It renders first-party +SVG geometry in light DOM, inherits the current text color, and has no font, +emoji, package, or network dependency. + +```html +<zen-icon name="search"></zen-icon> +<zen-icon name="archive" label="Archive repository" size="1.25rem"></zen-icon> +``` + +Icons without `label` are decorative and receive `aria-hidden="true"`. An icon +with a label receives `role="img"` and the matching accessible name. Buttons +that already have an accessible name should use an unlabeled icon. + +## Names + +The initial set contains: + +```text +add alert archive arrow-down arrow-left arrow-right arrow-up +branch calendar check chevron-down chevron-left chevron-right chevron-up +close code copy download error external file folder history info +menu moon more pause play repository retry search settings sun +trash upload user +``` + +Unknown names render the `alert` fallback rather than inserting authored HTML. +The catalog at `/icons` renders the authoritative runtime list. + +## Hard requirements + +- Do not use emoji or text glyphs as interface icons. +- Do not add inline SVG outside `components/icon.js`. +- Do not import an external icon package or icon font. +- Use `currentColor` through `zen-icon`; the owning component must obtain that + color from a `--zen-color-*` token. +- Add new geometry to the first-party icon registry and include a catalog test. + +`//design_system/test:design_system_policy_test` enforces these requirements.
--- a/design_system/README.md Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/README.md Tue Aug 04 15:12:09 2026 -0700 @@ -4,6 +4,11 @@ It stays close to ordinary HTML: components wrap native controls and sectioning elements instead of replacing their semantics. +The catalog includes native Zenbu implementations of all 65 concepts currently +listed by shadcn/ui. These are original Web Components using Zenbu tokens, not +ports of shadcn's React, Radix, or third-party runtime. See +[`COMPONENTS.md`](COMPONENTS.md) for the complete mapping and HTML contracts. + ## Catalog Run the Seobeo-backed component catalog: @@ -50,11 +55,22 @@ ``` Every visual token is a `--zen-*` custom property and can be overridden by the -application. +application. See [`COLORS.md`](COLORS.md) for the complete warm color ramps, +material aliases, semantic roles, dark-theme behavior, and data palette. + +Use [`zen-icon`](ICONS.md) for all interface iconography. Design-system source +does not use emoji, ad hoc SVG, icon fonts, or third-party icon packages. + +All exported JavaScript APIs use Google-style JSDoc. The policy target checks +the documentation contract along with token-only colors and first-party icons. + +The catalog navigation is searchable with `/`, and every route is exercised at +desktop and mobile widths. Form controls reset browser chrome; date selection +uses Zenbu's first-party keyboard calendar. ## Native HTML contract -| Component | Native content | Attributes | Events | +| Component family | Native content | Attributes | Events | | --- | --- | --- | --- | | `zen-button` | direct `button` or `a` | `variant`, `loading`, `disabled` | native click/form events | | `zen-card` | direct `article` or `section` | `interactive` | native descendant events | @@ -62,6 +78,10 @@ | `zen-field` | direct `label`, form control, help text | native control attributes | native input/change/invalid | | `zen-notifications` | descendant event producers | `aria-label`, `dismiss-label` | `zen-notification-action`, `zen-notification-removed` | | `zen-stack` | any HTML | `direction`, `gap` | native descendant events | +| Disclosure and tabs | `details`, `summary`, buttons, sections | `multiple`, `activation` | `zen-change` | +| Dialogs and menus | native `dialog`, buttons, links | `placement`, native attributes | native click/close | +| Form controls | native input, select, textarea, fieldset | native control attributes | native form events | +| Collections | table, article, section, scroll containers | component-specific data hooks | `zen-change`, `zen-sort`, `zen-resize` | Do not put business state into the design-system components. They provide presentation, small accessibility wiring, and interaction affordances while
--- a/design_system/main.c Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/main.c Tue Aug 04 15:12:09 2026 -0700 @@ -3,6 +3,76 @@ #include <stdlib.h> #include <string.h> +static const char *const COMPONENT_PATHS[] = { + "/components/accordion", + "/components/alert", + "/components/alert-dialog", + "/components/aspect-ratio", + "/components/attachment", + "/components/avatar", + "/components/badge", + "/components/breadcrumb", + "/components/bubble", + "/components/button", + "/components/button-group", + "/components/calendar", + "/components/card", + "/components/carousel", + "/components/chart", + "/components/checkbox", + "/components/collapsible", + "/components/combobox", + "/components/command", + "/components/context-menu", + "/components/data-table", + "/components/date-picker", + "/components/dialog", + "/components/direction", + "/components/drawer", + "/components/dropdown-menu", + "/components/empty", + "/components/field", + "/components/form", + "/components/hover-card", + "/components/input", + "/components/input-group", + "/components/input-otp", + "/components/item", + "/components/kbd", + "/components/label", + "/components/marker", + "/components/menubar", + "/components/message", + "/components/message-scroller", + "/components/native-select", + "/components/navigation-menu", + "/components/notifications", + "/components/pagination", + "/components/popover", + "/components/progress", + "/components/radio-group", + "/components/resizable", + "/components/scroll-area", + "/components/select", + "/components/separator", + "/components/sheet", + "/components/sidebar", + "/components/skeleton", + "/components/slider", + "/components/sonner", + "/components/spinner", + "/components/stack", + "/components/switch", + "/components/table", + "/components/tabs", + "/components/textarea", + "/components/toast", + "/components/toggle", + "/components/toggle-group", + "/components/tooltip", + "/components/typography", +}; + static Seobeo_Request_Entry *Get_Component_Catalog( Seobeo_Request_Entry *request, Dowa_Arena *arena) @@ -71,13 +141,17 @@ Seobeo_Router_Init(); Seobeo_Router_Register("GET", "/", Get_Component_Catalog); Seobeo_Router_Register("GET", "/tokens", Get_Component_Catalog); + Seobeo_Router_Register("GET", "/icons", Get_Component_Catalog); Seobeo_Router_Register("GET", "/components", Get_Component_Catalog); - Seobeo_Router_Register("GET", "/components/button", Get_Component_Catalog); - Seobeo_Router_Register("GET", "/components/card", Get_Component_Catalog); - Seobeo_Router_Register("GET", "/components/alert", Get_Component_Catalog); - Seobeo_Router_Register("GET", "/components/field", Get_Component_Catalog); - Seobeo_Router_Register("GET", "/components/notifications", Get_Component_Catalog); - Seobeo_Router_Register("GET", "/components/stack", Get_Component_Catalog); + for (size_t i = 0; + i < sizeof(COMPONENT_PATHS) / sizeof(COMPONENT_PATHS[0]); + i++) + { + Seobeo_Router_Register( + "GET", + COMPONENT_PATHS[i], + Get_Component_Catalog); + } const char *port = getenv("DESIGN_SYSTEM_PORT"); if (!port || port[0] == '\0')
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/catalog.js Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,732 @@ +/** @const {!ReadonlyArray<!Object>} shadcn concepts shown by the catalog. */ +export const componentCatalog = [ + { + name: "Accordion", + slug: "accordion", + element: "zen-accordion", + group: "Disclosure", + description: "A group of native details elements with optional single-open behavior.", + markup: `<zen-accordion> + <details open><summary>Is it accessible?</summary><p>It keeps native details and summary semantics.</p></details> + <details><summary>Can several open?</summary><p>Add the multiple attribute to allow it.</p></details> +</zen-accordion>`, + }, + { + name: "Alert", + slug: "alert", + element: "zen-alert", + group: "Feedback", + description: "Status messaging with semantic tones and optional dismissal.", + existing: true, + }, + { + name: "Alert Dialog", + slug: "alert-dialog", + element: "zen-alert-dialog", + group: "Overlays", + description: "A native modal dialog for decisions that require confirmation.", + markup: `<zen-alert-dialog> + <button type="button" data-zen-trigger>Delete deployment</button> + <dialog> + <h3>Delete deployment?</h3> + <p>This action cannot be undone.</p> + <button type="button" data-zen-close>Cancel</button> + <button type="button" value="confirm" data-zen-close>Delete</button> + </dialog> +</zen-alert-dialog>`, + }, + { + name: "Aspect Ratio", + slug: "aspect-ratio", + element: "zen-aspect-ratio", + group: "Layout", + description: "A media frame driven by a readable ratio attribute.", + markup: `<zen-aspect-ratio ratio="16/9"> + <div class="demo-media">16 / 9</div> +</zen-aspect-ratio>`, + }, + { + name: "Attachment", + slug: "attachment", + element: "zen-attachment", + group: "Chat", + description: "File and upload state presentation using ordinary links and buttons.", + markup: `<zen-attachment state="complete"> + <span aria-hidden="true">PDF</span> + <div><strong>release-notes.pdf</strong><small>248 KB · Ready</small></div> + <button type="button" aria-label="Remove attachment"><zen-icon name="close"></zen-icon></button> +</zen-attachment>`, + }, + { + name: "Avatar", + slug: "avatar", + element: "zen-avatar", + group: "Display", + description: "A compact identity image with a visible text fallback.", + markup: `<zen-avatar aria-label="June Park"><span>JP</span></zen-avatar>`, + }, + { + name: "Badge", + slug: "badge", + element: "zen-badge", + group: "Display", + description: "A small label for metadata, state, or counts.", + markup: `<zen-stack direction="row"> + <zen-badge>Default</zen-badge> + <zen-badge tone="success">Healthy</zen-badge> + <zen-badge tone="danger">Failed</zen-badge> +</zen-stack>`, + }, + { + name: "Breadcrumb", + slug: "breadcrumb", + element: "zen-breadcrumb", + group: "Navigation", + description: "A native navigation landmark for hierarchical location.", + markup: `<zen-breadcrumb> + <nav><ol> + <li><a href="/">Home</a></li> + <li><a href="/components">Components</a></li> + <li aria-current="page">Breadcrumb</li> + </ol></nav> +</zen-breadcrumb>`, + }, + { + name: "Bubble", + slug: "bubble", + element: "zen-bubble", + group: "Chat", + description: "A message bubble with incoming and outgoing alignment.", + markup: `<zen-stack> + <zen-bubble><p>The deployment is ready.</p></zen-bubble> + <zen-bubble align="end"><p>Ship it.</p></zen-bubble> +</zen-stack>`, + }, + { + name: "Button", + slug: "button", + element: "zen-button", + group: "Actions", + description: "Native buttons and links with shared variants and states.", + existing: true, + }, + { + name: "Button Group", + slug: "button-group", + element: "zen-button-group", + group: "Actions", + description: "Related native buttons joined into one visual control.", + markup: `<zen-button-group aria-label="Text alignment"> + <button type="button">Left</button> + <button type="button">Center</button> + <button type="button">Right</button> +</zen-button-group>`, + }, + { + name: "Calendar", + slug: "calendar", + element: "zen-calendar", + group: "Forms", + description: "A first-party date month grid backed by a native form value.", + markup: `<zen-calendar> + <label for="calendar-demo">Release date</label> + <input id="calendar-demo" type="date" value="2026-08-04" /> +</zen-calendar>`, + }, + { + name: "Card", + slug: "card", + element: "zen-card", + group: "Display", + description: "A visual container around native article or section content.", + existing: true, + }, + { + name: "Carousel", + slug: "carousel", + element: "zen-carousel", + group: "Collections", + description: "A scroll-snap collection with native buttons and keyboard controls.", + markup: `<zen-carousel aria-label="Recent projects"> + <div data-zen-viewport> + <article>Mercurial forge</article> + <article>HLS player</article> + <article>LaTeX editor</article> + </div> + <button type="button" data-zen-prev aria-label="Previous slide"><zen-icon name="arrow-left"></zen-icon></button> + <button type="button" data-zen-next aria-label="Next slide"><zen-icon name="arrow-right"></zen-icon></button> +</zen-carousel>`, + }, + { + name: "Chart", + slug: "chart", + element: "zen-chart", + group: "Data", + description: "A dependency-free chart frame for semantic labels and custom marks.", + markup: `<zen-chart aria-label="Build minutes by day"> + <div data-zen-chart-plot> + <span style="--zen-chart-value: 42%" data-label="Mon"></span> + <span style="--zen-chart-value: 76%" data-label="Tue"></span> + <span style="--zen-chart-value: 58%" data-label="Wed"></span> + <span style="--zen-chart-value: 88%" data-label="Thu"></span> + </div> +</zen-chart>`, + }, + { + name: "Checkbox", + slug: "checkbox", + element: "zen-checkbox", + group: "Forms", + description: "A styled native checkbox that remains form-associated.", + markup: `<zen-checkbox> + <label><input type="checkbox" checked /> Include deployment artifacts</label> +</zen-checkbox>`, + }, + { + name: "Collapsible", + slug: "collapsible", + element: "zen-collapsible", + group: "Disclosure", + description: "A single native details disclosure.", + markup: `<zen-collapsible> + <details><summary>Three changed files</summary><p>server.c, styles.css, README.md</p></details> +</zen-collapsible>`, + }, + { + name: "Combobox", + slug: "combobox", + element: "zen-combobox", + group: "Forms", + description: "A filterable text input backed by an accessible option list.", + markup: `<zen-combobox> + <label for="repo-combobox">Repository</label> + <input id="repo-combobox" autocomplete="off" placeholder="Find repository" /> + <div role="listbox"> + <button type="button" role="option" data-value="zenbu">zenbu</button> + <button type="button" role="option" data-value="hg-web">hg-web</button> + <button type="button" role="option" data-value="seobeo">seobeo</button> + </div> +</zen-combobox>`, + }, + { + name: "Command", + slug: "command", + element: "zen-command", + group: "Navigation", + description: "A keyboard-first filterable command list.", + markup: `<zen-command> + <input type="search" aria-label="Commands" placeholder="Type a command" /> + <div role="menu"> + <button type="button" role="menuitem" data-value="new">New repository</button> + <button type="button" role="menuitem" data-value="deploy">Deploy release</button> + <button type="button" role="menuitem" data-value="theme">Toggle theme</button> + </div> +</zen-command>`, + }, + { + name: "Context Menu", + slug: "context-menu", + element: "zen-context-menu", + group: "Menus", + description: "A right-click menu with keyboard navigation and native buttons.", + markup: `<zen-context-menu> + <div data-zen-trigger tabindex="0">Right-click this repository</div> + <div role="menu" hidden> + <button type="button" role="menuitem">Open</button> + <button type="button" role="menuitem">Clone</button> + <button type="button" role="menuitem">Archive</button> + </div> +</zen-context-menu>`, + }, + { + name: "Data Table", + slug: "data-table", + element: "zen-data-table", + group: "Data", + description: "A native table enhanced with optional client-side sorting.", + markup: `<zen-data-table> + <table> + <thead><tr> + <th><button type="button" data-zen-sort="name">Repository</button></th> + <th><button type="button" data-zen-sort="commits">Commits</button></th> + </tr></thead> + <tbody> + <tr><td data-key="name">seobeo</td><td data-key="commits">87</td></tr> + <tr><td data-key="name">hg-web</td><td data-key="commits">142</td></tr> + <tr><td data-key="name">design-system</td><td data-key="commits">12</td></tr> + </tbody> + </table> +</zen-data-table>`, + }, + { + name: "Date Picker", + slug: "date-picker", + element: "zen-date-picker", + group: "Forms", + description: "A tokenized date trigger and first-party keyboard calendar.", + markup: `<zen-date-picker> + <label for="date-picker-demo">Deploy on</label> + <input id="date-picker-demo" type="date" /> +</zen-date-picker>`, + }, + { + name: "Dialog", + slug: "dialog", + element: "zen-dialog", + group: "Overlays", + description: "A native modal dialog with trigger and close wiring.", + markup: `<zen-dialog> + <button type="button" data-zen-trigger>Edit profile</button> + <dialog> + <h3>Edit profile</h3> + <label>Display name <input value="June" /></label> + <button type="button" data-zen-close>Done</button> + </dialog> +</zen-dialog>`, + }, + { + name: "Direction", + slug: "direction", + element: "zen-direction", + group: "Utilities", + description: "A light-DOM boundary for left-to-right or right-to-left content.", + markup: `<zen-direction dir="rtl"><p>مرحبا بالعالم</p></zen-direction>`, + }, + { + name: "Drawer", + slug: "drawer", + element: "zen-drawer", + group: "Overlays", + description: "A native dialog presented as an edge drawer.", + markup: `<zen-drawer placement="bottom"> + <button type="button" data-zen-trigger>Open drawer</button> + <dialog><h3>Build queue</h3><p>Two jobs are running.</p><button type="button" data-zen-close>Close</button></dialog> +</zen-drawer>`, + }, + { + name: "Dropdown Menu", + slug: "dropdown-menu", + element: "zen-dropdown-menu", + group: "Menus", + description: "A button-owned action menu with arrow-key navigation.", + markup: `<zen-dropdown-menu> + <button type="button" data-zen-trigger>Repository actions</button> + <div role="menu" hidden> + <button type="button" role="menuitem">Clone</button> + <button type="button" role="menuitem">Bookmark</button> + <button type="button" role="menuitem">Archive</button> + </div> +</zen-dropdown-menu>`, + }, + { + name: "Empty", + slug: "empty", + element: "zen-empty", + group: "Feedback", + description: "A structured empty state with an optional native action.", + markup: `<zen-empty> + <strong>No deployments yet</strong> + <p>Create the first release from the current default branch.</p> + <button type="button">Create deployment</button> +</zen-empty>`, + }, + { + name: "Field", + slug: "field", + element: "zen-field", + group: "Forms", + description: "Label, help text, validity, and a native form control.", + existing: true, + }, + { + name: "Form", + slug: "form", + element: "zen-form", + group: "Forms", + description: "A native form boundary with visible browser validity state.", + markup: `<zen-form> + <form> + <zen-field><label>Repository name</label><input required /><small>Required</small></zen-field> + <button type="submit">Create</button> + </form> +</zen-form>`, + }, + { + name: "Hover Card", + slug: "hover-card", + element: "zen-hover-card", + group: "Overlays", + description: "Supplemental content shown on hover or keyboard focus.", + markup: `<zen-hover-card> + <a href="#" data-zen-trigger>@mrjunejune</a> + <div data-zen-content hidden><strong>June Park</strong><p>Building Zenbu in C and native HTML.</p></div> +</zen-hover-card>`, + }, + { + name: "Input", + slug: "input", + element: "zen-input", + group: "Forms", + description: "A styled native input with no replacement semantics.", + markup: `<zen-input><input type="email" placeholder="[email protected]" aria-label="Email" /></zen-input>`, + }, + { + name: "Input Group", + slug: "input-group", + element: "zen-input-group", + group: "Forms", + description: "A native input with prefix, suffix, or inline actions.", + markup: `<zen-input-group> + <span aria-hidden="true">https://</span> + <input aria-label="Host" value="zenbu.babocoder.com" /> + <button type="button">Copy</button> +</zen-input-group>`, + }, + { + name: "Input OTP", + slug: "input-otp", + element: "zen-input-otp", + group: "Forms", + description: "A single native one-time-code input with visual slots.", + markup: `<zen-input-otp> + <label for="otp-demo">Verification code</label> + <input id="otp-demo" inputmode="numeric" autocomplete="one-time-code" maxlength="6" /> + <div data-zen-otp-slots aria-hidden="true"></div> +</zen-input-otp>`, + }, + { + name: "Item", + slug: "item", + element: "zen-item", + group: "Display", + description: "A flexible list row for an icon, content, and actions.", + markup: `<zen-item> + <zen-icon name="repository"></zen-icon> + <div><strong>hg-web</strong><small>Updated two minutes ago</small></div> + <button type="button">Open</button> +</zen-item>`, + }, + { + name: "Kbd", + slug: "kbd", + element: "zen-kbd", + group: "Display", + description: "Keyboard shortcut presentation using native kbd elements.", + markup: `<p>Open commands with <zen-kbd><kbd>Ctrl</kbd><span>+</span><kbd>K</kbd></zen-kbd>.</p>`, + }, + { + name: "Label", + slug: "label", + element: "zen-label", + group: "Forms", + description: "A styled native label that keeps browser association.", + markup: `<zen-label><label for="label-demo">Branch name</label></zen-label> +<zen-input><input id="label-demo" value="default" /></zen-input>`, + }, + { + name: "Marker", + slug: "marker", + element: "zen-marker", + group: "Chat", + description: "An inline system marker for chat and activity streams.", + markup: `<zen-marker><span>Today</span></zen-marker>`, + }, + { + name: "Menubar", + slug: "menubar", + element: "zen-menubar", + group: "Menus", + description: "A horizontal application menu using native buttons and menu roles.", + markup: `<zen-menubar> + <button type="button" data-zen-trigger>File</button> + <div role="menu" hidden><button role="menuitem">New</button><button role="menuitem">Open</button></div> + <button type="button" data-zen-trigger>View</button> + <div role="menu" hidden><button role="menuitem">Graph</button><button role="menuitem">History</button></div> +</zen-menubar>`, + }, + { + name: "Message", + slug: "message", + element: "zen-message", + group: "Chat", + description: "An avatar, author, and content row for conversations.", + markup: `<zen-message> + <zen-avatar aria-label="June"><span>JP</span></zen-avatar> + <div><strong>June</strong><zen-bubble><p>Can we deploy this release?</p></zen-bubble></div> +</zen-message>`, + }, + { + name: "Message Scroller", + slug: "message-scroller", + element: "zen-message-scroller", + group: "Chat", + description: "A chat viewport that follows new content until the reader scrolls away.", + markup: `<zen-message-scroller> + <div data-zen-viewport> + <p>Worker connected.</p><p>Build started.</p><p>Bundle created.</p><p>Deployment complete.</p> + </div> + <button type="button" data-zen-scroll-end hidden>Jump to latest</button> +</zen-message-scroller>`, + }, + { + name: "Native Select", + slug: "native-select", + element: "zen-native-select", + group: "Forms", + description: "A styled native select with full platform behavior.", + markup: `<zen-native-select><select aria-label="Environment"><option>Development</option><option>Production</option></select></zen-native-select>`, + }, + { + name: "Navigation Menu", + slug: "navigation-menu", + element: "zen-navigation-menu", + group: "Navigation", + description: "Top-level navigation with optional keyboard-accessible flyouts.", + markup: `<zen-navigation-menu> + <nav aria-label="Products"> + <button type="button" data-zen-trigger>Projects</button> + <div role="menu" hidden><a role="menuitem" href="/components">Zenbu UI</a><a role="menuitem" href="/">hg-web</a></div> + <a href="/tokens">Tokens</a> + </nav> +</zen-navigation-menu>`, + }, + { + name: "Pagination", + slug: "pagination", + element: "zen-pagination", + group: "Navigation", + description: "Page navigation built from native links.", + markup: `<zen-pagination> + <nav><a href="#">Previous</a><a href="#">1</a><a href="#" aria-current="page">2</a><a href="#">3</a><a href="#">Next</a></nav> +</zen-pagination>`, + }, + { + name: "Popover", + slug: "popover", + element: "zen-popover", + group: "Overlays", + description: "A lightweight panel using the native Popover API when available.", + markup: `<zen-popover> + <button type="button" data-zen-trigger>Open settings</button> + <div data-zen-content><strong>Dimensions</strong><label>Width <input value="100%" /></label></div> +</zen-popover>`, + }, + { + name: "Progress", + slug: "progress", + element: "zen-progress", + group: "Feedback", + description: "A styled native progress element.", + markup: `<zen-progress><label for="progress-demo">Uploading</label><progress id="progress-demo" value="68" max="100">68%</progress></zen-progress>`, + }, + { + name: "Radio Group", + slug: "radio-group", + element: "zen-radio-group", + group: "Forms", + description: "A fieldset of native radio inputs.", + markup: `<zen-radio-group> + <fieldset><legend>Visibility</legend> + <label><input type="radio" name="visibility" checked /> Public</label> + <label><input type="radio" name="visibility" /> Private</label> + </fieldset> +</zen-radio-group>`, + }, + { + name: "Resizable", + slug: "resizable", + element: "zen-resizable", + group: "Layout", + description: "Two native panels separated by a pointer and keyboard handle.", + markup: `<zen-resizable> + <section data-zen-panel>Repository tree</section> + <div data-zen-handle tabindex="0"></div> + <section data-zen-panel>File preview</section> +</zen-resizable>`, + }, + { + name: "Scroll Area", + slug: "scroll-area", + element: "zen-scroll-area", + group: "Layout", + description: "A bounded native scroll container with theme-aware scrollbars.", + markup: `<zen-scroll-area tabindex="0"> + <p>default</p><p>feature/design-system</p><p>release/2026-08</p><p>experiment/trading</p> +</zen-scroll-area>`, + }, + { + name: "Select", + slug: "select", + element: "zen-select", + group: "Forms", + description: "A native select styled as the default accessible implementation.", + markup: `<zen-select><label>Branch <select><option>default</option><option>stable</option></select></label></zen-select>`, + }, + { + name: "Separator", + slug: "separator", + element: "zen-separator", + group: "Layout", + description: "A native horizontal rule or ARIA separator.", + markup: `<div>Repository</div><zen-separator><hr /></zen-separator><div>Changesets</div>`, + }, + { + name: "Sheet", + slug: "sheet", + element: "zen-sheet", + group: "Overlays", + description: "A native dialog presented from a selected screen edge.", + markup: `<zen-sheet placement="right"> + <button type="button" data-zen-trigger>Open filters</button> + <dialog><h3>Filters</h3><label>Author <input /></label><button type="button" data-zen-close>Apply</button></dialog> +</zen-sheet>`, + }, + { + name: "Sidebar", + slug: "sidebar", + element: "zen-sidebar", + group: "Navigation", + description: "A collapsible navigation region with an explicit native trigger.", + markup: `<zen-sidebar open> + <button type="button" data-zen-sidebar-trigger>Toggle sidebar</button> + <aside data-zen-sidebar-panel><nav><a href="/">Overview</a><a href="/components">Components</a><a href="/tokens">Tokens</a></nav></aside> +</zen-sidebar>`, + }, + { + name: "Skeleton", + slug: "skeleton", + element: "zen-skeleton", + group: "Feedback", + description: "A reduced-motion-safe placeholder for pending content.", + markup: `<zen-stack> + <zen-skeleton shape="circle"></zen-skeleton> + <zen-skeleton></zen-skeleton> + <zen-skeleton width="60%"></zen-skeleton> +</zen-stack>`, + }, + { + name: "Slider", + slug: "slider", + element: "zen-slider", + group: "Forms", + description: "A native range input with browser keyboard and pointer behavior.", + markup: `<zen-slider><label for="slider-demo">Volume</label><input id="slider-demo" type="range" value="65" /></zen-slider>`, + }, + { + name: "Sonner", + slug: "sonner", + element: "zen-notifications", + group: "Feedback", + description: "Zenbu's bounded, scoped, Sonner-like notification stack.", + markup: `<zen-notifications aria-label="Sonner demo"> + <button type="button" data-notification-demo data-notification-id="sonner-demo" data-tone="success" data-message="Deployment complete">Show notification</button> +</zen-notifications>`, + }, + { + name: "Spinner", + slug: "spinner", + element: "zen-spinner", + group: "Feedback", + description: "A small CSS loading indicator with status semantics.", + markup: `<zen-spinner aria-label="Loading repositories"></zen-spinner>`, + }, + { + name: "Switch", + slug: "switch", + element: "zen-switch", + group: "Forms", + description: "A native checkbox presented with switch semantics.", + markup: `<zen-switch><label><input type="checkbox" checked /> Automatic deployment</label></zen-switch>`, + }, + { + name: "Table", + slug: "table", + element: "zen-table", + group: "Data", + description: "Responsive styling around a native HTML table.", + markup: `<zen-table><table> + <caption>Recent changesets</caption> + <thead><tr><th>Revision</th><th>Author</th></tr></thead> + <tbody><tr><td>253:fdf3816959cb</td><td>June</td></tr><tr><td>252:7a7581f040e8</td><td>June</td></tr></tbody> +</table></zen-table>`, + }, + { + name: "Tabs", + slug: "tabs", + element: "zen-tabs", + group: "Disclosure", + description: "ARIA tabs with automatic or manual keyboard activation.", + markup: `<zen-tabs> + <div role="tablist" aria-label="Repository views"> + <button type="button" role="tab" data-value="files">Files</button> + <button type="button" role="tab" data-value="history">History</button> + <button type="button" role="tab" data-value="graph">Graph</button> + </div> + <section role="tabpanel">Browse repository files.</section> + <section role="tabpanel">Inspect changesets.</section> + <section role="tabpanel">View branch topology.</section> +</zen-tabs>`, + }, + { + name: "Textarea", + slug: "textarea", + element: "zen-textarea", + group: "Forms", + description: "A styled native multiline text control.", + markup: `<zen-textarea><textarea aria-label="Commit message" placeholder="Describe this change"></textarea></zen-textarea>`, + }, + { + name: "Toast", + slug: "toast", + element: "zen-notifications", + group: "Feedback", + description: "The deprecated toast concept maps to zen-notifications.", + markup: `<zen-alert tone="warning"><p><strong>Toast is deprecated.</strong> Use <code>zen-notifications</code> for scoped transient messages.</p></zen-alert>`, + }, + { + name: "Toggle", + slug: "toggle", + element: "zen-toggle", + group: "Actions", + description: "A native button with managed pressed state.", + markup: `<zen-toggle><button type="button">Bold</button></zen-toggle>`, + }, + { + name: "Toggle Group", + slug: "toggle-group", + element: "zen-toggle-group", + group: "Actions", + description: "Single or multiple pressed-state buttons with roving keyboard focus.", + markup: `<zen-toggle-group type="single" value="center" aria-label="Alignment"> + <button type="button" value="left">Left</button> + <button type="button" value="center">Center</button> + <button type="button" value="right">Right</button> +</zen-toggle-group>`, + }, + { + name: "Tooltip", + slug: "tooltip", + element: "zen-tooltip", + group: "Overlays", + description: "A delayed, non-interactive description for hover and focus.", + markup: `<zen-tooltip> + <button type="button" data-zen-trigger aria-label="Add repository"><zen-icon name="add"></zen-icon></button> + <span data-zen-content hidden>Add repository</span> +</zen-tooltip>`, + }, + { + name: "Typography", + slug: "typography", + element: "zen-typography", + group: "Foundations", + description: "Readable defaults for native headings, paragraphs, lists, and code.", + markup: `<zen-typography> + <h2>The Mercurial forge</h2> + <p>Typography stays semantic and inherits Zenbu tokens.</p> + <blockquote>Own the stack, understand the stack.</blockquote> +</zen-typography>`, + }, +]; + +/** @const {number} Number of mapped shadcn component concepts. */ +export const shadcnComponentCount = componentCatalog.length;
--- a/design_system/src/components/alert.js Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/src/components/alert.js Tue Aug 04 15:12:09 2026 -0700 @@ -1,3 +1,8 @@ +/** + * Presents a semantic status message with optional dismissal. + * + * @extends {HTMLElement} + */ export class ZenAlert extends HTMLElement { static get observedAttributes() { return ["dismissible", "tone"]; @@ -50,7 +55,9 @@ dismiss.dataset.zenDismiss = ""; dismiss.dataset.zenGenerated = "true"; dismiss.setAttribute("aria-label", "Dismiss message"); - dismiss.textContent = "×"; + const icon = document.createElement("zen-icon"); + icon.setAttribute("name", "close"); + dismiss.append(icon); this.append(dismiss); } if (dismiss === this._dismissControl) return;
--- a/design_system/src/components/button.js Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/src/components/button.js Tue Aug 04 15:12:09 2026 -0700 @@ -1,3 +1,8 @@ +/** + * Enhances a native button or link with disabled and loading states. + * + * @extends {HTMLElement} + */ export class ZenButton extends HTMLElement { static get observedAttributes() { return ["disabled", "loading"];
--- a/design_system/src/components/card.js Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/src/components/card.js Tue Aug 04 15:12:09 2026 -0700 @@ -1,3 +1,8 @@ +/** + * Provides presentation for a native article or section. + * + * @extends {HTMLElement} + */ export class ZenCard extends HTMLElement {} if (!customElements.get("zen-card")) {
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/collections.js Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,579 @@ +const HTMLElementBase = globalThis.HTMLElement || class {}; + +function rememberAttribute(state, element, name) { + let attributes = state.get(element); + if (!attributes) { + attributes = new Map(); + state.set(element, attributes); + } + if (!attributes.has(name)) { + attributes.set(name, element.getAttribute(name)); + } +} + +function setManagedAttribute(state, element, name, value) { + rememberAttribute(state, element, name); + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); +} + +function originalAttribute(state, element, name) { + const attributes = state.get(element); + return attributes?.has(name) + ? attributes.get(name) + : element.getAttribute(name); +} + +function restoreElement(state, element) { + const attributes = state.get(element); + if (!attributes) return; + for (const [name, value] of attributes) { + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); + } + state.delete(element); +} + +function restoreAttributes(state) { + for (const element of [...state.keys()]) restoreElement(state, element); +} + +function pruneAttributes(state, retained) { + for (const element of [...state.keys()]) { + if (!retained.has(element)) restoreElement(state, element); + } +} + +function setDefaultAttribute(state, element, name, value) { + if (!element.hasAttribute(name) || state.get(element)?.has(name)) { + setManagedAttribute(state, element, name, value); + } +} + +function eventElement(event) { + const target = event.target; + if (target?.nodeType === 1) return target; + return target?.parentElement || null; +} + +function ownedElement(host, selector) { + return [...host.querySelectorAll(selector)].find( + element => element.closest(host.localName) === host, + ) || null; +} + +function emit(element, type, detail) { + element.dispatchEvent(new CustomEvent(type, { + bubbles: true, + detail, + })); +} + +function isDisabled(control) { + return Boolean(control?.disabled) || + control?.getAttribute("aria-disabled") === "true"; +} + +/** + * Enhances a native scroll-snap viewport with navigation controls. + * + * @extends {HTMLElement} + */ +export class ZenCarousel extends HTMLElementBase { + connectedCallback() { + this._managed ||= new Map(); + this._onClick ||= event => { + const control = eventElement(event)?.closest( + "[data-zen-prev], [data-zen-next]", + ); + if (control === this._prev && !isDisabled(control)) this.move(-1); + else if (control === this._next && !isDisabled(control)) this.move(1); + }; + this._onKeydown ||= event => { + if (event.altKey || event.ctrlKey || event.metaKey || + !["ArrowLeft", "ArrowRight"].includes(event.key)) { + return; + } + const target = eventElement(event); + if (target?.closest("input, textarea, select, [contenteditable]")) return; + event.preventDefault(); + this.move(event.key === "ArrowLeft" ? -1 : 1); + }; + this._onScroll ||= () => { + globalThis.clearTimeout(this._scrollTimer); + this._scrollTimer = globalThis.setTimeout(() => { + this._scrollTimer = null; + this.setIndex(this.currentIndex(), true); + }, 100); + }; + this.addEventListener("click", this._onClick); + this.addEventListener("keydown", this._onKeydown); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._onClick); + this.removeEventListener("keydown", this._onKeydown); + this._viewport?.removeEventListener("scroll", this._onScroll); + globalThis.clearTimeout(this._scrollTimer); + restoreAttributes(this._managed); + this._viewport = null; + this._slides = []; + this._prev = null; + this._next = null; + this._index = null; + this._scrollTimer = null; + } + + sync() { + const viewport = ownedElement(this, "[data-zen-viewport]"); + if (viewport !== this._viewport) { + this._viewport?.removeEventListener("scroll", this._onScroll); + this._viewport = viewport; + viewport?.addEventListener("scroll", this._onScroll, { passive: true }); + } + this._slides = viewport ? [...viewport.children] : []; + this._prev = ownedElement(this, "[data-zen-prev]"); + this._next = ownedElement(this, "[data-zen-next]"); + + const retained = new Set([ + this, + viewport, + this._prev, + this._next, + ...this._slides, + ].filter(Boolean)); + pruneAttributes(this._managed, retained); + setDefaultAttribute(this._managed, this, "role", "region"); + setDefaultAttribute( + this._managed, + this, + "aria-roledescription", + "carousel", + ); + setDefaultAttribute(this._managed, this, "aria-label", "Carousel"); + if (viewport) { + setDefaultAttribute(this._managed, viewport, "tabindex", "0"); + } + for (const [index, slide] of this._slides.entries()) { + setDefaultAttribute(this._managed, slide, "role", "group"); + setDefaultAttribute( + this._managed, + slide, + "aria-roledescription", + "slide", + ); + setDefaultAttribute( + this._managed, + slide, + "aria-label", + `Slide ${index + 1} of ${this._slides.length}`, + ); + } + + const index = this.currentIndex(); + this._index = index; + this.render(); + } + + currentIndex() { + if (!this._viewport || !this._slides.length) return 0; + const viewportLeft = this._viewport.getBoundingClientRect().left; + let closest = 0; + let distance = Infinity; + for (const [index, slide] of this._slides.entries()) { + const current = Math.abs(slide.getBoundingClientRect().left - viewportLeft); + if (current < distance) { + closest = index; + distance = current; + } + } + return closest; + } + + move(offset) { + if (!this._slides.length) return; + const current = Number.isInteger(this._index) + ? this._index + : this.currentIndex(); + this.scrollTo(Math.max(0, Math.min(this._slides.length - 1, current + offset))); + } + + scrollTo(index) { + const slide = this._slides[index]; + if (!this._viewport || !slide || index === this._index) return; + const viewportRect = this._viewport.getBoundingClientRect(); + const slideRect = slide.getBoundingClientRect(); + const left = this._viewport.scrollLeft + slideRect.left - viewportRect.left; + if (typeof this._viewport.scrollTo === "function") { + this._viewport.scrollTo({ left, behavior: "smooth" }); + } else if (typeof slide.scrollIntoView === "function") { + slide.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "start", + }); + } + this.setIndex(index, true); + } + + setIndex(index, notify) { + const next = Math.max( + 0, + Math.min(this._slides.length ? this._slides.length - 1 : 0, index), + ); + if (next === this._index) { + this.render(); + return; + } + this._index = next; + this.render(); + if (notify) emit(this, "zen-change", { index: next }); + } + + render() { + const last = Math.max(0, this._slides.length - 1); + this.syncDisabled(this._prev, !this._slides.length || this._index <= 0); + this.syncDisabled( + this._next, + !this._slides.length || this._index >= last, + ); + } + + syncDisabled(control, disabled) { + if (!control) return; + const authored = originalAttribute(this._managed, control, "disabled"); + setManagedAttribute( + this._managed, + control, + "disabled", + disabled || authored !== null ? "" : null, + ); + } +} + +/** + * Keeps a message viewport pinned while the reader remains near its end. + * + * @extends {HTMLElement} + */ +export class ZenMessageScroller extends HTMLElementBase { + connectedCallback() { + this._managed ||= new Map(); + this._onScroll ||= () => { + this._nearEnd = this.isNearEnd(); + this.render(); + }; + this._onClick ||= event => { + const button = eventElement(event)?.closest("[data-zen-scroll-end]"); + if (button === this._button && !isDisabled(button)) this.scrollEnd(true); + }; + this._viewport = ownedElement(this, "[data-zen-viewport]") || this; + this._button = ownedElement(this, "[data-zen-scroll-end]"); + this._nearEnd = this.isNearEnd(); + this.addEventListener("click", this._onClick); + this._viewport.addEventListener("scroll", this._onScroll, { passive: true }); + this._observer ||= new MutationObserver(records => { + if (!records.some(record => record.addedNodes.length)) return; + const wasNearEnd = this._nearEnd; + if (wasNearEnd) this.scrollEnd(false); + else this.render(); + }); + this._observer.observe(this._viewport, { childList: true, subtree: true }); + this.render(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._onClick); + this._viewport?.removeEventListener("scroll", this._onScroll); + restoreAttributes(this._managed); + this._viewport = null; + this._button = null; + } + + isNearEnd() { + if (!this._viewport) return true; + return this._viewport.scrollHeight - this._viewport.scrollTop - + this._viewport.clientHeight <= 24; + } + + scrollEnd(smooth) { + if (!this._viewport) return; + if (typeof this._viewport.scrollTo === "function") { + this._viewport.scrollTo({ + top: this._viewport.scrollHeight, + behavior: smooth ? "smooth" : "auto", + }); + } else { + this._viewport.scrollTop = this._viewport.scrollHeight; + } + this._nearEnd = true; + this.render(); + } + + render() { + if (!this._button) return; + setManagedAttribute( + this._managed, + this._button, + "hidden", + this._nearEnd ? "" : null, + ); + } +} + +/** + * Adds pointer and keyboard resizing to two native content panels. + * + * @extends {HTMLElement} + */ +export class ZenResizable extends HTMLElementBase { + static get observedAttributes() { + return ["min", "max"]; + } + + connectedCallback() { + this._managed ||= new Map(); + this._onPointerDown ||= event => { + const handle = eventElement(event)?.closest("[data-zen-handle]"); + if (handle !== this._handle || (event.button !== 0 && event.button !== -1)) { + return; + } + event.preventDefault(); + this._pointerId = event.pointerId; + this._handle.setPointerCapture?.(event.pointerId); + }; + this._onPointerMove ||= event => { + if (event.pointerId !== this._pointerId) return; + const rect = this.getBoundingClientRect(); + if (!rect.width) return; + this.setPercent((event.clientX - rect.left) / rect.width * 100, true); + }; + this._onPointerEnd ||= event => { + if (event.pointerId !== this._pointerId) return; + if (this._handle?.hasPointerCapture?.(event.pointerId)) { + this._handle.releasePointerCapture(event.pointerId); + } + this._pointerId = null; + }; + this._onKeydown ||= event => { + const handle = eventElement(event)?.closest("[data-zen-handle]"); + if (handle !== this._handle || + !["ArrowLeft", "ArrowRight"].includes(event.key)) { + return; + } + event.preventDefault(); + const step = Number(this.getAttribute("step")); + const amount = Number.isFinite(step) && step > 0 ? step : 1; + this.setPercent( + this._percent + (event.key === "ArrowLeft" ? -amount : amount), + true, + ); + }; + this.addEventListener("pointerdown", this._onPointerDown); + this.addEventListener("pointermove", this._onPointerMove); + this.addEventListener("pointerup", this._onPointerEnd); + this.addEventListener("pointercancel", this._onPointerEnd); + this.addEventListener("keydown", this._onKeydown); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("pointerdown", this._onPointerDown); + this.removeEventListener("pointermove", this._onPointerMove); + this.removeEventListener("pointerup", this._onPointerEnd); + this.removeEventListener("pointercancel", this._onPointerEnd); + this.removeEventListener("keydown", this._onKeydown); + restoreAttributes(this._managed); + this._first = null; + this._second = null; + this._handle = null; + this._pointerId = null; + this._percent = null; + } + + attributeChangedCallback() { + if (this.isConnected && Number.isFinite(this._percent)) { + this.setPercent(this._percent, false); + } + } + + limits() { + const minimum = this.getAttribute("min"); + const maximum = this.getAttribute("max"); + const min = minimum !== null && Number.isFinite(Number(minimum)) + ? Math.max(0, Math.min(100, Number(minimum))) + : 0; + const max = maximum !== null && Number.isFinite(Number(maximum)) + ? Math.max(min, Math.min(100, Number(maximum))) + : 100; + return { min, max }; + } + + sync() { + const children = [...this.children]; + const handle = children.find(child => child.hasAttribute("data-zen-handle")); + const handleIndex = children.indexOf(handle); + const first = children.slice(0, handleIndex).reverse().find( + child => child.hasAttribute("data-zen-panel"), + ); + const second = children.slice(handleIndex + 1).find( + child => child.hasAttribute("data-zen-panel"), + ); + + if (!first || !second || !handle) { + if (this._first) restoreElement(this._managed, this._first); + if (this._handle) restoreElement(this._managed, this._handle); + this._first = null; + this._second = null; + this._handle = null; + this._percent = null; + return; + } + if (this._first && this._first !== first) restoreElement(this._managed, this._first); + if (this._handle && this._handle !== handle) { + restoreElement(this._managed, this._handle); + } + this._first = first; + this._second = second; + this._handle = handle; + + setManagedAttribute(this._managed, this._handle, "role", "separator"); + setManagedAttribute( + this._managed, + this._handle, + "aria-orientation", + "vertical", + ); + setDefaultAttribute(this._managed, this._handle, "tabindex", "0"); + + if (!Number.isFinite(this._percent)) { + const containerWidth = this.getBoundingClientRect().width; + const rendered = containerWidth + ? this._first.getBoundingClientRect().width / containerWidth * 100 + : Number.parseFloat(this._first.style.flexBasis); + this._percent = Number.isFinite(rendered) ? rendered : 50; + } + this.setPercent(this._percent, false); + } + + setPercent(percent, notify) { + if (!this._first || !this._handle) return; + const { min, max } = this.limits(); + const next = Math.round(Math.max(min, Math.min(max, percent)) * 100) / 100; + const changed = next !== this._percent; + this._percent = next; + rememberAttribute(this._managed, this._first, "style"); + this._first.style.flexBasis = `${next}%`; + setManagedAttribute(this._managed, this._handle, "aria-valuemin", String(min)); + setManagedAttribute(this._managed, this._handle, "aria-valuemax", String(max)); + setManagedAttribute(this._managed, this._handle, "aria-valuenow", String(next)); + if (notify && changed) emit(this, "zen-resize", { percent: next }); + } +} + +/** + * Adds stable client-side sorting to a native HTML table. + * + * @extends {HTMLElement} + */ +export class ZenDataTable extends HTMLElementBase { + connectedCallback() { + this._managed ||= new Map(); + this._onClick ||= event => { + const button = eventElement(event)?.closest("button[data-zen-sort]"); + if (!this._buttons.includes(button) || isDisabled(button)) return; + this.sort(button); + }; + this.addEventListener("click", this._onClick); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._onClick); + restoreAttributes(this._managed); + this._table = null; + this._buttons = []; + } + + sync() { + this._table = + this.querySelector(":scope > table") || this.querySelector("table"); + this._buttons = this._table + ? [...this._table.querySelectorAll("button[data-zen-sort]")].filter( + button => button.closest("table") === this._table && + button.closest("th"), + ) + : []; + pruneAttributes( + this._managed, + new Set(this._buttons.map(button => button.closest("th"))), + ); + } + + sort(button) { + const key = button.getAttribute("data-zen-sort"); + const header = button.closest("th"); + if (!this._table || !key || !header) return; + const direction = + header.getAttribute("aria-sort") === "ascending" + ? "descending" + : "ascending"; + + for (const candidate of this._buttons) { + const candidateHeader = candidate.closest("th"); + setManagedAttribute( + this._managed, + candidateHeader, + "aria-sort", + candidateHeader === header ? direction : null, + ); + } + + for (const body of this._table.tBodies) { + const entries = [...body.rows].map((row, index) => { + const cell = [...row.cells].find( + candidate => candidate.getAttribute("data-key") === key, + ); + return { index, row, value: cell?.textContent?.trim() ?? "" }; + }); + const numeric = entries.every( + entry => Number.isFinite(Number(entry.value)), + ); + const factor = direction === "ascending" ? 1 : -1; + entries.sort((left, right) => { + const compared = numeric + ? Number(left.value) - Number(right.value) + : left.value.localeCompare(right.value); + return compared ? compared * factor : left.index - right.index; + }); + body.append(...entries.map(entry => entry.row)); + } + emit(this, "zen-sort", { column: key, direction }); + } +} + +const definitions = { + "zen-carousel": ZenCarousel, + "zen-data-table": ZenDataTable, + "zen-message-scroller": ZenMessageScroller, + "zen-resizable": ZenResizable, +}; + +if (globalThis.customElements) { + for (const [name, definition] of Object.entries(definitions)) { + if (!globalThis.customElements.get(name)) { + globalThis.customElements.define(name, definition); + } + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/disclosure.js Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,800 @@ +let nextDisclosureId = 1; + +const HTMLElementBase = globalThis.HTMLElement || class {}; + +function rememberAttribute(state, element, name) { + let attributes = state.get(element); + if (!attributes) { + attributes = new Map(); + state.set(element, attributes); + } + if (!attributes.has(name)) { + attributes.set(name, element.getAttribute(name)); + } +} + +function setManagedAttribute(state, element, name, value) { + rememberAttribute(state, element, name); + if (value === null) { + if (element.hasAttribute(name)) element.removeAttribute(name); + } else if (element.getAttribute(name) !== value) { + element.setAttribute(name, value); + } +} + +function restoreAttribute(state, element, name) { + const attributes = state.get(element); + if (!attributes?.has(name)) return; + const value = attributes.get(name); + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); + attributes.delete(name); + if (!attributes.size) state.delete(element); +} + +function restoreElement(state, element) { + const attributes = state.get(element); + if (!attributes) return; + for (const [name, value] of attributes) { + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); + } + state.delete(element); +} + +function restoreAttributes(state) { + for (const element of [...state.keys()]) restoreElement(state, element); +} + +function pruneAttributes(state, retained) { + for (const element of [...state.keys()]) { + if (!retained.has(element)) restoreElement(state, element); + } +} + +function uniqueId(element, prefix) { + const document = element.ownerDocument; + let id; + do id = `${prefix}-${nextDisclosureId++}`; + while ([...document.querySelectorAll("[id]")].some(item => item.id === id)); + return id; +} + +function ensureId(state, element, prefix) { + const document = element.ownerDocument; + const duplicate = element.id && + [...document.querySelectorAll("[id]")].some( + item => item !== element && item.id === element.id, + ); + if (!element.id || duplicate) { + setManagedAttribute(state, element, "id", uniqueId(element, prefix)); + } + return element.id; +} + +function eventElement(event) { + const target = event.target; + if (target?.nodeType === 1) return target; + return target?.parentElement || null; +} + +function isDisabled(control) { + return Boolean(control.disabled) || + control.getAttribute("aria-disabled") === "true"; +} + +function emitChange(element, detail) { + element.dispatchEvent(new CustomEvent("zen-change", { + bubbles: true, + detail, + })); +} + +function ownedElements(host, selector) { + return [...host.querySelectorAll(selector)].filter( + element => element.closest(host.localName) === host, + ); +} + +function syncDisclosureIcon(details) { + const summary = details.querySelector(":scope > summary"); + if (!summary) return; + let icon = summary.querySelector( + ":scope > zen-icon[data-zen-disclosure-icon]", + ); + if (!icon) { + icon = document.createElement("zen-icon"); + icon.dataset.zenDisclosureIcon = ""; + icon.dataset.zenGenerated = "true"; + summary.append(icon); + } + if (!icon.hasAttribute("name")) icon.setAttribute("name", "chevron-down"); +} + +function removeGeneratedDisclosureIcons(host) { + for (const icon of host.querySelectorAll( + 'zen-icon[data-zen-disclosure-icon][data-zen-generated="true"]', + )) { + icon.remove(); + } +} + +/** + * Coordinates native details elements and summary keyboard navigation. + * + * @extends {HTMLElement} + */ +export class ZenAccordion extends HTMLElementBase { + static get observedAttributes() { + return ["multiple"]; + } + + connectedCallback() { + this._details ||= new Set(); + this._onToggle ||= event => { + const opened = event.currentTarget; + if (!opened.open || this.hasAttribute("multiple")) return; + for (const details of this._details) { + if (details !== opened) details.open = false; + } + }; + this._onKeydown ||= event => { + if (!["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) { + return; + } + const summary = eventElement(event)?.closest("summary"); + const summaries = this.summaries(); + const index = summaries.indexOf(summary); + if (index < 0) return; + event.preventDefault(); + let next = event.key === "Home" ? 0 : summaries.length - 1; + if (event.key === "ArrowUp") { + next = (index - 1 + summaries.length) % summaries.length; + } else if (event.key === "ArrowDown") { + next = (index + 1) % summaries.length; + } + summaries[next]?.focus(); + }; + this.addEventListener("keydown", this._onKeydown); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("keydown", this._onKeydown); + for (const details of this._details || []) { + details.removeEventListener("toggle", this._onToggle); + } + removeGeneratedDisclosureIcons(this); + this._details?.clear(); + } + + attributeChangedCallback() { + if (this.isConnected && this._details) this.sync(); + } + + summaries() { + return [...this._details] + .map(details => details.querySelector(":scope > summary")) + .filter(Boolean); + } + + sync() { + const current = new Set( + ownedElements(this, "details").filter(details => { + const parent = details.parentElement?.closest("details"); + return !parent || parent.closest("zen-accordion") !== this; + }), + ); + for (const details of this._details) { + if (!current.has(details)) { + details.removeEventListener("toggle", this._onToggle); + } + } + for (const details of current) { + if (!this._details.has(details)) { + details.addEventListener("toggle", this._onToggle); + } + syncDisclosureIcon(details); + } + this._details = current; + + if (!this.hasAttribute("multiple")) { + const opened = [...current].filter(details => details.open); + for (const details of opened.slice(1)) details.open = false; + } + } +} + +/** + * Styles a single native details disclosure without replacing its behavior. + * + * @extends {HTMLElement} + */ +export class ZenCollapsible extends HTMLElementBase { + connectedCallback() { + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + removeGeneratedDisclosureIcons(this); + } + + sync() { + for (const details of ownedElements(this, "details")) { + syncDisclosureIcon(details); + } + } +} + +/** + * Connects an ARIA tablist with its native content panels. + * + * @extends {HTMLElement} + */ +export class ZenTabs extends HTMLElementBase { + connectedCallback() { + this._managed ||= new Map(); + this._onClick ||= event => { + const tab = eventElement(event)?.closest('[role="tab"]'); + if (!this.isTab(tab) || isDisabled(tab)) return; + this.activate(tab, true); + }; + this._onKeydown ||= event => { + const tab = eventElement(event)?.closest('[role="tab"]'); + if (!this.isTab(tab)) return; + const tabs = this._tabs.filter(candidate => !isDisabled(candidate)); + const index = tabs.indexOf(tab); + if (index < 0) return; + + let next; + if (event.key === "Home") next = tabs[0]; + else if (event.key === "End") next = tabs[tabs.length - 1]; + else if (event.key === "ArrowLeft") { + next = tabs[(index - 1 + tabs.length) % tabs.length]; + } else if (event.key === "ArrowRight") { + next = tabs[(index + 1) % tabs.length]; + } else { + return; + } + + event.preventDefault(); + this._focusTab = next; + this.render(); + next.focus(); + if (this.getAttribute("activation") !== "manual") { + this.activate(next, true); + } + }; + this.addEventListener("click", this._onClick); + this.addEventListener("keydown", this._onKeydown); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { + attributeFilter: ["aria-controls", "aria-disabled", "disabled", "id"], + attributes: true, + childList: true, + subtree: true, + }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._onClick); + this.removeEventListener("keydown", this._onKeydown); + restoreAttributes(this._managed); + this._tablist = null; + this._tabs = []; + this._panels = []; + this._pairs = new Map(); + this._active = null; + this._focusTab = null; + } + + isTab(tab) { + return Boolean( + tab && this._tabs?.includes(tab) && this._tablist?.contains(tab), + ); + } + + sync() { + this._tablist = ownedElements(this, '[role="tablist"]')[0] || null; + this._tabs = this._tablist + ? [...this._tablist.querySelectorAll('[role="tab"]')].filter( + tab => tab.closest("zen-tabs") === this, + ) + : []; + this._panels = ownedElements(this, '[role="tabpanel"]'); + pruneAttributes( + this._managed, + new Set([...this._tabs, ...this._panels]), + ); + + for (const tab of this._tabs) { + ensureId(this._managed, tab, "zen-tab"); + } + for (const panel of this._panels) { + ensureId(this._managed, panel, "zen-tabpanel"); + } + + const available = new Set(this._panels); + this._pairs = new Map(); + for (const [index, tab] of this._tabs.entries()) { + const controlled = tab.getAttribute("aria-controls"); + let panel = this._panels.find( + candidate => available.has(candidate) && candidate.id === controlled, + ); + if (!panel && available.has(this._panels[index])) { + panel = this._panels[index]; + } + panel ||= [...available][0] || null; + if (panel) { + available.delete(panel); + this._pairs.set(tab, panel); + setManagedAttribute( + this._managed, + tab, + "aria-controls", + panel.id, + ); + setManagedAttribute( + this._managed, + panel, + "aria-labelledby", + tab.id, + ); + } else { + restoreAttribute(this._managed, tab, "aria-controls"); + } + } + for (const panel of available) { + restoreAttribute(this._managed, panel, "aria-labelledby"); + } + + if (!this._tabs.includes(this._active) || isDisabled(this._active)) { + this._active = this._tabs.find( + tab => !isDisabled(tab) && tab.getAttribute("aria-selected") === "true", + ) || this._tabs.find(tab => !isDisabled(tab)) || this._tabs[0] || null; + } + if (!this._tabs.includes(this._focusTab) || isDisabled(this._focusTab)) { + this._focusTab = this._active; + } + this.render(); + } + + render() { + for (const tab of this._tabs) { + const active = tab === this._active; + setManagedAttribute( + this._managed, + tab, + "aria-selected", + String(active), + ); + setManagedAttribute( + this._managed, + tab, + "tabindex", + tab === this._focusTab && !isDisabled(tab) ? "0" : "-1", + ); + } + const activePanel = this._pairs.get(this._active); + for (const panel of this._panels) { + setManagedAttribute( + this._managed, + panel, + "hidden", + panel === activePanel ? null : "", + ); + } + } + + activate(tab, notify) { + if (!this.isTab(tab) || isDisabled(tab)) return; + this._focusTab = tab; + if (tab === this._active) { + this.render(); + return; + } + this._active = tab; + this.render(); + if (notify) { + emitChange(this, { + value: tab.dataset.value ?? tab.getAttribute("value") ?? tab.id, + }); + } + } +} + +/** + * Manages the pressed state of one direct native button. + * + * @extends {HTMLElement} + */ +export class ZenToggle extends HTMLElementBase { + static get observedAttributes() { + return ["pressed"]; + } + + get pressed() { + return this.hasAttribute("pressed"); + } + + set pressed(value) { + this.toggleAttribute("pressed", Boolean(value)); + } + + connectedCallback() { + this._managed ||= new Map(); + this._onClick ||= event => { + const button = eventElement(event)?.closest("button"); + if (button !== this._button || isDisabled(button)) return; + this.pressed = !this.pressed; + emitChange(this, { pressed: this.pressed }); + }; + this.addEventListener("click", this._onClick); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._onClick); + restoreAttributes(this._managed); + this._button = null; + } + + attributeChangedCallback() { + if (this.isConnected && this._managed) this.sync(); + } + + sync() { + const button = this.querySelector(":scope > button"); + if (button !== this._button) { + if (this._button) restoreElement(this._managed, this._button); + this._button = button; + } + if (button) { + setManagedAttribute( + this._managed, + button, + "aria-pressed", + String(this.pressed), + ); + } + } +} + +function buttonValue(button) { + return button.getAttribute("value") ?? button.dataset.value ?? ""; +} + +function parseMultipleValue(value) { + if (!value) return []; + if (value.trim().startsWith("[")) { + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) return parsed.map(String); + } catch { + // Fall through to token parsing. + } + } + return value.trim().split(/[\s,]+/).filter(Boolean); +} + +/** + * Manages single or multiple pressed buttons with roving focus. + * + * @extends {HTMLElement} + */ +export class ZenToggleGroup extends HTMLElementBase { + static get observedAttributes() { + return ["type", "value"]; + } + + get values() { + return [...(this._values || [])]; + } + + set values(values) { + this.setValues(Array.isArray(values) ? values.map(String) : [], false); + } + + connectedCallback() { + this._managed ||= new Map(); + this._buttons ||= []; + if (!this._initialized) { + this._values = this.initialValues(); + this._initialized = true; + } + this._hasConnected = true; + this._onClick ||= event => { + const button = eventElement(event)?.closest("button"); + if (!this._buttons.includes(button) || isDisabled(button)) return; + const value = buttonValue(button); + this._focusButton = button; + let values; + if (this.multiple) { + values = this._values.includes(value) + ? this._values.filter(item => item !== value) + : [...this._values, value]; + } else { + values = this._values.includes(value) ? [] : [value]; + } + this.setValues(values, true); + }; + this._onKeydown ||= event => { + const button = eventElement(event)?.closest("button"); + const buttons = this._buttons.filter(candidate => !isDisabled(candidate)); + const index = buttons.indexOf(button); + if (index < 0) return; + + let next; + if (event.key === "Home") next = buttons[0]; + else if (event.key === "End") next = buttons[buttons.length - 1]; + else if (event.key === "ArrowLeft" || event.key === "ArrowUp") { + next = buttons[(index - 1 + buttons.length) % buttons.length]; + } else if (event.key === "ArrowRight" || event.key === "ArrowDown") { + next = buttons[(index + 1) % buttons.length]; + } else { + return; + } + event.preventDefault(); + this._focusButton = next; + this.render(); + next.focus(); + }; + this.addEventListener("click", this._onClick); + this.addEventListener("keydown", this._onKeydown); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { + attributeFilter: ["aria-disabled", "data-value", "disabled", "value"], + attributes: true, + childList: true, + subtree: true, + }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._onClick); + this.removeEventListener("keydown", this._onKeydown); + restoreAttributes(this._managed); + this._buttons = []; + this._focusButton = null; + } + + attributeChangedCallback(name) { + if (name === "value" && !this._settingValue && + this.hasAttribute("value")) { + this._values = this.readValue(); + this._initialized = true; + } else if (name === "value" && !this._settingValue) { + this._values = []; + this._initialized = true; + } else if (name === "type" && !this._hasConnected && + this.hasAttribute("value")) { + this._values = this.readValue(); + this._initialized = true; + } + if (this.isConnected && this._managed) this.sync(); + } + + get multiple() { + return this.getAttribute("type") === "multiple"; + } + + readValue() { + const value = this.getAttribute("value"); + if (value === null) return []; + return this.multiple ? parseMultipleValue(value) : [value]; + } + + initialValues() { + if (this.hasAttribute("value")) return this.readValue(); + const pressed = [...this.querySelectorAll(":scope > button")] + .filter(button => button.getAttribute("aria-pressed") === "true"); + return pressed.map(buttonValue); + } + + sync() { + const buttons = [...this.querySelectorAll(":scope > button")]; + pruneAttributes(this._managed, new Set([this, ...buttons])); + this._buttons = buttons; + if (!this.hasAttribute("role")) { + setManagedAttribute(this._managed, this, "role", "group"); + } + if (!this.multiple && this._values.length > 1) { + this._values = this._values.slice(0, 1); + this.writeValue(); + } + if (!buttons.includes(this._focusButton) || + isDisabled(this._focusButton)) { + this._focusButton = buttons.find( + button => + !isDisabled(button) && + button.getAttribute("tabindex") === "0", + ) || buttons.find( + button => + !isDisabled(button) && this._values.includes(buttonValue(button)), + ) || buttons.find(button => !isDisabled(button)) || null; + } + this.render(); + } + + render() { + for (const button of this._buttons) { + setManagedAttribute( + this._managed, + button, + "aria-pressed", + String(this._values.includes(buttonValue(button))), + ); + setManagedAttribute( + this._managed, + button, + "tabindex", + button === this._focusButton ? "0" : "-1", + ); + } + } + + writeValue() { + this._settingValue = true; + const value = this.multiple + ? (this._values.some(item => /[\s,]/.test(item)) + ? JSON.stringify(this._values) + : this._values.join(" ")) + : this._values[0]; + if (value === undefined || value === "") this.removeAttribute("value"); + else this.setAttribute("value", value); + this._settingValue = false; + } + + setValues(values, notify) { + const unique = [...new Set(values.map(String))]; + this._values = this.multiple ? unique : unique.slice(0, 1); + this.writeValue(); + if (this.isConnected) this.render(); + if (notify) emitChange(this, { values: this.values }); + } +} + +/** + * Controls a responsive native sidebar region and its trigger. + * + * @extends {HTMLElement} + */ +export class ZenSidebar extends HTMLElementBase { + static get observedAttributes() { + return ["open"]; + } + + get open() { + return this.hasAttribute("open"); + } + + set open(value) { + this.toggleAttribute("open", Boolean(value)); + } + + connectedCallback() { + this._managed ||= new Map(); + this._onClick ||= event => { + const trigger = eventElement(event)?.closest( + "[data-zen-sidebar-trigger]", + ); + if (trigger !== this._trigger || isDisabled(trigger)) return; + this.setOpen(!this.open, true); + }; + this._onKeydown ||= event => { + if (event.key !== "Escape" || !this.open) return; + event.preventDefault(); + this.setOpen(false, true); + this._trigger?.focus(); + }; + this._onMediaChange ||= () => this.syncState(); + this.addEventListener("click", this._onClick); + this.addEventListener("keydown", this._onKeydown); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true }); + this._media = globalThis.matchMedia?.("(max-width: 640px)") || null; + if (this._media?.addEventListener) { + this._media.addEventListener("change", this._onMediaChange); + } else { + this._media?.addListener?.(this._onMediaChange); + } + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._onClick); + this.removeEventListener("keydown", this._onKeydown); + if (this._media?.removeEventListener) { + this._media.removeEventListener("change", this._onMediaChange); + } else { + this._media?.removeListener?.(this._onMediaChange); + } + restoreAttributes(this._managed); + this._trigger = null; + this._panel = null; + this._media = null; + } + + attributeChangedCallback() { + if (this.isConnected) this.syncState(); + } + + sync() { + const trigger = ownedElements( + this, + "[data-zen-sidebar-trigger]", + )[0] || null; + const panel = ownedElements( + this, + "[data-zen-sidebar-panel]", + )[0] || null; + if (trigger !== this._trigger || panel !== this._panel) { + if (this._trigger) restoreElement(this._managed, this._trigger); + if (this._panel) restoreElement(this._managed, this._panel); + this._trigger = trigger; + this._panel = panel; + } + if (!trigger || !panel) return; + const panelId = ensureId(this._managed, panel, "zen-sidebar-panel"); + setManagedAttribute( + this._managed, + trigger, + "aria-controls", + panelId, + ); + this.syncState(); + } + + syncState() { + if (!this._trigger || !this._panel) return; + setManagedAttribute( + this._managed, + this._panel, + "hidden", + this.open ? null : "", + ); + setManagedAttribute( + this._managed, + this._trigger, + "aria-expanded", + String(this.open), + ); + } + + setOpen(open, notify) { + if (this.open === open) return; + this.open = open; + this.syncState(); + if (notify) emitChange(this, { open }); + } +} + +const definitions = { + "zen-accordion": ZenAccordion, + "zen-collapsible": ZenCollapsible, + "zen-sidebar": ZenSidebar, + "zen-tabs": ZenTabs, + "zen-toggle": ZenToggle, + "zen-toggle-group": ZenToggleGroup, +}; + +const registry = globalThis.customElements; +if (registry) { + for (const [name, definition] of Object.entries(definitions)) { + if (!registry.get(name)) registry.define(name, definition); + } +}
--- a/design_system/src/components/field.js Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/src/components/field.js Tue Aug 04 15:12:09 2026 -0700 @@ -9,6 +9,11 @@ return candidate; } +/** + * Associates a label, native form control, and descriptive help text. + * + * @extends {HTMLElement} + */ export class ZenField extends HTMLElement { connectedCallback() { if (!this._observer) {
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/forms.js Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,1240 @@ +let nextFormId = 1; + +function uniqueId(prefix) { + let id; + do id = `${prefix}-${nextFormId++}`; + while (document.getElementById(id)); + return id; +} + +function rememberAttribute(state, element, name) { + let attributes = state.get(element); + if (!attributes) { + attributes = new Map(); + state.set(element, attributes); + } + if (!attributes.has(name)) { + attributes.set(name, element.getAttribute(name)); + } +} + +function setManagedAttribute(state, element, name, value) { + rememberAttribute(state, element, name); + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); +} + +function restoreElement(state, element) { + const attributes = state.get(element); + if (!attributes) return; + for (const [name, value] of attributes) { + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); + } + state.delete(element); +} + +function restoreAttribute(state, element, name) { + const attributes = state.get(element); + if (!attributes?.has(name)) return; + const value = attributes.get(name); + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); + attributes.delete(name); + if (!attributes.size) state.delete(element); +} + +function restoreAttributes(state) { + for (const element of [...state.keys()]) restoreElement(state, element); +} + +function originalAttribute(state, element, name) { + const attributes = state.get(element); + return attributes?.has(name) + ? attributes.get(name) + : element.getAttribute(name); +} + +function ensureId(state, element, prefix) { + const owner = element.id && document.getElementById(element.id); + if (!element.id || (owner && owner !== element)) { + setManagedAttribute(state, element, "id", uniqueId(prefix)); + } + return element.id; +} + +function emit(element, type, detail) { + element.dispatchEvent(new CustomEvent(type, { + bubbles: true, + detail, + })); +} + +class NativeControl extends HTMLElement { + connectedCallback() { + if (!this._observer) { + this._observer = new MutationObserver(() => this.sync()); + } + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this._control = null; + } + + sync() { + this._control = this.querySelector(this.controlSelector); + } +} + +/** + * Provides presentation around a native checkbox input. + * + * @extends {NativeControl} + */ +export class ZenCheckbox extends NativeControl { + get controlSelector() { + return 'input[type="checkbox"]'; + } +} + +/** + * Provides presentation around a native select control. + * + * @extends {NativeControl} + */ +export class ZenSelect extends NativeControl { + get controlSelector() { + return "select"; + } + + sync() { + super.sync(); + if (!this._control) return; + let icon = this.querySelector( + ":scope > zen-icon[data-zen-select-icon]", + ); + if (!icon) { + icon = document.createElement("zen-icon"); + icon.dataset.zenSelectIcon = ""; + icon.dataset.zenGenerated = "true"; + icon.setAttribute("name", "chevron-down"); + this.append(icon); + } + } + + disconnectedCallback() { + this.querySelector( + ':scope > zen-icon[data-zen-select-icon][data-zen-generated="true"]', + )?.remove(); + super.disconnectedCallback(); + } +} + +/** + * Provides presentation around a native range input. + * + * @extends {NativeControl} + */ +export class ZenSlider extends NativeControl { + get controlSelector() { + return 'input[type="range"]'; + } +} + +function createDate(year, month, day) { + const date = new Date(0); + date.setHours(0, 0, 0, 0); + date.setFullYear(year, month, day); + return date; +} + +function parseDate(value) { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value || ""); + if (!match) return null; + const year = Number(match[1]); + const month = Number(match[2]) - 1; + const day = Number(match[3]); + const date = createDate(year, month, day); + if (date.getFullYear() !== year || + date.getMonth() !== month || + date.getDate() !== day) return null; + return date; +} + +function dateKey(date) { + const year = String(date.getFullYear()).padStart(4, "0"); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function addDays(date, amount) { + return createDate( + date.getFullYear(), + date.getMonth(), + date.getDate() + amount, + ); +} + +function addMonths(date, amount) { + const first = createDate(date.getFullYear(), date.getMonth() + amount, 1); + const last = createDate( + first.getFullYear(), + first.getMonth() + 1, + 0, + ).getDate(); + return createDate( + first.getFullYear(), + first.getMonth(), + Math.min(date.getDate(), last), + ); +} + +/** + * Renders a tokenized calendar backed by a native date input. + * + * @extends {HTMLElement} + */ +export class ZenCalendar extends HTMLElement { + connectedCallback() { + this._managed ||= new Map(); + this._onClick ||= event => this.onClick(event); + this._onKeydown ||= event => this.onKeydown(event); + this._onInput ||= event => { + if (event.target !== this._input) return; + this.readInput(); + if (this._input.validity.valid) this.setInvalid(false); + this.render(); + }; + this._onInvalid ||= event => { + if (event.target !== this._input) return; + event.preventDefault(); + this.setInvalid(true); + queueMicrotask(() => { + if (!this.isConnected) return; + const target = this._trigger || + this._grid?.querySelector('[tabindex="0"]:not(:disabled)'); + target?.focus(); + }); + }; + this._onDocumentPointerDown ||= event => { + if (this.isPicker && this._open && !this.contains(event.target)) { + this.setOpen(false); + } + }; + this._onReset ||= () => queueMicrotask(() => { + if (!this.isConnected) return; + this.readInput(); + this.render(); + }); + this.addEventListener("click", this._onClick); + this.addEventListener("keydown", this._onKeydown); + this.addEventListener("input", this._onInput); + this.addEventListener("change", this._onInput); + this.addEventListener("invalid", this._onInvalid, true); + document.addEventListener("pointerdown", this._onDocumentPointerDown); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true }); + this._inputObserver ||= new MutationObserver(() => { + this.readInput(); + this.render(); + }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this._inputObserver?.disconnect(); + this._form?.removeEventListener("reset", this._onReset); + this.removeEventListener("click", this._onClick); + this.removeEventListener("keydown", this._onKeydown); + this.removeEventListener("input", this._onInput); + this.removeEventListener("change", this._onInput); + this.removeEventListener("invalid", this._onInvalid, true); + document.removeEventListener("pointerdown", this._onDocumentPointerDown); + this.release(); + } + + get isPicker() { + return this.localName === "zen-date-picker"; + } + + sync() { + const input = [...this.children].find( + child => child.matches?.('input[type="date"]'), + ) || null; + if (input === this._input) return; + this.release(); + this._input = input; + if (!input) return; + + setManagedAttribute( + this._managed, + input, + "data-zen-calendar-input", + "", + ); + setManagedAttribute(this._managed, input, "tabindex", "-1"); + setManagedAttribute(this._managed, input, "aria-hidden", "true"); + this._inputObserver.observe(input, { + attributes: true, + attributeFilter: ["disabled", "max", "min", "readonly", "value"], + }); + this._form = input.form; + this._form?.addEventListener("reset", this._onReset); + this.readInput(); + this.createView(); + this.render(); + } + + release() { + this._inputObserver?.disconnect(); + this._form?.removeEventListener("reset", this._onReset); + this._generated?.remove(); + this._generated = null; + this._view = null; + this._grid = null; + this._heading = null; + this._trigger = null; + this._panel = null; + this._labelText = ""; + this._labelIds = []; + this._form = null; + this._open = false; + restoreAttributes(this._managed || new Map()); + this._input = null; + } + + readInput() { + const selected = parseDate(this._input?.value); + const today = new Date(); + this._selected = selected; + this._focusDate = selected || this._focusDate || today; + this._month = createDate( + (selected || this._focusDate).getFullYear(), + (selected || this._focusDate).getMonth(), + 1, + ); + } + + limits() { + return { + maximum: parseDate(this._input?.max), + minimum: parseDate(this._input?.min), + }; + } + + isEditable() { + return Boolean(this._input) && + !this._input.disabled && + !this._input.readOnly; + } + + isAllowed(date) { + if (!date) return false; + const { maximum, minimum } = this.limits(); + return (!minimum || date >= minimum) && (!maximum || date <= maximum); + } + + clampDate(date) { + const { maximum, minimum } = this.limits(); + if (minimum && date < minimum) return minimum; + if (maximum && date > maximum) return maximum; + return date; + } + + setInvalid(invalid) { + setManagedAttribute( + this._managed, + this, + "data-invalid", + invalid ? "" : null, + ); + if (this._trigger) { + this._trigger.setAttribute("aria-invalid", String(invalid)); + } + if (this._grid) { + this._grid.setAttribute("aria-invalid", String(invalid)); + } + } + + createIcon(name) { + const icon = document.createElement("zen-icon"); + icon.setAttribute("name", name); + return icon; + } + + createControl(name, label) { + const button = document.createElement("button"); + button.type = "button"; + button.dataset[name] = ""; + button.setAttribute("aria-label", label); + return button; + } + + createView() { + const generated = document.createElement("div"); + generated.dataset.zenGenerated = "calendar"; + this._generated = generated; + const labels = [...this._input.labels]; + const authoredLabelIds = (this._input.getAttribute("aria-labelledby") || "") + .split(/\s+/) + .filter(id => id && document.getElementById(id)); + const associatedLabelIds = labels.map(label => + ensureId(this._managed, label, "zen-date-label") + ); + this._labelIds = authoredLabelIds.length + ? authoredLabelIds + : associatedLabelIds; + this._labelText = this._labelIds.map(id => + document.getElementById(id)?.textContent.trim() + ) + .filter(Boolean) + .join(", ") || + labels.map(label => label.textContent.trim()) + .filter(Boolean) + .join(", ") || + this._input.getAttribute("aria-label") || + "Date"; + + if (this.isPicker) { + const trigger = this.createControl( + "zenDateTrigger", + "Choose date", + ); + trigger.id = uniqueId("zen-date-picker-trigger"); + trigger.append(this.createIcon("calendar")); + const triggerText = document.createElement("span"); + triggerText.dataset.zenDateValue = ""; + triggerText.id = uniqueId("zen-date-picker-value"); + trigger.append(triggerText); + this._trigger = trigger; + + const panel = document.createElement("div"); + panel.dataset.zenCalendarPanel = ""; + panel.id = uniqueId("zen-date-picker-panel"); + panel.setAttribute("role", "dialog"); + panel.setAttribute("aria-label", `${this._labelText} calendar`); + panel.hidden = true; + trigger.setAttribute("aria-haspopup", "dialog"); + trigger.setAttribute("aria-controls", panel.id); + if (this._input.hasAttribute("aria-describedby")) { + trigger.setAttribute( + "aria-describedby", + this._input.getAttribute("aria-describedby"), + ); + } + if (this._labelIds.length) { + trigger.setAttribute( + "aria-labelledby", + [...this._labelIds, triggerText.id].join(" "), + ); + } + for (const label of labels) { + if (label.htmlFor === this._input.id) { + setManagedAttribute(this._managed, label, "for", trigger.id); + } + } + this._panel = panel; + generated.append(trigger, panel); + } + + const view = document.createElement("div"); + view.dataset.zenCalendarView = ""; + const header = document.createElement("header"); + const previous = this.createControl( + "zenCalendarPrevious", + "Previous month", + ); + previous.append(this.createIcon("chevron-left")); + const heading = document.createElement("strong"); + heading.setAttribute("aria-live", "polite"); + const next = this.createControl("zenCalendarNext", "Next month"); + next.append(this.createIcon("chevron-right")); + header.append(previous, heading, next); + + const weekdays = document.createElement("div"); + weekdays.dataset.zenCalendarWeekdays = ""; + weekdays.setAttribute("aria-hidden", "true"); + const formatter = new Intl.DateTimeFormat(undefined, { + weekday: "narrow", + }); + for (let day = 4; day < 11; day++) { + const label = document.createElement("span"); + label.textContent = formatter.format(new Date(2026, 0, day)); + weekdays.append(label); + } + + const grid = document.createElement("div"); + grid.dataset.zenCalendarGrid = ""; + grid.setAttribute("role", "grid"); + grid.setAttribute( + "aria-label", + this._labelText ? `${this._labelText} calendar` : "Calendar", + ); + view.append(header, weekdays, grid); + this._view = view; + this._heading = heading; + this._grid = grid; + (this._panel || generated).append(view); + this.append(generated); + if (this.isPicker) this.setOpen(false); + } + + render() { + if (!this._grid || !this._month) return; + this._focusDate = this.clampDate(this._focusDate); + this._heading.textContent = new Intl.DateTimeFormat(undefined, { + month: "long", + year: "numeric", + }).format(this._month); + + if (this._trigger) { + const text = this._trigger.querySelector("[data-zen-date-value]"); + text.textContent = this._selected + ? new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + }).format(this._selected) + : "Choose date"; + if (this._labelIds.length) { + this._trigger.removeAttribute("aria-label"); + } else { + this._trigger.setAttribute( + "aria-label", + `${this._labelText}, ${text.textContent}`, + ); + } + this._trigger.disabled = !this.isEditable(); + } + for (const control of this._view.querySelectorAll( + "[data-zen-calendar-previous], [data-zen-calendar-next]", + )) { + control.disabled = !this.isEditable(); + } + + const first = createDate( + this._month.getFullYear(), + this._month.getMonth(), + 1, + ); + const start = addDays(first, -first.getDay()); + const { maximum, minimum } = this.limits(); + const today = dateKey(new Date()); + const selected = this._selected ? dateKey(this._selected) : ""; + const focused = dateKey(this._focusDate); + const fragment = document.createDocumentFragment(); + const formatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "full", + }); + + for (let week = 0; week < 6; week++) { + const row = document.createElement("div"); + row.setAttribute("role", "row"); + for (let day = 0; day < 7; day++) { + const date = addDays(start, week * 7 + day); + const key = dateKey(date); + const button = document.createElement("button"); + button.type = "button"; + button.dataset.zenCalendarDay = key; + button.setAttribute("role", "gridcell"); + button.setAttribute("aria-label", formatter.format(date)); + button.setAttribute("aria-selected", String(key === selected)); + const disabled = !this.isEditable() || + Boolean((minimum && date < minimum) || (maximum && date > maximum)); + button.tabIndex = key === focused && !disabled ? 0 : -1; + button.textContent = String(date.getDate()); + button.toggleAttribute( + "data-outside", + date.getMonth() !== this._month.getMonth(), + ); + button.toggleAttribute("data-today", key === today); + button.disabled = disabled; + row.append(button); + } + fragment.append(row); + } + this._grid.replaceChildren(fragment); + } + + onClick(event) { + const target = event.target instanceof Element ? event.target : null; + if (!target) return; + if (target.closest("[data-zen-date-trigger]") === this._trigger) { + if (!this.isEditable()) return; + this.setOpen(!this._open); + return; + } + if (target.closest("[data-zen-calendar-previous]")) { + if (!this.isEditable()) return; + this.changeMonth(-1); + return; + } + if (target.closest("[data-zen-calendar-next]")) { + if (!this.isEditable()) return; + this.changeMonth(1); + return; + } + const day = target.closest("[data-zen-calendar-day]"); + if (day && !day.disabled) this.selectDate(parseDate(day.dataset.zenCalendarDay)); + } + + onKeydown(event) { + if (event.key === "Escape" && this.isPicker && this._open) { + event.preventDefault(); + this.setOpen(false); + this._trigger.focus(); + return; + } + const day = event.target instanceof Element + ? event.target.closest("[data-zen-calendar-day]") + : null; + if (!day) return; + const current = parseDate(day.dataset.zenCalendarDay); + let next = null; + if (event.key === "ArrowLeft") next = addDays(current, -1); + else if (event.key === "ArrowRight") next = addDays(current, 1); + else if (event.key === "ArrowUp") next = addDays(current, -7); + else if (event.key === "ArrowDown") next = addDays(current, 7); + else if (event.key === "Home") next = addDays(current, -current.getDay()); + else if (event.key === "End") next = addDays(current, 6 - current.getDay()); + else if (event.key === "PageUp") next = addMonths(current, -1); + else if (event.key === "PageDown") next = addMonths(current, 1); + if (!next) return; + next = this.clampDate(next); + event.preventDefault(); + this._focusDate = next; + this._month = createDate(next.getFullYear(), next.getMonth(), 1); + this.render(); + this._grid.querySelector( + `[data-zen-calendar-day="${dateKey(next)}"]`, + )?.focus(); + } + + changeMonth(amount) { + if (!this.isEditable()) return; + this._month = addMonths(this._month, amount); + this._focusDate = this.clampDate(createDate( + this._month.getFullYear(), + this._month.getMonth(), + 1, + )); + this._month = createDate( + this._focusDate.getFullYear(), + this._focusDate.getMonth(), + 1, + ); + this.render(); + this._grid.querySelector('[tabindex="0"]')?.focus(); + } + + selectDate(date) { + if (!this.isEditable() || !this.isAllowed(date)) return; + this._selected = date; + this._focusDate = date; + this._month = createDate(date.getFullYear(), date.getMonth(), 1); + this._input.value = dateKey(date); + this._input.dispatchEvent(new Event("input", { + bubbles: true, + composed: true, + })); + this._input.dispatchEvent(new Event("change", { bubbles: true })); + emit(this, "zen-change", { value: this._input.value }); + this.render(); + if (this.isPicker) { + this.setOpen(false); + this._trigger.focus(); + } + } + + setOpen(open) { + if (!this.isPicker || !this._panel || !this._trigger) return; + this._open = Boolean(open) && this.isEditable(); + this._panel.hidden = !this._open; + this._trigger.setAttribute("aria-expanded", String(this._open)); + if (this._open) { + this._grid.querySelector('[tabindex="0"]')?.focus(); + } + } +} + +/** + * Presents the first-party calendar behind a tokenized disclosure trigger. + * + * @extends {ZenCalendar} + */ +export class ZenDatePicker extends ZenCalendar {} + +/** + * Adds radiogroup semantics when no native fieldset owns the radios. + * + * @extends {HTMLElement} + */ +export class ZenRadioGroup extends HTMLElement { + connectedCallback() { + this._managed ||= new Map(); + if (!this._observer) { + this._observer = new MutationObserver(() => this.sync()); + } + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + restoreAttributes(this._managed); + } + + sync() { + const radios = this.querySelectorAll('input[type="radio"]'); + const needsRole = radios.length > 0 && !this.querySelector("fieldset"); + if (needsRole && !this.hasAttribute("role")) { + setManagedAttribute(this._managed, this, "role", "radiogroup"); + } else if (!needsRole) { + restoreElement(this._managed, this); + } + } +} + +/** + * Synchronizes switch semantics onto a native checkbox. + * + * @extends {HTMLElement} + */ +export class ZenSwitch extends HTMLElement { + connectedCallback() { + this._managed ||= new Map(); + this._onStateChange ||= event => { + if (event.target === this._control) this.updateState(); + }; + this._onReset ||= () => { + queueMicrotask(() => { + if (this.isConnected) this.updateState(); + }); + }; + this.addEventListener("input", this._onStateChange); + this.addEventListener("change", this._onStateChange); + if (!this._observer) { + this._observer = new MutationObserver(() => this.sync()); + } + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("input", this._onStateChange); + this.removeEventListener("change", this._onStateChange); + this._form?.removeEventListener("reset", this._onReset); + restoreAttributes(this._managed); + this._control = null; + this._form = null; + } + + sync() { + const control = this.querySelector('input[type="checkbox"]'); + const form = control?.form || null; + if (control !== this._control) { + if (this._control) restoreElement(this._managed, this._control); + this._control = control; + } + if (form !== this._form) { + this._form?.removeEventListener("reset", this._onReset); + this._form = form; + form?.addEventListener("reset", this._onReset); + } + this.updateState(); + } + + updateState() { + if (!this._control) return; + setManagedAttribute(this._managed, this._control, "role", "switch"); + setManagedAttribute( + this._managed, + this._control, + "aria-checked", + String(this._control.checked), + ); + } +} + +/** + * Reflects native form validity on the component boundary. + * + * @extends {HTMLElement} + */ +export class ZenForm extends HTMLElement { + connectedCallback() { + this._managed ||= new Map(); + this._syncValidity ||= () => { + if (!this._form) return; + setManagedAttribute( + this._managed, + this, + "data-invalid", + this._form.checkValidity() ? null : "", + ); + }; + this._markInvalid ||= () => { + setManagedAttribute(this._managed, this, "data-invalid", ""); + }; + this._onReset ||= () => queueMicrotask(this._syncValidity); + if (!this._observer) { + this._observer = new MutationObserver(() => this.sync()); + } + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.releaseForm(); + restoreAttributes(this._managed); + } + + releaseForm() { + this._form?.removeEventListener("submit", this._syncValidity); + this._form?.removeEventListener("input", this._syncValidity); + this._form?.removeEventListener("invalid", this._markInvalid, true); + this._form?.removeEventListener("reset", this._onReset); + this._form = null; + } + + sync() { + const form = this.querySelector("form"); + if (form === this._form) return; + this.releaseForm(); + restoreElement(this._managed, this); + this._form = form; + form?.addEventListener("submit", this._syncValidity); + form?.addEventListener("input", this._syncValidity); + form?.addEventListener("invalid", this._markInvalid, true); + form?.addEventListener("reset", this._onReset); + } +} + +class FilterableList extends HTMLElement { + connectedCallback() { + this._managed ||= new Map(); + this._active = null; + this._onInput ||= event => { + if (event.target !== this._input) return; + if (this.isCombobox) this.setOpen(true); + this.filter(); + }; + this._onKeydown ||= event => this.onKeydown(event); + this._onClick ||= event => this.onClick(event); + this._onFocus ||= event => { + if (this.isCombobox && event.target === this._input) { + this.setOpen(true); + this.filter(); + } + }; + this.addEventListener("input", this._onInput); + this.addEventListener("keydown", this._onKeydown); + this.addEventListener("click", this._onClick); + this.addEventListener("focusin", this._onFocus); + if (!this._observer) { + this._observer = new MutationObserver(() => this.sync()); + } + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("input", this._onInput); + this.removeEventListener("keydown", this._onKeydown); + this.removeEventListener("click", this._onClick); + this.removeEventListener("focusin", this._onFocus); + restoreAttributes(this._managed); + this._input = null; + this._list = null; + this._items = []; + this._active = null; + } + + get isCombobox() { + return false; + } + + get listRole() { + return "menu"; + } + + get itemRole() { + return "menuitem"; + } + + sync() { + const input = this.querySelector("input"); + const list = this.querySelector(`[role="${this.listRole}"]`); + if (input !== this._input || list !== this._list) { + restoreAttributes(this._managed); + this._input = input; + this._list = list; + this._items = []; + this._active = null; + this._open = false; + } + if (!input || !list) return; + + const items = [...list.querySelectorAll(`[role="${this.itemRole}"]`)]; + for (const item of this._items) { + if (!items.includes(item)) restoreElement(this._managed, item); + } + this._items = items; + + const listId = ensureId( + this._managed, + list, + this.isCombobox ? "zen-listbox" : "zen-command-menu", + ); + if (!input.hasAttribute("aria-controls")) { + setManagedAttribute(this._managed, input, "aria-controls", listId); + } + if (this.isCombobox) { + if (!input.hasAttribute("role")) { + setManagedAttribute(this._managed, input, "role", "combobox"); + } + if (!input.hasAttribute("aria-autocomplete")) { + setManagedAttribute( + this._managed, + input, + "aria-autocomplete", + "list", + ); + } + this.setOpen(this._open); + } + this.filter(); + } + + filter() { + if (!this._input || !this._list) return; + const query = this._input.value.trim().toLowerCase(); + for (const item of this._items) { + const originallyHidden = + originalAttribute(this._managed, item, "hidden") !== null; + const matches = item.textContent.toLowerCase().includes(query); + setManagedAttribute( + this._managed, + item, + "hidden", + originallyHidden || !matches ? "" : null, + ); + } + if (this._active?.hidden) this.setActive(null); + } + + visibleItems() { + return this._items.filter(item => { + if (item.hidden || item.closest("[hidden]")) return false; + if (item.getAttribute("aria-disabled") === "true") return false; + return !("disabled" in item && item.disabled); + }); + } + + setActive(item) { + this._active = item; + for (const candidate of this._items) { + if (this.isCombobox) { + setManagedAttribute( + this._managed, + candidate, + "aria-selected", + String(candidate === item), + ); + } else { + restoreAttribute(this._managed, candidate, "aria-selected"); + } + } + if (!this._input) return; + if (item) { + setManagedAttribute( + this._managed, + this._input, + "aria-activedescendant", + ensureId(this._managed, item, "zen-option"), + ); + item.scrollIntoView?.({ block: "nearest" }); + } else { + setManagedAttribute( + this._managed, + this._input, + "aria-activedescendant", + null, + ); + } + } + + moveActive(offset) { + const items = this.visibleItems(); + if (!items.length) { + this.setActive(null); + return; + } + const current = items.indexOf(this._active); + const index = current < 0 + ? (offset > 0 ? 0 : items.length - 1) + : (current + offset + items.length) % items.length; + this.setActive(items[index]); + } + + onKeydown(event) { + if (event.target !== this._input) return; + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + if (this.isCombobox) this.setOpen(true); + this.moveActive(event.key === "ArrowDown" ? 1 : -1); + return; + } + if (event.key === "Enter") { + const item = this._active || this.visibleItems()[0]; + if (!item) return; + event.preventDefault(); + this.choose(item); + } + } + + onClick(event) { + const target = event.target instanceof Element + ? event.target.closest(`[role="${this.itemRole}"]`) + : null; + if (!target || !this._list?.contains(target) || target.hidden) return; + if (target.getAttribute("aria-disabled") === "true" || + ("disabled" in target && target.disabled)) return; + this.choose(target); + } + + valueFor(item) { + return item.dataset.value ?? item.getAttribute("value") ?? + item.textContent.trim(); + } +} + +/** + * Filters and selects native option elements from a text input. + * + * @extends {FilterableList} + */ +export class ZenCombobox extends FilterableList { + get isCombobox() { + return true; + } + + get listRole() { + return "listbox"; + } + + get itemRole() { + return "option"; + } + + setOpen(open) { + this._open = Boolean(open); + if (!this._input || !this._list) return; + setManagedAttribute( + this._managed, + this._input, + "aria-expanded", + String(this._open), + ); + setManagedAttribute( + this._managed, + this._list, + "hidden", + this._open ? null : "", + ); + if (!this._open) this.setActive(null); + } + + onKeydown(event) { + if (event.target === this._input && event.key === "Escape") { + if (this._open) event.preventDefault(); + this.setOpen(false); + return; + } + super.onKeydown(event); + } + + choose(item) { + const value = this.valueFor(item); + this._input.value = value; + this._input.dispatchEvent(new Event("input", { + bubbles: true, + composed: true, + })); + this._input.dispatchEvent(new Event("change", { bubbles: true })); + emit(this, "zen-change", { value }); + this.setOpen(false); + } +} + +/** + * Filters a keyboard-oriented command menu and emits selected commands. + * + * @extends {FilterableList} + */ +export class ZenCommand extends FilterableList { + choose(item) { + emit(this, "zen-command", { value: this.valueFor(item) }); + } +} + +/** + * Enhances one native one-time-code input with visual slots. + * + * @extends {HTMLElement} + */ +export class ZenInputOtp extends HTMLElement { + connectedCallback() { + this._managed ||= new Map(); + this._complete ??= false; + this._onInput ||= event => { + if (event.target === this._input) this.update(true); + }; + this.addEventListener("input", this._onInput); + if (!this._observer) { + this._observer = new MutationObserver(() => this.sync()); + } + this._observer.observe(this, { + attributes: true, + attributeFilter: ["allow", "maxlength", "pattern"], + childList: true, + subtree: true, + }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("input", this._onInput); + const input = this._input; + const complete = this._complete; + this.release(); + this._previousInput = input; + this._complete = complete; + } + + release() { + restoreAttributes(this._managed); + if (this._slots && this._originalSlots) { + this._slots.replaceChildren(...this._originalSlots); + } + this._input = null; + this._slots = null; + this._originalSlots = null; + this._complete = false; + } + + sync() { + const inputs = [...this.querySelectorAll("input")]; + const input = inputs.length === 1 ? inputs[0] : null; + const slots = this.querySelector("[data-zen-otp-slots]"); + if (input !== this._input || slots !== this._slots) { + const remainedComplete = + (input === this._input || input === this._previousInput) && + this._complete; + this.release(); + this._input = input; + this._slots = slots; + this._originalSlots = slots ? [...slots.childNodes] : null; + this._complete = remainedComplete; + this._previousInput = null; + } + if (!this._input) return; + + if (!this._input.hasAttribute("maxlength")) { + setManagedAttribute(this._managed, this._input, "maxlength", "6"); + } + if (this.numericOnly() && !this._input.hasAttribute("inputmode")) { + setManagedAttribute(this._managed, this._input, "inputmode", "numeric"); + } else if (!this.numericOnly()) { + restoreAttribute(this._managed, this._input, "inputmode"); + } + this.update(true); + } + + numericOnly() { + return !this.hasAttribute("allow") && + !this.hasAttribute("pattern") && + !this._input.hasAttribute("allow") && + !this._input.hasAttribute("pattern"); + } + + length() { + const value = Number.parseInt(this._input.getAttribute("maxlength"), 10); + return Number.isFinite(value) && value > 0 ? value : 6; + } + + update(notify) { + const length = this.length(); + const before = this._input.value; + let value = this.numericOnly() ? before.replace(/\D/g, "") : before; + value = [...value].slice(0, length).join(""); + if (value !== before) { + const selection = this._input.selectionStart; + this._input.value = value; + if (selection !== null) { + const preceding = before.slice(0, selection); + const position = this.numericOnly() + ? preceding.replace(/\D/g, "").length + : [...preceding].slice(0, length).join("").length; + try { + this._input.setSelectionRange(position, position); + } catch { + // Some native input types do not expose text selection. + } + } + } + this.renderSlots(value, length); + + const complete = [...value].length === length; + if (notify && complete && !this._complete) { + emit(this, "zen-complete", { value }); + } + this._complete = complete; + } + + renderSlots(value, length) { + if (!this._slots) return; + const characters = [...value]; + const current = [...this._slots.children]; + const unchanged = current.length === length && + current.every((slot, index) => + slot.localName === "span" && + slot.textContent === (characters[index] || "")); + if (unchanged) return; + + const fragment = document.createDocumentFragment(); + for (let index = 0; index < length; index++) { + const slot = document.createElement("span"); + slot.textContent = characters[index] || ""; + fragment.append(slot); + } + this._slots.replaceChildren(fragment); + } +} + +const definitions = { + "zen-calendar": ZenCalendar, + "zen-checkbox": ZenCheckbox, + "zen-combobox": ZenCombobox, + "zen-command": ZenCommand, + "zen-date-picker": ZenDatePicker, + "zen-form": ZenForm, + "zen-input-otp": ZenInputOtp, + "zen-radio-group": ZenRadioGroup, + "zen-select": ZenSelect, + "zen-slider": ZenSlider, + "zen-switch": ZenSwitch, +}; + +if (globalThis.customElements) { + for (const [name, definition] of Object.entries(definitions)) { + if (!customElements.get(name)) customElements.define(name, definition); + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/icon.js Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,109 @@ +const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; + +const paths = { + add: ["M12 5v14", "M5 12h14"], + alert: ["M12 3 2.8 20h18.4L12 3Z", "M12 9v5", "M12 17h.01"], + archive: ["M4 7h16v13H4Z", "M3 3h18v4H3Z", "M9 11h6"], + "arrow-down": ["M12 4v16", "m6 14-6 6-6-6"], + "arrow-left": ["M20 12H4", "m10 6-6-6 6-6"], + "arrow-right": ["M4 12h16", "m-6-6 6 6-6 6"], + "arrow-up": ["M12 20V4", "m-6 6 6-6 6 6"], + check: ["m5 12 4 4L19 6"], + "chevron-down": ["m5 9 7 7 7-7"], + "chevron-left": ["m15 5-7 7 7 7"], + "chevron-right": ["m9 5 7 7-7 7"], + "chevron-up": ["m5 15 7-7 7 7"], + close: ["M6 6l12 12", "M18 6 6 18"], + code: ["m9 6-6 6 6 6", "m6-12 6 6-6 6"], + copy: ["M8 8h12v12H8Z", "M4 16V4h12"], + download: ["M12 3v12", "m7-7 5 5 5-5", "M5 21h14"], + error: ["M5 5l14 14", "M19 5 5 19"], + external: ["M14 5h5v5", "m19 5-8 8", "M19 13v6H5V5h6"], + file: ["M6 3h8l4 4v14H6Z", "M14 3v5h4"], + folder: ["M3 6h7l2 2h9v12H3Z"], + branch: ["M7 5v10a4 4 0 0 0 4 4h6", "M7 9h7a3 3 0 0 0 3-3V5"], + calendar: ["M5 4h14v17H5Z", "M8 2v4", "M16 2v4", "M5 9h14"], + history: ["M4 5v5h5", "M5 9a8 8 0 1 0 2-4", "M12 7v5l3 2"], + info: ["M12 11v6", "M12 7h.01", "M3 12a9 9 0 1 0 18 0 9 9 0 1 0-18 0"], + menu: ["M4 6h16", "M4 12h16", "M4 18h16"], + moon: ["M20 15a8 8 0 0 1-11-11 9 9 0 1 0 11 11Z"], + more: ["M5 12h.01", "M12 12h.01", "M19 12h.01"], + pause: ["M8 5v14", "M16 5v14"], + play: ["m8 5 11 7-11 7Z"], + repository: ["M4 4h13a3 3 0 0 1 3 3v13H7a3 3 0 0 1-3-3Z", "M8 4v16"], + retry: ["M4 4v6h6", "M5 9a8 8 0 1 1 2 8"], + search: ["M4 11a7 7 0 1 0 14 0 7 7 0 1 0-14 0", "m16 16 4 4"], + settings: [ + "M9.5 4.2 10 2h4l.5 2.2 2 .8 2-1.2 2.8 2.8-1.2 2 .8 2L22 10v4l-2.2.5-.8 2 1.2 2-2.8 2.8-2-1.2-2 .8-.5 2.2h-4L9.5 20l-2-.8-2 1.2-2.8-2.8 1.2-2-.8-2L2 14v-4l2.2-.5.8-2-1.2-2 2.8-2.8 2 1.2Z", + "M9 12a3 3 0 1 0 6 0 3 3 0 1 0-6 0", + ], + sun: ["M12 2v3", "M12 19v3", "M2 12h3", "M19 12h3", "m5 5 2 2", "m17 17 2 2", "m19 5-2 2", "M7 17l-2 2", "M8 12a4 4 0 1 0 8 0 4 4 0 1 0-8 0"], + trash: ["M4 7h16", "M9 3h6l1 4H8Z", "m6 7 1 14h10l1-14", "M10 11v6", "M14 11v6"], + upload: ["M12 21V9", "m5 14 5-5 5 5", "M5 3h14"], + user: ["M8 8a4 4 0 1 0 8 0 4 4 0 1 0-8 0", "M4 21a8 8 0 0 1 16 0"], +}; + +/** @const {!ReadonlyArray<string>} Names in the first-party icon registry. */ +export const ICON_NAMES = Object.freeze(Object.keys(paths).sort()); + +function safeSize(value) { + if (/^\d+(?:\.\d+)?(?:px|rem|em)?$/.test(value || "")) return value; + return "1em"; +} + +/** + * Renders validated first-party SVG geometry with inherited color. + * + * @extends {HTMLElement} + */ +export class ZenIcon extends HTMLElement { + static get observedAttributes() { + return ["label", "name", "size"]; + } + + connectedCallback() { + this.render(); + } + + attributeChangedCallback() { + if (this.isConnected) this.render(); + } + + render() { + const requested = this.getAttribute("name") || "info"; + const known = Object.hasOwn(paths, requested); + const definition = known ? paths[requested] : paths.alert; + const svg = document.createElementNS(SVG_NAMESPACE, "svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "2"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + svg.setAttribute("focusable", "false"); + + for (const data of definition) { + const path = document.createElementNS(SVG_NAMESPACE, "path"); + path.setAttribute("d", data); + svg.append(path); + } + + const label = this.getAttribute("label"); + if (label) { + this.setAttribute("role", "img"); + this.setAttribute("aria-label", label); + this.removeAttribute("aria-hidden"); + } else { + this.removeAttribute("role"); + this.removeAttribute("aria-label"); + this.setAttribute("aria-hidden", "true"); + } + this.dataset.zenIcon = known ? requested : "alert"; + this.style.setProperty("--zen-icon-size", safeSize(this.getAttribute("size"))); + this.replaceChildren(svg); + } +} + +if (!customElements.get("zen-icon")) { + customElements.define("zen-icon", ZenIcon); +}
--- a/design_system/src/components/index.js Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/src/components/index.js Tue Aug 04 15:12:09 2026 -0700 @@ -1,11 +1,56 @@ export { ZenAlert } from "./alert.js"; export { ZenButton } from "./button.js"; export { ZenCard } from "./card.js"; +export { + ZenCarousel, + ZenDataTable, + ZenMessageScroller, + ZenResizable, +} from "./collections.js"; +export { + ZenAccordion, + ZenCollapsible, + ZenSidebar, + ZenTabs, + ZenToggle, + ZenToggleGroup, +} from "./disclosure.js"; export { ZenField } from "./field.js"; export { + ZenCalendar, + ZenCheckbox, + ZenCombobox, + ZenCommand, + ZenDatePicker, + ZenForm, + ZenInputOtp, + ZenRadioGroup, + ZenSelect, + ZenSlider, + ZenSwitch, +} from "./forms.js"; +export { ICON_NAMES, ZenIcon } from "./icon.js"; +export { ZenNotifications, validateNotification, validateNotificationDismiss, } from "./notifications.js"; +export { + ZenAlertDialog, + ZenContextMenu, + ZenDialog, + ZenDrawer, + ZenDropdownMenu, + ZenHoverCard, + ZenMenubar, + ZenNavigationMenu, + ZenPopover, + ZenSheet, + ZenTooltip, +} from "./overlays.js"; +export { + ZEN_PRIMITIVE_ELEMENTS, + ZenPrimitive, +} from "./primitives.js"; export { ZenStack } from "./stack.js"; export { ZenStory } from "./story.js";
--- a/design_system/src/components/notifications.js Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/src/components/notifications.js Tue Aug 04 15:12:09 2026 -0700 @@ -13,6 +13,12 @@ (allowEmpty || value.trim().length > 0); } +/** + * Validates and normalizes a notification event record. + * + * @param {*} value Candidate event detail. + * @return {?Object} Frozen normalized record, or null when invalid. + */ export function validateNotification(value) { if (!value || value.version !== 1 || @@ -53,6 +59,12 @@ }); } +/** + * Validates a programmatic notification dismissal record. + * + * @param {*} value Candidate event detail. + * @return {?string} Valid notification ID, or null when invalid. + */ export function validateNotificationDismiss(value) { if (!value || value.version !== 1 || @@ -72,6 +84,11 @@ }; } +/** + * Renders a bounded, scoped, Sonner-like notification queue. + * + * @extends {HTMLElement} + */ export class ZenNotifications extends HTMLElement { static version = 1; @@ -363,6 +380,8 @@ const tone = document.createElement("span"); tone.className = "zen-notification-tone"; tone.setAttribute("aria-hidden", "true"); + const toneIcon = document.createElement("zen-icon"); + tone.append(toneIcon); const content = document.createElement("div"); content.className = "zen-notification-content"; @@ -373,7 +392,9 @@ const dismiss = document.createElement("button"); dismiss.type = "button"; dismiss.className = "zen-notification-dismiss"; - dismiss.textContent = "×"; + const dismissIcon = document.createElement("zen-icon"); + dismissIcon.setAttribute("name", "close"); + dismiss.append(dismissIcon); dismiss.addEventListener("click", () => { this.dismiss(record.id, "dismissed"); }); @@ -402,18 +423,19 @@ dismiss, message, tone, + toneIcon, }; this.#resizeObserver?.observe(article); } const view = record.view; view.article.dataset.tone = record.tone; - view.tone.textContent = { - info: "i", - success: "✓", - warning: "!", - error: "×", - }[record.tone]; + view.toneIcon.setAttribute("name", { + info: "info", + success: "check", + warning: "alert", + error: "error", + }[record.tone]); view.message.textContent = record.message; view.dismiss.setAttribute( "aria-label",
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/overlays.js Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,865 @@ +let nextOverlayId = 1; + +function uniqueId(prefix) { + let id; + do id = `${prefix}-${nextOverlayId++}`; + while (document.getElementById(id)); + return id; +} + +function rememberAttribute(state, element, name) { + let attributes = state.get(element); + if (!attributes) { + attributes = new Map(); + state.set(element, attributes); + } + if (!attributes.has(name)) { + attributes.set(name, element.getAttribute(name)); + } +} + +function setManagedAttribute(state, element, name, value) { + rememberAttribute(state, element, name); + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); +} + +function restoreAttributes(state) { + for (const [element, attributes] of state) { + for (const [name, value] of attributes) { + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); + } + } + state.clear(); +} + +function resetManagedAttribute(state, element, name) { + const attributes = state.get(element); + if (!attributes?.has(name)) return; + const value = attributes.get(name); + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); +} + +function ensureId(state, element, prefix) { + const existing = element.id && document.getElementById(element.id); + if (!element.id || (existing && existing !== element)) { + setManagedAttribute(state, element, "id", uniqueId(prefix)); + } + return element.id; +} + +function eventElement(event) { + return event.target instanceof Element ? event.target : null; +} + +const OVERLAY_COMPONENTS = [ + "zen-dialog", + "zen-alert-dialog", + "zen-sheet", + "zen-drawer", + "zen-popover", + "zen-hover-card", + "zen-tooltip", +].join(","); + +function ownedElement(host, selector) { + return [...host.querySelectorAll(selector)].find( + element => element.closest(OVERLAY_COMPONENTS) === host, + ) || null; +} + +/** + * Wires a native modal dialog to explicit trigger and close controls. + * + * @extends {HTMLElement} + */ +export class ZenDialog extends HTMLElement { + static dialogRole = null; + + constructor() { + super(); + this._managed = new Map(); + this._open = false; + this._openedByComponent = false; + this._onTriggerClick = event => { + event.preventDefault(); + this.open(); + }; + this._onDialogClick = event => { + const control = eventElement(event)?.closest("[data-zen-close]"); + if (!control || !this._dialog?.contains(control)) return; + const value = control.hasAttribute("value") + ? control.getAttribute("value") + : undefined; + this.close(value); + }; + this._onNativeClose = () => this.syncOpen(false); + } + + connectedCallback() { + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + sync() { + const trigger = ownedElement(this, "button[data-zen-trigger]"); + const dialog = ownedElement(this, "dialog"); + if (trigger === this._trigger && dialog === this._dialog) return; + if (this._openedByComponent) this.close(); + this.unbind(); + this._trigger = trigger; + this._dialog = dialog; + if (!this._trigger || !this._dialog) return; + + const id = ensureId(this._managed, this._dialog, this.localName); + setManagedAttribute( + this._managed, + this._trigger, + "aria-haspopup", + "dialog", + ); + setManagedAttribute(this._managed, this._trigger, "aria-controls", id); + if (this.constructor.dialogRole) { + setManagedAttribute( + this._managed, + this._dialog, + "role", + this.constructor.dialogRole, + ); + } + this._trigger.addEventListener("click", this._onTriggerClick); + this._dialog.addEventListener("click", this._onDialogClick); + this._dialog.addEventListener("close", this._onNativeClose); + this.syncOpen(this._dialog.open); + } + + unbind() { + this._trigger?.removeEventListener("click", this._onTriggerClick); + this._dialog?.removeEventListener("click", this._onDialogClick); + this._dialog?.removeEventListener("close", this._onNativeClose); + restoreAttributes(this._managed); + this._trigger = null; + this._dialog = null; + this._open = false; + this._openedByComponent = false; + } + + disconnectedCallback() { + this._observer?.disconnect(); + if (this._openedByComponent) this.close(); + this.unbind(); + } + + open() { + if (!this._dialog || this._dialog.open || this._open) return; + try { + if (typeof this._dialog.showModal === "function") { + this._dialog.showModal(); + } else { + setManagedAttribute(this._managed, this._dialog, "open", ""); + } + this._openedByComponent = true; + this.syncOpen(true); + } catch { + this.syncOpen(Boolean(this._dialog.open)); + } + } + + close(returnValue) { + if (!this._dialog || (!this._dialog.open && !this._open)) return; + if (typeof this._dialog.close === "function") { + if (returnValue === undefined) this._dialog.close(); + else this._dialog.close(returnValue); + } else { + setManagedAttribute(this._managed, this._dialog, "open", null); + } + this.syncOpen(false); + } + + syncOpen(open) { + this._open = open; + if (!open) this._openedByComponent = false; + if (this._trigger) { + setManagedAttribute( + this._managed, + this._trigger, + "aria-expanded", + String(open), + ); + } + } +} + +/** + * Presents a native modal with alert-dialog semantics. + * + * @extends {ZenDialog} + */ +export class ZenAlertDialog extends ZenDialog { + static dialogRole = "alertdialog"; +} + +/** + * Presents a native dialog as an edge-aligned sheet. + * + * @extends {ZenDialog} + */ +export class ZenSheet extends ZenDialog {} + +/** + * Presents a native dialog as a drawer. + * + * @extends {ZenDialog} + */ +export class ZenDrawer extends ZenDialog {} + +function isPopoverOpen(element) { + try { + return element.matches(":popover-open"); + } catch { + return false; + } +} + +/** + * Connects a trigger to a native popover with an accessible fallback. + * + * @extends {HTMLElement} + */ +export class ZenPopover extends HTMLElement { + constructor() { + super(); + this._managed = new Map(); + this._open = false; + this._onClick = event => { + const target = eventElement(event); + if (!target) return; + if (this._trigger?.contains(target)) { + event.preventDefault(); + this.toggle(); + } else if (this._content?.contains(target) && + target.closest("[data-zen-close]")) { + this.hide(); + } + }; + this._onToggle = event => { + this.syncOpen(event.newState + ? event.newState === "open" + : isPopoverOpen(this._content)); + }; + this._onDocumentPointerDown = event => { + if (!this._native && this._open && !this.contains(event.target)) { + this.hide(); + } + }; + this._onDocumentKeyDown = event => { + if (!this._native && this._open && event.key === "Escape") { + this.hide(); + this._trigger?.focus(); + } + }; + } + + connectedCallback() { + this.addEventListener("click", this._onClick); + document.addEventListener("pointerdown", this._onDocumentPointerDown); + document.addEventListener("keydown", this._onDocumentKeyDown); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + sync() { + const trigger = ownedElement(this, "[data-zen-trigger]"); + const content = ownedElement( + this, + "[data-zen-content], [popover]", + ); + if (trigger === this._trigger && content === this._content) return; + if (this._open) this.hide(); + this.unbindElements(); + this._trigger = trigger; + this._content = content; + if (!this._trigger || !this._content) return; + + const id = ensureId(this._managed, this._content, "zen-popover"); + setManagedAttribute(this._managed, this._trigger, "aria-controls", id); + this._native = typeof this._content.showPopover === "function" && + typeof this._content.hidePopover === "function"; + if (this._native && !this._content.hasAttribute("popover")) { + setManagedAttribute(this._managed, this._content, "popover", "auto"); + } + if (this._native && "popoverTargetElement" in this._trigger) { + this._originalPopoverTarget = this._trigger.popoverTargetElement; + this._trigger.popoverTargetElement = this._content; + } + if (this._native) { + setManagedAttribute(this._managed, this._content, "hidden", null); + } else { + setManagedAttribute(this._managed, this._content, "hidden", ""); + } + + this._content.addEventListener("toggle", this._onToggle); + this.syncOpen(this._native && isPopoverOpen(this._content)); + } + + unbindElements() { + this._content?.removeEventListener("toggle", this._onToggle); + if (this._trigger && "popoverTargetElement" in this._trigger) { + this._trigger.popoverTargetElement = this._originalPopoverTarget || null; + } + restoreAttributes(this._managed); + this._trigger = null; + this._content = null; + this._native = false; + this._open = false; + this._originalPopoverTarget = null; + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._onClick); + document.removeEventListener("pointerdown", this._onDocumentPointerDown); + document.removeEventListener("keydown", this._onDocumentKeyDown); + if (this._open) this.hide(); + this.unbindElements(); + } + + toggle() { + if (this._open || isPopoverOpen(this._content)) this.hide(); + else this.show(); + } + + show() { + if (!this._content || this._open) return; + if (this._native) { + try { + this._content.showPopover(); + } catch { + this._native = false; + setManagedAttribute(this._managed, this._content, "hidden", null); + } + } else { + setManagedAttribute(this._managed, this._content, "hidden", null); + } + this.syncOpen(true); + } + + hide() { + if (!this._content || !this._open) return; + if (this._native) { + try { + this._content.hidePopover(); + } catch { + this._native = false; + setManagedAttribute(this._managed, this._content, "hidden", ""); + } + } else { + setManagedAttribute(this._managed, this._content, "hidden", ""); + } + this.syncOpen(false); + } + + syncOpen(open) { + this._open = open; + if (this._trigger) { + setManagedAttribute( + this._managed, + this._trigger, + "aria-expanded", + String(open), + ); + } + if (!this._native && this._content) { + setManagedAttribute( + this._managed, + this._content, + "hidden", + open ? null : "", + ); + } + } +} + +class ZenTimedOverlay extends HTMLElement { + static delay = 150; + static tooltip = false; + + constructor() { + super(); + this._managed = new Map(); + this._timer = null; + this._open = false; + this._pointerWithin = false; + this._focusWithin = false; + this._onPointerEnter = () => { + this._pointerWithin = true; + this.schedule(true); + }; + this._onPointerLeave = event => { + if (event.relatedTarget instanceof Node && + this.contains(event.relatedTarget)) return; + this._pointerWithin = false; + this.schedule(this._focusWithin); + }; + this._onFocusIn = () => { + this._focusWithin = true; + this.schedule(true); + }; + this._onFocusOut = event => { + if (event.relatedTarget instanceof Node && + this.contains(event.relatedTarget)) return; + this._focusWithin = false; + this.schedule(this._pointerWithin); + }; + this._onKeyDown = event => { + if (event.key !== "Escape" || !this._open) return; + clearTimeout(this._timer); + this._timer = null; + this.setOpen(false); + }; + } + + connectedCallback() { + this.addEventListener("keydown", this._onKeyDown); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + sync() { + const trigger = ownedElement(this, "[data-zen-trigger]"); + const content = ownedElement(this, "[data-zen-content]"); + if (trigger === this._trigger && content === this._content) return; + this.unbindElements(); + this._trigger = trigger; + this._content = content; + if (!this._trigger || !this._content) return; + + const id = ensureId(this._managed, this._content, this.localName); + setManagedAttribute(this._managed, this._content, "hidden", ""); + if (this.constructor.tooltip) { + setManagedAttribute(this._managed, this._content, "role", "tooltip"); + const ids = new Set( + (this._trigger.getAttribute("aria-describedby") || "") + .split(/\s+/) + .filter(Boolean), + ); + ids.add(id); + setManagedAttribute( + this._managed, + this._trigger, + "aria-describedby", + [...ids].join(" "), + ); + } else { + setManagedAttribute(this._managed, this._trigger, "aria-controls", id); + setManagedAttribute( + this._managed, + this._trigger, + "aria-expanded", + "false", + ); + } + + for (const element of [this._trigger, this._content]) { + element.addEventListener("pointerenter", this._onPointerEnter); + element.addEventListener("pointerleave", this._onPointerLeave); + element.addEventListener("focusin", this._onFocusIn); + element.addEventListener("focusout", this._onFocusOut); + } + } + + unbindElements() { + clearTimeout(this._timer); + this._timer = null; + for (const element of [this._trigger, this._content]) { + element?.removeEventListener("pointerenter", this._onPointerEnter); + element?.removeEventListener("pointerleave", this._onPointerLeave); + element?.removeEventListener("focusin", this._onFocusIn); + element?.removeEventListener("focusout", this._onFocusOut); + } + restoreAttributes(this._managed); + this._trigger = null; + this._content = null; + this._open = false; + this._pointerWithin = false; + this._focusWithin = false; + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("keydown", this._onKeyDown); + this.unbindElements(); + } + + schedule(open) { + clearTimeout(this._timer); + this._timer = setTimeout( + () => this.setOpen(open), + this.constructor.delay, + ); + } + + setOpen(open) { + if (!this._content || open === this._open) return; + this._open = open; + setManagedAttribute( + this._managed, + this._content, + "hidden", + open ? null : "", + ); + if (!this.constructor.tooltip) { + setManagedAttribute( + this._managed, + this._trigger, + "aria-expanded", + String(open), + ); + } + } +} + +/** + * Shows supplemental interactive content on hover or focus. + * + * @extends {ZenTimedOverlay} + */ +export class ZenHoverCard extends ZenTimedOverlay { + static delay = 150; +} + +/** + * Shows a delayed non-interactive description on hover or focus. + * + * @extends {ZenTimedOverlay} + */ +export class ZenTooltip extends ZenTimedOverlay { + static delay = 350; + static tooltip = true; +} + +const MENU_COMPONENTS = [ + "zen-dropdown-menu", + "zen-context-menu", + "zen-menubar", + "zen-navigation-menu", +].join(","); +const MENU_SELECTOR = "[data-zen-menu], [role='menu'], [data-zen-content]"; +const ITEM_SELECTOR = [ + "[role='menuitem']", + "[role='menuitemcheckbox']", + "[role='menuitemradio']", + "[data-zen-menu-item]", + "a[href]", + "button", + "input[type='button']", + "input[type='submit']", +].join(","); + +class ZenMenu extends HTMLElement { + static contextMenu = false; + static menubar = false; + static multiple = false; + + constructor() { + super(); + this._managed = new Map(); + this._pairs = []; + this._openPair = null; + this._onClick = event => { + const target = eventElement(event); + if (!target) return; + const pair = this._pairs.find(({ trigger }) => + trigger.contains(target)); + if (pair) { + event.preventDefault(); + if (this._openPair === pair) this.close(false); + else this.open(pair, 0, this.constructor.contextMenu ? event : null); + return; + } + const item = target.closest(ITEM_SELECTOR); + if (this._openPair?.menu.contains(target) && + item && + !item.hasAttribute("disabled") && + item.getAttribute("aria-disabled") !== "true") { + this.close(true); + } + }; + this._onContextMenu = event => { + if (!this.constructor.contextMenu || + this._pairs.some(({ menu }) => menu.contains(event.target))) return; + event.preventDefault(); + this.open(this._pairs[0], 0, event); + }; + this._onKeyDown = event => this.handleKeyDown(event); + this._onDocumentPointerDown = event => { + if (!this._openPair) return; + const { trigger, menu } = this._openPair; + if (!trigger.contains(event.target) && !menu.contains(event.target)) { + this.close(false); + } + }; + } + + connectedCallback() { + this.setupPairs(); + this.addEventListener("click", this._onClick); + this.addEventListener("contextmenu", this._onContextMenu); + this.addEventListener("keydown", this._onKeyDown); + document.addEventListener("pointerdown", this._onDocumentPointerDown); + this._observer ||= new MutationObserver(() => this.refreshPairs()); + this._observer.observe(this, { childList: true, subtree: true }); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._onClick); + this.removeEventListener("contextmenu", this._onContextMenu); + this.removeEventListener("keydown", this._onKeyDown); + document.removeEventListener("pointerdown", this._onDocumentPointerDown); + this._openPair = null; + restoreAttributes(this._managed); + this._pairs = []; + } + + refreshPairs() { + if (this._openPair) this.close(false); + restoreAttributes(this._managed); + this._pairs = []; + this.setupPairs(); + } + + ownedElements(selector) { + return [...this.querySelectorAll(selector)].filter(element => + element.closest(MENU_COMPONENTS) === this); + } + + setupPairs() { + let triggers = this.ownedElements("[data-zen-trigger]"); + const menus = this.ownedElements(MENU_SELECTOR); + triggers = triggers.filter(trigger => + !menus.some(menu => menu.contains(trigger))); + if (!this.constructor.multiple) triggers = triggers.slice(0, 1); + + const unused = new Set(menus); + for (const trigger of triggers) { + const controlled = trigger.getAttribute("aria-controls"); + let menu = controlled + ? menus.find(candidate => candidate.id === controlled) + : null; + if (!menu) menu = [...unused][0]; + if (!menu) continue; + unused.delete(menu); + + const triggerId = ensureId(this._managed, trigger, `${this.localName}-trigger`); + const menuId = ensureId(this._managed, menu, `${this.localName}-menu`); + setManagedAttribute(this._managed, trigger, "aria-haspopup", "menu"); + setManagedAttribute(this._managed, trigger, "aria-controls", menuId); + setManagedAttribute(this._managed, trigger, "aria-expanded", "false"); + setManagedAttribute(this._managed, menu, "role", "menu"); + setManagedAttribute(this._managed, menu, "aria-labelledby", triggerId); + setManagedAttribute(this._managed, menu, "hidden", ""); + if (this.constructor.menubar) { + setManagedAttribute(this._managed, trigger, "role", "menuitem"); + } + const pair = { trigger, menu }; + this._pairs.push(pair); + this.prepareItems(pair); + } + if (this.constructor.menubar) { + setManagedAttribute(this._managed, this, "role", "menubar"); + this.setMenubarTabStop(this._pairs[0]); + } + } + + prepareItems(pair) { + const items = [...pair.menu.querySelectorAll(ITEM_SELECTOR)].filter(item => + item.closest(MENU_SELECTOR) === pair.menu && + !item.hidden && + !item.hasAttribute("disabled") && + item.getAttribute("aria-disabled") !== "true"); + for (const item of items) { + if (!item.hasAttribute("role")) { + setManagedAttribute(this._managed, item, "role", "menuitem"); + } + setManagedAttribute(this._managed, item, "tabindex", "-1"); + } + return items; + } + + open(pair, itemIndex = 0, pointerEvent = null) { + if (!pair) return; + if (this._openPair && this._openPair !== pair) this.close(false); + this._openPair = pair; + if (this.constructor.menubar) this.setMenubarTabStop(pair); + if (pointerEvent) { + rememberAttribute(this._managed, pair.menu, "style"); + pair.menu.style.position = "fixed"; + pair.menu.style.left = `${Number(pointerEvent.clientX) || 0}px`; + pair.menu.style.top = `${Number(pointerEvent.clientY) || 0}px`; + } else { + resetManagedAttribute(this._managed, pair.menu, "style"); + } + setManagedAttribute(this._managed, pair.menu, "hidden", null); + setManagedAttribute(this._managed, pair.trigger, "aria-expanded", "true"); + const items = this.prepareItems(pair); + if (items.length) { + const index = itemIndex < 0 ? items.length - 1 : itemIndex; + setManagedAttribute(this._managed, items[index], "tabindex", "0"); + items[index].focus(); + } else { + setManagedAttribute(this._managed, pair.menu, "tabindex", "-1"); + pair.menu.focus(); + } + } + + close(restoreFocus) { + if (!this._openPair) return; + const pair = this._openPair; + this._openPair = null; + setManagedAttribute(this._managed, pair.menu, "hidden", ""); + setManagedAttribute(this._managed, pair.trigger, "aria-expanded", "false"); + resetManagedAttribute(this._managed, pair.menu, "style"); + if (restoreFocus) pair.trigger.focus(); + } + + moveItem(pair, offset, edge = null) { + const active = document.activeElement; + const items = this.prepareItems(pair); + if (!items.length) return; + let index = items.indexOf(active); + if (edge === "first") index = 0; + else if (edge === "last") index = items.length - 1; + else index = (Math.max(index, 0) + offset + items.length) % items.length; + setManagedAttribute(this._managed, items[index], "tabindex", "0"); + items[index].focus(); + } + + moveMenubarTrigger(pair, offset, openMenu) { + const index = this._pairs.indexOf(pair); + const next = this._pairs[ + (index + offset + this._pairs.length) % this._pairs.length + ]; + this.setMenubarTabStop(next); + if (openMenu) this.open(next); + else next.trigger.focus(); + } + + setMenubarTabStop(activePair) { + for (const pair of this._pairs) { + setManagedAttribute( + this._managed, + pair.trigger, + "tabindex", + pair === activePair ? "0" : "-1", + ); + } + } + + handleKeyDown(event) { + const target = eventElement(event); + if (!target) return; + const triggerPair = this._pairs.find(({ trigger }) => + trigger.contains(target)); + if (triggerPair) { + if (this.constructor.menubar && + (event.key === "ArrowRight" || event.key === "ArrowLeft")) { + event.preventDefault(); + this.moveMenubarTrigger( + triggerPair, + event.key === "ArrowRight" ? 1 : -1, + false, + ); + } else if (["Enter", " ", "ArrowDown", "ArrowUp"].includes(event.key)) { + event.preventDefault(); + this.open(triggerPair, event.key === "ArrowUp" ? -1 : 0); + } else if (event.key === "Escape" && this._openPair) { + event.preventDefault(); + this.close(true); + } + return; + } + + const pair = this._pairs.find(({ menu }) => menu.contains(target)); + if (!pair || this._openPair !== pair) return; + if (event.key === "ArrowDown") { + event.preventDefault(); + this.moveItem(pair, 1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + this.moveItem(pair, -1); + } else if (event.key === "Home" || event.key === "End") { + event.preventDefault(); + this.moveItem(pair, 0, event.key === "Home" ? "first" : "last"); + } else if (event.key === "Escape") { + event.preventDefault(); + this.close(true); + } else if (event.key === "Tab") { + this.close(false); + } else if (this.constructor.menubar && + (event.key === "ArrowRight" || event.key === "ArrowLeft")) { + event.preventDefault(); + this.moveMenubarTrigger( + pair, + event.key === "ArrowRight" ? 1 : -1, + true, + ); + } + } +} + +/** + * Provides an accessible action menu opened from a native trigger. + * + * @extends {ZenMenu} + */ +export class ZenDropdownMenu extends ZenMenu {} + +/** + * Opens an accessible action menu at the pointer location. + * + * @extends {ZenMenu} + */ +export class ZenContextMenu extends ZenMenu { + static contextMenu = true; +} + +/** + * Coordinates multiple keyboard-accessible application menus. + * + * @extends {ZenMenu} + */ +export class ZenMenubar extends ZenMenu { + static menubar = true; + static multiple = true; +} + +/** + * Provides keyboard-accessible navigation flyouts. + * + * @extends {ZenMenu} + */ +export class ZenNavigationMenu extends ZenMenu {} + +const definitions = { + "zen-dialog": ZenDialog, + "zen-alert-dialog": ZenAlertDialog, + "zen-sheet": ZenSheet, + "zen-drawer": ZenDrawer, + "zen-popover": ZenPopover, + "zen-hover-card": ZenHoverCard, + "zen-tooltip": ZenTooltip, + "zen-dropdown-menu": ZenDropdownMenu, + "zen-context-menu": ZenContextMenu, + "zen-menubar": ZenMenubar, + "zen-navigation-menu": ZenNavigationMenu, +}; + +for (const [name, constructor] of Object.entries(definitions)) { + if (!customElements.get(name)) customElements.define(name, constructor); +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/primitives.js Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,143 @@ +/** @const {!ReadonlyArray<string>} Registered presentation-only elements. */ +export const ZEN_PRIMITIVE_ELEMENTS = [ + "zen-aspect-ratio", + "zen-attachment", + "zen-avatar", + "zen-badge", + "zen-breadcrumb", + "zen-bubble", + "zen-button-group", + "zen-chart", + "zen-direction", + "zen-empty", + "zen-input", + "zen-input-group", + "zen-item", + "zen-kbd", + "zen-label", + "zen-marker", + "zen-message", + "zen-native-select", + "zen-pagination", + "zen-progress", + "zen-scroll-area", + "zen-separator", + "zen-skeleton", + "zen-spinner", + "zen-table", + "zen-textarea", + "zen-typography", +]; + +function parseRatio(value) { + const [width, height] = String(value || "16/9") + .split("/") + .map(Number); + if (!Number.isFinite(width) || !Number.isFinite(height) || + width <= 0 || height <= 0) return "16 / 9"; + return `${width} / ${height}`; +} + +/** + * Applies small semantic enhancements shared by presentation primitives. + * + * @extends {HTMLElement} + */ +export class ZenPrimitive extends HTMLElement { + connectedCallback() { + this.sync(); + } + + static get observedAttributes() { + return ["dir", "ratio", "value", "width"]; + } + + attributeChangedCallback() { + if (this.isConnected) this.sync(); + } + + sync() { + switch (this.localName) { + case "zen-aspect-ratio": + this.style.setProperty( + "--zen-aspect-ratio", + parseRatio(this.getAttribute("ratio")), + ); + break; + case "zen-breadcrumb": { + const navigation = this.querySelector(":scope > nav"); + if (navigation && !navigation.hasAttribute("aria-label")) { + navigation.setAttribute("aria-label", "Breadcrumb"); + } + break; + } + case "zen-chart": + if (!this.hasAttribute("role")) this.setAttribute("role", "img"); + break; + case "zen-direction": + if (!this.hasAttribute("dir") && this.hasAttribute("value")) { + this.dir = this.getAttribute("value"); + } + break; + case "zen-pagination": { + const navigation = this.querySelector(":scope > nav"); + if (navigation && !navigation.hasAttribute("aria-label")) { + navigation.setAttribute("aria-label", "Pagination"); + } + break; + } + case "zen-native-select": { + if (!this.querySelector("select")) break; + let icon = this.querySelector( + ":scope > zen-icon[data-zen-select-icon]", + ); + if (!icon) { + icon = document.createElement("zen-icon"); + icon.dataset.zenSelectIcon = ""; + icon.dataset.zenGenerated = "true"; + icon.setAttribute("name", "chevron-down"); + this.append(icon); + } + break; + } + case "zen-separator": + if (!this.querySelector(":scope > hr")) { + if (!this.hasAttribute("role")) { + this.setAttribute("role", "separator"); + } + if (!this.hasAttribute("aria-orientation")) { + this.setAttribute( + "aria-orientation", + this.getAttribute("orientation") === "vertical" + ? "vertical" + : "horizontal", + ); + } + } + break; + case "zen-skeleton": + if (this.hasAttribute("width")) { + this.style.setProperty( + "--zen-skeleton-width", + this.getAttribute("width"), + ); + } else { + this.style.removeProperty("--zen-skeleton-width"); + } + if (!this.hasAttribute("role")) this.setAttribute("aria-hidden", "true"); + break; + case "zen-spinner": + if (!this.hasAttribute("role")) this.setAttribute("role", "status"); + if (!this.hasAttribute("aria-label")) { + this.setAttribute("aria-label", "Loading"); + } + break; + } + } +} + +for (const name of ZEN_PRIMITIVE_ELEMENTS) { + if (!customElements.get(name)) { + customElements.define(name, class extends ZenPrimitive {}); + } +}
--- a/design_system/src/components/stack.js Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/src/components/stack.js Tue Aug 04 15:12:09 2026 -0700 @@ -1,3 +1,8 @@ +/** + * Provides a token-driven flex layout around arbitrary native HTML. + * + * @extends {HTMLElement} + */ export class ZenStack extends HTMLElement {} if (!customElements.get("zen-stack")) {
--- a/design_system/src/components/story.js Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/src/components/story.js Tue Aug 04 15:12:09 2026 -0700 @@ -9,6 +9,11 @@ return lines.map(line => line.slice(minimum)).join("\n"); } +/** + * Renders a declarative component example and its copyable HTML source. + * + * @extends {HTMLElement} + */ export class ZenStory extends HTMLElement { connectedCallback() { if (this.dataset.ready === "true") return;
--- a/design_system/src/index.html Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/src/index.html Tue Aug 04 15:12:09 2026 -0700 @@ -18,10 +18,25 @@ Zenbu UI </a> + <zen-input-group class="catalog-search"> + <zen-icon name="search"></zen-icon> + <input + id="componentSearch" + type="search" + placeholder="Search components" + aria-label="Search components" + aria-describedby="componentSearchStatus" + autocomplete="off" + /> + <kbd aria-hidden="true">/</kbd> + </zen-input-group> + <p id="componentSearchStatus" class="search-status" role="status"></p> + <h2>Foundations</h2> <nav class="catalog-nav" aria-label="Foundations"> <a href="/" data-catalog-link>Overview</a> <a href="/tokens" data-catalog-link>Tokens</a> + <a href="/icons" data-catalog-link>Icons</a> </nav> <h2>Components</h2> @@ -97,14 +112,58 @@ </header> <div class="token-grid"> <section class="token-group"> - <h2>Color</h2> + <h2>Semantic color</h2> <dl class="swatch-list"> - <div><span class="swatch" style="--swatch: var(--zen-color-accent)"></span><div><dt>--zen-color-accent</dt><dd>Primary action</dd></div></div> - <div><span class="swatch" style="--swatch: var(--zen-color-info)"></span><div><dt>--zen-color-info</dt><dd>Informational state</dd></div></div> - <div><span class="swatch" style="--swatch: var(--zen-color-success)"></span><div><dt>--zen-color-success</dt><dd>Successful state</dd></div></div> - <div><span class="swatch" style="--swatch: var(--zen-color-danger)"></span><div><dt>--zen-color-danger</dt><dd>Dangerous state</dd></div></div> + <div><span class="swatch" style="--zen-color-swatch: var(--zen-color-accent)"></span><div><dt>--zen-color-accent</dt><dd>Primary action</dd></div></div> + <div><span class="swatch" style="--zen-color-swatch: var(--zen-color-info)"></span><div><dt>--zen-color-info</dt><dd>Informational state</dd></div></div> + <div><span class="swatch" style="--zen-color-swatch: var(--zen-color-success)"></span><div><dt>--zen-color-success</dt><dd>Successful state</dd></div></div> + <div><span class="swatch" style="--zen-color-swatch: var(--zen-color-danger)"></span><div><dt>--zen-color-danger</dt><dd>Dangerous state</dd></div></div> </dl> </section> + <section class="token-group token-group-wide"> + <h2>Material palette</h2> + <p>Warm, quiet colors inspired by paper, natural dye, clay, and wood.</p> + <div class="palette-grid"> + <div><span style="--zen-color-swatch: var(--zen-color-paper)"></span><code>paper</code></div> + <div><span style="--zen-color-swatch: var(--zen-color-washi)"></span><code>washi</code></div> + <div><span style="--zen-color-swatch: var(--zen-color-linen)"></span><code>linen</code></div> + <div><span style="--zen-color-swatch: var(--zen-color-sumi)"></span><code>sumi</code></div> + <div><span style="--zen-color-swatch: var(--zen-color-brick)"></span><code>brick</code></div> + <div><span style="--zen-color-swatch: var(--zen-color-persimmon)"></span><code>persimmon</code></div> + <div><span style="--zen-color-swatch: var(--zen-color-ochre)"></span><code>ochre</code></div> + <div><span style="--zen-color-swatch: var(--zen-color-moss)"></span><code>moss</code></div> + <div><span style="--zen-color-swatch: var(--zen-color-patina)"></span><code>patina</code></div> + <div><span style="--zen-color-swatch: var(--zen-color-indigo)"></span><code>indigo</code></div> + <div><span style="--zen-color-swatch: var(--zen-color-plum)"></span><code>plum</code></div> + <div><span style="--zen-color-swatch: var(--zen-color-clay)"></span><code>clay</code></div> + <div><span style="--zen-color-swatch: var(--zen-color-wood)"></span><code>wood</code></div> + </div> + </section> + <section class="token-group token-group-wide"> + <h2>Data palette</h2> + <p>Ten distinct aliases for charts and categorical comparisons.</p> + <div class="data-palette" aria-label="Data color tokens"> + <span style="--zen-color-swatch: var(--zen-color-data-1)" title="--zen-color-data-1"></span> + <span style="--zen-color-swatch: var(--zen-color-data-2)" title="--zen-color-data-2"></span> + <span style="--zen-color-swatch: var(--zen-color-data-3)" title="--zen-color-data-3"></span> + <span style="--zen-color-swatch: var(--zen-color-data-4)" title="--zen-color-data-4"></span> + <span style="--zen-color-swatch: var(--zen-color-data-5)" title="--zen-color-data-5"></span> + <span style="--zen-color-swatch: var(--zen-color-data-6)" title="--zen-color-data-6"></span> + <span style="--zen-color-swatch: var(--zen-color-data-7)" title="--zen-color-data-7"></span> + <span style="--zen-color-swatch: var(--zen-color-data-8)" title="--zen-color-data-8"></span> + <span style="--zen-color-swatch: var(--zen-color-data-9)" title="--zen-color-data-9"></span> + <span style="--zen-color-swatch: var(--zen-color-data-10)" title="--zen-color-data-10"></span> + </div> + </section> + <section class="token-group"> + <h2>Color ramps</h2> + <zen-stack gap="tight"> + <code>neutral · red · orange · amber</code> + <code>green · teal · blue · violet</code> + <code>rose · brown</code> + <small>Each ramp includes 50–900 plus 950.</small> + </zen-stack> + </section> <section class="token-group"> <h2>Spacing</h2> <zen-stack gap="tight"> @@ -126,6 +185,15 @@ </div> </section> + <section class="catalog-page" data-catalog-page="icons" hidden> + <header class="page-heading"> + <p class="eyebrow">Foundations</p> + <h1>Icons</h1> + <p>First-party SVG icons rendered by <code>zen-icon</code>. Icons inherit color and never depend on emoji, fonts, or an external package.</p> + </header> + <div id="iconGrid" class="icon-grid" aria-label="Zenbu icon names"></div> + </section> + <section class="catalog-page" data-catalog-page="button" hidden> <header class="page-heading"> <p class="eyebrow">Component</p>
--- a/design_system/src/storybook.css Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/src/storybook.css Tue Aug 04 15:12:09 2026 -0700 @@ -47,6 +47,34 @@ text-decoration: none; } +.catalog-search { + width: 100%; + margin-top: var(--zen-space-5); +} + +.catalog-search > zen-icon { + align-self: center; + margin-inline-start: var(--zen-space-3); + color: var(--zen-color-text-muted); +} + +.catalog-search > kbd { + align-self: center; + margin-inline-end: var(--zen-space-2); + padding: 0 var(--zen-space-1); + border: 1px solid var(--zen-color-border-muted); + border-radius: var(--zen-radius-sm); + color: var(--zen-color-text-muted); + font: var(--zen-font-size-xs)/1.5 var(--zen-font-mono); +} + +.search-status { + min-height: 1.25rem; + margin: var(--zen-space-1) 0 0; + color: var(--zen-color-text-muted); + font-size: var(--zen-font-size-xs); +} + .catalog-mark { display: grid; width: 2.5rem; @@ -55,7 +83,7 @@ border: var(--zen-border-width) solid var(--zen-color-border); border-radius: 50%; background: var(--zen-color-accent); - color: #171a21; + color: var(--zen-color-on-accent); } .catalog-sidebar h2 { @@ -78,10 +106,15 @@ text-decoration: none; } +.catalog-nav a[hidden], +.component-grid > a[hidden] { + display: none; +} + .catalog-nav a:hover, .catalog-nav a[aria-current] { background: var(--zen-color-accent); - color: #171a21; + color: var(--zen-color-on-accent); } .theme-toggle { @@ -97,10 +130,15 @@ .catalog-main { width: min(100%, var(--zen-content-width)); + min-width: 0; padding: var(--zen-space-7) clamp(1rem, 5vw, 5rem); outline: none; } +.catalog-page { + min-width: 0; +} + .catalog-page[hidden] { display: none; } @@ -160,6 +198,15 @@ height: 100%; } +.component-grid code { + color: var(--zen-color-info); + font-size: var(--zen-font-size-xs); +} + +.component-grid p { + color: var(--zen-color-text-muted); +} + .swatch-list { display: grid; gap: var(--zen-space-2); @@ -185,11 +232,81 @@ height: 2rem; border: 1px solid var(--zen-color-border); border-radius: 50%; - background: var(--swatch); + background: var(--zen-color-swatch); +} + +.token-group-wide { + grid-column: span 2; +} + +.token-group > p { + color: var(--zen-color-text-muted); +} + +.palette-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(6rem, 1fr)); + gap: var(--zen-space-3); +} + +.palette-grid > div { + display: grid; + gap: var(--zen-space-1); +} + +.palette-grid span { + min-height: 3.5rem; + border: 1px solid var(--zen-color-border-muted); + border-radius: var(--zen-radius-md); + background: var(--zen-color-swatch); +} + +.palette-grid code { + font-size: var(--zen-font-size-xs); +} + +.data-palette { + display: grid; + grid-template-columns: repeat(10, minmax(1.75rem, 1fr)); + min-height: 4rem; + overflow: hidden; + border: 1px solid var(--zen-color-border); + border-radius: var(--zen-radius-md); +} + +.data-palette span { + background: var(--zen-color-swatch); +} + +.icon-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr)); + gap: var(--zen-space-3); +} + +.icon-grid figure { + display: grid; + min-height: 7rem; + margin: 0; + padding: var(--zen-space-3); + place-items: center; + gap: var(--zen-space-2); + border: 1px solid var(--zen-color-border-muted); + border-radius: var(--zen-radius-md); + background: var(--zen-color-surface); +} + +.icon-grid figcaption { + max-width: 100%; + overflow: hidden; + color: var(--zen-color-text-muted); + font-size: var(--zen-font-size-xs); + text-overflow: ellipsis; } zen-story { display: block; + max-width: 100%; margin-bottom: var(--zen-space-6); overflow: hidden; border: var(--zen-border-width) solid var(--zen-color-border); @@ -223,8 +340,8 @@ min-height: 8rem; padding: clamp(1rem, 4vw, 3rem); background: - linear-gradient(90deg, rgba(127, 127, 127, 0.08) 1px, transparent 1px), - linear-gradient(rgba(127, 127, 127, 0.08) 1px, transparent 1px); + linear-gradient(90deg, var(--zen-color-gridline) 1px, transparent 1px), + linear-gradient(var(--zen-color-gridline) 1px, transparent 1px); background-size: 1rem 1rem; } @@ -234,8 +351,8 @@ margin: 0; padding: var(--zen-space-4); border-top: 1px solid var(--zen-color-border); - background: #111318; - color: #f4f4f5; + background: var(--zen-color-code-surface); + color: var(--zen-color-code-text); font: var(--zen-font-size-sm)/1.55 var(--zen-font-mono); } @@ -265,4 +382,12 @@ .catalog-main { padding: var(--zen-space-6) var(--zen-space-4); } + + .token-group-wide { + grid-column: auto; + } + + .data-palette { + grid-template-columns: repeat(5, 1fr); + } }
--- a/design_system/src/storybook.js Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/src/storybook.js Tue Aug 04 15:12:09 2026 -0700 @@ -1,9 +1,22 @@ import "./components/index.js"; +import { componentCatalog } from "./catalog.js"; +import { ICON_NAMES } from "./components/icon.js"; + +const stackComponent = { + name: "Stack", + slug: "stack", + element: "zen-stack", + group: "Layout", + description: "A Zenbu-specific flex layout primitive with tokenized gaps.", + existing: true, +}; +const displayedComponents = [...componentCatalog, stackComponent]; const pages = new Map([ ["/", "overview"], ["/components", "overview"], ["/tokens", "tokens"], + ["/icons", "icons"], ["/components/button", "button"], ["/components/card", "card"], ["/components/alert", "alert"], @@ -12,6 +25,160 @@ ["/components/stack", "stack"], ]); +for (const component of componentCatalog) { + pages.set(`/components/${component.slug}`, component.slug); +} + +function createCatalogLink(component) { + const link = document.createElement("a"); + link.href = `/components/${component.slug}`; + link.dataset.catalogLink = ""; + link.dataset.zenElement = component.element; + link.textContent = component.name; + return link; +} + +function createComponentCard(component) { + const link = createCatalogLink(component); + const card = document.createElement("zen-card"); + card.setAttribute("interactive", ""); + const article = document.createElement("article"); + const heading = document.createElement("h3"); + heading.textContent = component.name; + const description = document.createElement("p"); + description.textContent = component.description; + const element = document.createElement("code"); + element.textContent = component.element; + article.append(heading, description, element); + card.append(article); + link.replaceChildren(card); + return link; +} + +function createCatalogPage(component) { + const section = document.createElement("section"); + section.className = "catalog-page"; + section.dataset.catalogPage = component.slug; + section.hidden = true; + + const header = document.createElement("header"); + header.className = "page-heading"; + const eyebrow = document.createElement("p"); + eyebrow.className = "eyebrow"; + eyebrow.textContent = component.group; + const heading = document.createElement("h1"); + heading.textContent = component.name; + const description = document.createElement("p"); + description.textContent = component.description; + header.append(eyebrow, heading, description); + + const story = document.createElement("zen-story"); + story.setAttribute("name", `${component.name} example`); + const template = document.createElement("template"); + template.innerHTML = component.markup; + story.append(template); + section.append(header, story); + return section; +} + +function installComponentCatalog() { + const navigation = document.querySelector( + '.catalog-nav[aria-label="Components"]', + ); + const componentGrid = document.querySelector(".component-grid"); + const main = document.querySelector("#catalogMain"); + if (!navigation || !componentGrid || !main) return; + + navigation.replaceChildren( + ...displayedComponents.map(createCatalogLink), + ); + componentGrid.replaceChildren( + ...displayedComponents.map(createComponentCard), + ); + + for (const component of componentCatalog) { + if (component.existing) continue; + main.append(createCatalogPage(component)); + } +} + +function installIconCatalog() { + const grid = document.querySelector("#iconGrid"); + if (!grid) return; + const items = ICON_NAMES.map(name => { + const figure = document.createElement("figure"); + const icon = document.createElement("zen-icon"); + icon.setAttribute("name", name); + icon.setAttribute("size", "2rem"); + const caption = document.createElement("figcaption"); + const code = document.createElement("code"); + code.textContent = name; + caption.append(code); + figure.append(icon, caption); + return figure; + }); + grid.replaceChildren(...items); +} + +function installComponentSearch() { + const input = document.querySelector("#componentSearch"); + const status = document.querySelector("#componentSearchStatus"); + if (!input || !status) return; + const links = [ + ...document.querySelectorAll( + '.catalog-nav[aria-label="Components"] a', + ), + ]; + const cards = [...document.querySelectorAll(".component-grid > a")]; + const descriptions = new Map( + displayedComponents.map(component => [ + component.slug, + [ + component.name, + component.element, + component.group, + component.description, + ].join(" ").toLocaleLowerCase(), + ]), + ); + const filter = () => { + const query = input.value.trim().toLocaleLowerCase(); + let matches = 0; + for (const link of links) { + const slug = new URL(link.href).pathname.split("/").pop(); + const visible = !query || descriptions.get(slug)?.includes(query); + link.hidden = !visible; + if (visible) matches++; + } + for (const card of cards) { + const slug = new URL(card.href).pathname.split("/").pop(); + card.hidden = Boolean(query) && + !descriptions.get(slug)?.includes(query); + } + status.textContent = query + ? `${matches} component${matches === 1 ? "" : "s"} found` + : `${links.length} components`; + }; + + input.addEventListener("input", filter); + input.addEventListener("keydown", event => { + if (event.key !== "Escape" || !input.value) return; + input.value = ""; + filter(); + }); + document.addEventListener("keydown", event => { + const target = event.target; + const typing = target instanceof HTMLElement && + target.matches("input, textarea, select, [contenteditable]"); + if (event.key !== "/" || typing || event.metaKey || event.ctrlKey || + event.altKey) return; + event.preventDefault(); + input.focus(); + input.select(); + }); + filter(); +} + function showPage(pathname) { const pageName = pages.get(pathname) || "overview"; for (const page of document.querySelectorAll("[data-catalog-page]")) { @@ -31,6 +198,9 @@ } window.addEventListener("DOMContentLoaded", () => { + installComponentCatalog(); + installIconCatalog(); + installComponentSearch(); const root = document.documentElement; const systemTheme = matchMedia("(prefers-color-scheme: dark)"); const storedTheme = localStorage.getItem("zen-theme");
--- a/design_system/src/styles/components.css Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/src/styles/components.css Tue Aug 04 15:12:09 2026 -0700 @@ -1,3 +1,5 @@ +@import url("./shadcn.css"); + :where( zen-button, zen-card, @@ -27,7 +29,7 @@ border-radius: var(--zen-radius-md); box-shadow: var(--zen-shadow-sm); background: var(--zen-color-accent); - color: #171a21; + color: var(--zen-color-on-accent); font: 700 var(--zen-font-size-md)/1 var(--zen-font-sans); text-decoration: none; cursor: pointer; @@ -40,7 +42,7 @@ zen-button > :where(button, a):hover { background: var(--zen-color-accent-hover); transform: translate(-1px, -1px); - box-shadow: 5px 5px 0 rgba(23, 26, 33, 0.18); + box-shadow: var(--zen-shadow-hover); } zen-button > :where(button, a):active { @@ -49,12 +51,15 @@ } zen-button > :where(button, a):focus-visible, -zen-alert [data-zen-dismiss]:focus-visible, -zen-field :where(input, select, textarea):focus-visible { +zen-alert [data-zen-dismiss]:focus-visible { outline: 3px solid var(--zen-color-focus); outline-offset: 3px; } +zen-field :where(input, select, textarea):focus-visible { + outline: none; +} + zen-button[variant="quiet"] > :where(button, a) { background: var(--zen-color-surface); color: var(--zen-color-text); @@ -108,7 +113,7 @@ zen-card[interactive] > :where(article, section):hover { transform: translate(-2px, -2px); - box-shadow: 10px 10px 0 rgba(23, 26, 33, 0.18); + box-shadow: var(--zen-shadow-lifted); } zen-card :where(h2, h3, p) {
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/styles/shadcn.css Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,1744 @@ +:where( + zen-accordion, + zen-alert-dialog, + zen-aspect-ratio, + zen-attachment, + zen-avatar, + zen-badge, + zen-breadcrumb, + zen-bubble, + zen-button-group, + zen-calendar, + zen-carousel, + zen-chart, + zen-checkbox, + zen-collapsible, + zen-combobox, + zen-command, + zen-context-menu, + zen-data-table, + zen-date-picker, + zen-dialog, + zen-direction, + zen-drawer, + zen-dropdown-menu, + zen-empty, + zen-form, + zen-hover-card, + zen-icon, + zen-input, + zen-input-group, + zen-input-otp, + zen-item, + zen-kbd, + zen-label, + zen-marker, + zen-menubar, + zen-message, + zen-message-scroller, + zen-native-select, + zen-navigation-menu, + zen-pagination, + zen-popover, + zen-progress, + zen-radio-group, + zen-resizable, + zen-scroll-area, + zen-select, + zen-separator, + zen-sheet, + zen-sidebar, + zen-skeleton, + zen-slider, + zen-spinner, + zen-switch, + zen-table, + zen-tabs, + zen-textarea, + zen-toggle, + zen-toggle-group, + zen-tooltip, + zen-typography +) { + box-sizing: border-box; + color: var(--zen-color-text); + font-family: var(--zen-font-sans); +} + +zen-icon { + display: inline-grid; + width: var(--zen-icon-size, 1em); + height: var(--zen-icon-size, 1em); + flex: 0 0 auto; + place-items: center; + color: inherit; + vertical-align: -0.125em; +} + +zen-icon > svg { + display: block; + width: 100%; + height: 100%; +} + +:where( + zen-alert-dialog, + zen-calendar, + zen-combobox, + zen-command, + zen-date-picker, + zen-dialog, + zen-drawer, + zen-form, + zen-input-otp, + zen-popover, + zen-progress, + zen-radio-group, + zen-resizable, + zen-select, + zen-sheet, + zen-sidebar, + zen-slider, + zen-tabs +) { + display: block; +} + +:where( + zen-accordion, + zen-collapsible +) { + display: block; +} + +:where( + zen-accordion, + zen-collapsible +) details { + interpolate-size: allow-keywords; + border-block-end: 1px solid color-mix( + in srgb, + var(--zen-color-border) 28%, + transparent + ); +} + +:where( + zen-accordion, + zen-collapsible +) summary { + display: flex; + align-items: center; + gap: var(--zen-space-3); + padding: var(--zen-space-3) 0; + font-weight: 800; + list-style: none; + cursor: pointer; +} + +:where( + zen-accordion, + zen-collapsible +) summary::marker, +:where( + zen-accordion, + zen-collapsible +) summary::-webkit-details-marker { + display: none; + content: ""; +} + +:where( + zen-accordion, + zen-collapsible +) summary > [data-zen-disclosure-icon] { + margin-inline-start: auto; + transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +:where( + zen-accordion, + zen-collapsible +) details[open] > summary > [data-zen-disclosure-icon] { + transform: rotate(180deg); +} + +:where( + zen-accordion, + zen-collapsible +) details::details-content { + block-size: 0; + overflow: clip; + opacity: 0; + transition: + block-size 220ms cubic-bezier(0.2, 0.8, 0.2, 1), + content-visibility 220ms allow-discrete, + opacity 180ms ease; +} + +:where( + zen-accordion, + zen-collapsible +) details[open]::details-content { + block-size: auto; + opacity: 1; +} + +:where( + zen-accordion, + zen-collapsible +) details > :not(summary) { + margin-block-start: 0; + padding-block-end: var(--zen-space-3); + color: var(--zen-color-text-muted); +} + +:where( + zen-alert-dialog, + zen-dialog, + zen-drawer, + zen-sheet +) > [data-zen-trigger], +:where( + zen-dropdown-menu, + zen-menubar, + zen-navigation-menu, + zen-popover +) [data-zen-trigger], +zen-empty > button, +zen-form button, +zen-input-group button { + min-height: 2.5rem; + padding: var(--zen-space-2) var(--zen-space-3); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-md); + box-shadow: var(--zen-shadow-sm); + background: var(--zen-color-accent); + color: var(--zen-color-on-accent); + font-weight: 800; + cursor: pointer; +} + +:where( + zen-alert-dialog, + zen-dialog, + zen-drawer, + zen-sheet +) dialog { + width: min(32rem, calc(100vi - 2rem)); + padding: var(--zen-space-5); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-lg); + box-shadow: var(--zen-shadow-md); + background: var(--zen-color-surface-raised); + color: var(--zen-color-text); +} + +:where( + zen-alert-dialog, + zen-dialog, + zen-drawer, + zen-sheet +) dialog[open], +:where( + zen-combobox, + zen-command, + zen-context-menu, + zen-dropdown-menu, + zen-hover-card, + zen-menubar, + zen-navigation-menu, + zen-popover, + zen-tooltip +) :where( + [data-zen-content], + [role="listbox"], + [role="menu"] +):not([hidden]) { + animation: zen-surface-open 160ms ease-out; +} + +:where( + zen-alert-dialog, + zen-dialog, + zen-drawer, + zen-sheet +) dialog::backdrop { + background: var(--zen-color-surface-overlay); + backdrop-filter: blur(3px); +} + +:where( + zen-alert-dialog, + zen-dialog, + zen-drawer, + zen-sheet +) dialog > h3 { + margin-block-start: 0; +} + +:where( + zen-alert-dialog, + zen-dialog, + zen-drawer, + zen-sheet +) dialog button { + min-height: 2.35rem; + margin-inline-end: var(--zen-space-2); + padding-inline: var(--zen-space-3); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-sm); + background: var(--zen-color-surface); + color: var(--zen-color-text); + font-weight: 700; + cursor: pointer; +} + +zen-alert-dialog dialog button[value="confirm"] { + background: var(--zen-color-danger); + color: var(--zen-color-on-danger); +} + +:where(zen-sheet, zen-drawer) dialog { + position: fixed; + max-width: none; + max-height: none; + margin: 0; +} + +:where(zen-sheet, zen-drawer)[placement="right"] dialog { + inset: 0 0 0 auto; + width: min(28rem, 92vi); + height: 100vb; + border-radius: var(--zen-radius-lg) 0 0 var(--zen-radius-lg); +} + +:where(zen-sheet, zen-drawer)[placement="left"] dialog { + inset: 0 auto 0 0; + width: min(28rem, 92vi); + height: 100vb; + border-radius: 0 var(--zen-radius-lg) var(--zen-radius-lg) 0; +} + +:where(zen-sheet, zen-drawer)[placement="bottom"] dialog, +zen-drawer:not([placement]) dialog { + inset: auto 0 0; + width: 100vi; + border-radius: var(--zen-radius-lg) var(--zen-radius-lg) 0 0; +} + +zen-aspect-ratio { + display: block; + aspect-ratio: var(--zen-aspect-ratio, 16 / 9); + overflow: hidden; + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-lg); + background: var(--zen-color-surface); +} + +zen-aspect-ratio > * { + width: 100%; + height: 100%; +} + +.demo-media { + display: grid; + place-items: center; + background: var(--zen-color-surface-muted); + color: var(--zen-color-text); + font-size: var(--zen-font-size-lg); + font-weight: 900; +} + +zen-attachment { + display: grid; + grid-template-columns: 3rem minmax(0, 1fr) auto; + align-items: center; + gap: var(--zen-space-3); + padding: var(--zen-space-3); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-md); + background: var(--zen-color-surface); +} + +zen-attachment > span { + display: grid; + width: 3rem; + height: 3rem; + place-items: center; + border-radius: var(--zen-radius-sm); + background: var(--zen-color-info); + color: var(--zen-color-on-info); + font-size: var(--zen-font-size-xs); + font-weight: 900; +} + +zen-attachment > div, +zen-item > div { + display: grid; +} + +:where(zen-attachment, zen-item) small { + color: var(--zen-color-text-muted); +} + +:where(zen-attachment, zen-item) > button { + border: 0; + background: transparent; + color: inherit; + cursor: pointer; +} + +zen-avatar { + display: inline-grid; + width: var(--zen-avatar-size, 2.75rem); + height: var(--zen-avatar-size, 2.75rem); + overflow: hidden; + place-items: center; + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: 50%; + background: var(--zen-color-accent); + color: var(--zen-color-on-accent); + font-weight: 900; +} + +zen-avatar > img { + width: 100%; + height: 100%; + object-fit: cover; +} + +zen-badge { + display: inline-flex; + align-items: center; + min-height: 1.75rem; + padding: 0 var(--zen-space-2); + border: 1px solid var(--zen-color-border); + border-radius: 999px; + background: var(--zen-color-surface); + font-size: var(--zen-font-size-xs); + font-weight: 850; +} + +zen-badge[tone="success"] { + background: var(--zen-color-success); + color: var(--zen-color-on-success); +} + +zen-badge[tone="danger"] { + background: var(--zen-color-danger); + color: var(--zen-color-on-danger); +} + +:where(zen-breadcrumb, zen-pagination) nav { + display: flex; + align-items: center; + gap: var(--zen-space-2); +} + +zen-breadcrumb ol { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--zen-space-2); + margin: 0; + padding: 0; + list-style: none; +} + +zen-breadcrumb li:not(:last-child)::after { + margin-inline-start: var(--zen-space-2); + color: var(--zen-color-text-muted); + content: "/"; +} + +:where(zen-breadcrumb, zen-pagination) a { + color: var(--zen-color-info); + text-underline-offset: 0.2em; +} + +zen-bubble { + display: block; + width: fit-content; + max-width: min(42rem, 88%); + padding: var(--zen-space-3) var(--zen-space-4); + border-radius: var(--zen-radius-lg) var(--zen-radius-lg) + var(--zen-radius-lg) var(--zen-radius-sm); + background: var(--zen-color-surface-raised); + box-shadow: var(--zen-shadow-sm); +} + +zen-bubble[align="end"] { + margin-inline-start: auto; + border-radius: var(--zen-radius-lg) var(--zen-radius-lg) + var(--zen-radius-sm) var(--zen-radius-lg); + background: var(--zen-color-accent); + color: var(--zen-color-on-accent); +} + +zen-bubble > :first-child { + margin-block-start: 0; +} + +zen-bubble > :last-child { + margin-block-end: 0; +} + +zen-button-group { + display: inline-flex; +} + +zen-button-group > :where(button, a) { + min-height: 2.5rem; + padding-inline: var(--zen-space-3); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: 0; + background: var(--zen-color-surface); + color: var(--zen-color-text); + font-weight: 750; + cursor: pointer; +} + +zen-button-group > :first-child { + border-radius: var(--zen-radius-md) 0 0 var(--zen-radius-md); +} + +zen-button-group > :last-child { + border-radius: 0 var(--zen-radius-md) var(--zen-radius-md) 0; +} + +zen-button-group > :not(:first-child) { + margin-inline-start: calc(-1 * var(--zen-border-width)); +} + +:where( + zen-calendar, + zen-date-picker, + zen-slider +) { + display: grid; + gap: var(--zen-space-2); + max-width: 22rem; +} + +:where( + zen-calendar, + zen-date-picker, + zen-input, + zen-input-group, + zen-input-otp, + zen-native-select, + zen-select, + zen-slider, + zen-textarea +) :where(input, select, textarea) { + appearance: none; + min-height: 2.75rem; + box-sizing: border-box; + padding: var(--zen-space-2) var(--zen-space-3); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-md); + background: var(--zen-color-surface); + color: var(--zen-color-text); + font: inherit; +} + +:where( + zen-input, + zen-input-group, + zen-input-otp, + zen-native-select, + zen-select, + zen-textarea, + zen-calendar, + zen-date-picker +) :where(input, select, textarea), +zen-field > :where(input, select, textarea) { + appearance: none; + border-radius: var(--zen-radius-md); + background: var(--zen-color-surface); + color: var(--zen-color-text); +} + +:where(zen-native-select, zen-select) { + position: relative; +} + +:where(zen-native-select, zen-select) select { + width: 100%; + padding-inline-end: 2.75rem; + cursor: pointer; +} + +:where(zen-native-select, zen-select) > [data-zen-select-icon] { + position: absolute; + inset-inline-end: var(--zen-space-3); + inset-block-end: 0.9rem; + pointer-events: none; +} + +:where( + zen-calendar, + zen-date-picker, + zen-input, + zen-native-select, + zen-select, + zen-textarea +) { + display: grid; + gap: var(--zen-space-2); +} + +zen-textarea textarea { + width: 100%; + min-height: 8rem; + resize: vertical; +} + +:where(zen-calendar, zen-date-picker) > input[data-zen-calendar-input] { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + pointer-events: none; +} + +:where(zen-calendar, zen-date-picker) [data-zen-generated="calendar"] { + display: grid; + gap: var(--zen-space-2); +} + +zen-date-picker [data-zen-generated="calendar"] { + position: relative; +} + +:where(zen-calendar, zen-date-picker) :where( + [data-zen-date-trigger], + [data-zen-calendar-previous], + [data-zen-calendar-next], + [data-zen-calendar-day] +) { + appearance: none; + border: 0; + background: transparent; + color: var(--zen-color-text); + font: inherit; + cursor: pointer; +} + +zen-date-picker [data-zen-date-trigger] { + display: inline-flex; + min-height: 2.75rem; + align-items: center; + gap: var(--zen-space-2); + justify-content: flex-start; + padding: var(--zen-space-2) var(--zen-space-3); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-md); + box-shadow: var(--zen-shadow-sm); + background: var(--zen-color-surface); +} + +zen-date-picker[data-invalid] [data-zen-date-trigger], +zen-calendar[data-invalid] [data-zen-calendar-view] { + border-color: var(--zen-color-danger); +} + +zen-date-picker [data-zen-calendar-panel] { + position: absolute; + z-index: 40; + inset-block-start: calc(100% + var(--zen-space-2)); + inset-inline-start: 0; + width: min(21rem, calc(100vi - 2rem)); + padding: var(--zen-space-3); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-lg); + box-shadow: var(--zen-shadow-md); + background: var(--zen-color-surface-raised); + transform-origin: top left; + animation: zen-calendar-open 160ms ease-out; +} + +zen-date-picker [data-zen-calendar-panel][hidden] { + display: none; +} + +:where(zen-calendar, zen-date-picker) [data-zen-calendar-view] { + display: grid; + gap: var(--zen-space-2); + padding: var(--zen-space-3); + border: 1px solid var(--zen-color-border-muted); + border-radius: var(--zen-radius-lg); + background: var(--zen-color-surface); +} + +zen-date-picker [data-zen-calendar-view] { + padding: 0; + border: 0; +} + +:where(zen-calendar, zen-date-picker) [data-zen-calendar-view] > header { + display: grid; + grid-template-columns: 2.5rem minmax(0, 1fr) 2.5rem; + align-items: center; + gap: var(--zen-space-2); + text-align: center; +} + +:where(zen-calendar, zen-date-picker) :where( + [data-zen-calendar-previous], + [data-zen-calendar-next] +) { + display: grid; + width: 2.5rem; + height: 2.5rem; + place-items: center; + border: 1px solid var(--zen-color-border-muted); + border-radius: 50%; +} + +:where(zen-calendar, zen-date-picker) :where( + [data-zen-calendar-previous], + [data-zen-calendar-next] +):hover { + background: var(--zen-color-accent-muted); +} + +:where(zen-calendar, zen-date-picker) [data-zen-calendar-weekdays], +:where(zen-calendar, zen-date-picker) [data-zen-calendar-grid] > [role="row"] { + display: grid; + grid-template-columns: repeat(7, minmax(2rem, 1fr)); + gap: var(--zen-space-1); +} + +:where(zen-calendar, zen-date-picker) [data-zen-calendar-weekdays] > span { + padding-block: var(--zen-space-1); + color: var(--zen-color-text-muted); + font-size: var(--zen-font-size-xs); + font-weight: 800; + text-align: center; +} + +:where(zen-calendar, zen-date-picker) [data-zen-calendar-grid] { + display: grid; + gap: var(--zen-space-1); +} + +:where(zen-calendar, zen-date-picker) [data-zen-calendar-day] { + aspect-ratio: 1; + min-width: 2rem; + border-radius: 50%; + font-size: var(--zen-font-size-sm); +} + +:where(zen-calendar, zen-date-picker) [data-zen-calendar-day]:hover { + background: var(--zen-color-accent-muted); +} + +:where(zen-calendar, zen-date-picker) [data-zen-calendar-day][data-outside] { + color: var(--zen-color-text-subtle); + opacity: 0.55; +} + +:where(zen-calendar, zen-date-picker) [data-zen-calendar-day][data-today] { + outline: 1px solid var(--zen-color-info); + outline-offset: -2px; +} + +:where(zen-calendar, zen-date-picker) [data-zen-calendar-day][aria-selected="true"] { + background: var(--zen-color-accent); + color: var(--zen-color-on-accent); + font-weight: 900; +} + +:where(zen-calendar, zen-date-picker) [data-zen-calendar-day]:disabled { + color: var(--zen-color-disabled); + cursor: not-allowed; +} + +@keyframes zen-calendar-open { + from { + opacity: 0; + transform: translateY(calc(-1 * var(--zen-space-1))) scale(0.98); + } + + @keyframes zen-surface-open { + from { + opacity: 0; + transform: translateY(calc(-1 * var(--zen-space-1))) scale(0.98); + } + } +} + +zen-input-group { + display: flex; + align-items: stretch; + width: min(100%, 36rem); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-md); + background: var(--zen-color-surface); +} + +zen-input-group > :where(span, button) { + display: inline-flex; + align-items: center; + padding-inline: var(--zen-space-3); +} + +zen-input-group input { + min-width: 0; + flex: 1; + border: 0; + border-radius: 0; +} + +zen-input-group button { + min-height: auto; + border-width: 0 0 0 var(--zen-border-width); + border-radius: 0 var(--zen-radius-sm) var(--zen-radius-sm) 0; + box-shadow: none; +} + +zen-carousel { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: var(--zen-space-2); +} + +zen-carousel [data-zen-viewport] { + display: flex; + grid-column: 1 / -1; + gap: var(--zen-space-3); + overflow: auto; + scroll-snap-type: inline mandatory; + scrollbar-width: none; +} + +zen-carousel [data-zen-viewport] > * { + min-width: min(22rem, 82vi); + min-height: 9rem; + padding: var(--zen-space-5); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-lg); + background: var(--zen-color-surface-raised); + scroll-snap-align: start; +} + +zen-carousel :where([data-zen-prev], [data-zen-next]) { + width: 2.5rem; + height: 2.5rem; + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: 50%; + background: var(--zen-color-surface); + color: var(--zen-color-text); + cursor: pointer; +} + +zen-carousel [data-zen-prev] { + grid-column: 1; +} + +zen-carousel [data-zen-next] { + grid-column: 3; +} + +zen-chart { + display: block; + min-height: 15rem; + padding: var(--zen-space-4); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-lg); + background: var(--zen-color-surface); +} + +zen-chart [data-zen-chart-plot] { + display: flex; + height: 12rem; + align-items: end; + justify-content: space-around; + gap: var(--zen-space-3); + border-block-end: 1px solid var(--zen-color-border); +} + +zen-chart [data-zen-chart-plot] > span { + position: relative; + width: min(3rem, 15%); + height: var(--zen-chart-value); + min-height: 1rem; + border-radius: var(--zen-radius-sm) var(--zen-radius-sm) 0 0; + background: var(--zen-color-data-1); +} + +zen-chart [data-zen-chart-plot] > span:nth-child(2) { + background: var(--zen-color-data-2); +} + +zen-chart [data-zen-chart-plot] > span:nth-child(3) { + background: var(--zen-color-data-3); +} + +zen-chart [data-zen-chart-plot] > span:nth-child(4) { + background: var(--zen-color-data-4); +} + +zen-chart [data-zen-chart-plot] > span:nth-child(5) { + background: var(--zen-color-data-5); +} + +zen-chart [data-zen-chart-plot] > span::after { + position: absolute; + inset-block-start: 100%; + inset-inline-start: 50%; + padding-block-start: var(--zen-space-1); + color: var(--zen-color-text-muted); + content: attr(data-label); + font-size: var(--zen-font-size-xs); + transform: translateX(-50%); +} + +:where(zen-checkbox, zen-switch) label, +zen-radio-group label { + display: flex; + align-items: center; + gap: var(--zen-space-2); + width: fit-content; + cursor: pointer; +} + +:where(zen-checkbox, zen-radio-group) input { + appearance: none; + display: inline-grid; + width: 1.2rem; + height: 1.2rem; + flex: 0 0 auto; + place-content: center; + border: var(--zen-border-width) solid var(--zen-color-border); + background: var(--zen-color-surface); + cursor: pointer; +} + +zen-checkbox input { + border-radius: var(--zen-radius-sm); +} + +zen-checkbox input::before { + width: 0.55rem; + height: 0.3rem; + border-block-end: 2px solid var(--zen-color-on-accent); + border-inline-start: 2px solid var(--zen-color-on-accent); + content: ""; + opacity: 0; + transform: translateY(-0.08rem) rotate(-45deg); +} + +zen-checkbox input:checked { + background: var(--zen-color-accent); +} + +zen-checkbox input:checked::before { + opacity: 1; +} + +zen-radio-group input { + border-radius: 50%; +} + +zen-radio-group input::before { + width: 0.55rem; + height: 0.55rem; + border-radius: 50%; + background: var(--zen-color-info); + content: ""; + opacity: 0; +} + +zen-radio-group input:checked::before { + opacity: 1; +} + +zen-radio-group fieldset { + display: grid; + gap: var(--zen-space-2); + padding: var(--zen-space-4); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-md); +} + +zen-switch input { + appearance: none; + width: 2.75rem; + height: 1.5rem; + margin: 0; + padding: 2px; + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: 999px; + background: var(--zen-color-surface); + cursor: pointer; + transition: background var(--zen-motion-fast) ease; +} + +zen-switch input::before { + display: block; + width: 1rem; + height: 1rem; + border-radius: 50%; + background: var(--zen-color-border); + content: ""; + transition: transform var(--zen-motion-fast) ease; +} + +zen-switch input:checked { + background: var(--zen-color-success); +} + +zen-switch input:checked::before { + background: var(--zen-color-on-success); + transform: translateX(1.2rem); +} + +:where(zen-combobox, zen-command) { + position: relative; + display: grid; + width: min(100%, 28rem); + gap: var(--zen-space-2); +} + +:where(zen-combobox, zen-command) > input { + min-height: 2.75rem; + padding: var(--zen-space-2) var(--zen-space-3); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-md); + background: var(--zen-color-surface); + color: var(--zen-color-text); +} + +:where( + zen-combobox, + zen-command, + zen-context-menu, + zen-dropdown-menu, + zen-menubar, + zen-navigation-menu +) :where([role="listbox"], [role="menu"]) { + z-index: 20; + display: grid; + min-width: 12rem; + padding: var(--zen-space-1); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-md); + box-shadow: var(--zen-shadow-md); + background: var(--zen-color-surface-raised); +} + +:where( + zen-combobox, + zen-command, + zen-context-menu, + zen-dropdown-menu, + zen-menubar, + zen-navigation-menu +) :where([role="listbox"], [role="menu"])[hidden] { + display: none; +} + +:where( + zen-combobox, + zen-command, + zen-context-menu, + zen-dropdown-menu, + zen-menubar, + zen-navigation-menu +) :where([role="option"], [role="menuitem"]) { + display: block; + width: 100%; + padding: var(--zen-space-2) var(--zen-space-3); + border: 0; + border-radius: var(--zen-radius-sm); + background: transparent; + color: var(--zen-color-text); + text-align: start; + text-decoration: none; + cursor: pointer; +} + +:where( + zen-combobox, + zen-command, + zen-context-menu, + zen-dropdown-menu, + zen-menubar, + zen-navigation-menu +) :where([role="option"], [role="menuitem"]):is( + :hover, + :focus, + [aria-selected="true"] +) { + outline: none; + background: var(--zen-color-accent); + color: var(--zen-color-on-accent); +} + +:where( + zen-context-menu, + zen-dropdown-menu, + zen-navigation-menu +) { + position: relative; + display: inline-block; +} + +zen-context-menu > [data-zen-trigger] { + min-height: 8rem; + padding: var(--zen-space-5); + border: 2px dashed var(--zen-color-border); + border-radius: var(--zen-radius-lg); + background: var(--zen-color-surface); +} + +:where( + zen-dropdown-menu, + zen-navigation-menu +) > [role="menu"], +zen-navigation-menu nav > [role="menu"] { + position: absolute; + inset-block-start: calc(100% + var(--zen-space-2)); + inset-inline-start: 0; +} + +zen-context-menu > [role="menu"] { + position: fixed; +} + +zen-menubar { + position: relative; + display: flex; + width: fit-content; + padding: var(--zen-space-1); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-md); + background: var(--zen-color-surface); +} + +zen-menubar > [data-zen-trigger] { + border: 0; + box-shadow: none; + background: transparent; + color: var(--zen-color-text); +} + +zen-menubar > [role="menu"] { + position: absolute; + inset-block-start: calc(100% + var(--zen-space-1)); +} + +zen-data-table, +zen-table { + display: block; + max-width: 100%; + overflow: auto; +} + +:where(zen-data-table, zen-table) table { + width: 100%; + border-collapse: collapse; + background: var(--zen-color-surface); +} + +:where(zen-data-table, zen-table) :where(th, td) { + padding: var(--zen-space-3); + border-block-end: 1px solid color-mix( + in srgb, + var(--zen-color-border) 30%, + transparent + ); + text-align: start; +} + +:where(zen-data-table, zen-table) th { + font-size: var(--zen-font-size-sm); +} + +zen-data-table [data-zen-sort] { + padding: 0; + border: 0; + background: transparent; + color: inherit; + font-weight: 850; + cursor: pointer; +} + +zen-direction { + display: contents; +} + +zen-empty { + display: grid; + justify-items: center; + gap: var(--zen-space-2); + padding: var(--zen-space-7) var(--zen-space-5); + border: var(--zen-border-width) dashed var(--zen-color-border); + border-radius: var(--zen-radius-lg); + background: var(--zen-color-surface); + text-align: center; +} + +zen-empty > p { + max-width: 34rem; + margin: 0; + color: var(--zen-color-text-muted); +} + +zen-form form { + display: grid; + max-width: 34rem; + gap: var(--zen-space-4); +} + +zen-form[data-invalid] form { + padding-inline-start: var(--zen-space-3); + border-inline-start: 0.35rem solid var(--zen-color-danger); +} + +:where(zen-hover-card, zen-tooltip, zen-popover) { + position: relative; + display: inline-block; +} + +:where(zen-hover-card, zen-tooltip, zen-popover) [data-zen-content] { + position: absolute; + z-index: 30; + inset-block-start: calc(100% + var(--zen-space-2)); + inset-inline-start: 0; + width: max-content; + max-width: min(22rem, calc(100vi - 2rem)); + padding: var(--zen-space-3); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-md); + box-shadow: var(--zen-shadow-md); + background: var(--zen-color-surface-raised); + color: var(--zen-color-text); +} + +:where(zen-hover-card, zen-tooltip, zen-popover) [data-zen-content][hidden] { + display: none; +} + +zen-tooltip [data-zen-content] { + padding: var(--zen-space-1) var(--zen-space-2); + background: var(--zen-color-border); + color: var(--zen-color-surface); + font-size: var(--zen-font-size-xs); +} + +zen-input-otp [data-zen-otp-slots] { + display: flex; + gap: var(--zen-space-1); +} + +zen-input-otp [data-zen-otp-slots] > span { + display: grid; + width: 2.5rem; + height: 2.75rem; + place-items: center; + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-sm); + background: var(--zen-color-surface); + font-weight: 850; +} + +zen-item { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: var(--zen-space-3); + padding: var(--zen-space-3); + border-block-end: 1px solid color-mix( + in srgb, + var(--zen-color-border) 28%, + transparent + ); +} + +zen-kbd { + display: inline-flex; + align-items: center; + gap: var(--zen-space-1); +} + +zen-kbd kbd { + min-width: 1.75rem; + padding: 0.1rem var(--zen-space-1); + border: 1px solid var(--zen-color-border); + border-block-end-width: 3px; + border-radius: var(--zen-radius-sm); + background: var(--zen-color-surface); + font: 700 var(--zen-font-size-xs)/1.5 var(--zen-font-mono); + text-align: center; +} + +zen-label { + display: block; + margin-block-end: var(--zen-space-2); + font-weight: 800; +} + +zen-marker { + display: flex; + align-items: center; + gap: var(--zen-space-2); + color: var(--zen-color-text-muted); + font-size: var(--zen-font-size-xs); + text-transform: uppercase; +} + +zen-marker::before, +zen-marker::after { + height: 1px; + flex: 1; + background: color-mix( + in srgb, + var(--zen-color-border) 32%, + transparent + ); + content: ""; +} + +zen-message { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: var(--zen-space-3); +} + +zen-message > div { + display: grid; + gap: var(--zen-space-1); +} + +zen-message-scroller { + position: relative; + display: block; +} + +zen-message-scroller [data-zen-viewport] { + max-height: 12rem; + overflow: auto; + padding: var(--zen-space-3); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-md); + background: var(--zen-color-surface); +} + +zen-message-scroller [data-zen-scroll-end] { + position: absolute; + inset-inline-end: var(--zen-space-3); + inset-block-end: var(--zen-space-3); +} + +zen-navigation-menu nav { + display: flex; + align-items: center; + gap: var(--zen-space-3); +} + +zen-pagination nav { + flex-wrap: wrap; +} + +zen-pagination a { + display: inline-grid; + min-width: 2.35rem; + min-height: 2.35rem; + place-items: center; + padding-inline: var(--zen-space-2); + border: 1px solid transparent; + border-radius: var(--zen-radius-sm); + text-decoration: none; +} + +zen-pagination a[aria-current] { + border-color: var(--zen-color-border); + background: var(--zen-color-accent); + color: var(--zen-color-on-accent); +} + +zen-progress { + display: grid; + gap: var(--zen-space-2); +} + +zen-progress progress { + appearance: none; + width: 100%; + height: 0.75rem; + overflow: hidden; + border: 0; + border-radius: 999px; + accent-color: var(--zen-color-success); + background: var(--zen-color-surface); +} + +zen-progress progress::-webkit-progress-bar { + background: var(--zen-color-surface-muted); +} + +zen-progress progress::-webkit-progress-value { + background: var(--zen-color-success); +} + +zen-progress progress::-moz-progress-bar { + background: var(--zen-color-success); +} + +zen-resizable { + display: flex; + min-height: 12rem; + overflow: hidden; + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-lg); +} + +zen-resizable [data-zen-panel] { + min-width: 0; + flex: 1 1 50%; + padding: var(--zen-space-4); + background: var(--zen-color-surface); +} + +zen-resizable [data-zen-handle] { + width: 0.65rem; + flex: 0 0 0.65rem; + border-inline: 1px solid var(--zen-color-border); + background: var(--zen-color-accent); + cursor: col-resize; +} + +zen-scroll-area { + display: block; + max-height: 10rem; + overflow: auto; + padding: var(--zen-space-3); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-md); + background: var(--zen-color-surface); + scrollbar-color: var(--zen-color-accent) transparent; +} + +zen-separator { + display: block; +} + +zen-separator hr { + margin-block: var(--zen-space-4); + border: 0; + border-block-start: 1px solid var(--zen-color-border); +} + +zen-separator[orientation="vertical"] { + display: inline-block; + width: 1px; + min-height: 1.5rem; + background: var(--zen-color-border); +} + +zen-sidebar { + display: grid; + grid-template-columns: minmax(12rem, 17rem) minmax(0, 1fr); + gap: var(--zen-space-3); +} + +zen-sidebar > [data-zen-sidebar-trigger] { + grid-column: 1 / -1; + justify-self: start; +} + +zen-sidebar [data-zen-sidebar-panel] { + padding: var(--zen-space-3); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-lg); + background: var(--zen-color-surface); +} + +zen-sidebar [data-zen-sidebar-panel] nav { + display: grid; + gap: var(--zen-space-1); +} + +zen-sidebar [data-zen-sidebar-panel] a { + padding: var(--zen-space-2); + border-radius: var(--zen-radius-sm); + color: inherit; + text-decoration: none; +} + +zen-sidebar [data-zen-sidebar-panel] a:hover { + background: var(--zen-color-accent); + color: var(--zen-color-on-accent); +} + +zen-skeleton { + display: block; + width: var(--zen-skeleton-width, 100%); + height: 1rem; + border-radius: var(--zen-radius-sm); + background: + linear-gradient( + 100deg, + color-mix(in srgb, var(--zen-color-text-muted) 18%, transparent) 20%, + color-mix(in srgb, var(--zen-color-text-muted) 8%, transparent) 38%, + color-mix(in srgb, var(--zen-color-text-muted) 18%, transparent) 56% + ); + background-size: 200% 100%; + animation: zen-skeleton-shimmer 1.4s linear infinite; +} + +zen-skeleton[shape="circle"] { + width: 3rem; + height: 3rem; + border-radius: 50%; +} + +zen-slider input { + appearance: none; + width: 100%; + height: 0.5rem; + padding-inline: 0; + border: 0; + border-radius: 999px; + background: var(--zen-color-surface-sunken); + cursor: pointer; +} + +zen-slider input::-webkit-slider-thumb { + appearance: none; + width: 1.35rem; + height: 1.35rem; + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: 50%; + box-shadow: var(--zen-shadow-sm); + background: var(--zen-color-accent); +} + +zen-slider input::-moz-range-thumb { + width: 1.35rem; + height: 1.35rem; + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: 50%; + box-shadow: var(--zen-shadow-sm); + background: var(--zen-color-accent); +} + +zen-spinner { + display: inline-block; + width: 1.35rem; + height: 1.35rem; + border: 3px solid color-mix( + in srgb, + var(--zen-color-text-muted) 28%, + transparent + ); + border-inline-end-color: var(--zen-color-info); + border-radius: 50%; + animation: zen-shadcn-spin 650ms linear infinite; +} + +zen-tabs [role="tablist"] { + display: flex; + width: fit-content; + gap: var(--zen-space-1); + padding: var(--zen-space-1); + border: 1px solid var(--zen-color-border); + border-radius: var(--zen-radius-md); + background: var(--zen-color-surface); +} + +zen-tabs [role="tab"] { + min-height: 2.25rem; + padding-inline: var(--zen-space-3); + border: 0; + border-radius: var(--zen-radius-sm); + background: transparent; + color: var(--zen-color-text); + font-weight: 750; + cursor: pointer; +} + +zen-tabs [role="tab"][aria-selected="true"] { + background: var(--zen-color-accent); + color: var(--zen-color-on-accent); +} + +zen-tabs [role="tabpanel"] { + padding-block: var(--zen-space-4); +} + +zen-tabs [role="tabpanel"][hidden] { + display: none; +} + +:where(zen-toggle, zen-toggle-group) { + display: inline-flex; +} + +:where(zen-toggle, zen-toggle-group) > button { + min-height: 2.5rem; + padding-inline: var(--zen-space-3); + border: 1px solid transparent; + border-radius: var(--zen-radius-sm); + background: transparent; + color: var(--zen-color-text); + font-weight: 750; + cursor: pointer; +} + +:where(zen-toggle, zen-toggle-group) > button[aria-pressed="true"] { + border-color: var(--zen-color-border); + background: var(--zen-color-accent); + color: var(--zen-color-on-accent); +} + +zen-typography { + display: block; + max-width: 65ch; + line-height: var(--zen-line-height); +} + +zen-typography > :where(h1, h2, h3) { + line-height: 1.15; + text-wrap: balance; +} + +zen-typography blockquote { + margin-inline: 0; + padding-inline-start: var(--zen-space-4); + border-inline-start: 0.35rem solid var(--zen-color-accent); + color: var(--zen-color-text-muted); + font-style: italic; +} + +:where( + button, + summary, + [tabindex] +):focus-visible { + outline: 3px solid var(--zen-color-focus); + outline-offset: 3px; +} + +:where( + zen-calendar, + zen-checkbox, + zen-combobox, + zen-command, + zen-date-picker, + zen-field, + zen-form, + zen-input, + zen-input-group, + zen-input-otp, + zen-native-select, + zen-radio-group, + zen-select, + zen-slider, + zen-switch, + zen-textarea +) :where(input, select, textarea):focus-visible { + outline: none; +} + +@keyframes zen-shadcn-spin { + to { transform: rotate(1turn); } +} + +@keyframes zen-skeleton-shimmer { + to { background-position-x: -200%; } +} + +@media (max-width: 640px) { + zen-sidebar { + display: block; + } + + zen-sidebar > [data-zen-sidebar-trigger] { + margin-block-end: var(--zen-space-2); + } + + :where(zen-calendar, zen-date-picker) { + width: 100%; + max-width: 100%; + } + + zen-date-picker [data-zen-calendar-panel] { + position: fixed; + inset: auto var(--zen-space-3) var(--zen-space-3); + width: auto; + max-height: calc(100vb - 2 * var(--zen-space-3)); + overflow: auto; + transform-origin: bottom center; + } +} + +@media (forced-colors: active) { + :where( + zen-aspect-ratio, + zen-attachment, + zen-avatar, + zen-badge, + zen-empty, + zen-resizable, + zen-scroll-area + ) { + border: 1px solid CanvasText; + } +} + +@media (prefers-reduced-motion: reduce) { + zen-skeleton, + zen-spinner { + animation: none; + } + + zen-switch input, + zen-switch input::before, + :where( + zen-accordion, + zen-collapsible + ) details::details-content, + :where( + zen-accordion, + zen-collapsible + ) summary > [data-zen-disclosure-icon] { + transition: none; + } + + zen-date-picker [data-zen-calendar-panel] { + animation: none; + } + + :where( + zen-alert-dialog, + zen-dialog, + zen-drawer, + zen-sheet + ) dialog[open], + :where( + zen-combobox, + zen-command, + zen-context-menu, + zen-dropdown-menu, + zen-hover-card, + zen-menubar, + zen-navigation-menu, + zen-popover, + zen-tooltip + ) :where( + [data-zen-content], + [role="listbox"], + [role="menu"] + ):not([hidden]) { + animation: none; + } +}
--- a/design_system/src/styles/tokens.css Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/src/styles/tokens.css Tue Aug 04 15:12:09 2026 -0700 @@ -1,6 +1,143 @@ :root { color-scheme: light; + /* Warm primitive ramps: restrained enough for large surfaces, distinct + enough for data and status work. */ + --zen-color-neutral-50: #fffdf7; + --zen-color-neutral-100: #f7f3e8; + --zen-color-neutral-200: #ece5d5; + --zen-color-neutral-300: #ddd2bd; + --zen-color-neutral-400: #b9aa90; + --zen-color-neutral-500: #8e8069; + --zen-color-neutral-600: #6f6351; + --zen-color-neutral-700: #564c3e; + --zen-color-neutral-800: #3b352d; + --zen-color-neutral-900: #27241f; + --zen-color-neutral-950: #171613; + + --zen-color-red-50: #fff8f5; + --zen-color-red-100: #f9e9e2; + --zen-color-red-200: #efd1c5; + --zen-color-red-300: #dda995; + --zen-color-red-400: #c77c63; + --zen-color-red-500: #a9553f; + --zen-color-red-600: #8f4132; + --zen-color-red-700: #71342a; + --zen-color-red-800: #5b2e27; + --zen-color-red-900: #4b2924; + --zen-color-red-950: #291411; + + --zen-color-orange-50: #fff8ed; + --zen-color-orange-100: #fcebd1; + --zen-color-orange-200: #f7d4a1; + --zen-color-orange-300: #efb670; + --zen-color-orange-400: #de8d3f; + --zen-color-orange-500: #c76a26; + --zen-color-orange-600: #a94f1c; + --zen-color-orange-700: #873d1b; + --zen-color-orange-800: #6d331c; + --zen-color-orange-900: #5b2d1d; + --zen-color-orange-950: #32160c; + + --zen-color-amber-50: #fffbea; + --zen-color-amber-100: #f6efc6; + --zen-color-amber-200: #ebde8b; + --zen-color-amber-300: #d9c455; + --zen-color-amber-400: #bfa332; + --zen-color-amber-500: #9e8124; + --zen-color-amber-600: #80651e; + --zen-color-amber-700: #664e1d; + --zen-color-amber-800: #55411e; + --zen-color-amber-900: #49391e; + --zen-color-amber-950: #291e0c; + + --zen-color-green-50: #f6f8ed; + --zen-color-green-100: #e9edd5; + --zen-color-green-200: #d0d9aa; + --zen-color-green-300: #afbf79; + --zen-color-green-400: #8da053; + --zen-color-green-500: #6d8139; + --zen-color-green-600: #55672d; + --zen-color-green-700: #424f27; + --zen-color-green-800: #374124; + --zen-color-green-900: #303923; + --zen-color-green-950: #181f10; + + --zen-color-teal-50: #eff9f5; + --zen-color-teal-100: #dcefe8; + --zen-color-teal-200: #b9ded1; + --zen-color-teal-300: #8cc6b4; + --zen-color-teal-400: #5fa793; + --zen-color-teal-500: #438878; + --zen-color-teal-600: #356d61; + --zen-color-teal-700: #2d574f; + --zen-color-teal-800: #28463f; + --zen-color-teal-900: #243b36; + --zen-color-teal-950: #10221f; + + --zen-color-blue-50: #f3f6fa; + --zen-color-blue-100: #e4eaf2; + --zen-color-blue-200: #cbd7e6; + --zen-color-blue-300: #a6bad2; + --zen-color-blue-400: #7b99b8; + --zen-color-blue-500: #5c7d9d; + --zen-color-blue-600: #48647f; + --zen-color-blue-700: #3c5167; + --zen-color-blue-800: #354655; + --zen-color-blue-900: #303c49; + --zen-color-blue-950: #1c252e; + + --zen-color-violet-50: #f8f5f8; + --zen-color-violet-100: #eee7ee; + --zen-color-violet-200: #dfd1df; + --zen-color-violet-300: #c9b1c8; + --zen-color-violet-400: #ad8dad; + --zen-color-violet-500: #916f91; + --zen-color-violet-600: #755774; + --zen-color-violet-700: #5e465d; + --zen-color-violet-800: #4e3b4d; + --zen-color-violet-900: #423541; + --zen-color-violet-950: #281d27; + + --zen-color-rose-50: #fff6f6; + --zen-color-rose-100: #f8e7e7; + --zen-color-rose-200: #efcfd0; + --zen-color-rose-300: #dfa9ad; + --zen-color-rose-400: #ca7d84; + --zen-color-rose-500: #ae5b65; + --zen-color-rose-600: #924550; + --zen-color-rose-700: #763943; + --zen-color-rose-800: #63333b; + --zen-color-rose-900: #552f36; + --zen-color-rose-950: #30171c; + + --zen-color-brown-50: #faf7f2; + --zen-color-brown-100: #efe8dd; + --zen-color-brown-200: #ddcebb; + --zen-color-brown-300: #c5ac91; + --zen-color-brown-400: #a8896c; + --zen-color-brown-500: #8d6d52; + --zen-color-brown-600: #725641; + --zen-color-brown-700: #5b4436; + --zen-color-brown-800: #4d3a31; + --zen-color-brown-900: #42332c; + --zen-color-brown-950: #251b17; + + /* Material names are stable aliases for the palette's visual character. */ + --zen-color-paper: var(--zen-color-neutral-50); + --zen-color-washi: var(--zen-color-neutral-100); + --zen-color-linen: var(--zen-color-neutral-200); + --zen-color-sumi: var(--zen-color-neutral-950); + --zen-color-brick: var(--zen-color-red-600); + --zen-color-persimmon: var(--zen-color-orange-500); + --zen-color-ochre: var(--zen-color-amber-500); + --zen-color-moss: var(--zen-color-green-600); + --zen-color-patina: var(--zen-color-teal-600); + --zen-color-indigo: var(--zen-color-blue-700); + --zen-color-plum: var(--zen-color-violet-700); + --zen-color-clay: var(--zen-color-rose-500); + --zen-color-wood: var(--zen-color-brown-600); + --zen-color-canvas: #f7f3e8; --zen-color-surface: #fffdf6; --zen-color-surface-raised: #ffffff; @@ -9,6 +146,7 @@ --zen-color-border: #272b35; --zen-color-accent: #ffb000; --zen-color-accent-hover: #ffca4a; + --zen-color-on-accent: #171a21; --zen-color-info: #2764d7; --zen-color-on-info: #ffffff; --zen-color-success: #18794e; @@ -18,6 +156,34 @@ --zen-color-danger: #c62f3e; --zen-color-on-danger: #ffffff; --zen-color-focus: #7c3aed; + --zen-color-surface-muted: var(--zen-color-neutral-100); + --zen-color-surface-sunken: var(--zen-color-neutral-200); + --zen-color-surface-overlay: rgba(23, 22, 19, 0.56); + --zen-color-text-subtle: var(--zen-color-neutral-600); + --zen-color-text-inverse: var(--zen-color-neutral-50); + --zen-color-border-muted: var(--zen-color-neutral-300); + --zen-color-border-strong: var(--zen-color-neutral-800); + --zen-color-accent-muted: var(--zen-color-amber-100); + --zen-color-info-muted: var(--zen-color-blue-100); + --zen-color-success-muted: var(--zen-color-green-100); + --zen-color-warning-muted: var(--zen-color-orange-100); + --zen-color-danger-muted: var(--zen-color-red-100); + --zen-color-disabled: var(--zen-color-neutral-300); + --zen-color-selection: var(--zen-color-amber-200); + --zen-color-code-surface: #111318; + --zen-color-code-text: #f4f4f5; + --zen-color-gridline: rgba(127, 127, 127, 0.08); + + --zen-color-data-1: var(--zen-color-indigo); + --zen-color-data-2: var(--zen-color-persimmon); + --zen-color-data-3: var(--zen-color-moss); + --zen-color-data-4: var(--zen-color-plum); + --zen-color-data-5: var(--zen-color-ochre); + --zen-color-data-6: var(--zen-color-patina); + --zen-color-data-7: var(--zen-color-brick); + --zen-color-data-8: var(--zen-color-wood); + --zen-color-data-9: var(--zen-color-clay); + --zen-color-data-10: var(--zen-color-blue-400); --zen-font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --zen-font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; @@ -42,6 +208,8 @@ --zen-border-width: 2px; --zen-shadow-sm: 3px 3px 0 rgba(23, 26, 33, 0.16); --zen-shadow-md: 7px 7px 0 rgba(23, 26, 33, 0.16); + --zen-shadow-hover: 5px 5px 0 rgba(23, 26, 33, 0.18); + --zen-shadow-lifted: 10px 10px 0 rgba(23, 26, 33, 0.18); --zen-motion-fast: 120ms; --zen-content-width: 76rem; } @@ -56,6 +224,7 @@ --zen-color-border: #f5f1e8; --zen-color-accent: #ffc247; --zen-color-accent-hover: #ffd77d; + --zen-color-on-accent: #171a21; --zen-color-info: #8ab4ff; --zen-color-on-info: #171a21; --zen-color-success: #65d6a3; @@ -65,8 +234,37 @@ --zen-color-danger: #ff8792; --zen-color-on-danger: #171a21; --zen-color-focus: #c4a5ff; + --zen-color-surface-muted: var(--zen-color-neutral-800); + --zen-color-surface-sunken: var(--zen-color-neutral-900); + --zen-color-surface-overlay: rgba(0, 0, 0, 0.68); + --zen-color-text-subtle: var(--zen-color-neutral-400); + --zen-color-text-inverse: var(--zen-color-neutral-950); + --zen-color-border-muted: var(--zen-color-neutral-700); + --zen-color-border-strong: var(--zen-color-neutral-200); + --zen-color-accent-muted: var(--zen-color-amber-800); + --zen-color-info-muted: var(--zen-color-blue-800); + --zen-color-success-muted: var(--zen-color-green-800); + --zen-color-warning-muted: var(--zen-color-orange-800); + --zen-color-danger-muted: var(--zen-color-red-800); + --zen-color-disabled: var(--zen-color-neutral-700); + --zen-color-selection: var(--zen-color-amber-800); + --zen-color-code-surface: #0b0d12; + --zen-color-code-text: #f4f4f5; + --zen-color-gridline: rgba(255, 255, 255, 0.06); + --zen-color-data-1: var(--zen-color-blue-300); + --zen-color-data-2: var(--zen-color-orange-300); + --zen-color-data-3: var(--zen-color-green-300); + --zen-color-data-4: var(--zen-color-violet-300); + --zen-color-data-5: var(--zen-color-amber-300); + --zen-color-data-6: var(--zen-color-teal-300); + --zen-color-data-7: var(--zen-color-red-300); + --zen-color-data-8: var(--zen-color-brown-300); + --zen-color-data-9: var(--zen-color-rose-300); + --zen-color-data-10: var(--zen-color-blue-200); --zen-shadow-sm: 3px 3px 0 rgba(0, 0, 0, 0.5); --zen-shadow-md: 7px 7px 0 rgba(0, 0, 0, 0.5); + --zen-shadow-hover: 5px 5px 0 rgba(0, 0, 0, 0.55); + --zen-shadow-lifted: 10px 10px 0 rgba(0, 0, 0, 0.55); } @media (prefers-color-scheme: dark) { @@ -80,6 +278,7 @@ --zen-color-border: #f5f1e8; --zen-color-accent: #ffc247; --zen-color-accent-hover: #ffd77d; + --zen-color-on-accent: #171a21; --zen-color-info: #8ab4ff; --zen-color-on-info: #171a21; --zen-color-success: #65d6a3; @@ -89,5 +288,36 @@ --zen-color-danger: #ff8792; --zen-color-on-danger: #171a21; --zen-color-focus: #c4a5ff; + --zen-color-surface-muted: var(--zen-color-neutral-800); + --zen-color-surface-sunken: var(--zen-color-neutral-900); + --zen-color-surface-overlay: rgba(0, 0, 0, 0.68); + --zen-color-text-subtle: var(--zen-color-neutral-400); + --zen-color-text-inverse: var(--zen-color-neutral-950); + --zen-color-border-muted: var(--zen-color-neutral-700); + --zen-color-border-strong: var(--zen-color-neutral-200); + --zen-color-accent-muted: var(--zen-color-amber-800); + --zen-color-info-muted: var(--zen-color-blue-800); + --zen-color-success-muted: var(--zen-color-green-800); + --zen-color-warning-muted: var(--zen-color-orange-800); + --zen-color-danger-muted: var(--zen-color-red-800); + --zen-color-disabled: var(--zen-color-neutral-700); + --zen-color-selection: var(--zen-color-amber-800); + --zen-color-code-surface: #0b0d12; + --zen-color-code-text: #f4f4f5; + --zen-color-gridline: rgba(255, 255, 255, 0.06); + --zen-color-data-1: var(--zen-color-blue-300); + --zen-color-data-2: var(--zen-color-orange-300); + --zen-color-data-3: var(--zen-color-green-300); + --zen-color-data-4: var(--zen-color-violet-300); + --zen-color-data-5: var(--zen-color-amber-300); + --zen-color-data-6: var(--zen-color-teal-300); + --zen-color-data-7: var(--zen-color-red-300); + --zen-color-data-8: var(--zen-color-brown-300); + --zen-color-data-9: var(--zen-color-rose-300); + --zen-color-data-10: var(--zen-color-blue-200); + --zen-shadow-sm: 3px 3px 0 rgba(0, 0, 0, 0.5); + --zen-shadow-md: 7px 7px 0 rgba(0, 0, 0, 0.5); + --zen-shadow-hover: 5px 5px 0 rgba(0, 0, 0, 0.55); + --zen-shadow-lifted: 10px 10px 0 rgba(0, 0, 0, 0.55); } }
--- a/design_system/test/BUILD Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/test/BUILD Tue Aug 04 15:12:09 2026 -0700 @@ -1,6 +1,13 @@ load("@aspect_rules_js//js:defs.bzl", "js_test") js_test( + name = "design_system_policy_test", + entry_point = "design_system_policy_test.js", + data = ["//design_system:policy_sources"], + no_copy_to_bin = ["//design_system:policy_sources"], +) + +js_test( name = "storybook_test", entry_point = "storybook_test.js", data = [
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/test/design_system_policy_test.js Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,311 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const RUNFILES = process.env.JS_BINARY__RUNFILES; +const WORKSPACE = process.env.JS_BINARY__WORKSPACE; +assert.ok(RUNFILES); +assert.ok(WORKSPACE); + +const sourceRoot = path.join(RUNFILES, WORKSPACE, 'design_system/src'); +const files = []; + +function walk(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) walk(entryPath); + else if (/\.(?:css|html|js)$/.test(entry.name)) files.push(entryPath); + } +} + +walk(sourceRoot); + +function validateCallableDocumentation( + relative, + source, + index, + name, + parameterSource, + failures, +) { + const prefix = source.slice(0, index); + const comment = prefix.match(/\/\*\*([\s\S]*?)\*\/\s*$/); + const body = comment?.[1] || ''; + const parameters = parameterSource + .split(',') + .map(parameter => parameter.trim() + .replace(/^\.\.\./, '') + .split('=')[0].trim()) + .filter(Boolean); + if (!/@return\s+\{[^}]+\}/.test(body)) { + failures.push(`${relative} exported callable ${name} lacks @return`); + } + const documented = [...body.matchAll( + /@param\s+\{[^}]+\}\s+([A-Za-z0-9_$]+)/g, + )].map(parameter => parameter[1]); + for (const parameter of parameters) { + if (!documented.includes(parameter)) { + failures.push( + `${relative} exported callable ${name} lacks @param for ${parameter}`, + ); + } + } +} + +const rawColor = new RegExp([ + '#[0-9a-fA-F]{3,8}\\b', + '\\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch)\\s*\\(', +].join('|'), 'gi'); +const forbiddenGlyph = /[×←→↑↓+◆◇✓✔✕✖★☆⚠]/gu; +const pictographic = /\p{Extended_Pictographic}/gu; +const failures = []; + +for (const file of files) { + const relative = path.relative(sourceRoot, file); + const source = fs.readFileSync(file, 'utf8'); + + if (relative !== 'styles/tokens.css') { + for (const match of source.matchAll(rawColor)) { + failures.push( + `${relative}:${source.slice(0, match.index).split('\n').length} ` + + `raw color ${JSON.stringify(match[0])}`, + ); + } + if (relative.endsWith('.css')) { + const css = source.replace(/\/\*[\s\S]*?\*\//g, ''); + const allowedWords = new Set([ + 'none', + 'transparent', + 'currentcolor', + 'inherit', + 'initial', + 'unset', + 'revert', + 'solid', + 'dashed', + 'dotted', + 'double', + 'groove', + 'ridge', + 'inset', + 'outset', + 'canvas', + 'canvastext', + 'linktext', + 'visitedtext', + 'activetext', + 'buttonface', + 'buttontext', + 'field', + 'fieldtext', + 'highlight', + 'highlighttext', + 'graytext', + 'mark', + 'marktext', + 'selecteditem', + 'selecteditemtext', + 'accentcolor', + 'accentcolortext', + 'color-mix', + 'in', + 'srgb', + 'linear-gradient', + 'radial-gradient', + 'conic-gradient', + 'to', + 'top', + 'right', + 'bottom', + 'left', + 'at', + 'circle', + 'ellipse', + 'px', + 'rem', + 'em', + 'vh', + 'vw', + 'vi', + 'vb', + 'vmin', + 'vmax', + 'deg', + 'rad', + 'turn', + ]); + for (const declaration of css.matchAll( + /(^|[;{])\s*([-\w]+)\s*:\s*([^;{}]+)/gm, + )) { + const property = declaration[2].toLowerCase(); + const value = declaration[3]; + const colorProperty = [ + 'accent-color', + 'background', + 'background-color', + 'box-shadow', + 'caret-color', + 'color', + 'fill', + 'outline', + 'outline-color', + 'scrollbar-color', + 'stroke', + 'text-shadow', + ].includes(property) || + (property.startsWith('border') && + !/(?:collapse|image|radius|spacing|style|width)/.test(property)); + if (!colorProperty) continue; + const withoutTokens = value.replace( + /var\(\s*--zen-[\w-]+\s*\)/g, + '', + ); + const words = withoutTokens.toLowerCase() + .match(/[a-z][a-z-]*/g) || []; + if (words.some(word => !allowedWords.has(word)) && + !/^\s*(?:0|none|transparent|currentColor|inherit)\s*$/i.test( + value, + )) { + failures.push( + `${relative}:${css.slice(0, declaration.index).split('\n').length} ` + + `${property} must use a --zen-* token`, + ); + } + } + } + } + + for (const pattern of [forbiddenGlyph, pictographic]) { + for (const match of source.matchAll(pattern)) { + failures.push( + `${relative}:${source.slice(0, match.index).split('\n').length} ` + + `icon glyph ${JSON.stringify(match[0])}; use <zen-icon>`, + ); + } + } + if (relative !== 'components/icon.js' && + /<svg\b|createElementNS\([^)]*["']svg["']/i.test(source)) { + failures.push(`${relative} defines SVG outside components/icon.js`); + } + if (relative.endsWith('.js')) { + if (/\bimport\s*\/[/*]/.test(source)) { + failures.push(`${relative} places comments inside import syntax`); + } + const importPattern = + /(?:import\s+(?:[^'"]+\s+from\s+)?|export\s+[^'"]+\s+from\s+)["']([^"']+)["']/g; + for (const match of source.matchAll(importPattern)) { + if (!match[1].startsWith('.')) { + failures.push( + `${relative} imports external runtime ${JSON.stringify(match[1])}`, + ); + } + } + if (/\bimport\s*\(/.test(source)) { + failures.push(`${relative} uses forbidden dynamic import()`); + } + } + if (relative.endsWith('.css') && + /@font-face|(?:font|font-family)\s*:[^;]*(?:icon|awesome|material)/i.test( + source, + )) { + failures.push(`${relative} declares an icon font`); + } + if (relative.endsWith('.css')) { + for (const match of source.matchAll( + /@import\s+(?:url\(\s*)?["']?([^"')\s;]+)["']?\s*\)?/g, + )) { + if (!match[1].startsWith('.')) { + failures.push( + `${relative} imports external stylesheet ${JSON.stringify(match[1])}`, + ); + } + } + } +} + +for (const file of files.filter(file => + file.endsWith('.js') +)) { + const relative = path.relative(sourceRoot, file); + const source = fs.readFileSync(file, 'utf8'); + const declaration = + /^export\s+(?:default\s+)?(?:async\s+)?(class|const|let|var|function)\s+([A-Za-z0-9_]+)/gm; + for (const match of source.matchAll(declaration)) { + const prefix = source.slice(0, match.index); + const comment = prefix.match(/\/\*\*([\s\S]*?)\*\/\s*$/); + const line = prefix.split('\n').length; + if (!comment) { + failures.push(`${relative}:${line} ${match[2]} lacks Google-style JSDoc`); + continue; + } + const body = comment[1]; + if (match[1] === 'class' && !/@extends\s+\{[^}]+\}/.test(body)) { + failures.push(`${relative}:${line} ${match[2]} lacks @extends`); + } else if (['const', 'let', 'var'].includes(match[1]) && + !/@(?:const|type)\b/.test(body)) { + failures.push(`${relative}:${line} ${match[2]} lacks @const/@type`); + } else if (match[1] === 'function' && + (!/@param\s+\{[^}]+\}/.test(body) || + !/@return\s+\{[^}]+\}/.test(body))) { + failures.push(`${relative}:${line} ${match[2]} lacks @param/@return`); + } else if (match[1] === 'function') { + const signature = source.slice(match.index).match( + /^export\s+(?:default\s+)?(?:async\s+)?function\s+[A-Za-z0-9_]+\s*\(([^)]*)\)/, + ); + const parameters = (signature?.[1] || '') + .split(',') + .map(parameter => parameter.trim() + .replace(/^\.\.\./, '') + .split('=')[0].trim()) + .filter(Boolean); + const documented = [...body.matchAll( + /@param\s+\{[^}]+\}\s+([A-Za-z0-9_$]+)/g, + )].map(parameter => parameter[1]); + for (const parameter of parameters) { + if (!documented.includes(parameter)) { + failures.push( + `${relative}:${line} ${match[2]} lacks @param for ${parameter}`, + ); + } + } + } + } + for (const match of source.matchAll( + /^export\s+const\s+([A-Za-z0-9_]+)\s*=\s*(?:async\s*)?(?:\(([^)]*)\)|([A-Za-z0-9_$]+))\s*=>/gm, + )) { + validateCallableDocumentation( + relative, + source, + match.index, + match[1], + match[2] || match[3] || '', + failures, + ); + } + for (const match of source.matchAll( + /^export\s+const\s+([A-Za-z0-9_]+)\s*=\s*(?:async\s*)?function(?:\s+[A-Za-z0-9_]+)?\s*\(([^)]*)\)/gm, + )) { + validateCallableDocumentation( + relative, + source, + match.index, + match[1], + match[2], + failures, + ); + } + for (const match of source.matchAll( + /export\s*\{[\s\S]*?\}\s*(?!from\b)(?:;|$)/g, + )) { + failures.push( + `${relative}:${source.slice(0, match.index).split('\n').length} ` + + "uses an undocumented local export list", + ); + } +} + +assert.deepEqual( + failures, + [], + `Design-system policy violations:\n${failures.join('\n')}`, +);
--- a/design_system/test/storybook_test.js Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/test/storybook_test.js Tue Aug 04 15:12:09 2026 -0700 @@ -120,6 +120,7 @@ for (const route of [ '/', '/tokens', + '/icons', '/components', '/components/button', '/components/card', @@ -138,7 +139,10 @@ for (const asset of [ '/styles/tokens.css', '/styles/components.css', + '/styles/shadcn.css', '/components/index.js', + '/components/icon.js', + '/catalog.js', '/storybook.js', ]) { const response = await fetch(`${baseUrl}${asset}`); @@ -154,9 +158,23 @@ const context = await browser.newContext({ colorScheme: 'light' }); const page = await context.newPage(); const errors = []; - page.on('pageerror', error => errors.push(error.message)); + const devtools = await context.newCDPSession(page); + await devtools.send('Runtime.enable'); + devtools.on('Runtime.exceptionThrown', event => { + const exception = event.exceptionDetails; + errors.push( + `${exception.text}: ${exception.exception?.description || ''} ` + + `${exception.url || ''}:${exception.lineNumber + 1}`, + ); + }); + page.on('pageerror', error => errors.push(error.stack || error.message)); page.on('console', message => { - if (message.type() === 'error') errors.push(message.text()); + if (message.type() === 'error') { + const location = message.location(); + errors.push( + `${message.text()} ${location.url || ''}:${location.lineNumber + 1}`, + ); + } }); page.on('response', response => { if (response.status() >= 400) { @@ -167,10 +185,121 @@ await page.goto(`${baseUrl}/components/button`, { waitUntil: 'networkidle', }); - await page.waitForFunction(() => - customElements.get('zen-button') && - customElements.get('zen-story') + try { + await page.waitForFunction( + () => customElements.get('zen-button') && + customElements.get('zen-story'), + undefined, + { timeout: 5000 }, + ); + } catch (error) { + throw new Error(`${error.message}\n${errors.join('\n')}`); + } + const catalogState = await page.evaluate(async () => { + const links = [ + ...document.querySelectorAll( + '.catalog-nav[aria-label="Components"] a', + ), + ]; + const elements = [...new Set( + links.map(link => link.dataset.zenElement).filter(Boolean), + )]; + const statuses = await Promise.all( + links.map(async link => (await fetch(link.href)).status), + ); + return { + cards: document.querySelectorAll('.component-grid > a').length, + elements, + links: links.length, + missing: elements.filter(name => !customElements.get(name)), + routes: links.map(link => new URL(link.href).pathname), + statuses, + }; + }); + assert.equal(catalogState.cards, 66); + assert.equal(catalogState.links, 66); + assert.equal(catalogState.elements.length, 65); + assert.deepEqual(catalogState.missing, []); + assert.deepEqual( + [...new Set(catalogState.statuses)], + [200], ); + const brokenCatalogPages = await page.evaluate(routes => { + const failures = []; + for (const route of ['/', '/tokens', '/icons', '/components', ...routes]) { + history.replaceState({}, '', route); + window.dispatchEvent(new PopStateEvent('popstate')); + const visible = [ + ...document.querySelectorAll('[data-catalog-page]:not([hidden])'), + ]; + const expected = route === '/' || route === '/components' + ? 'overview' + : route.slice(route.lastIndexOf('/') + 1); + if (visible.length !== 1 || + visible[0].dataset.catalogPage !== expected || + !visible[0].querySelector('h1')?.textContent.trim()) { + failures.push(route); + continue; + } + if (route.startsWith('/components/')) { + const story = visible[0].querySelector('zen-story'); + const canvas = story?.querySelector('.story-canvas'); + const source = story?.querySelector('pre code'); + if (!story || !canvas?.children.length || + !source?.textContent.trim()) { + failures.push(`${route}:empty-story`); + } + } + } + history.replaceState({}, '', '/components/button'); + window.dispatchEvent(new PopStateEvent('popstate')); + return failures; + }, catalogState.routes); + assert.deepEqual(brokenCatalogPages, []); + await page.keyboard.press('/'); + assert.equal( + await page.locator('#componentSearch').evaluate( + input => document.activeElement === input, + ), + true, + ); + await page.locator('#componentSearch').fill('date'); + assert.equal( + await page.locator( + '.catalog-nav[aria-label="Components"] a:not([hidden])', + ).count(), + 2, + ); + assert.equal( + await page.locator('.component-grid > a:not([hidden])').count(), + 2, + ); + assert.match( + await page.locator('#componentSearchStatus').textContent(), + /^2 components found$/, + ); + await page.locator('#componentSearch').press('Escape'); + assert.equal(await page.locator('#componentSearch').inputValue(), ''); + assert.equal( + await page.locator( + '.catalog-nav[aria-label="Components"] a:not([hidden])', + ).count(), + 66, + ); + await page.locator('#componentSearch').fill('stack'); + assert.equal( + await page.locator( + '.catalog-nav[aria-label="Components"] a[href="/components/stack"]', + ).getAttribute('hidden'), + null, + ); + assert.equal( + await page.locator( + '.component-grid > a[href="/components/stack"]', + ).getAttribute('hidden'), + null, + ); + await page.locator('#componentSearch').press('Escape'); assert.equal( await page.locator('[data-catalog-page="button"]').isVisible(), true, @@ -265,6 +394,114 @@ }); assert.equal(modifiedClickAllowed, true); + await page.goto(`${baseUrl}/icons`, { waitUntil: 'networkidle' }); + const iconCount = await page.locator('#iconGrid figure').count(); + assert.ok(iconCount >= 30); + assert.equal( + await page.locator('#iconGrid figure zen-icon > svg').count(), + iconCount, + ); + const iconFallback = await page.evaluate(() => { + const icon = document.createElement('zen-icon'); + icon.setAttribute('name', 'not-a-real-icon'); + icon.setAttribute('label', 'Missing icon fallback'); + document.body.append(icon); + return { + fallback: icon.dataset.zenIcon, + hidden: icon.getAttribute('aria-hidden'), + label: icon.getAttribute('aria-label'), + role: icon.getAttribute('role'), + }; + }); + assert.deepEqual(iconFallback, { + fallback: 'alert', + hidden: null, + label: 'Missing icon fallback', + role: 'img', + }); + + await page.goto(`${baseUrl}/tokens`, { waitUntil: 'networkidle' }); + const colorTokenState = await page.evaluate(() => { + const root = document.documentElement; + const value = name => getComputedStyle(root) + .getPropertyValue(`--zen-color-${name}`) + .trim(); + const families = [ + 'neutral', + 'red', + 'orange', + 'amber', + 'green', + 'teal', + 'blue', + 'violet', + 'rose', + 'brown', + ]; + const steps = [ + '50', + '100', + '200', + '300', + '400', + '500', + '600', + '700', + '800', + '900', + '950', + ]; + const rampsComplete = families.every(family => + steps.every(step => value(`${family}-${step}`)) + ); + const data = Array.from( + { length: 10 }, + (_, index) => value(`data-${index + 1}`), + ); + const materials = [ + 'paper', + 'washi', + 'linen', + 'sumi', + 'brick', + 'persimmon', + 'ochre', + 'moss', + 'patina', + 'indigo', + 'plum', + 'clay', + 'wood', + ].map(value); + const lightMuted = value('surface-muted'); + root.dataset.zenTheme = 'dark'; + const darkMuted = value('surface-muted'); + const darkData = Array.from( + { length: 10 }, + (_, index) => value(`data-${index + 1}`), + ); + delete root.dataset.zenTheme; + return { + darkData, + darkMuted, + data, + lightMuted, + materials, + rampsComplete, + }; + }); + assert.equal(colorTokenState.rampsComplete, true); + assert.equal(new Set(colorTokenState.materials).size, 13); + assert.equal(new Set(colorTokenState.data).size, 10); + assert.equal(new Set(colorTokenState.darkData).size, 10); + assert.notDeepEqual(colorTokenState.data, colorTokenState.darkData); + assert.notEqual( + colorTokenState.lightMuted, + colorTokenState.darkMuted, + ); + assert.equal(await page.locator('.palette-grid > div').count(), 13); + assert.equal(await page.locator('.data-palette > span').count(), 10); + await page.goto(`${baseUrl}/components/field`, { waitUntil: 'networkidle', }); @@ -285,6 +522,13 @@ assert.equal(fieldWiring.labelFor, fieldWiring.inputId); assert.equal(fieldWiring.describedBy, fieldWiring.helpId); assert.equal(fieldWiring.invalid, true); + await page.locator('zen-field input').first().focus(); + assert.equal( + await page.locator('zen-field input').first().evaluate( + input => getComputedStyle(input).outlineStyle, + ), + 'none', + ); const replacementField = await page.locator('zen-field').first().evaluate( async field => { const label = field.querySelector('label'); @@ -351,6 +595,519 @@ assert.equal(authoredDismissState.connected, true); assert.equal(authoredDismissState.events, 0); + await page.goto(`${baseUrl}/components/accordion`, { + waitUntil: 'networkidle', + }); + const accordion = page.locator('zen-accordion').first(); + const disclosureIcon = accordion.locator( + 'summary > zen-icon[data-zen-disclosure-icon]', + ).nth(1); + const collapsedDisclosure = await disclosureIcon.evaluate(icon => { + const style = getComputedStyle(icon); + return { + duration: style.transitionDuration, + transform: style.transform, + }; + }); + assert.notEqual(collapsedDisclosure.duration, '0s'); + await accordion.locator('summary').nth(1).click(); + await page.waitForTimeout(250); + assert.notEqual( + await disclosureIcon.evaluate(icon => getComputedStyle(icon).transform), + collapsedDisclosure.transform, + ); + assert.equal( + await accordion.locator('details').first().getAttribute('open'), + null, + ); + assert.equal( + await accordion.locator('details').nth(1).getAttribute('open'), + '', + ); + await accordion.locator('summary').nth(1).press('ArrowUp'); + assert.equal( + await accordion.locator('summary').first().evaluate( + summary => document.activeElement === summary, + ), + true, + ); + + await page.goto(`${baseUrl}/components/aspect-ratio`, { + waitUntil: 'networkidle', + }); + const aspectColors = await page.locator('.demo-media').evaluate(media => { + const style = getComputedStyle(media); + const probe = document.createElement('span'); + probe.style.background = 'var(--zen-color-surface-muted)'; + document.body.append(probe); + const token = getComputedStyle(probe).backgroundColor; + probe.remove(); + return { + background: style.backgroundColor, + image: style.backgroundImage, + token, + }; + }); + assert.equal(aspectColors.image, 'none'); + assert.equal(aspectColors.background, aspectColors.token); + + await page.goto(`${baseUrl}/components/tabs`, { + waitUntil: 'networkidle', + }); + await page.evaluate(() => { + window.__tabValue = null; + document.querySelector('zen-tabs').addEventListener( + 'zen-change', + event => { + window.__tabValue = event.detail.value; + }, + ); + }); + await page.locator('zen-tabs [role="tab"]').nth(1).click(); + assert.equal( + await page.locator('zen-tabs [role="tab"]').nth(1) + .getAttribute('aria-selected'), + 'true', + ); + assert.equal( + await page.locator('zen-tabs [role="tabpanel"]').nth(1) + .getAttribute('hidden'), + null, + ); + assert.equal(await page.evaluate(() => window.__tabValue), 'history'); + + await page.goto(`${baseUrl}/components/dialog`, { + waitUntil: 'networkidle', + }); + await page.locator('zen-dialog [data-zen-trigger]').click(); + assert.equal(await page.locator('zen-dialog dialog').getAttribute('open'), ''); + await page.locator('zen-dialog [data-zen-close]').click(); + assert.equal(await page.locator('zen-dialog dialog').getAttribute('open'), null); + const dynamicOverlays = await page.evaluate(async () => { + const dialogHost = document.createElement('zen-dialog'); + const popoverHost = document.createElement('zen-popover'); + const tooltipHost = document.createElement('zen-tooltip'); + const menuHost = document.createElement('zen-dropdown-menu'); + document.body.append(dialogHost, popoverHost, tooltipHost, menuHost); + + const dialogTrigger = document.createElement('button'); + dialogTrigger.type = 'button'; + dialogTrigger.dataset.zenTrigger = ''; + const dialog = document.createElement('dialog'); + dialogHost.append(dialogTrigger, dialog); + + const popoverTrigger = document.createElement('button'); + popoverTrigger.type = 'button'; + popoverTrigger.dataset.zenTrigger = ''; + const popover = document.createElement('div'); + popover.dataset.zenContent = ''; + popoverHost.append(popoverTrigger, popover); + + const tooltipTrigger = document.createElement('button'); + tooltipTrigger.type = 'button'; + tooltipTrigger.dataset.zenTrigger = ''; + const tooltip = document.createElement('span'); + tooltip.dataset.zenContent = ''; + tooltipHost.append(tooltipTrigger, tooltip); + + const menuTrigger = document.createElement('button'); + menuTrigger.type = 'button'; + menuTrigger.dataset.zenTrigger = ''; + const menu = document.createElement('div'); + menu.setAttribute('role', 'menu'); + const item = document.createElement('button'); + item.type = 'button'; + item.setAttribute('role', 'menuitem'); + menu.append(item); + menuHost.append(menuTrigger, menu); + + await new Promise(resolve => setTimeout(resolve)); + dialogTrigger.click(); + const dialogOpen = dialog.open; + dialog.close(); + popoverTrigger.click(); + menuTrigger.click(); + tooltipTrigger.focus(); + await new Promise(resolve => setTimeout(resolve, 375)); + return { + dialogControlled: Boolean(dialogTrigger.getAttribute('aria-controls')), + dialogOpen, + menuOpen: !menu.hidden, + popoverExpanded: popoverTrigger.getAttribute('aria-expanded'), + tooltipOpen: !tooltip.hidden, + }; + }); + assert.deepEqual(dynamicOverlays, { + dialogControlled: true, + dialogOpen: true, + menuOpen: true, + popoverExpanded: 'true', + tooltipOpen: true, + }); + + await page.goto(`${baseUrl}/components/dropdown-menu`, { + waitUntil: 'networkidle', + }); + const dropdownTrigger = page.locator( + 'zen-dropdown-menu [data-zen-trigger]', + ); + await dropdownTrigger.click(); + assert.equal( + await page.locator('zen-dropdown-menu [role="menu"]') + .getAttribute('hidden'), + null, + ); + await page.keyboard.press('Escape'); + assert.equal( + await page.locator('zen-dropdown-menu [role="menu"]') + .getAttribute('hidden'), + '', + ); + assert.equal( + await dropdownTrigger.evaluate( + trigger => document.activeElement === trigger, + ), + true, + ); + await dropdownTrigger.click(); + await page.locator('zen-dropdown-menu [role="menuitem"]').first().click(); + assert.equal( + await dropdownTrigger.evaluate( + trigger => document.activeElement === trigger, + ), + true, + ); + + await page.goto(`${baseUrl}/components/combobox`, { + waitUntil: 'networkidle', + }); + const comboboxInput = page.locator('zen-combobox input'); + await comboboxInput.fill('seo'); + assert.equal( + await page.locator('zen-combobox [role="option"]:not([hidden])').count(), + 1, + ); + await comboboxInput.press('ArrowDown'); + await comboboxInput.press('Enter'); + assert.equal(await comboboxInput.inputValue(), 'seobeo'); + assert.equal( + await page.locator('zen-combobox [role="listbox"]') + .getAttribute('hidden'), + '', + ); + + await page.goto(`${baseUrl}/components/date-picker`, { + waitUntil: 'networkidle', + }); + const dateInput = page.locator('zen-date-picker input[type="date"]'); + assert.equal(await dateInput.getAttribute('aria-hidden'), 'true'); + assert.equal( + await dateInput.evaluate(input => getComputedStyle(input).position), + 'absolute', + ); + await page.evaluate(() => { + window.__dateValue = null; + document.querySelector('zen-date-picker').addEventListener( + 'zen-change', + event => { + window.__dateValue = event.detail.value; + }, + ); + }); + const dateTrigger = page.locator( + 'zen-date-picker [data-zen-date-trigger]', + ); + await dateTrigger.click(); + assert.equal( + await page.locator('zen-date-picker [data-zen-calendar-panel]') + .getAttribute('hidden'), + null, + ); + assert.equal( + await page.locator( + 'zen-date-picker [data-zen-calendar-day]', + ).count(), + 42, + ); + const chosenDate = await page.locator( + 'zen-date-picker [data-zen-calendar-day]:not([data-outside]):not(:disabled)', + ).nth(14).getAttribute('data-zen-calendar-day'); + await page.locator( + `zen-date-picker [data-zen-calendar-day="${chosenDate}"]`, + ).click(); + assert.equal(await dateInput.inputValue(), chosenDate); + assert.equal(await page.evaluate(() => window.__dateValue), chosenDate); + assert.equal(await dateTrigger.getAttribute('aria-expanded'), 'false'); + const triggerName = await dateTrigger.evaluate(trigger => + (trigger.getAttribute('aria-labelledby') || '') + .split(/\s+/) + .map(id => document.getElementById(id)?.textContent || '') + .join(' ') + ); + assert.match(triggerName, /Deploy on/); + assert.equal( + await page.locator('zen-date-picker > label').getAttribute('for'), + await dateTrigger.getAttribute('id'), + ); + const minimumDate = `${chosenDate.slice(0, 8)}10`; + await dateInput.evaluate((input, minimum) => { + input.min = minimum; + }, minimumDate); + await page.waitForTimeout(0); + await dateTrigger.click(); + const focusedBoundary = page.locator( + `zen-date-picker [data-zen-calendar-day="${chosenDate}"]`, + ); + await focusedBoundary.press('PageUp'); + assert.equal( + await page.evaluate(() => + document.activeElement?.dataset.zenCalendarDay + ), + minimumDate, + ); + assert.equal( + await page.locator( + 'zen-date-picker [data-zen-calendar-day][tabindex="0"]:not(:disabled)', + ).count(), + 1, + ); + await page.keyboard.press('Escape'); + await dateInput.evaluate(input => { + input.disabled = true; + }); + await page.waitForTimeout(0); + assert.equal(await dateTrigger.isDisabled(), true); + await dateInput.evaluate(input => { + input.disabled = false; + input.readOnly = true; + }); + await page.waitForTimeout(0); + assert.equal(await dateTrigger.isDisabled(), true); + await dateInput.evaluate(input => { + input.readOnly = false; + input.required = true; + input.value = ''; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.reportValidity(); + }); + await page.waitForTimeout(0); + assert.equal(await dateTrigger.getAttribute('aria-invalid'), 'true'); + assert.equal( + await dateTrigger.evaluate( + trigger => document.activeElement === trigger, + ), + true, + ); + assert.equal( + await page.locator('zen-date-picker').getAttribute('data-invalid'), + '', + ); + await dateInput.evaluate((input, value) => { + input.value = value; + input.dispatchEvent(new Event('input', { bubbles: true })); + }, chosenDate); + assert.equal(await dateTrigger.getAttribute('aria-invalid'), 'false'); + const earlyYear = await page.evaluate(async () => { + const calendar = document.createElement('zen-calendar'); + const input = document.createElement('input'); + input.type = 'date'; + input.value = '0001-01-01'; + input.setAttribute('aria-label', 'Early date'); + calendar.append(input); + document.body.append(calendar); + await new Promise(resolve => setTimeout(resolve)); + return { + selected: calendar.querySelector( + '[data-zen-calendar-day][aria-selected="true"]', + )?.dataset.zenCalendarDay, + value: input.value, + }; + }); + assert.deepEqual(earlyYear, { + selected: '0001-01-01', + value: '0001-01-01', + }); + const resetDateState = await page.evaluate(async () => { + const label = document.createElement('span'); + label.id = 'reset-date-label'; + label.textContent = 'Reset date'; + const form = document.createElement('form'); + const picker = document.createElement('zen-date-picker'); + const input = document.createElement('input'); + input.type = 'date'; + input.defaultValue = '2026-08-04'; + input.setAttribute('aria-labelledby', label.id); + picker.append(input); + form.append(picker); + document.body.append(label, form); + await new Promise(resolve => setTimeout(resolve)); + input.value = '2026-08-12'; + input.dispatchEvent(new Event('input', { bubbles: true })); + form.reset(); + await new Promise(resolve => setTimeout(resolve)); + const trigger = picker.querySelector('[data-zen-date-trigger]'); + return { + labelledBy: trigger.getAttribute('aria-labelledby'), + selected: picker.querySelector( + '[data-zen-calendar-day][aria-selected="true"]', + )?.dataset.zenCalendarDay, + value: input.value, + }; + }); + assert.match(resetDateState.labelledBy, /^reset-date-label /); + assert.deepEqual( + { + selected: resetDateState.selected, + value: resetDateState.value, + }, + { + selected: '2026-08-04', + value: '2026-08-04', + }, + ); + + await page.goto(`${baseUrl}/components/input-otp`, { + waitUntil: 'networkidle', + }); + await page.evaluate(() => { + window.__otpValue = null; + document.querySelector('zen-input-otp').addEventListener( + 'zen-complete', + event => { + window.__otpValue = event.detail.value; + }, + ); + }); + await page.locator('zen-input-otp input').evaluate(input => { + input.value = '12a3456'; + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + assert.equal(await page.locator('zen-input-otp input').inputValue(), '123456'); + assert.equal( + await page.locator('zen-input-otp [data-zen-otp-slots] > span').count(), + 6, + ); + assert.equal(await page.evaluate(() => window.__otpValue), '123456'); + + await page.goto(`${baseUrl}/components/data-table`, { + waitUntil: 'networkidle', + }); + await page.locator('zen-data-table [data-zen-sort="commits"]').click(); + assert.equal( + await page.locator('zen-data-table th').nth(1).getAttribute('aria-sort'), + 'ascending', + ); + assert.equal( + await page.locator('zen-data-table tbody tr').first().locator( + '[data-key="commits"]', + ).textContent(), + '12', + ); + + await page.goto(`${baseUrl}/components/carousel`, { + waitUntil: 'networkidle', + }); + await page.evaluate(() => { + window.__carouselIndex = null; + document.querySelector('zen-carousel').addEventListener( + 'zen-change', + event => { + window.__carouselIndex = event.detail.index; + }, + ); + }); + await page.locator('zen-carousel [data-zen-next]').click(); + assert.equal(await page.evaluate(() => window.__carouselIndex), 1); + assert.equal( + await page.locator('zen-carousel [data-zen-prev]').isDisabled(), + false, + ); + + await page.goto(`${baseUrl}/components/resizable`, { + waitUntil: 'networkidle', + }); + const resizeHandle = page.locator('zen-resizable [data-zen-handle]'); + const initialPercent = Number( + await resizeHandle.getAttribute('aria-valuenow'), + ); + await resizeHandle.focus(); + await resizeHandle.press('ArrowRight'); + assert.ok( + Number(await resizeHandle.getAttribute('aria-valuenow')) > initialPercent, + ); + + await page.goto(`${baseUrl}/components/switch`, { + waitUntil: 'networkidle', + }); + const switchInput = page.locator('zen-switch input'); + assert.equal(await switchInput.getAttribute('role'), 'switch'); + assert.equal(await switchInput.getAttribute('aria-checked'), 'true'); + await switchInput.uncheck(); + assert.equal(await switchInput.getAttribute('aria-checked'), 'false'); + + await page.goto(`${baseUrl}/components/checkbox`, { + waitUntil: 'networkidle', + }); + assert.equal( + await page.locator('zen-checkbox input').evaluate( + input => getComputedStyle(input).appearance, + ), + 'none', + ); + await page.goto(`${baseUrl}/components/native-select`, { + waitUntil: 'networkidle', + }); + assert.equal( + await page.locator('zen-native-select select').evaluate( + select => getComputedStyle(select).appearance, + ), + 'none', + ); + assert.equal( + await page.locator( + 'zen-native-select > zen-icon[data-zen-select-icon]', + ).count(), + 1, + ); + await page.goto(`${baseUrl}/components/slider`, { + waitUntil: 'networkidle', + }); + assert.equal( + await page.locator('zen-slider input').evaluate( + input => getComputedStyle(input).appearance, + ), + 'none', + ); + + await page.goto(`${baseUrl}/components/toggle-group`, { + waitUntil: 'networkidle', + }); + await page.locator('zen-toggle-group button[value="right"]').click(); + assert.equal( + await page.locator('zen-toggle-group button[value="right"]') + .getAttribute('aria-pressed'), + 'true', + ); + assert.equal( + await page.locator('zen-toggle-group').getAttribute('value'), + 'right', + ); + + await page.goto(`${baseUrl}/components/sidebar`, { + waitUntil: 'networkidle', + }); + const sidebarTrigger = page.locator( + 'zen-sidebar [data-zen-sidebar-trigger]', + ); + const sidebarPanel = page.locator( + 'zen-sidebar [data-zen-sidebar-panel]', + ); + assert.equal(await sidebarTrigger.getAttribute('aria-expanded'), 'true'); + assert.equal(await sidebarPanel.getAttribute('hidden'), null); + await sidebarTrigger.click(); + assert.equal(await sidebarTrigger.getAttribute('aria-expanded'), 'false'); + assert.equal(await sidebarPanel.getAttribute('hidden'), ''); + await page.goto(`${baseUrl}/components/notifications`, { waitUntil: 'networkidle', }); @@ -846,6 +1603,74 @@ ); assert.notEqual(systemDarkCanvas, explicitLightCanvas); await darkContext.close(); + + const mobileContext = await browser.newContext({ + colorScheme: 'light', + viewport: { width: 390, height: 844 }, + }); + const mobilePage = await mobileContext.newPage(); + const mobileErrors = []; + mobilePage.on('pageerror', error => { + mobileErrors.push(error.stack || error.message); + }); + mobilePage.on('console', message => { + if (message.type() === 'error') mobileErrors.push(message.text()); + }); + await mobilePage.goto(baseUrl, { waitUntil: 'networkidle' }); + await mobilePage.waitForFunction(() => + customElements.get('zen-date-picker') && + document.querySelectorAll( + '.catalog-nav[aria-label="Components"] a', + ).length === 66 + ); + const mobileFailures = await mobilePage.evaluate(() => { + const routes = [ + '/', + '/tokens', + '/icons', + ...[...document.querySelectorAll( + '.catalog-nav[aria-label="Components"] a', + )].map(link => new URL(link.href).pathname), + ]; + const failures = []; + for (const route of routes) { + history.replaceState({}, '', route); + window.dispatchEvent(new PopStateEvent('popstate')); + const page = document.querySelector( + '[data-catalog-page]:not([hidden])', + ); + if (!page || + document.documentElement.scrollWidth > innerWidth + 1 || + page.getBoundingClientRect().right > innerWidth + 1) { + failures.push(route); + } + } + return failures; + }); + assert.deepEqual(mobileFailures, []); + await mobilePage.goto(`${baseUrl}/components/date-picker`, { + waitUntil: 'networkidle', + }); + await mobilePage.locator( + 'zen-date-picker [data-zen-date-trigger]', + ).click(); + const mobileCalendarBounds = await mobilePage.locator( + 'zen-date-picker [data-zen-calendar-panel]', + ).evaluate(panel => { + const bounds = panel.getBoundingClientRect(); + return { + bottom: bounds.bottom, + left: bounds.left, + right: bounds.right, + top: bounds.top, + }; + }); + assert.ok(mobileCalendarBounds.left >= 0); + assert.ok(mobileCalendarBounds.right <= 390); + assert.ok(mobileCalendarBounds.top >= 0); + assert.ok(mobileCalendarBounds.bottom <= 844); + assert.deepEqual(mobileErrors, []); + await mobileContext.close(); } finally { if (browser) await browser.close(); await stopProcess(server);