# Design decisions

Each entry records a choice this language made, the options it rejected, and why. Read it before you change a component, so a new project does not try a rejected option again. [Component specs](components.md) hold the values; this file holds the reasons.

| Decision | Layer | Chosen | Rejected, and why | Guarded by |
| --- | --- | --- | --- | --- |
| Units | Core | Type (`--text-*`), spacing (`--space-*`), and control and row heights (`--control-*`, `--row`) are rem, as are the page literals that size text, padding, gaps, and line heights. Each equals its old pixel value at the default 16px root, so the default render does not change. Hairlines, focus rings and their offsets, shadows, fixed icon and box sizes, layout widths, and radii stay px. Media query breakpoints stay px and are out of scope here. | All px: text and controls ignored the reader's browser font-size setting. All rem: 1px hairlines and the 2px focus ring would grow or round to uneven widths. | rem sizes: `tests/design-foundations.test.mjs` "type, spacing, and control sizes are rem on every page, in tokens.css, and in the report stylesheet" |
| Depth | Core | Quiet lift: borderless controls take one tight shadow at rest (`--shadow-raise`) and sink when pressed. Containers take a fainter edge (`--shadow-surface`). No outer light highlight, and no blur wider than 2px. A transparent 1px border stays for forced colors. | A dual raise (a dark blur below and a light highlight above) on controls and containers alike: cards looked heavy, idle buttons looked too raised, and dark mode showed a fuzzy halo. Before that, a crisp, bordered look. | shadows from tokens: `tests/design-foundations.test.mjs` "pages take depth from tokens.css: no literal shadow outside a focus ring or divider"; raise and sink recipe: no test yet |
| Primary button | Core | Filled with the project hue (`--color-accent`), with `--color-accent-text`. | Plain ink (black in light, white in dark) looked too severe. Graphite ink looked disabled. | label contrast in every state: `tests/catalogue/catalogue.spec.ts` "contrast: every enabled primary button label reaches 4.5:1"; hue fill: no test yet |
| Danger button | Core | A raised `--color-error-surface` button with danger-tinted text, mixed toward `--color-text` as status badges are. Hover and press deepen the tint, and press sinks it. The label must read at 4.5:1 on all three fills; where the mix misses, it falls back to `--color-text`, black, or white. | A flat button with `--color-text` and a hover border read as a pink secondary button, and pressing it showed nothing. | label contrast on all three fills: `tests/catalogue/catalogue.spec.ts` "contrast: every enabled danger button label reaches 4.5:1"; fills, raise, and sink: no test yet |
| Pressed button | Core | Every variant but text sinks 1px, takes `--shadow-sink`, and changes its fill. Press is a button state: other controls show their latched state, and quiet actions tint without sinking. | The sink shadow alone was too faint to show a click, especially on secondary and ghost. | rule present: `tests/design-foundations.test.mjs` "every state rule the guard requires exists in the catalogue"; 1px move, sink, and fill: no test yet |
| Busy button | Core | Busy also disables the button, so it takes no hover or press. | A busy button that still hovered and pressed looked ready for a second click. | No test yet |
| Quiet actions | Core | One recipe for Copy, Full size, dialog and drawer close, and the project trigger: transparent, `currentColor` at 6% on hover and 12% pressed (see Quiet action fill), no sink, and the control ring. The project trigger keeps the tint but takes the field corner (see One-line non-action controls). | Each had its own hover (`--color-row-hover`, a colour change only, or a 12% tint), so the same kind of action behaved differently. | rule present: `tests/design-foundations.test.mjs` "every state rule the guard requires exists in the catalogue"; tint recipe: `tests/catalogue/catalogue.spec.ts` "a hovering pointer adds a 6% fill, and a press 12%" |
| Quiet action fill | Core | `currentColor` at 6% under a hovering pointer and 12% pressed, a faint overlay after Astryx's ghost button overlay (the idea only; no code copied). The user approved it from rendered demos. | 12% hover and 18% pressed, the previous values, replaced by the fainter overlay the user chose. No fill on hover, with only the icon brightening: it hides the hit target, so the reader cannot see what a click will reach. | hover and press alpha: `tests/catalogue/catalogue.spec.ts` "a hovering pointer adds a 6% fill, and a press 12%"; no fill after a tap: "a tap leaves no fill behind" |
| One source per control | Core | Each control comes from the owning library's component or class, including a link styled as a button. | Local copies built from the same tokens drifted: a docs button had no raise and only faded when pressed. | copied rules unchanged: `tests/design-foundations.test.mjs` "example pages copy the catalogue rules for the shared controls and components they use unchanged" |
| Section mark | Identity | The `##` mark goes only on the page-level section headings (h2) of documentation, CLI tool, article, and product pages, which are mono with a `--color-border-subtle` rule above. Application and inline-visualization section headings are plain `--font-ui` semibold at `--text-xl` or smaller, with no mark and no rule. h3 and lower never take it. | The mark on every section heading: on a dense screen, such as a metrics dashboard with six sections, it repeated down the page and competed with the data. The mark on h2 and h3: deeper levels repeat more often. A per-page opt-out class: every application page would need to remember it. Dropping the mark: reading pages lose the folio identity that ties them to the `~/name` header. Mono application section headings without the mark: the mono face alone still reads as a document heading among compact sans rows. | No test yet |
| Command plate | Identity | A dark plate tinted with the project hue (`--color-command-bg`). Terminal captures keep the neutral plate. | The near-black plate looked too severe. | text and prompt contrast: `tests/catalogue/catalogue.spec.ts` "contrast: the command plate text reaches 4.5:1", "contrast: the command plate prompt reaches 3:1"; plate tint: no test yet |
| Navigation current row | Core | A quiet lifted fill (`--color-nav-current`, `--shadow-nav-current`) with 6px corners (`--radius-inline`) and accent text. In-page location is a 3×14 accent pip with no fill in strips and contents lists; a section rail's current section takes the current-page fill instead (see Rail current section). In forced colors the current page and a rail's current section take a `Highlight` edge, and the pip redraws in `Highlight`. | A raised pill looked odd. The old 2px inset bar curved away on rounded rows and disappeared in the dark drawer. | accent text contrast: `tests/design-foundations.test.mjs` "accent text on the selected fill reaches 4.5:1 on every surface in both themes"; 6px corners: `tests/catalogue/catalogue.spec.ts` "each component renders the corner radius of its role, and no other part has one"; pip and forced colors: no test yet |
| Fields | Core | 10px rounded rectangles (`--radius-field`) with a 1px `--color-border-subtle` edge (`--color-field-edge`) in both themes. Light fields take a `--color-surface-raised` fill and no shadow (`--color-field-bg`, `--shadow-field`). Dark fields stay in the sunk `--color-well` well with `--shadow-sink`; the well is about 1.01:1 against the canvas, so the edge carries the shape. A disabled field takes the `--color-well` well and no shadow. `--color-well` itself still fills tracks, pressed buttons, and selected radio cards. | Pill fields, the earlier shape. A grey sunk well with a transparent edge in light: the user said it looked disabled. Without the edge, dark fields disappeared. | 10px corners: `tests/catalogue/catalogue.spec.ts` "each component renders the corner radius of its role, and no other part has one"; edge, fill, and depth: no test yet |
| Switch | Core | Borderless track, 44×22 with an 18px knob. The off state shows through the track fill. | A visible track border, which was not needed. | No test yet |
| Segmented control | Core | One thumb glides between options: the leading edge takes 260ms and the trailing edge 300ms, with CSS `ease`, so the thumb stretches a little on the way. This was matched frame by frame to a reference. There is no motion on load, on a restored value, or on a theme change. Reduced motion makes the change instant. | The earlier 180ms ease-out looked like a jump. | No test yet |
| Segmented label | Core | The selected label reads at 4.5:1 on its thumb, and an icon at 3:1 (`--color-segmented-current-text`): `--color-accent`, or the first of `--color-text`, black, and white that reads. | Accent at 3:1 for labels too, which let some hues' text fall below 4.5:1. | label contrast: `tests/catalogue/catalogue.spec.ts` "contrast: every selected segmented label on its thumb reaches 4.5:1"; icon at 3:1: no test yet |
| Phone drawer trigger | Core | A page-level contents drawer opens from a soft secondary button with a three-line menu glyph and a label. The no-JavaScript fallback keeps a chevron, because it opens in place. The site shell uses the same trigger below its header (see Phone header). | A down chevron promised a dropdown, but the trigger opens a side drawer. | No test yet |
| Shape follows role | Core | A pill acts, or sets a value in place with every option shown, on one line (`--radius-control`); a select, which opens a list, is a one-line non-action control. Small state marks are pills (`--radius-pill`). Places and picked rows are 6px (`--radius-inline`). Typed fields, controls whose label wraps, and one-line controls that are not actions are 10px (`--radius-field`). Containers are 16px (`--radius-panel`). A pill never holds text that can wrap, and nested rectangles are concentric. The token names predate the rule: they say inline, control, and field, where the uses are rows, pills, and typed fields. The names stay, because a rename breaks every page that uses them. | One shape for every control. All pills put wrapping labels in pills, which the toggle card, fields, and the raised pill row already rejected; all rectangles lose the shape that marks an action. A list of components for each token with no reason: a new component had no rule to pick from, and a reader took a 6px row beside a pill for an error. | rendered corners by role: `tests/catalogue/catalogue.spec.ts` "each component renders the corner radius of its role, and no other part has one", "the DESIGN.md shape table names each role token with its value"; slider track and thumb, and select picker: `tests/design-foundations.test.mjs` "the slider track, slider thumb, and select picker take their role’s corners"; no literal radius: `tests/design-foundations.test.mjs` "no page or the report stylesheet writes a literal corner radius" |
| Themes | Core | A theme sets only its inputs: a hue for light and for dark, and optionally its two fonts and its neutrals (page, surfaces, hover, edges, and text, as a whole set for both modes). Shape, spacing, states, and status colours stay core, so actions are pills in every theme. Neutrals are an input so an open palette keeps its own surfaces: Catppuccin and GitHub's Primer ship as themes, each mapped by its palette's own roles and unaltered. Where a hue cannot carry the text on a selected fill at 4.5:1, the build takes the first of the theme's text colour, black, and white that can, as the token roles say. Every theme passes the contrast guards in both modes. Theme files use the W3C Design Tokens format (2025.10) and ship beside the CSS, so design-token tools can read and write them: one file per theme, with a `light` and a `dark` group of sRGB colour tokens. Tools read the two groups as separate tokens (`light.color-bg`, `dark.color-bg`), not as one token with two modes; the Design Tokens Resolver module expresses modes, and it can replace the groups once tools support it. Graphite Indigo is the default. The family sites' own hues ship as themes named for their colour, so a site can load its theme file in place of an inline hue pair: Lagoon (annoterm), Copper (devps), Iris (filip-stack), Lapis (Workbench), and Fern (wtree). Each theme's note names its site. Where a site's light hue falls just short of 4.5:1 as selected-row text, its theme takes the smallest OKLCH lightness step that passes and keeps the hue and chroma, so the selection keeps the hue; the site can adopt the theme's value. A third-party palette is never altered. The theme mapper (`./theme-mapper`, experimental) turns a VS Code colour theme into these inputs for its one mode by selection: each input takes the first of its keys whose colour passes that input's contrast gate, and no colour is changed. What still fails is reported, as a `theme` problem in the theme's colours or a `core` problem in a core link, status, syntax, or command-plate colour on its fills. The checks are data: every ink on every fill the catalogue draws, hover and pressed states included, so a state axe never renders is still measured. Syntax colours stay core: comment colours in 6 of 8 popular dark themes measure 2.3–4.0:1 on their own editor background, so syntax as an input would flag nearly every import. The build and the mapper share one engine for applying a theme, resolving it, and checking it. | The action shape as a theme input (a pill, the 10px field corner, or the 6px row corner): each rounded choice gives actions another role's shape, a select's at 10px and a tab's or row's at 6px, so shape stops telling an action from a field. Radix Themes and shadcn scale every corner together, and Primer, Carbon, Polaris, and Spectrum keep shape core; revisit it as a whole radius scale when a theme needs one. Linear and OpenAI themes: neither publishes an open palette, and their colours are brand identity. Pine and Mauve, the earlier invented themes: no product used them, and they read as unrelated to the family's sites. A grey-blue quiet theme (Slate): it sat beside the muted text and the info blue, so its selection read as metadata and its primary button looked disabled. Adjusting an imported colour until it passes: the result is no longer the theme, and nothing tells the reader it changed. Syntax colours as a theme input: most imports would fail on their comment colours alone. | Per-theme contrast guards (`tests/design-foundations.test.mjs`), the catalogue's rendered recipes in every theme, the Themes page tests, and the theme mapper's tests (`tests/theme-mapper.test.mjs`, the React smoke test with the mode locked opposite the system's, and the catalogue test that every axe contrast failure is a reported problem) |
| Navigation row corners | Core | Navigation rows, header and strip rows, list-selection rows, collapsible group labels, and tabs take 6px corners (`--radius-inline`), except rows inside a floating menu (see Floating-menu rows). The owner chose the more rectangular rows from the Gate M mockup (2026-11-09). | 10px (`--radius-field`), the previous corners, shared with fields. | 6px corners: `tests/catalogue/catalogue.spec.ts` "each component renders the corner radius of its role, and no other part has one"; Foundations swatch: `tests/design-foundations.test.mjs` "the Foundations current-row swatch and radius list follow the navigation row radius" |
| Floating-menu rows | Core | Rows inside a floating menu, such as the project menu, are concentric with the 16px menu: `calc(var(--radius-panel) - var(--space-1))`, 12px, as select picker rows are. The owner approved it from the DS-3b comparison. | 6px like rail rows: a 6px row inside a 16px panel looks pinched. | 12px corners: `tests/catalogue/catalogue.spec.ts` "each component renders the corner radius of its role, and no other part has one" |
| One-line non-action controls | Core | A one-line control that is not an action takes the 10px field corner (`--radius-field`), as a select does: the select, the project-menu trigger, the labelled contents trigger and its no-JavaScript summary ("On this page"), and the skip link. The project trigger keeps the quiet-action tint. The owner chose it on 2026-11-10. | The pill (`--radius-control`): a pill means an action, and these controls open a menu or list, pick a value, or move focus. 16px (`--radius-panel`), like the menu and other floating layers: on a one-line control 40 to 44px tall it reads almost as a pill. | 10px corners: `tests/catalogue/catalogue.spec.ts` "each component renders the corner radius of its role, and no other part has one" |
| Optical label centring | Core | A text-only navigation row and the breadcrumb centre their label on the cap height with `text-box: trim-both cap alphabetic`, inside `@supports`; a row becomes a block with `align-content: center` for it, and `--space-2` block padding keeps a wrapped label clear of its fill's edges. Browsers without `text-box` keep the flex centring of the line box. Buttons, tabs, and segmented options already centre their ink within 0.5px, so they are unchanged; Chromium does not trim a button's label in any case. | Centring the line box alone: it keeps descender room, so a label without descenders, such as "Overview", sat 1.25px above centre in the 44px header row (15.75px above the ink, 18.25px below). `text-box` on the flex row itself: Chromium trims only block containers, and the label's anonymous flex item does not inherit the trim. A fixed nudge in em: it depends on the font's metrics, which an identity can change. | label inside the fill, and centred: `tests/site/site.spec.ts` "a wrapped navigation row or crumb keeps its label inside the fill and focus ring in ${colorScheme}", "the breadcrumb centres its link's ink in ${colorScheme}" |
| Rail current section | Core | In a section rail the current section takes the current-page look: `--color-nav-current` fill, `--shadow-nav-current`, and `--color-nav-current-text`, with no pip. It keeps `aria-current="location"`. Strips and contents lists keep the pip. In forced colors it takes the current page's 2px `Highlight` leading border. The owner chose it at Gate M, so the rail's place reads like the live site's current page. | The pip in the rail too, as in strips and contents lists: the owner preferred the rail to match the site's current-page look. | rule present on pages: `tests/design-foundations.test.mjs` "example pages copy the state rules of every component variant their markup uses"; recipe: no test yet |
| Tab glide | Core | The selected tab's fill is a `::before` layer that glides between tabs like the segmented thumb: `glide-left` and `glide-right`, 260ms leading and 300ms trailing, with CSS `ease`. No glide on first render or under reduced motion, and no layer in forced colors. The owner chose it at Gate M. | The fill jumping from tab to tab, unlike the segmented control beside it. | glide and reduced motion: `tests/catalogue/catalogue.spec.ts` "the tab fill glides to the new tab", "the tab fill moves at once" |
| Segmented touch size | Core | At tablet width an unsized segmented control sets `--item` to `--control-lg`. | Growing only the buttons' `min-height`: `--item` stayed at `--control-md`, so icon-only options were 36×44 ovals (found at Gate M). | No test yet |
| Phone header | Core | The family shell, option A, which the owner chose on 2026-11-11: the site's header matches the other family sites. At tablet width the static header has two rows, the project switcher and the appearance control, then the Pages strip. Where both do not fit, as at 320px, the appearance control wraps to its own row at the start, as UiSiteHeader does on PluginBench and Filip Stack, and the project name never truncates. The rule below the header is the family's solid `--color-text` line. Just below it, at the start, the labelled trigger ("Components: By purpose") opens the drawer from the left, the side it sits on. The drawer is titled with the rail's name and holds Find and the rail's groups. `/` opens it with Find focused. Without JavaScript nothing moves. | The Gate M single sticky row with the site name, then icon-only Find and Menu buttons, with the pages and appearance in the Menu drawer: the site had no way to the other family sites, and the Menu button sat at the right while the drawer slid in from the left. Option B, keeping that row and sliding the drawer in from the right: it kept the site apart from the other seven family sites. Truncating the project name at 320px so both controls shared row 1: the family wraps the picker instead, and the name should never truncate. | `tests/site/site.spec.ts` "the switcher and appearance share the first row, then the pages, then the trigger", "the switcher and appearance take a row each, then the pages, then the trigger", "every clipped strip link shows at least 16px", "the trigger opens the drawer from the left edge, titled with the rail and holding Find and the rail only", "the trigger label follows the current section as the page scrolls"; `tests/catalogue/catalogue.spec.ts` "the trigger opens the drawer on the current row, and a link closes it and focuses the target" |
| Drawer instead of a long strip | Core | A horizontal strip holds 6 links or fewer; a longer list moves into the drawer at tablet width. | The catalogue's 26-link strip on phones: most links sat off screen, and the page broke its own drawer spec. | rail moves into the drawer: `tests/catalogue/catalogue.spec.ts` "resizing moves the rail between the page and the drawer, and the pages and appearance stay in the header"; six-link strip limit: no test yet |
| Purpose groups | Core | The catalogue and `components.md` group components by purpose (Actions, Selection, Inputs, Feedback and status, Containers and overlays, Navigation, Code and examples, then Identity). Each section keeps its Core or Identity badge, and the heading ids stay unchanged, so old deep links still work. | Core, then Identity, in build order: a reader had to know a component's layer to find it. Splitting the catalogue into pages: agents copy from and grep one file, and the hosts serve a fixed list of files. | No test yet |
| Find and its shortcut | Core | A find field filters the rail; `/` focuses it. A saved switch in the rail footer turns single-key shortcuts off (WCAG 2.1.4), and the shortcut is ignored in editable elements and while a modal dialog is open. | No shortcut: Find is a long Tab away. A shortcut with no off switch: it fails WCAG 2.1.4 for speech and switch users. | shortcut and its switch: `tests/catalogue/catalogue.spec.ts` "/ focuses Find, types normally in a field, and does nothing once shortcuts are off" |
| No command palette | Core | Find, which filters the rail, and no command palette. Revisit when a site has more than one searchable index. | A command palette: Find already covers a single-page catalogue and a site of about 25 skills. | No test yet |
| One pager per page | Core | One pager at the end of `main`, for the sequence of pages. | A pager per component: on one page with a sticky rail it adds 26 repeated blocks. | No test yet |
| Header that stays still | Core | Option A: the design system's site header is the same on every page. The project switcher `~/ Design system` starts it, as on every family site; the Pages strip follows it, and Appearance, the icon-only segmented control, sits at the end. The first breadcrumb leads back to Overview. Until 2026-11-11 the start was the site name as a link to Overview. At `min-width: 801px` the header is a grid (`auto minmax(0, 1fr) auto`), so the links start at the same x on every page. Each strip link reserves its width at the current row's weight (a hidden `data-label` copy on the label's line), so becoming current moves nothing; under forced colors the current row's padding gives up 2px for its border. A link reached with Tab scrolls clear of the strip's fades. The owner chose it on 2026-11-11. | The flex row that centred the strip between the start item and Appearance: the start item was a breadcrumb that changed width from page to page, so the links started at x=674 on Overview and 451 to 465 elsewhere, and the current link's medium weight added 1–2px. Option B, the strip at the end beside Appearance: the owner chose A. A hidden copy in a 0-height block or a float: the block ended the label's text-box trim, so the label sat 3.4px high, and a float added its width to the label's. | `tests/site/site.spec.ts` "the header links sit at the same x on every page, and the current link keeps its width, with forced colors ${forcedColors}"; `tests/catalogue/catalogue.spec.ts` "a header link reached with Tab scrolls clear of the strip’s faded edges" |
| Breadcrumb above the title | Core | Every site page but the home page has a breadcrumb right above its h1, and the header keeps the project switcher. Separators, and the phone back mark, are chevrons drawn with two borders of a turned box. The trail sits on its text line, level with the Find field at 1280px, and shows in full on phones when it has two crumbs, as in the approved mock. The owner chose it with header option A on 2026-11-11. | The breadcrumb as the header's start item: it read as the page title and moved the strip. The depth-3 rule it replaces kept trails off a two-level site, which then had only the header to show the way back. A `/` or `‹` glyph with empty alternative text: the owner chose chevrons, and a chevron drawn with borders holds no text in any engine. | `tests/site/site.spec.ts` "the breadcrumb sits above the h1 on every page but Overview, with no separator text", "the breadcrumb lines up with Find, and shows both crumbs on phones" |
| Header current page | Core | Header links are navigation rows 44px tall, and a link to the current page takes `aria-current="page"` with the current-page recipe. | Muted text links with no current state: the header did not show which page the reader was on. | No test yet |
| One scroll offset | Core | `html { scroll-padding-top }` is the only offset: any sticky bar plus `--space-4`. The location tracker reads the same value. | `scroll-margin-top` on each section: every page set its own value, and the tracker needs one inset to compare against. | No test yet |
| Specimen captions | Core | Every catalogue specimen has a caption that names its state, such as "Disabled" or "Rest, live". The owner chose it at Gate M. | Captions only on some specimens: a disabled or static specimen with no caption could read as broken. | every specimen captioned: `tests/catalogue/catalogue.spec.ts` "every specimen has a caption" |
| Focus ring | Core | One accent ring for every role but fields: `outline: var(--focus-width) solid var(--color-focus); outline-offset: var(--focus-offset)` (2px, 3px), with no glow, over the control's own shadow. Variants recolour it with `outline-color` only. `--color-focus` is `--color-accent`: 6.26:1 on the page, 5.72:1 on a surface, and 6.61:1 on a raised surface in light; 8.31:1, 7.85:1, and 7.39:1 in dark. Dark plates use `--color-project-dark`: 6.98:1 on the command plate on a light page, 7.48:1 on a dark page, and 8.53:1 on the terminal capture. Inspired by Astryx `focusDefaults` (`utils/focusOutline.stylex.ts`), which writes the width, accent colour, and offset once from tokens. The user chose it from rendered demos and accepted an accent ring around primary, danger, and selected controls. | The neutral ink ring (`--color-focus: var(--color-text)`, offset 2px), the previous choice; the user preferred the accent ring with a 3px gap in rendered demos. Before that, an accent ring on controls and a `--color-link` ring on links, tabs, and navigation rows: two ring hues, cyan around the purple selected tab and current row. A ring in each control's own tone, such as `--color-error` around the danger button: every role needs its own ring rule, and the ring changes colour from control to control. A 2px ring with a 6px accent halo felt jarring, and a 1.5px ring was hard to see; Chromium drew it 1px at 1x. | one accent ring: `tests/design-foundations.test.mjs` "every focus ring is the one accent ring: no per-role ring colour or hand-written ring on any page or in tokens.css"; ring contrast: `tests/design-foundations.test.mjs` "the accent focus ring reaches 3:1 on --color-bg, --color-surface, and --color-surface-raised in both themes" |
| Page colours outside the base layer | Core | `tokens.css` sets the body background and text colour unlayered; the rest of the base stays in `@layer base`, so component rules still outrank it. | Everything in the layer: a host that wraps the page in its own unlayered reset, such as an artifact viewer, kept its light body while the tokens switched to dark, so token-coloured text sat near-white on white. A note telling each page to restate the body: every delivered page would need to remember it, and the failure only shows in dark mode. | unlayered page colours: `tests/design-foundations.test.mjs` "keeps the page background and text colour outside the base layer, so a host reset cannot win" |
| Field focus | Core | No ring. The 1px edge turns `--color-accent` with a 2px inset line of the accent at 18% (`--shadow-field-focus`; `--shadow-field-focus-invalid` for an invalid field), and the fill does not change in either theme. An invalid field keeps `--color-error` for the edge and the line. Forced colors drop the line, so a focused field there draws a `Highlight` outline 1px inside its edge. Inspired by Astryx `Field/inputStyles.stylex.ts`: a full accent border with an inset muted-accent line, and `Highlight` in forced colors. The user approved it from rendered demos. | The control ring plus a lift out of the well onto the control surface (`--field-focus`, `--color-control-thumb`, `--shadow-raise`), the previous choice, replaced by the edge the user chose from rendered demos. Before that, the lift with a 35% accent edge and no ring, which measured below 3:1 in light, and an accent edge with a halo and an underline, which felt too heavy. | edge contrast of focused inputs (valid and invalid), selects, textareas, and search fields, light and dark: `tests/catalogue/catalogue.spec.ts` "contrast: every focused field edge, on its fill and on the surface around it reaches 3:1"; inset line and forced colors: no test yet |
| Selected tab | Core | The navigation row's lifted fill (`--color-nav-current`, `--shadow-nav-current`, accent text, 6px corners), with no underline. Navigation and tabs share one selected state. | A 2px accent underline curved at rounded corners and did not match navigation. | rule present: `tests/design-foundations.test.mjs` "every state rule the guard requires exists in the catalogue"; 6px corners: `tests/catalogue/catalogue.spec.ts` "each component renders the corner radius of its role, and no other part has one"; fill recipe: no test yet |
| Selected text | Core | The first of accent, body text, black, and white that reaches 4.5:1 over the rendered current-row fill, on the page and on a surface (`--color-nav-current-text`). | Accent text everywhere: the accent is validated only at 3:1, so some hues miss 4.5:1 as text on the fill. | accent text at 4.5:1: `tests/design-foundations.test.mjs` "accent text on the selected fill reaches 4.5:1 on every surface in both themes"; fallback order: no test yet |
| Selected tint | Core | The current row, selected tabs, and selected list rows take an accent tint: `--color-accent` at 14% over `--color-surface-raised` in light, at 20% over transparent in dark, and the capture's terminal accent at 20%. With the default hue, accent text measures 5.34:1 in light, and in dark 5.87:1 on `--color-bg`, 5.42:1 on `--color-surface`, and 5.05:1 on `--color-surface-raised`. The filip-stack site hue measures 4.94:1 in light and at least 5.06:1 in dark. A hue that misses 4.5:1 takes another `--color-nav-current-text`. | The plain white fill vanished on near-white panels, such as the light phone drawer. An 8% light tint (14% in dark): the light fill measured 1.07:1 against the page and read as nearly invisible. | tint keeps accent text at 4.5:1: `tests/design-foundations.test.mjs` "accent text on the selected fill reaches 4.5:1 on every surface in both themes"; quoted ratios: no test yet |
| Toggle card | Core | A 10px card (`--radius-field`). It is the named exception to borderless raised controls: a visible `--color-border-subtle` edge and no rest raise. Pressed, it takes an 8% accent tint over `--color-surface-raised` and a 35% accent hairline, and the switch carries the on state. Pressed text measures 15.99:1 in light and 12.15:1 in dark; muted text 6.28:1 and 5.79:1. | A pill with a solid accent border looked heavy, and wrapped labels sat badly in pill corners. | label and description contrast, and the quoted ratios: `tests/catalogue/catalogue.spec.ts` "contrast: every enabled toggle card label and description reaches 4.5:1", "contrast: the toggle card ratios in decisions.md match the render"; 10px corners: `tests/catalogue/catalogue.spec.ts` "each component renders the corner radius of its role, and no other part has one"; edge and hairline: no test yet |
| List selection | Core | A selected list row takes the navigation row's current recipe: `--color-nav-current` fill, `--shadow-nav-current`, medium weight, and `--color-nav-current-text` on its primary text. Dividers are straight pseudo-element lines inset by the row padding, hidden next to a filled row. In forced colors, a 2px `Highlight` border on the leading edge. | A 2px inset accent bar: it curved away on rounded rows, and the same bar was already rejected for navigation. A straight square bar beside a rounded fill: a second selection mark that navigation and tabs do not have. Square-cornered rows: they would break the rounded row shape shared with navigation and the select picker. | no inset bar: `tests/design-foundations.test.mjs` "no page marks a selected row with an inset accent bar"; row recipe: no test yet |
| Code blocks | Core | `text-size-adjust: 100%` on code, and prompts aligned to the line's baseline. | iOS Safari enlarged one line of wrapped code and left the `$` prompt misaligned. | No test yet |
| Live example layout | Core | Stage over source: one frame with the live preview on top and its code below one hairline, no header row and no language label, and Copy in the code's top right corner. shadcn/ui, Radix Themes, Mantine, Tailwind, Nuxt UI, React Aria, Primer, MUI, and Vuetify also stack preview over code. | A preview box and a code box, each with its own header at a different height, read as two unrelated boxes. | No test yet |
| Live example side by side | Core | Stage over source, at every width. | Preview and code side by side: a tall preview leaves the code column empty, long code leaves the preview floating, and the order breaks when the columns stack on phones. | No test yet |
| Live example code view | Core | The preview and its code show together. | Preview and Code tabs, as in Chakra UI: the reader never sees the element and its code together, the frame changes height with the tab, and reading the code takes an extra click. Storybook hides code by default, for the same cost. | No test yet |
| Live example stage | Core | The stage sits on the page background (`--color-bg`), not in a well or a panel. It is the named exception to the container depth tier. | A raised or inset stage changes the surface under the preview, so raised controls do not look as they do on a page. | No test yet |
| Live example source | Core | Inside the frame, the code block drops its own radius, border, and shadow, and spans the frame's width. The frame draws the one edge. | A rounded well with its own edge and shadow inside the frame is a frame inside a frame. | No test yet |
| Token names | Core | One set of names, the design system's own: `--color-*` for colours, `--shadow-*` for shadows, and `--text-*`, `--radius-*`, and `--font-*` for type sizes, radii, and fonts. See [Token names](#token-names). | Two sets, the [1.x short names](#token-names) here and `--color-*` names in ui: every site and agent translated between them through `ui:` comments, and the 1.x control radius used Tailwind's `-md` name for a 999px pill where Tailwind's is 0.375rem. Short names everywhere: about 920 references in 27 files across six repositories, a ui major release, and 18 new names. | no 1.x names: `tests/token-names.test.mjs` "no payload file uses a 1.x token name" |
| Versioning | Core | Releases are 1.0.x patches while the system has no outside users, tagged `ds-v*` from the `ds-v1.0.0` seed. Every release, a breaking one included, is a patch; the release notes name a breaking change written as `!` or a `BREAKING CHANGE:` footer in the commit message. npm already holds 1.1.0 through 2.4.0 from the earlier `v*` series, so leaving 1.0.x means 2.5.0 or 3.0.0. | Semver majors and minors (2.x): the numbers signalled a maturity the system does not have yet. 0.0.x: npm already holds 1.0.0, and a `^0.0.1` range matches only 0.0.1. | patch cap: `tests/release.test.mjs` "the release analyzer caps feat, fix, and breaking changes, from a commit or a PR body, at a patch", "the release analyzer releases nothing for chore-only commits"; tag series: `tests/release.test.mjs` "the site version comes from the latest ds-v tag and ignores the earlier v tags" |

## Token names

The 2.0.0 token rename, in the earlier version series (see Versioning), renamed 56 short names to the names `@filipgutica/ui` already declared. No value changed. Each `tokens.css` line before the rename named its ui variable in a `ui:` comment, and that variable became the line's name. Where a comment named several, the first one did.

To update a page or stylesheet that still uses the old names, run `node scripts/rename-tokens.mjs <file>...`. It reads the table below. A test fails when an old name appears in `language/` outside this table.

Name a new token by these rules:

- Give it one name, with no alias.
- Use Tailwind 4's theme namespaces where one fits: `--color-<role>` for a colour, `--shadow-<role>` for a shadow, `--radius-<role>` for a radius, `--font-<role>` for a font family, and `--text-<step>` for a type size. Other families keep their prefix, such as `--space-*`, `--control-*`, `--weight-*`, and `--duration-*`.
- Name the role, not the value: `--color-error`, not `--color-red`.
- A component role that always has the same value as a general token is not a new token. The component uses the general token.
- Do not reuse an old name from this table.

<!-- rename-table:start -->
Prose patterns moved with the names: `--*-soft` is now `--color-*-surface`, and `--code-*` is now `--color-code`, `--color-code-text`, and `--color-syntax-*`.

| Name before the 2.0.0 rename | Name after |
| --- | --- |
| `--project` | `--color-project` |
| `--project-dark` | `--color-project-dark` |
| `--font-sans` | `--font-ui` |
| `--bg` | `--color-bg` |
| `--surface` | `--color-surface` |
| `--surface-raised` | `--color-surface-raised` |
| `--surface-hover` | `--color-surface-muted` |
| `--field` | `--color-well` |
| `--field-bg` | `--color-field-bg` |
| `--field-edge` | `--color-field-edge` |
| `--field-shadow` | `--shadow-field` |
| `--control-thumb` | `--color-control-thumb` |
| `--nav-current` | `--color-nav-current` |
| `--nav-current-text` | `--color-nav-current-text` |
| `--segmented-current-text` | `--color-segmented-current-text` |
| `--row-hover` | `--color-row-hover` |
| `--row-selected` | `--color-row-selected` |
| `--border-subtle` | `--color-border-subtle` |
| `--border` | `--color-border` |
| `--text` | `--color-text` |
| `--text-muted` | `--color-muted` |
| `--overlay` | `--color-overlay` |
| `--accent` | `--color-accent` |
| `--accent-text` | `--color-accent-text` |
| `--accent-hover` | `--color-accent-hover` |
| `--button-hover` | `--color-button-hover` |
| `--link` | `--color-link` |
| `--focus` | `--color-focus` |
| `--success` | `--color-success` |
| `--warning` | `--color-warning` |
| `--danger` | `--color-error` |
| `--info` | `--color-info` |
| `--success-soft` | `--color-success-surface` |
| `--warning-soft` | `--color-warning-surface` |
| `--danger-soft` | `--color-error-surface` |
| `--info-soft` | `--color-info-surface` |
| `--command-bg` | `--color-command-bg` |
| `--command-border` | `--color-command-border` |
| `--terminal` | `--color-terminal` |
| `--terminal-text` | `--color-terminal-text` |
| `--terminal-border` | `--color-terminal-border` |
| `--terminal-caption` | `--color-terminal-caption` |
| `--terminal-caption-text` | `--color-terminal-caption-text` |
| `--terminal-shadow-nav-current` | `--shadow-nav-current-dark` |
| `--code-bg` | `--color-code` |
| `--code-text` | `--color-code-text` |
| `--code-key` | `--color-syntax-keyword` |
| `--code-string` | `--color-syntax-string` |
| `--code-number` | `--color-syntax-number` |
| `--code-comment` | `--color-syntax-comment` |
| `--radius-sm` | `--radius-inline` |
| `--radius-md` | `--radius-control` |
| `--radius-lg` | `--radius-panel` |
| `--shadow-popover` | `--shadow-dialog` |
| `--field-shadow-focus` | `--shadow-field-focus` |
| `--field-shadow-focus-invalid` | `--shadow-field-focus-invalid` |
<!-- rename-table:end -->

## Known gaps

- Field focus is a 1px `--color-accent` edge with a faint 2px inset line. The edge reaches 3:1 for a focus indicator (WCAG 1.4.11): 6.61:1 against the light field fill and 6.26:1 against `--color-bg`, and 8.43:1 and 8.31:1 in dark. It is thinner than the 2px perimeter that the WCAG 2.2 AAA focus appearance criterion (2.4.13) asks for; the inset line, at 18% of the accent, does not reach 3:1 on its own.
- In forced colors (high contrast), an invalid field shows the system edge, not the `--color-error` edge, focused or not; the error text carries the state. A visible high-contrast invalid edge needs an error colour validated against high-contrast fills.
