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).
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.
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.
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.5The 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).
.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.
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.
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).
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-headbadge +.field-metabadges) — the badges read as generic web-form chrome and were explicitly called out as looking wrong for a character-menu. - Value column:
input/selectcapped atmax-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 ownsaria-expandedandaria-controls; open paths persist instate.expandedDescriptionsacross 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 separateQuick togglesshelf. Each.field-booleanis 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-fullinstead of the cramped value column, with their own@containerresponsive breakpoints (see below) and a.field-reset-rowfor their reset button.
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.
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.
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.
.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.
- 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-themeis 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-medalliontoken frame. Its bevel/rings never vary structurally; only--portrait-frame-colorchanges 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 | 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 |
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.