diff --git a/docs/superpowers/specs/2026-05-17-stylesheet-redesign-design.md b/docs/superpowers/specs/2026-05-17-stylesheet-redesign-design.md new file mode 100644 index 0000000..ecb36a9 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-stylesheet-redesign-design.md @@ -0,0 +1,431 @@ +# Stylesheet redesign + +## Context + +The current stylesheet is ~1,436 LOC across six files in `l4d2web/static/css/`: +`tokens.css` (84 LOC), `layout.css` (42), `components.css` (1,177; ~196 class +selectors), `logs.css` (13), `console-autocomplete.css` (88), `editor.css` (32). +Tokens are real (CSS custom properties, light + dark via `prefers-color-scheme`), +but `components.css` grew organically: 196 classes accumulated without a budget, +specificity is uneven, and the same idea ("state pill") is expressed several +ways in different places. + +The result reads as "developer wrote some CSS" rather than "this is a system." +That is a problem for human readers and a bigger problem for agentic +development — when an LLM agent adds a feature, the cheapest path is to +re-implement whichever pattern happens to be in its context window, which +re-introduces the inconsistency the next agent then has to imitate. The +incoherence compounds. + +This redesign replaces the current stylesheet with a small, explicitly-tiered +design system: two-tier tokens, an `@layer`-ordered cascade, a budgeted set of +component classes, a handful of macros where markup correctness is load-bearing, +and a live style guide that is the single canonical reference contributors (and +agents) read before producing new UI. + +## Goals + +- **Coherent visual** — every page in `l4d2web` reads as one system, in light + and dark mode. +- **Agentic-development friendly** — an agent given a UI task can produce + styled output by reading one file (the style guide) and following the + conventions there. No tribal knowledge required. +- **Smaller surface area** — `components.css`'s 1,177 LOC + 196 classes + collapses to a deliberate ~400 LOC budget covering exactly the primitives + used across the app; project-specific widgets live in their own files. +- **Enforced cascade** — specificity is determined by `@layer` order, not + selector specificity, so adding a component never accidentally beats + utilities or fights element defaults. +- **Closed system** — every page element comes from the catalog. New widget? + Extend the system, then use it. No inline `style=""`, no inventing class + names off-system. + +Non-goals: + +- Adopting any CSS framework as a base. Pico v2, Simple.css, Bulma, and + Tailwind were considered and rejected; see "Decisions" below. +- Changing the markup of project-specific widgets (file-tree, overlay-picker, + console-autocomplete, CodeMirror skin, log viewer, live-state). They get + relocated into the new file layout but keep their existing structure. +- Changing routes, blueprints, or htmx contracts. +- Adopting Open Props or any external token library. The refined `tokens.css` + remains the single source of truth. +- Build-step tooling. No Sass, no PostCSS, no bundler. Plain CSS using modern + features (`@layer`, `color-mix()`, CSS custom properties) the production + browsers already support. + +## Architecture + +### The eight layers + +The cascade order is declared once in the entry stylesheet: + +```css +@layer reset, tokens, elements, layout, components, widgets, utilities; +``` + +Each layer has one responsibility: + +| # | Layer | Contains | File(s) | +|---|-------------|----------------------------------------------------------------|-------------------------------------------------------------| +| 1 | `reset` | Modern reset (Andy-Bell / Josh-Comeau-style; ~30 LOC) | `reset.css` | +| 2 | `tokens` | Primitives + semantic tokens, light + dark | `tokens/primitives.css`, `tokens/semantic.css` | +| 3 | `elements` | Bare HTML defaults: `body`, `h1-h6`, `a`, `code`, `kbd`, `hr`, form elements, tables | `elements.css` | +| 4 | `layout` | Composition primitives: `.container`, `.stack`, `.stack-h` | `layout.css` | +| 5 | `components`| Named widgets: `.btn`, `.field`, `.table`, `.panel`, `.modal`, `.tabs`, `.badge`, `.nav`, `.dropdown` | `components/.css` | +| 6 | `widgets` | Project-specific composites: `.file-tree`, `.overlay-picker`, `.console-autocomplete`, log viewer, editor skin | `widgets/.css` | +| 7 | `utilities` | Single-purpose overrides: `.muted`, `.mono`, `.truncate`, `.sr-only` | `utilities.css` | + +The single entry file (`main.css`) declares the layer order and `@import`s the +rest. `base.html` swaps its current five `` tags for one +``. + +### File layout + +``` +l4d2web/static/css/ + main.css ← @layer order + @imports + reset.css + tokens/ + primitives.css ← raw palette: --gray-100, --blue-700, --space-4, --text-base, … + semantic.css ← aliases: --color-primary, --color-bg, --space-m, … + plus the data-theme="dark" branch + elements.css ← bare HTML defaults + layout.css + components/ + button.css + field.css + table.css + panel.css + modal.css + tabs.css + badge.css + nav.css + dropdown.css + widgets/ + file-tree.css ← moved from current components.css + overlay-picker.css ← extracted from current components.css + console-autocomplete.css ← moved (currently at static/css/ root) + editor.css ← moved + logs.css ← moved + live-state.css ← extracted from components.css + utilities.css +``` + +### Tokens — two tiers + +**Tier 1 (primitives)** in `tokens/primitives.css` is the raw palette: ~30 +custom properties for the grayscale ramp, brand blue ramp, semantic state +colors (red / green / amber), spacing scale, type scale, radii, shadow scale, +motion durations and easings, and font stacks. No semantic meaning. The +primitives never appear in component CSS directly — they are consumed only +by semantic tokens. + +**Tier 2 (semantic)** in `tokens/semantic.css` aliases primitives by *role*: +`--color-bg`, `--color-surface`, `--color-text`, `--color-muted`, +`--color-border`, `--color-primary`, `--color-on-primary`, `--color-danger`, +`--color-warning`, `--color-success`, `--color-focus`, plus `--space-s`, +`--space-m`, `--space-l`, `--radius-s`, `--shadow-sm`, `--shadow-md`, etc. +Dark mode lives here, in a `[data-theme="dark"]` selector — the primitives +do not change between modes; the semantic aliases re-point at different +primitives. This keeps dark mode in one file instead of duplicated inside +a `@media (prefers-color-scheme: dark)` block. + +A rule the agent can follow mechanically: **components reference only +semantic tokens (`var(--color-*)`, `var(--space-*)`, etc.). Raw hex codes +appear only in `tokens/primitives.css`.** This rule is codified in +`AGENTS.md` and is the simplest possible expression of the two-tier +contract. + +Dark mode is opted into by setting `data-theme="dark"` on the `` +element. The initial value comes from `prefers-color-scheme` via a small +inline script at the top of `` (avoids a flash of wrong theme on +first paint); a future user-preference toggle would write to localStorage +and override the OS default. Implementing the toggle is out of scope for +this redesign — the redesign only ensures the mechanism is in place. + +### Components — class-based, CSS-only + +Vocabulary (the components layer in full): + +- **`.btn`** with variants `.btn-primary`, `.btn-secondary`, `.btn-danger`, + `.btn-outline`, `.btn-link`, plus `.btn-sm`. States: `[disabled]`, + `[aria-busy="true"]` (loading). Loading state shows a spinner via a CSS + animation; no extra markup required. +- **`.button-row`** — flex+gap container for groups of buttons. +- **`.field`** — wrapper containing `.field-label`, `input` / `select` / + `textarea`, optional `.field-hint`, optional `.field-error`. Error state + uses native `[aria-invalid="true"]` on the input — no separate + `.field-input-error` class. Hint/error text is associated via + `aria-describedby` (enforced by the macro; see below). +- **`.inline-save`** — flex layout for the input + submit pair used several + places today (`.inline-save` exists already; conventions preserved). +- **`.table`** — bordered, slight zebra optional via `.table-striped`. +- **`.panel`** — bordered card with optional `.panel-heading`, `.panel-body`, + `.panel-footer`. Subsumes the current `.panel` / `.card` aliases. +- **`.modal`** — styles ``. Optional `.modal-wide` for the wider + variant already used today. Header / body / footer subdivisions. The + existing `modals.js` open/close machinery is unchanged. +- **`.tabs`** with `.tab` and `.tab-panel`. State is encoded as + `[aria-selected="true"]` on the tab — no `.tab-active` class. The existing + `tabs.js` reads and writes those ARIA attributes already; no JS changes + needed. +- **`.badge`** with semantic variants (`.badge-success`, `.badge-warning`, + `.badge-danger`, `.badge-muted`) and state variants + (`.state-running`, `.state-stopped`, `.state-unknown`, `.state-transient`, + `.state-drift`). The state variants exist today; preserved for templating + continuity. +- **`.site-header`** + **`.primary-nav`** + **`.account-nav`** + **`.brand`** + — already present, restyled on the new token base. +- **`.dropdown`** — styling around native `` pairing, `aria-describedby` wiring for hint+error, `aria-invalid` on error | +| `ui/_modal.html` | `ui.modal` | Owns the `` + `modal-header`/`modal-body`/`modal-footer` structure and the close-button + JS hooks | +| `ui/_tabs.html` | `ui.tabs`, `ui.tab_panel` | Owns the `role="tablist"` / `role="tab"` / `role="tabpanel"` + `aria-controls` / `aria-selected` wiring | +| `ui/_confirm_form.html` | `ui.confirm_form` | Owns CSRF token, POST action, and standard button row for destructive actions | +| `ui/_badge.html` | `ui.badge_state(state)` | Maps a server-state string to the correct `state-*` class — encapsulates a real source of inconsistency today | + +CSS-only is the default for trivial primitives — `` is its own canonical form and does not need a +macro wrapper. The macro tier is reserved for "if you forget a piece of +this, the result is silently wrong." + +Existing partials (`_console_line.html`, `_overlay_file_tree.html`, +`_overlay_picker_*.html`, `_recent_players_modal_body.html`, +`_server_actions.html`, `_live_state.html`) are unaffected. They are +page-scoped composites that solve a different problem from `ui/*`; they +keep their current shape and just reference the new class names. +`_macros.html` continues to host CSRF/utility helpers. + +## Workflow principle — the system is closed + +The redesign codifies a hard rule: + +> **Every page element comes from the catalog. If the catalog lacks what +> you need, extend the catalog first, then use it.** + +What this means in practice: + +- **Adding a feature with existing widgets** → just use them. +- **Adding a feature that needs a new widget** → three-step extension: + 1. Add the CSS to `components/.css` or `widgets/.css`. + 2. Add a style guide entry: rendered example + escaped source HTML, with + at least one "do" example, and a "don't" entry if there's a foreseeable + wrong way to use it. + 3. If the widget is composite or a11y-load-bearing, add a macro under + `templates/ui/`. + 4. Only then use the widget on the page. +- **New variant of an existing widget** → add the variant class to the + widget's CSS, add a style guide entry showing the variant alongside the + existing ones, then use it. +- **Page-specific tweak that doesn't reuse** → it still gets a name and + lives under `widgets/`. Documented in the style guide as "used only on + X page." The bar is "documented" not "reused" — a singleton with a + proper home is acceptable. +- **One-off inline `style=""`** → forbidden. + +Enforcement is light and layered: + +1. **Cultural** — code-review checklist: "did this change add UI without + going through the system?" +2. **Documented** — `AGENTS.md` carries the rule verbatim so agents see it + first. +3. **Style guide is the source of truth** — if a widget isn't in + `templates/styleguide.html`, it doesn't exist; PRs adding widgets must + update the style guide in the same commit. +4. **Optional lint** (deferred, post-redesign) — a small script scanning + `templates/*.html` for `style="`, magic hex colors, and class names not + present in the style guide. Cheap to add later; not blocking for this + work. + +The principle has an explicit cost: a feature that needs a new widget pays +"add widget first" overhead. For human contributors that can feel slow; +for agentic dev, the cost is much lower — an agent can extend the system +in the same session, in the same commit, without context-switching. This +is exactly the kind of friction that compounds positively. + +## Style guide + +A new public route at `/styleguide` rendering `templates/styleguide.html`. +Mounted by a small new blueprint `l4d2web/routes/styleguide_routes.py`. +Public access (no login required) — it leaks nothing sensitive and a +linkable URL is genuinely useful in PR descriptions and chat. + +Each primitive gets a section showing: + +1. **The rendered widget**, multiple variants if applicable. +2. **The source HTML**, captured once via `{% set src %}…{% endset %}` and + rendered twice — once as `{{ src|safe }}` (the live widget), once inside + `
{{ src|trim|e }}
` (copy-paste source). This + guarantees the rendered example and the displayed source cannot drift. +3. **Do / don't blocks** on at least `field`, `modal`, `tabs`, and `badge` + (the most-likely-to-be-screwed-up primitives). Format: + - ✅ DO — *correct example* + source + one sentence on why it's right + - ❌ DON'T — *anti-pattern example* + source + one sentence on what + breaks (a11y wiring missing, wrong state class, etc.) + +A token reference table at the top (or in a dedicated section) shows color +swatches, spacing scale, type scale, shadow scale. Manually maintained as +a Jinja loop over a Python dict — not parsing CSS. Stays in sync via the +"add widget = update style guide" rule. + +A dark-mode toggle button at the top of the style guide sets +`data-theme="dark"` on ``. Useful well beyond the style guide; +included here because the style guide is the natural place to introduce it. + +An entry in `AGENTS.md`: + +```markdown +## UI work + +Before adding any UI markup: + +1. Read `l4d2web/templates/styleguide.html` — it lists every available widget + with a canonical example. +2. If your change needs a widget that isn't in the style guide, add it to + the system FIRST: + - CSS goes in `components/.css` or `widgets/.css` + - Style guide entry with rendered example + source + at least one do/don't + - If composite/a11y-bearing, add a macro in `templates/ui/_.html` + - Only then use the widget on the page +3. Never inline `style="…"` attributes. +4. Never invent class names outside the system. +5. Use only `var(--color-*)`, `var(--space-*)`, etc. in component CSS — + raw hex codes appear only in `tokens/primitives.css`. +``` + +## What the spike validated + +A throwaway spike at `/spike` (gated on `LEFT4ME_SPIKE=1`) compared three +options on a representative page: Pico v2 as base, Simple.css as base, and +the proposed pure-custom design with the 8-layer cascade. The spike +artifacts live at `l4d2web/templates/spike.html` and +`l4d2web/static/css/spike/{pico,simple,custom}.css`. + +Findings: + +- **Pico v2** required ~50 LOC to remap our semantic tokens onto its + `--pico-*` variables, plus a `width: auto` override on buttons to undo + Pico's full-width-in-form default. Every customization needs the + "find Pico's variable → look up the docs → override" dance. The custom + widgets (file-tree, overlay-picker) sit next to Pico-styled primitives + in two-dialects mode. +- **Simple.css** was smaller and less opinionated, but its blog-flavored + typography (large H1/H2/H3) and per-`
` top borders surprised in + ways that would keep biting. ~165 LOC custom on top. +- **Pure custom** at ~280 LOC reads top-to-bottom as a design system: + reset → primitives → semantic → elements → layout → components → + widgets → utilities, each in its own `@layer`. No translation layer, + no override-fights. The `@layer` cascade specifically means the agent + writing new component CSS doesn't have to think about specificity at all. + +Pure custom won on the criterion you weighted highest ("bad code feel +leads to incoherent styling later"). The spike's `custom.css` is the seed +for the production stylesheet; it is not the final form, but the layer +ordering, the token shape, and the component vocabulary are validated. + +## Migration plan + +One branch, work directly on `master` (per user preference), staged as +clean commits each of which leaves the site functional (visual transition +may be visible mid-series, which is fine): + +1. **Foundation** — add `main.css`, `reset.css`, `tokens/primitives.css`, + `tokens/semantic.css`. Old files still in place; `base.html` unchanged. + Nothing renders differently. + +2. **Elements + layout** — add `elements.css`, restructure `layout.css` + into the new shape. Switch `base.html` to a single + ``. Old `components.css` + no longer loads — pages look bare. Intentional and visible. + +3. **Core components** — add `components/button.css`, + `components/panel.css`, `components/table.css`, `components/badge.css`, + `components/nav.css`. Half the surface starts looking right again. + +4. **Composite components + macros** — add `components/modal.css`, + `components/tabs.css`, `components/field.css`, `components/dropdown.css`, + plus `templates/ui/_field.html`, `_modal.html`, `_tabs.html`, + `_confirm_form.html`, `_badge.html`. Update templates that use these + primitives to invoke the macros where appropriate. + +5. **Project widgets** — move file-tree, overlay-picker, + console-autocomplete, editor, logs, live-state into `widgets/.css`. + This is largely content-preserving — rename token references to the + semantic-token namespace, otherwise the rules carry over. + +6. **Style guide** — add `routes/styleguide_routes.py`, + `templates/styleguide.html` with every primitive + do/don't blocks, + token reference table, dark-mode toggle button. Update `AGENTS.md` + with the UI workflow section. + +7. **Cleanup** — delete the old `components.css`, the old `tokens.css`, + the old root-level `logs.css` / `console-autocomplete.css` / `editor.css` + (now under `widgets/`). Delete the spike artifacts in the same commit: + `l4d2web/static/css/spike/`, `l4d2web/templates/spike.html`, + `l4d2web/routes/spike_routes.py`, `l4d2web/static/vendor/css/`, and the + `if spike_enabled(): app.register_blueprint(spike_bp)` block in + `app.py` (plus the `spike_routes` import). Run the existing Chromium + e2e suite and the pytest suite. Walk the major pages (dashboard, + servers, server detail, overlay detail, blueprint detail, profile, + admin) in light and dark mode, fixing any regressions in this commit. + +The spike was scaffolding for the framework decision; once the design is +in `main.css`, it is no longer load-bearing. + +Per-commit verification: open the dev server (`scripts/dev-server.py`, +which serves `LEFT4ME_ROOT=.tmp/dev-server` with seeded demo content), +walk through the affected pages, fix any regressions in the same commit. +Each commit should leave the *functionality* working even if mid-series +visuals are transitional. + +## Decisions log + +A summary of the brainstorm decisions and why, in case future-me needs to +revisit them. + +- **Custom system, not a framework base.** Pico, Simple.css, and Bulma + were each considered. Pico is the only "minimal classless framework" + with admin-app component coverage; the rest are blog-scale typography + systems. The spike showed that even Pico's coverage costs ~50 LOC of + token translation + override-fights, while the pure-custom path costs + only ~+100 LOC and yields a much cleaner code surface. The "code feel + bites incoherence later" criterion tipped this decisively. +- **No Tailwind / utility-first.** Off the table per user preference. +- **No build step.** No Sass, no PostCSS, no bundler. Modern CSS + (`@layer`, `color-mix()`, custom properties) suffices and matches the + project's zero-build philosophy. +- **Two-tier tokens, not three.** Three-tier (primitive / semantic / + component-scoped, as in Material 3 or Adobe Spectrum) is over-engineered + for a project this size. Two tiers buy clean dark mode and unambiguous + agent rules without the indirection cost. +- **`@layer` for cascade.** Underused, well-supported since 2022, exactly + fits the "layered design system" mental model. Replaces specificity + hacks with declared layer ordering. +- **Macros for five high-leverage primitives, CSS-only for the rest.** + The macro tier exists where forgetting a piece of markup silently + breaks accessibility. Buttons and panels don't qualify; fields and + modals do. +- **Style guide as enforcement, not just documentation.** Tied to a + workflow rule ("the system is closed"), referenced in `AGENTS.md`, + with do/don't anti-pattern blocks. Most leveraged change for the + agentic-dev goal. +- **Spike artifacts kept until cleanup commit.** They seeded the decision; + they go away when the production system lands.