Skip to content

feat(themes): grow the roster to seven and make the picker a popover - #278

Merged
andraderaul merged 3 commits into
mainfrom
feat/theme-roster-seven-popover
Jul 27, 2026
Merged

feat(themes): grow the roster to seven and make the picker a popover#278
andraderaul merged 3 commits into
mainfrom
feat/theme-roster-seven-popover

Conversation

@andraderaul

@andraderaul andraderaul commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Closes #272.

Problem

The deck shipped three Themes behind a header control that cycled — a shape ADR 0024 capped at four, because cycling trades discoverability for width and the trade only holds while any Theme is at most two activations away. The design work produced four more Themes from the Sprawl trilogy's interior vocabulary, and there was nowhere to put seven a user could navigate.

Solution

Grow the roster to seven and replace the cycling control with a popover.

The four new Themes

Each is one semantic-layer block in tokens.css — no new tokens, no component-token changes — and each cleared the Theme Contract, auto-discovered by the contrast guard:

  • kuang — Neuromancer's icebreaker: arterial red on near-black. The accent owns red, so danger vacates to hot magenta (#ff43a8) — the one palette where a role had to move, and the tightest against the contract.
  • ougou — Count Zero's loa of iron: a blued-steel field under a warm ember accent.
  • solitude — Mona Lisa Overdrive's toxic junkyard: rust corrosion under a sickly chem-yellow warning.
  • onyx — the deck with the neon off: silver on true black, colour spent only on status.

The picker becomes a popover

ThemeControl stops cycling: the trigger opens a panel listing the whole roster, marks the Theme in force (menuitemradio + aria-checked), and picks by name. It opens/moves/dismisses by keyboard, returns focus to the trigger on close, and carries accessible names throughout. It anchors small to the trigger so it fits the contested header at mobile widths.

Interfaces

  • useTheme now exposes { theme, themes, setTheme }; nextTheme/cycle are retired. Resolution of an absent/unknown stored value to ice is unchanged.
  • The four-Theme ceiling assertion (length <= 4) is removed; ice-first, default and named-once invariants stay in their own tests.
  • The three pre-paint scripts name the full roster in order; the roster guard keeps the TypeScript, the stylesheet and the scripts in step, and keeps SPRAWL//Atlas excluded.

Testing

  • Popover behaviour tests (open, list whole roster, select applies+persists+closes, active marked, keyboard open/move/dismiss with focus return, accessible names) and setTheme hook effects — written test-first.
  • The palettes, roster parity and pre-paint scripts are covered for free by the existing contrast / roster / vocabulary guards.
  • GOLEM's app-level smoke test updated from the cycling flow to the popover flow.
  • Full suite green across all workspaces (npm run test:run), typecheck and biome clean.

Changesets

@cyberdeck/deck-kit, ascii, glitch, golem — minor (control change); sprawl — patch (kit dependency, no on-screen change).

Review follow-ups

  • theme-control.test.tsx: the trigger() helper's rationale moved from a plain // block to JSDoc, per the deck's comment convention (JSDoc on module-level declarations).
  • theme-control.tsx: keyboard-nav lookup switched from findIndex to indexOf (Biome useIndexOf), with activeElement cast to the ref array's element type. No behaviour change; no changeset needed (both are format/lint-only).

🤖 Generated with Claude Code

The deck shipped three Themes behind a header control that cycled — a shape
ADR 0024 capped at four, because cycling trades discoverability for width and
the trade only holds while any Theme is two activations away. The design work
produced four more Themes from the Sprawl trilogy's interior vocabulary, and
the deck had nowhere to put seven a user could navigate.

Grow the roster to seven and replace the cycling control with a popover
(issue #272):

- `kuang` (arterial red; danger vacates to magenta), `ougou` (blued-steel under
  ember), `solitude` (rust with chem-yellow), `onyx` (silver on true black) join
  `ice`, `construct`, `chiba`. Each is one semantic-layer block; each cleared the
  Theme Contract, auto-discovered by the contrast guard.
- `ThemeControl` becomes a popover: lists the whole roster, marks the Theme in
  force, picks by name, keyboard-operable with focus returning to the trigger,
  accessible names throughout. `menuitemradio` + `aria-checked` semantics.
- `useTheme` now exposes `{ theme, themes, setTheme }`; `nextTheme`/`cycle` are
  retired. The four-Theme ceiling assertion is removed; ice-first, default and
  named-once invariants stay.
- The three pre-paint scripts name the full roster in order; the roster guard
  keeps the TypeScript, the stylesheet and the scripts in step, and keeps
  SPRAWL//Atlas excluded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ascii-art-converter Ready Ready Preview, Comment Jul 27, 2026 12:48am
atlas-sprawl Ready Ready Preview, Comment Jul 27, 2026 12:48am
cyberdeck-golem Ready Ready Preview, Comment Jul 27, 2026 12:48am
glitch-studio Ready Ready Preview, Comment Jul 27, 2026 12:48am

The comment convention mandates JSDoc on module-level declarations; the
trigger() helper carried its rationale as a plain // block. Format only —
wording unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ard nav

The findIndex callback only tests equality against a single value, so
indexOf expresses it directly (Biome useIndexOf). activeElement is cast to
the ref array's element type, which the equality check did implicitly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@andraderaul

Copy link
Copy Markdown
Owner Author

Code review — findings & disposition

A two-axis /code-review (Standards + Spec) ran against main...HEAD. Recording the outcome here since the findings weren't filed as inline threads.

Resolved

  • Standards (hard) — test-helper JSDoc. trigger() in theme-control.test.tsx carried its rationale as a plain // block; the comment convention mandates JSDoc on module-level declarations. Fixed in 23ef7e4.
  • Lint (Biome info) — useIndexOf. Keyboard-nav lookup used findIndex where the callback only tests equality; switched to indexOf with an activeElement cast. Fixed in 77a7afc.

Reviewed, no change (judgement calls / accepted partials)

  • Standards (minor) — Middle Man in useTheme. Returning themes: THEMES through the hook is thin delegation, but exposing the roster from the hook is what the PRD asked for ("expose … the roster itself so the popover can render every option"). Kept intentionally.
  • Standards (minor) — duplicated focus-ring string / marker span in theme-control.tsx. Small verbatim repeat across trigger and options; below the deck's extraction bar (empty diff + two real callers). Left as-is.
  • Spec (partial) — story 7, focus return on outside-click. Escape and pick paths return focus to the trigger; the outside-click path drops focus to <body>. Common, acceptable pattern — noted, not changed.
  • Spec (partial) — story 20, mobile fit. Popover is anchored (absolute right-0 top-full min-w-[8rem]) so it doesn't widen the header; no explicit max-width guard or test. Low risk given the 8rem min — noted, not changed.

No scope creep and no wrong implementations found; all palette values match the spec table, roster parity and the Theme Contract are guard-enforced.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PRD: Expand the Theme roster to seven, and make the picker a popover

1 participant