Skip to content

Latest commit

 

History

History
453 lines (388 loc) · 24.6 KB

File metadata and controls

453 lines (388 loc) · 24.6 KB

tmux-pilot Web Configuration Console — Design System

Scope: src/web/ (app.js, app.css, theme.js, carousel.js, index.html, assets/). This is the browser client for /tmux-pilot:webui, served by src/config-server.ts. For the harness/role artwork generation contract specifically (motifs, provenance rules, what's approved), see docs/webui-art-bible.md — this document covers everything else: layout, color, type, and component anatomy.

No framework, no bundler, no build step for the client itself — plain HTML + CSS + native ES modules, self-sufficient under a strict CSP (script-src 'self', style-src 'self', font-src 'self', img-src 'self' data:, no 'unsafe-inline' anywhere). That constraint shapes several decisions below (self-hosted webfont/art assets, no inline style=, and CSP-safe data: SVG only for procedural themed-art fallbacks).

Design premise: character menu, not a settings form

The console is a WoW-style talent-pane/quest-log composition, not a generic admin dashboard. Two zones with different jobs:

  • Chrome — header bar, side nav, footer, dialogs, and the bordered frame and header band of every card. Always neutral, identical regardless of which harness/role is on screen, and never reads theme color variables. "Neutral" means a warm charcoal ladder (#171619#3b373b), not flat black. The page keeps a black tonal anchor while borders, header bands, active nav, and controls remain visibly distinct.
  • Content — the interior of a card. Either a themed well (harness/role detail pages: dark art backdrop + translucent scrim panels tinted to that harness/role's identity color) or, for pages with no single owning harness/role (Overview, Global settings, Appearance's neutral panels, Advanced YAML, Review & save), a parchment body — literal tan paper texture with dark ink text, like a quest log page.

This split is physically real in the DOM, not just a class name: every card is .panel > (.panel-head-wrap, .panel-body). The head band is always raised charcoal chrome; only .panel-body carries the parchment (or, inside a themed well, the scrim). Anything that builds a class="panel" section by hand instead of going through panel()/scrimPanel() will end up with zero padding and no theming — this exact bug shipped twice in this repo's history (Advanced YAML's two sub-panels, and Review & save) before being caught. If you add a new panel, use the helpers.

Layout architecture

app-shell (grid: 13rem nav | 1fr main; collapses to 0 | 1fr)
├─ section-nav        chrome, neutral
└─ main
   └─ panel                              ← .panel: border, radius, overflow:hidden, padding:0
      ├─ panel-head-wrap                 ← always var(--surface) dark, own padding
      │  ├─ panel-head                   domain-tile + <h2>/<h3>
      │  └─ panel-rule                   thin gradient hairline
      └─ panel-body                      ← parchment (neutral) OR scrim (themed well)
         ├─ .muted copy paragraph
         └─ field-grid (single column stack of .field rows)

The header hamburger owns both responsive navigation modes. On desktop it collapses the sidebar fully to the left and persists that preference in localStorage; the header button remains available to restore it. At 760px and below the sidebar becomes a full-height off-canvas drawer with a dimming backdrop, Escape/backdrop dismissal, and a visible in-drawer close control. Do not bring back the old multi-row mobile nav strip: it consumed the most valuable vertical space while still wrapping labels unpredictably.

The Help page is a first-class section in this navigation. Its first panel is the party/raid-frame cheatsheet. Status glyph copy must match the exported vocabulary in src/agent-frames.ts; harness sigils/colors come from the live snapshot rather than a second client-side identity table.

scrimPanel() produces the exact same head/body shape; the only difference is CSS context. A .scrim-panel's .panel-head-wrap/.panel-body and the --text/--muted/--accent/etc. tokens .panel-body normally shadows to "ink" values are reverted back to the standard light-on-dark theme inside .content-well .scrim-panel .panel-body (see Theming below) — so a themed well never accidentally goes tan.

Color tokens

Root (chrome + default parchment-adjacent semantics), defined in :root at the top of app.css:

Token Value Used for
--bg #171619 Page background anchor
--surface #262429 Card/panel-head background, nav
--surface-raised #322f34 Active/raised chrome
--line #4e494f Hairlines, borders
--text #f2eeeb Default body text
--muted #bbb4b1 Secondary text
--accent #dfbd6b Gold — local override accent and focus-adjacent chrome
--parchment #d7c092 Fallback behind the generated paper asset
--parchment-ink #2c1d14 Primary neutral-panel ink
--input-text #ededee Always light — see "the ink-shadowing bug" below
--good / --warn / --cast / --mana / --danger greens/ambers/blue/red Status semantics (timing badges, diagnostics)
--focus #f5cf68 Focus ring
--display-font "Spectral", Georgia, …, serif See Typography

.panel-body shadows --text, --muted, --line, --accent, --good, --warn, --cast, --mana, --danger to dark-ink equivalents so the same component CSS (badges, captions, accent bars) reads correctly on tan instead of near-black:

Token Root (dark bg) .panel-body (tan bg) Contrast vs. its bg
--text #f2eeeb #2c1d14 9.16:1
--muted #bbb4b1 #57402b 5.45:1
--accent #dfbd6b #6d3219 5.59:1
--good #77c78b #1b4622 6.09:1
--warn/--cast #e0ae55 #52360f 6.26:1
--mana #6d90e8 #1e3c67 6.24:1
--danger #e5696a #6b251f 6.20:1

Every one of those tan-context values was picked by iterating with theme.js's own exported contrastRatio(a, b) helper until it cleared WCAG AA (4.5:1) against the conservative parchment fallback #d7c092 — not eyeballed. The generated texture is lighter across most of its range. If you add a new semantic color, check it the same way:

import { contrastRatio } from "./src/web/theme.js";
contrastRatio("#yourcolor", "#d7c092"); // must be ≥ 4.5

The ink-shadowing bug pattern (hit five separate times while building this — read this before adding a new component): any element with its own fixed background — inputs and .review-pre (#242327) or the read-only textarea (#211f23) — must not use the ambient --text/--muted for its own text, because .panel-body has shadowed those to dark ink for the surrounding tan prose. Dark ink text on a charcoal fixed background is effectively invisible. Either give the element an explicit fixed color (what .review-pre and .read-only do) or use a dedicated token that is never shadowed, like input/select/textarea do via color: var(--input-text, var(--text)). .stat tiles no longer need an exception: they are translucent parchment surfaces and intentionally inherit ink tokens. --danger/--warn/etc. are the third option when the color is genuinely semantic and already adaptive per-context (.diagnostics .error / .diagnostics .warning / .changes code all use this route instead of a hardcoded hex).

Parchment texture asset

.panel-body uses the generated 1254px square assets/textures/parchment-fiber-v2.webp, displayed as a 760px repeating tile over the --parchment fallback. It is an even, edge-to-edge material study: cotton/flax fibers, gentle age clouding, no focal stain, no border, no writing, and no dirt/static speckle. A faint warm overlay keeps separate tiles tonally contiguous.

The asset was generated with Codex built-in image generation on 2026-07-18 and converted to WebP at quality 88. Keep the source composition uniform if it is regenerated: a dramatic stain or directional light becomes an obvious repeated stamp on tall cards. Do not bring back SVG turbulence as a fallback; the flat --parchment color is a cleaner failure mode if the image is missing.

Typography

Self-hosted Spectral (SIL OFL, src/web/assets/fonts/), four static weights as WOFF2: Regular 400, SemiBold 600, Bold 700, Italic 400. Loaded via @font-face at the top of app.css, referenced through --display-font with a system-serif fallback chain.

Why self-hosted rather than a system stack: the original --display-font was Georgia, "Iowan Old Style", "Palatino Linotype", serif — reasonable on paper, but Georgia isn't installed in every environment (notably: not in this Linux dev container), so what renders is whatever the local fontconfig substitutes, which is neither predictable nor guaranteed to look good. Two rounds of user feedback ("cheap/off looking") traced back to this — the fix was a real embedded font, not another CSS tweak. font-src 'self' in the CSP means it must be self-hosted (no CDN); files live under assets/fonts/ and are picked up automatically by the existing static-asset pipeline (the dev server's asset map in src/config-server.ts).

Role-based usage — pick from these four combinations, don't introduce a fifth without a reason:

Role Weight/style Where
Section/page headers 600, font-variant-caps: small-caps, letter-spacing: 0.05em h1/h2/h3, panel titles via panelHead()
Field/attribute labels 700, regular case .field-info label, .collection-row label
Descriptive/secondary prose 400 italic .muted, .field-description, .field-caption, .field-resolved
Body default Inter (sans, unchanged) Everything else — nav, buttons, input values, data/code display

The body base is intentionally scaled to 1.035rem for a small visibility lift without changing the Spectral display hierarchy.

The line between "gets Spectral" and "stays Inter" is content role, not location: prose/labels that describe a setting get Spectral; functional UI chrome (nav buttons, the buttons themselves, typed-in values, JSON/YAML dumps) stays sans so it reads as interface rather than document. Empty- state messages ("No imported files resolved.", "No threshold actions configured.") must use .muted like their sibling copy — a bare <li>/<p> with no class silently stays Inter and breaks the contiguity the italic treatment is trying to establish. Checked this exact thing after a user report; there were three sibling empty-states and only one had been missed.

Component anatomy

Domain tile

A small filled bevelled square (.domain-tile, 1.75rem) with a single glyph. It retains the carved-rune construction of the terminal provenance badge but does not repeat the old black socket. GLYPH_TONES maps neutral domains to a small semantic palette (ember execution, arcane widget/appearance, verdant worktree/success, teal routing/imports, blue document/workspace, violet lifecycle/roles, crimson console). Inside a themed well, the owning --well-accent replaces that neutral palette so the header belongs to the artwork beneath it. The tile always has a real filled mid-tone, light glyph, bevel, and border; color is decorative because the adjacent title remains the semantic label.

Glyph vocabulary (GROUP_GLYPHS in app.js, one per config domain, plus a handful of one-off literals at individual panel()/scrimPanel() call sites):

Domain Glyph Domain Glyph
execution workspace
widget lifecycle
worktree appearance
routing default/Overview/Effective-config
Document Imports
Diagnostics / Review & save Terminal identity
Roles using this harness / No roles yet Console presentation

Every glyph in active use was verified to actually render (not tofu) in this environment before being committed — (chess pawn) and (scales) were both tried and rejected as tofu boxes; / rendered but ugly (broken shapes) and were swapped for /. If you add a new glyph, screenshot it at actual tile size before committing to it — don't assume Unicode coverage. Deliberately not reused: any harness sigil (π ⌘ ✳ ◎ ☿ ▲) or terminal widget buff/debuff glyph (Ψ ◔ ➤ … ↻ ✗ ☠ ⏸) except where the concept is genuinely the same thing (none currently are — the earlier reuse for "worktree" was retired specifically because it didn't render well here, not because reuse was wrong in principle).

Field row

The unit of the whole console — every setting is one .field. Two-column grid (1fr auto): info block left, value right, capped and never stretched.

┃ Max concurrent agents  [?]                    [___5___] [↺]
  local override
  • Left accent bar (border-left, 3px): gold = this scope sets it (source-entry), blue = comes from an import (source-import), none = default/inherited. Two-tier signal, not four — see the caption for the full text.
  • Caption line (.field-caption): one line, source[, flags], e.g. local override · replaces inherited list. Apply timing is appended to the collapsible description instead of repeated as a colored indicator on every setting. This replaced what used to be two separate badge rows (.field-head badge + .field-meta badges) — the badges read as generic web-form chrome and were explicitly called out as looking wrong for a character-menu.
  • Value column: input/select capped at max-width: 16rem (textarea 20rem, color swatch 3.4rem) regardless of viewport width — the original bug report was elements stretching to fill a wide screen.
  • Reset button (.icon-btn): icon-only (↺), not a text button, shown only when the field actually has a local/pending override.
  • Per-field help (.field-help): a compact ? beside the label toggles only that setting's description. It owns aria-expanded and aria-controls; open paths persist in state.expandedDescriptions across ordinary rerenders. There is deliberately no global descriptions control in the header and no keyboard shortcut that changes every setting at once.
  • Boolean settings: fieldGrid() partitions booleans into a separate Quick toggles shelf. Each .field-boolean is capped at 21rem and uses a compact switch, so one checkbox cannot stretch across a full panel; multiple booleans naturally pack together.
  • Collection fields (model-fallback list, turn-threshold list) span the full row width via .field-value-full instead of the cramped value column, with their own @container responsive breakpoints (see below) and a .field-reset-row for their reset button.

Terminal widget preview

The preview mirrors the current borderless party-frame anatomy rather than a generic stack of colored bars. Its left character/status column is three full cells tall: the portrait/role glyph at the top and harness/status cells below, beside three equally aligned ctx, turn, and activity lanes. The earlier preview rendered only the top-left portrait cell and blank spacers beneath it, which looked like a cropped quadrant and concealed the real status-panel layout. The adjacent raid sample remains a compact two-row identity cell.

Global previews sit on parchment; role previews inherit their themed well. Both read draft-aware values. Empty color fields are resolved through displayedColorValue() before reaching native <input type="color"> controls, because browsers otherwise display an unset value as misleading solid black.

Buttons

One shared look, deliberately matched to a specific reference (#570501, described as "similar to default WoW interface buttons"):

background: radial-gradient(130% 180% at 50% 25%, #7c0b04 0%, #570501 55%, #330300 100%);
color: #f0ded1;
border-color: #33130d;

Lighter glow near the top, fading to a darker edge — not flat. This is the action-button base rule and applies uniformly to primary, .secondary, .icon-btn, back, and carousel-arrow actions. Exceptions are based on interaction role, not page: navigation surfaces (side-nav rows, themed harness cards, role-list rows, carousel talisman/peek targets) keep their surface treatment, the tiny ? is a parchment disclosure affordance, and .danger (Remove/Delete) is an outlined destructive treatment. No ordinary secondary action silently becomes gray/black on a different page.

Button color is decoupled from --accent on purpose — --accent still drives the domain tiles and the field accent bar (gold on chrome, dark rust on parchment) and must not turn red just because buttons did. Text color for the base button uses a fixed #f0ded1 rather than a token, because at one point it was wired through a shared --accent-contrast token and that token had to track two very different backgrounds (gold and dark rust) — once buttons stopped depending on --accent, --accent-contrast became unnecessary and was removed. Don't reintroduce that coupling.

Container-query responsiveness

The 4/5-column collection-row grids (model fallback list, turn thresholds) collapse via @container, scoped to .field's own rendered width (container-type: inline-size on .field), not a viewport media query — a field's real available width depends on which page/column layout it's in, not the window size. Breakpoints (46rem → 2 columns, 26rem → 1 column) were derived by measuring the row's actual scrollWidth need (~661px) with a real overflow test, not guessed; an earlier 34rem threshold fired too late and the row still overflowed its card at moderate widths. Also relies on .collection-row > * { min-width: 0; } — without it, grid items refuse to shrink below their content's intrinsic width even once the template collapses. The @container blocks must remain after the base .collection-row declaration; putting them earlier lets the later base grid win the cascade and recreates the mobile overflow.

Theming: chrome-neutral vs. themed wells

.content-well (role/harness detail pages) carries per-theme CSS custom properties set by applyTheme() in theme.js: --well-base, --well-wash, --well-scrim, --well-accent, --well-glint, --well-glow, --well-scrim-opacity, --well-backdrop. Every themed rule falls back to the neutral chrome token when unset (var(--well-accent, var(--accent))), so the same component CSS works whether or not a theme is active.

The global widget style has no owning harness/role and therefore is an ordinary parchment panel(), not a neutral contentWell(). Its terminal preview uses a darker parchment inset rather than a black stage. Role-specific widget previews remain on their owning themed artwork.

--well-scrim-opacity (0.80 by default, from DEFAULT_SUPPRESSION in theme.js) lets ~20% of the backdrop art bleed through scrim panels. The backdrop suppression is also less severe than the first pass (darkenPct: 0.54, vignetteStrength: 0.44), so the art is readable without competing with configuration text. Theme scrims use 10.5% HSL lightness rather than the original near-black 7.2%, and receded carousel columns bottom out at 68% brightness instead of 55%. The variable was set in theme.js but never actually consumed anywhere in app.css for a while — scrim panels rendered fully opaque, and the commissioned harness/role artwork was effectively invisible in practice (visible only in the ~1rem gaps between panels). Fixed via color-mix():

background: color-mix(in srgb, var(--well-scrim, var(--surface))
  calc(var(--well-scrim-opacity, 1) * 100%), transparent);

If you add a new scrim-colored surface, use this pattern, not a flat background: var(--well-scrim, ...) — the latter silently drops the intended opacity.

Architectural boundary, enforced by a test (test/webui-accessibility.test.ts): CSS before the /* Class-fantasy design system */ marker comment in app.css must never reference a --well-* variable. Chrome-neutral rules live above that marker; anything reading --well-* (or .content-well/.scrim-panel-scoped overrides, including the ink-token reversion for .content-well .scrim-panel .panel-body) must live below it. Moving a rule to the wrong side is a one-line change that silently fails this test — it already happened once during this work (a .content-well .domain-tile rule landed in the wrong section on the first pass).

Fantasy voice titles (the italic line above a role/harness name — "The Wayfinder", "The Chronicler", etc.) exist only for role-pack themes (Inquisitor/Artisan/Cartographer/Loremaster/Runekeeper, ROLE_PACK_ART + their voice.title in theme.js). Harness themes (harnessTheme()) do not carry a voice title — that was an intentional removal; harness cards now show only the real harness id (pi, cmd, claude-code, codex, hermes). Don't reintroduce a HARNESS_VOICES-shaped map without checking this is actually wanted again.

Command Code is the single deliberate exception to the usual terminal-identity-derived web hue: its Shadecaller console art uses vivid #751cfc, while the terminal widget keeps the quieter established cmd identity token. The former abstract CMD Runekeeper pair is packaged separately as role-pack:runekeeper.

Field and portrait conventions

  • Every setting line owns a ? control. Descriptions are collapsed by default and append the apply-timing sentence; timing is not repeated as a badge or caption on every row.
  • Native color inputs must receive a valid resolved fallback value. An empty color value renders as black in browsers and misrepresents inherited bar, track, and role colors. displayedColorValue() mirrors the built-in dark bar palette and the renderer's faded-track derivation.
  • Role webui-theme is a card-level presentation choice, so its control lives directly beneath the role header/actions and above Routing. The Appearance panel is reserved for terminal-widget style.
  • Raster/procedural portraits sit inside the one shared .portrait-medallion token frame. Its bevel/rings never vary structurally; only --portrait-frame-color changes with harness/role identity. The small terminal provenance sigil is a filled circular seal, deliberately distinct from the rounded-square domain tiles used by panel headers.

File map

File Owns
src/web/index.html Shell markup, header bar, nav mount point
src/web/app.css Every visual rule described above
src/web/app.js All rendering logic: panel()/scrimPanel()/panelHead(), fieldControl(), per-page render functions, GROUP_GLYPHS
src/web/theme.js Theme manifests (createTheme, harnessTheme, role-pack themes), the color ramp formula (deriveRamp), contrastRatio(), applyTheme(), procedural backdrop/portrait SVG generation
src/web/carousel.js Role-roster carousel math (ring position, peek slivers) — no styling
src/web/assets/fonts/ Self-hosted Spectral WOFF2 + OFL license
src/web/assets/textures/ Generated neutral material textures, currently the parchment WebP
src/web/assets/themes/ Packaged harness/role-pack backdrop + portrait .webp pairs (see docs/webui-art-bible.md for provenance)
src/config-server.ts Serves the above; owns the CSP and the static-asset allowlist

Verifying changes

There is no visual regression suite. The pattern used throughout this work: pnpm webui:dev -- --port 43117, then drive the printed local URL with Playwright (npx playwright install chromium once, then a throwaway script using chromium.launch() + page.screenshot()) — screenshot after every non-trivial CSS/JS change, at real component boundaries (page.locator(...).screenshot()) where you're changing something, not just full-page. Check contrast with theme.js's contrastRatio() directly rather than eyeballing, especially for anything touching .panel-body's shadowed tokens. Run pnpm test after CSS-only changes too — the architectural boundary test (test/webui-accessibility.test.ts) and the theme drift-guard tests will catch structural mistakes CSS review alone won't.

webui:dev builds first and, by default, copies the showcase YAML into a disposable temporary config directory. Ctrl+C closes the server and removes that fixture. CSS/JS source is read on refresh; restart after adding or renaming static assets because the server snapshots the asset allowlist at startup. Use a non-default port when a live pi console already owns stable port 41273. Passing --config-dir /absolute/path is intentionally opt-in and makes Save write to that real configuration.