[UX] Establish a design system and component library #364

Closed
opened 2026-08-25 20:39:25 +00:00 by claude-bot · 9 comments
Collaborator

Found in the August 2026 session lifecycle review (#319). Follows the mockup selection.

Why

The review found the same concept rendered differently on different pages: attendance implemented four times with different labels for the same toggle, summary editing appearing twice (editable in one place, read-only in the other with no indication that editing lives elsewhere), two full proposal-review UIs that differ in three user-visible ways, and error handling split between inline messages, page-level errors that destroy the rendered content, and native alert() calls.

That is what happens without a shared vocabulary of components. A redesign that does not produce one will drift back to the same state.

What

Extract the chosen direction into tokens and components: type scale, spacing, colour with a genuine dark mode, status and severity treatments, buttons and their hierarchy, form controls, empty states, loading states, error states, badges and counts, cards, modals and confirmations, and the async-progress pattern that v4.3.0 introduces.

Codify the interaction rules the review found violated: an error never destroys already-rendered content; every destructive action explains its consequence (the codebase already does this well in places and should be the model); every async operation shows progress and a next action on failure; disabled controls explain why.

Acceptance criteria

  • Tokens defined for type, spacing, colour, and elevation, with dark mode
  • A component covering each pattern listed above, with documented states
  • Touch targets meet a stated minimum — the review found 16-32 px controls throughout
  • Documented interaction rules for errors, async work, and destructive actions
  • Components are the only way those patterns are built afterwards
Found in the August 2026 session lifecycle review (#319). Follows the mockup selection. ## Why The review found the same concept rendered differently on different pages: attendance implemented four times with different labels for the same toggle, summary editing appearing twice (editable in one place, read-only in the other with no indication that editing lives elsewhere), two full proposal-review UIs that differ in three user-visible ways, and error handling split between inline messages, page-level errors that destroy the rendered content, and native `alert()` calls. That is what happens without a shared vocabulary of components. A redesign that does not produce one will drift back to the same state. ## What Extract the chosen direction into tokens and components: type scale, spacing, colour with a genuine dark mode, status and severity treatments, buttons and their hierarchy, form controls, empty states, loading states, error states, badges and counts, cards, modals and confirmations, and the async-progress pattern that v4.3.0 introduces. Codify the interaction rules the review found violated: an error never destroys already-rendered content; every destructive action explains its consequence (the codebase already does this well in places and should be the model); every async operation shows progress and a next action on failure; disabled controls explain why. ## Acceptance criteria - [ ] Tokens defined for type, spacing, colour, and elevation, with dark mode - [ ] A component covering each pattern listed above, with documented states - [ ] Touch targets meet a stated minimum — the review found 16-32 px controls throughout - [ ] Documented interaction rules for errors, async work, and destructive actions - [ ] Components are the only way those patterns are built afterwards
Author
Collaborator

Two interaction rules decided for the design system (owner, 2026-09-15; surface catalogue #659):

D12, how strongly to confirm. Today it is typed names, browser confirm(), or nothing, with no rule.

  • Typed-name confirmation for irreversible deletes that reach a whole campaign, an account, or a member: delete campaign, delete account, delete user, erase a member's recordings.
  • A confirmation that states the consequence for other irreversible actions, e.g. "Reopen for voting — every vote will be lost". Not a bare "Are you sure?".
  • An undo instead of a confirmation for reversible actions, such as removing a shelf card or "Copy from" on the Shelf.

D13, updates. A quiet "Updated — reload" notice when a new version is ready, replacing today's silent auto-reload. Installation stays with the browser; there is no custom install prompt.

The catalogue's shared-patterns surface (SH-7) lists every confirmation, error, empty, busy and stale state the library needs to cover.

**Two interaction rules decided for the design system (owner, 2026-09-15; surface catalogue #659):** **D12, how strongly to confirm.** Today it is typed names, browser `confirm()`, or nothing, with no rule. - **Typed-name confirmation** for irreversible deletes that reach a whole campaign, an account, or a member: delete campaign, delete account, delete user, erase a member's recordings. - **A confirmation that states the consequence** for other irreversible actions, e.g. "Reopen for voting — every vote will be lost". Not a bare "Are you sure?". - **An undo instead of a confirmation** for reversible actions, such as removing a shelf card or "Copy from" on the Shelf. **D13, updates.** A quiet **"Updated — reload"** notice when a new version is ready, replacing today's silent auto-reload. Installation stays with the browser; there is no custom install prompt. The catalogue's shared-patterns surface (SH-7) lists every confirmation, error, empty, busy and stale state the library needs to cover.
Author
Collaborator

This is lane 0 of the build (2026-09-21). Its technical design is §3 of docs/design/v4.5.0-build-plan.md: the prototype kit's tokens verbatim into src/ui/tokens.css and through Tailwind 4's @theme; dark mode by the kit's data-mode attribute; the sixty-seven icons as one Icon component; the component list from the brief §8 made concrete (shell, page, controls, feedback, content, overlays, and the sheet's pieces for lane 11); the rules as code (D12's tiers, D22's leave guard, D36's Target, D65's Stop, the section boundary, the async pattern, 44 px targets); and a /design reference page in the dev build instead of Storybook. §3.4 says what happens to each of today's 20 pages and 39 components.

Acceptance, beyond the list above: every component's states on /design in both themes; eslint-plugin-jsx-a11y in the lint job (WCAG 2.2 AA is the stated standard, #367); nothing in the app uses the library yet, so main is unchanged for a person. Lane 1 (#727) is the first consumer. Umbrella: #366.

**This is lane 0 of the build (2026-09-21).** Its technical design is §3 of `docs/design/v4.5.0-build-plan.md`: the prototype kit's tokens verbatim into `src/ui/tokens.css` and through Tailwind 4's `@theme`; dark mode by the kit's `data-mode` attribute; the sixty-seven icons as one `Icon` component; the component list from the brief §8 made concrete (shell, page, controls, feedback, content, overlays, and the sheet's pieces for lane 11); the rules as code (D12's tiers, D22's leave guard, D36's `Target`, D65's `Stop`, the section boundary, the async pattern, 44 px targets); and a `/design` reference page in the dev build instead of Storybook. §3.4 says what happens to each of today's 20 pages and 39 components. Acceptance, beyond the list above: every component's states on `/design` in both themes; `eslint-plugin-jsx-a11y` in the lint job (WCAG 2.2 AA is the stated standard, #367); nothing in the app uses the library yet, so main is unchanged for a person. Lane 1 (#727) is the first consumer. Umbrella: #366.
Author
Collaborator

Picking this up, 2026-09-22. The owner agreed the brief (#361, closed) and confirmed the plan's five assumptions, so lane 0 starts. First PR: the foundation — src/ui/tokens.css with Lapis Table's light and dark tokens read through Tailwind's @theme, dark mode by data-mode, self-hosted fonts, the Icon component from the prototype's sixty-seven icons, the component conventions (one folder each, with a states.jsx the /design page collects), the /design page itself in the dev build, eslint-plugin-jsx-a11y in lint, and the first components with their states and tests. The shell components and the content and sheet pieces follow in the same PR series. Nothing in the app uses the library until lane 1.

**Picking this up, 2026-09-22.** The owner agreed the brief (#361, closed) and confirmed the plan's five assumptions, so lane 0 starts. First PR: the foundation — `src/ui/tokens.css` with Lapis Table's light and dark tokens read through Tailwind's `@theme`, dark mode by `data-mode`, self-hosted fonts, the `Icon` component from the prototype's sixty-seven icons, the component conventions (one folder each, with a `states.jsx` the `/design` page collects), the `/design` page itself in the dev build, `eslint-plugin-jsx-a11y` in lint, and the first components with their states and tests. The shell components and the content and sheet pieces follow in the same PR series. Nothing in the app uses the library until lane 1.
Author
Collaborator

Progress, 2026-09-22. Two PRs so far:

  • #739, merged: the foundation — Lapis Table's tokens in light and dark, read by Tailwind through @theme inline; dark mode by data-mode; self-hosted Figtree, Unbounded and Alegreya; the sixty-seven icons as one Icon; Button, Banner, Stop, Busy, Modal, SectionBoundary, Target, useAsync, useLeaveGuard; the /design reference page (dev build only) with a test that fails if any state throws; eslint-plugin-jsx-a11y at error on the library.
  • #740, open: the controls, content and overlays — Field/Select/Textarea, Toggle, Chip, Segmented, Tabs, InlineError, AudiencePicker, DateTime, Card, List, Label, Meter, Fold, PersonDisc/Person, Doc/Section/KV/StatRow, Table, SaveState, Menu, useOnClickOutside. 26 components, 138 states, 1,041 tests.

Remaining for lane 0: the shell components (app bar, Contents rail and phone sheet, account menu, bell, search, footer, live bar), and the sheet's pieces (Derivation, RankPips, Track, Resource, Dice, StepStrip, WhatChanges, Picker), which I intend to build when lane 11 starts rather than now, since only the sheet uses them. Nothing in the app uses the library yet; lane 1 (#727) is the first consumer.

**Progress, 2026-09-22.** Two PRs so far: - **#739, merged**: the foundation — Lapis Table's tokens in light and dark, read by Tailwind through `@theme inline`; dark mode by `data-mode`; self-hosted Figtree, Unbounded and Alegreya; the sixty-seven icons as one `Icon`; `Button`, `Banner`, `Stop`, `Busy`, `Modal`, `SectionBoundary`, `Target`, `useAsync`, `useLeaveGuard`; the `/design` reference page (dev build only) with a test that fails if any state throws; `eslint-plugin-jsx-a11y` at error on the library. - **#740, open**: the controls, content and overlays — `Field`/`Select`/`Textarea`, `Toggle`, `Chip`, `Segmented`, `Tabs`, `InlineError`, `AudiencePicker`, `DateTime`, `Card`, `List`, `Label`, `Meter`, `Fold`, `PersonDisc`/`Person`, `Doc`/`Section`/`KV`/`StatRow`, `Table`, `SaveState`, `Menu`, `useOnClickOutside`. 26 components, 138 states, 1,041 tests. Remaining for lane 0: the shell components (app bar, Contents rail and phone sheet, account menu, bell, search, footer, live bar), and the sheet's pieces (Derivation, RankPips, Track, Resource, Dice, StepStrip, WhatChanges, Picker), which I intend to build when lane 11 starts rather than now, since only the sheet uses them. Nothing in the app uses the library yet; lane 1 (#727) is the first consumer.
Author
Collaborator

PR #740 (the controls, content and overlays) is merged on green CI. Twenty-six components and 138 states on /design; 1,041 frontend tests.

The third and last slice is in progress on build/lane0-shell: the app frame (AppShell, AppBar, Contents as the desktop rail and the phone sheet with the You group, Footer, LiveBar in its three states, Banners stacked worst first) and the three popovers (AccountMenu, Bell as a role="dialog" panel, Search with grouped results and D16's transcript switch). Phone versus desktop is decided by the kit's container query on the .app root, not by a prop, so one component renders both and /design shows the phone form in a 390 px box.

PR #740 (the controls, content and overlays) is merged on green CI. Twenty-six components and 138 states on `/design`; 1,041 frontend tests. The third and last slice is in progress on `build/lane0-shell`: the app frame (`AppShell`, `AppBar`, `Contents` as the desktop rail and the phone sheet with the You group, `Footer`, `LiveBar` in its three states, `Banners` stacked worst first) and the three popovers (`AccountMenu`, `Bell` as a `role="dialog"` panel, `Search` with grouped results and D16's transcript switch). Phone versus desktop is decided by the kit's container query on the `.app` root, not by a prop, so one component renders both and `/design` shows the phone form in a 390 px box.
Author
Collaborator

PR #741 is the third and last slice: the shell. AppShell/Book, AppBar, Contents/ContentsSheet, Footer, LiveBar, Banners, AccountMenu, Bell, Search. Thirty-five components, 160 states, 1,127 tests; lint clean; render-checked in both themes with headless Edge, which caught a bug from the first PR (a link drawn as a primary button had an invisible label: the base link colour outranked the button's).

Reviewed against the kit rather than taken as delivered: the crumb bolds the campaign as lane 1's stills do; the phone's Contents sheet ends with Sign out after the theme, as the kit's You group does; Search gained the kit's Spoken and GM-only chips, the three-per-group cap with "All N in Sessions", the second-letter wait (CA-095) and the not-answering state. The PR body has the decisions.

After it merges, #364 stays open for the sheet pieces (Derivation, RankPips, Track, Resource, Dice, StepStrip, WhatChanges, Picker), which are best built when lane 11 (#737) starts and can use them. Lane 1 (#727) and the journeys harness (#726) are next.

PR #741 is the third and last slice: the shell. `AppShell`/`Book`, `AppBar`, `Contents`/`ContentsSheet`, `Footer`, `LiveBar`, `Banners`, `AccountMenu`, `Bell`, `Search`. Thirty-five components, 160 states, 1,127 tests; lint clean; render-checked in both themes with headless Edge, which caught a bug from the first PR (a link drawn as a primary button had an invisible label: the base link colour outranked the button's). Reviewed against the kit rather than taken as delivered: the crumb bolds the campaign as lane 1's stills do; the phone's Contents sheet ends with Sign out after the theme, as the kit's You group does; Search gained the kit's Spoken and GM-only chips, the three-per-group cap with "All N in Sessions", the second-letter wait (CA-095) and the not-answering state. The PR body has the decisions. After it merges, #364 stays open for the sheet pieces (Derivation, RankPips, Track, Resource, Dice, StepStrip, WhatChanges, Picker), which are best built when lane 11 (#737) starts and can use them. Lane 1 (#727) and the journeys harness (#726) are next.
Author
Collaborator

PR #741 (the shell) is merged on green CI. Lane 0's three PRs are in: #739 the foundation, #740 the controls, content and overlays, #741 the shell. src/ui/ has 35 components and 160 states on /design, 1,127 frontend tests, lint clean under jsx-a11y at error, render-checked in both themes. Nothing in the app uses the library yet; that starts with lane 1 (#727), which also brings the journeys harness (#726).

Left open here, deliberately: the sheet pieces (Derivation, RankPips, Track, Resource, Dice, StepStrip, WhatChanges, Picker), to be built when lane 11 (#737) starts and can use them. Anything lane 1 finds missing in the shell comes back here too.

PR #741 (the shell) is merged on green CI. Lane 0's three PRs are in: #739 the foundation, #740 the controls, content and overlays, #741 the shell. `src/ui/` has 35 components and 160 states on `/design`, 1,127 frontend tests, lint clean under jsx-a11y at error, render-checked in both themes. Nothing in the app uses the library yet; that starts with lane 1 (#727), which also brings the journeys harness (#726). Left open here, deliberately: the sheet pieces (Derivation, RankPips, Track, Resource, Dice, StepStrip, WhatChanges, Picker), to be built when lane 11 (#737) starts and can use them. Anything lane 1 finds missing in the shell comes back here too.
Author
Collaborator

Two corrections to the library, both found by lane 2 (#728) building on it. Neither is a question — both are fixed and merged — but this issue is still open and should carry the record.

1. .label shipped the version the look retired (fixed in #756)

base.css had:

.label { letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-faint); }

under a comment calling it a "small caps label". But prototype/shared/looks.css:153 says, in as many words:

/* Labels: sentence case everywhere; the kit's tracked capitals are retired. */
:root[data-look] .label { text-transform: none; letter-spacing: 0; … color: var(--ink-soft); }

The port took kit.css's rule and the look's --label-* tokens, but not the look's own three properties. :root[data-look] is on in every prototype page, so sentence case is what every drawing the owner reviewed shows — and the app read AN INVITATION where the drawing reads An invitation, on the front door, sign in, Home, You and all five public pages.

Now matches the look. A side benefit: --ink-soft rather than --ink-faint is 8.1:1 in light and 9.8:1 in dark against --paper, where it was 5.5:1 and 6.7:1.

Worth checking for siblings. This is the failure mode of porting kit.css without looks.css on top, and looks.css overrides more than labels — :root[data-look] h1…h4 is the one I checked and the port got right. Anything else the look re-declares deserves the same look.

2. The base now sits in a cascade layer (fixed in #751)

Not a port error — a latent fragility that lane 2 happened to trip.

base.css scopes the type to .qb, and AppFrame puts .qb on each chrome piece, so .qb { background: var(--paper) } and .appbar { background: var(--chrome-bg) } are equally specific on the same element. So are .qb a and each of a.btn--primary, .foot a, .appbar a, .toc--sub a. Every one of those ties is meant to go to the component, and did — on source order alone, which main.jsx arranges deliberately and explains.

Source order is not something the app controls. Adding a second eager consumer of src/ui/ tipped Vite into emitting the kit's CSS as a shared chunk, and a shared chunk's stylesheet is linked before the entry's. base.css landed last, .qb started winning, and the shell drew its near-white --chrome-ink on light paper: 82 contrast violations across the app bar, the Contents and the account links, and 18 red journeys.

The fix is one line in src/ui/index.css:

@import "./base.css" layer(qb-base);

An un-layered rule beats a layered one whatever order they load in, so "a component beats the base" is now a property of the stylesheet rather than of the bundler's output. Nothing else is layered, deliberately.

Two alternatives were measured and rejected: cssCodeSplit: false concatenates with the entry last and makes the inversion permanent; and :where() on individual base rules treats one symptom at a time — applied to .qb a it made things worse, handing .appbar a and .toc--sub a their win too.

The rule for later lanes: do not rely on import order to win a tie against the base. If a component's rule needs to beat base.css, it already does.

Also, for whoever builds lane 11's sheet pieces

Choices gained a named export Check in #759 — one .choice row with a checkbox rather than a radio, for the setup wizard's "I have kept a copy somewhere safe". Choices itself is untouched (zero removed lines, no CSS diff). The kit had no single checkbox: Choices is radios and Toggle is a role="switch" for a setting.

And one thing that will bite any port of a ::before: Chromium folds CSS generated content into the accessible name and jsdom does not, so a state glyph drawn that way gives a component two different accessible names depending on who is asking. Unit tests pass, journeys fail, same assertion. Carry the state in an aria-label and leave the glyph decorative.

🤖 Generated with Claude Code

**Two corrections to the library, both found by lane 2 (#728) building on it.** Neither is a question — both are fixed and merged — but this issue is still open and should carry the record. ## 1. `.label` shipped the version the look retired (fixed in #756) `base.css` had: ```css .label { letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-faint); } ``` under a comment calling it a "small caps label". But `prototype/shared/looks.css:153` says, in as many words: ```css /* Labels: sentence case everywhere; the kit's tracked capitals are retired. */ :root[data-look] .label { text-transform: none; letter-spacing: 0; … color: var(--ink-soft); } ``` The port took `kit.css`'s rule and the look's `--label-*` tokens, but not the look's own three properties. `:root[data-look]` is on in **every** prototype page, so sentence case is what every drawing the owner reviewed shows — and the app read `AN INVITATION` where the drawing reads *An invitation*, on the front door, sign in, Home, You and all five public pages. Now matches the look. A side benefit: `--ink-soft` rather than `--ink-faint` is **8.1:1** in light and **9.8:1** in dark against `--paper`, where it was 5.5:1 and 6.7:1. **Worth checking for siblings.** This is the failure mode of porting `kit.css` without `looks.css` on top, and `looks.css` overrides more than labels — `:root[data-look] h1…h4` is the one I checked and the port got right. Anything else the look re-declares deserves the same look. ## 2. The base now sits in a cascade layer (fixed in #751) Not a port error — a latent fragility that lane 2 happened to trip. `base.css` scopes the type to `.qb`, and `AppFrame` puts `.qb` on each chrome piece, so `.qb { background: var(--paper) }` and `.appbar { background: var(--chrome-bg) }` are **equally specific on the same element**. So are `.qb a` and each of `a.btn--primary`, `.foot a`, `.appbar a`, `.toc--sub a`. Every one of those ties is meant to go to the component, and did — on source order alone, which `main.jsx` arranges deliberately and explains. Source order is not something the app controls. Adding a *second* eager consumer of `src/ui/` tipped Vite into emitting the kit's CSS as a shared chunk, and a shared chunk's stylesheet is linked **before** the entry's. base.css landed last, `.qb` started winning, and the shell drew its near-white `--chrome-ink` on light paper: 82 contrast violations across the app bar, the Contents and the account links, and 18 red journeys. The fix is one line in `src/ui/index.css`: ```css @import "./base.css" layer(qb-base); ``` An un-layered rule beats a layered one whatever order they load in, so **"a component beats the base" is now a property of the stylesheet rather than of the bundler's output**. Nothing else is layered, deliberately. Two alternatives were measured and rejected: `cssCodeSplit: false` concatenates with the entry *last* and makes the inversion permanent; and `:where()` on individual base rules treats one symptom at a time — applied to `.qb a` it made things worse, handing `.appbar a` and `.toc--sub a` their win too. **The rule for later lanes: do not rely on import order to win a tie against the base.** If a component's rule needs to beat `base.css`, it already does. ## Also, for whoever builds lane 11's sheet pieces `Choices` gained a named export **`Check`** in #759 — one `.choice` row with a checkbox rather than a radio, for the setup wizard's "I have kept a copy somewhere safe". `Choices` itself is untouched (zero removed lines, no CSS diff). The kit had no single checkbox: `Choices` is radios and `Toggle` is a `role="switch"` for a setting. And one thing that will bite any port of a `::before`: **Chromium folds CSS generated content into the accessible name and jsdom does not**, so a state glyph drawn that way gives a component two different accessible names depending on who is asking. Unit tests pass, journeys fail, same assertion. Carry the state in an `aria-label` and leave the glyph decorative. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Collaborator

Signed off by the owner on the Redesign Review page, 2026-10-01, all seventeen pieces OK and the R3 re-check (/design for administrators) OK.

Their note: "Overall this looks great. However, selecting anything causes some of the modals to go blank. Not really sure why. I'm still going to sign off on this because the design work is done, I just am not 100% on the functionality, but I also don't think that this is critical to keep in the app upon release."

The blank modal specimens on /design are tracked in #1020. Lane 11's sheet pieces, the last thing this issue was open for, are on main. Closing.

**Signed off by the owner on the Redesign Review page, 2026-10-01**, all seventeen pieces OK and the R3 re-check (/design for administrators) OK. Their note: "Overall this looks great. However, selecting anything causes some of the modals to go blank. Not really sure why. I'm still going to sign off on this because the design work is done, I just am not 100% on the functionality, but I also don't think that this is critical to keep in the app upon release." The blank modal specimens on /design are tracked in #1020. Lane 11's sheet pieces, the last thing this issue was open for, are on main. Closing.
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/Quest-Board#364
No description provided.