left4me/docs/superpowers/specs/2026-05-17-stylesheet-redesign-design.md
mwiegand 308fa4eb26
docs(stylesheet): redesign from first principles
Throw away the historical naming. New vocabulary chosen for clarity and
agentic-dev predictability: parts use hyphenated child classes
(.card-header), variant modifiers chain on the parent (.button.primary),
state stays on ARIA attributes. Variants compose via Tier-3
component-scoped tokens (--button-bg etc.) — .button.danger.outline is a
real outlined-danger button with no combination rule.

Adds toast, spinner, heading, app-header as first-class components.
Renames panel→card, modal→dialog, badge→tag; collapses state-* into tag
variants via ui.lifecycle_tag. Adds an explicit template-rewrite phase
in the migration plan, since every template's class attributes change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 00:33:47 +02:00

29 KiB

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 areacomponents.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:

@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/<name>.css
6 widgets Project-specific composites: .file-tree, .overlay-picker, .console-autocomplete, log viewer, editor skin widgets/<name>.css
7 utilities Single-purpose overrides: .muted, .mono, .truncate, .sr-only utilities.css

The single entry file (main.css) declares the layer order and @imports the rest. base.html swaps its current five <link> tags for one <link rel="stylesheet" href="…/main.css">.

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 — three 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-info, --color-focus, plus --space-1 through --space-7, --radius-1 / -2 / -full, --shadow-sm / -md / -lg, 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.

Tier 3 (component-scoped) lives inside each component's CSS file, never exposed beyond it. Example: components/button.css declares --button-bg, --button-fg, --button-border on .button, and each variant (.button.primary, .button.danger) re-points those local properties at semantic tokens. Modifiers like .button.outline then read the inherited component-scoped values, which is what lets variants compose.button.danger.outline is a real outlined-danger button without a single rule mentioning the combination. Same pattern for .tag (--tag-bg, --tag-fg, --tag-border) and .toast (--toast-bg, etc.).

A rule the agent can follow mechanically: components reference Tier 2 semantic tokens (var(--color-*), var(--space-*), …). Tier 3 component-scoped tokens are private to each component and never appear in other components' CSS. Raw hex codes appear only in tokens/primitives.css. These three rules are codified in AGENTS.md and constitute the entire token contract.

Dark mode is opted into by setting data-theme="dark" on the <html> element. The initial value comes from prefers-color-scheme via a small inline script at the top of <head> (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

Naming convention (two rules, no exceptions):

  1. Parts of a component use hyphenated child classes. .card-header, .field-hint, .dialog-body. The part belongs to the parent; the hyphen makes the relationship visible.
  2. Variant modifiers chain on the parent class. .button.primary, .tag.success, .button.outline, .dialog.wide. A modifier alone is meaningless — it must be read together with the component class.

State stays on ARIA attributes, not modifier classes: [disabled], [aria-busy="true"], [aria-selected="true"], [aria-invalid="true"]. This avoids the class-vs-aria drift that produces broken accessibility.

Vocabulary (the components layer in full):

  • .button with modifiers .primary, .outline, .ghost, .danger, .link, .small. Modifiers compose: .button.danger.outline is an outlined-danger button, no new rule required. States: [disabled], [aria-busy="true"] (loading; spinner via CSS animation, no extra markup).
  • .field — wrapper containing .field-label, input / select / textarea, optional .field-hint, optional .field-error. Error state uses native [aria-invalid="true"] on the input. Hint/error text wired to the input via aria-describedby (enforced by the ui.field macro). .field-checkbox handles the checkbox-with-label layout.
  • .card — bordered surface with .card-header, .card-body, .card-footer. Replaces the historically duplicated .panel / .card pair.
  • .table — data table. Modifier .striped for zebra rows.
  • .dialog — styles <dialog>. Parts: .dialog-header, .dialog-body, .dialog-footer, .dialog-close. Modifier .wide for the wider variant used by editor-style modals. The existing modals.js open/close machinery is unchanged; the close button keeps its data-inline-modal-close attribute (the JS hook).
  • .tabs with parts .tab and .tab-panel. State is [aria-selected] on each .tab; the existing tabs.js reads/writes the attribute.
  • .tag with modifiers .success, .warning, .danger, .info, .muted. Replaces the current .badge vocabulary AND the project-specific .state-running / .state-stopped / .state-unknown / .state-transient / .state-drift classes — lifecycle states map to semantic tag variants via the ui.lifecycle_tag(state) macro ("running".tag.success, "stopped".tag.muted, "unknown".tag.muted, "starting" / "stopping" / "resetting" etc. → .tag.warning, "drift".tag.danger). One vocabulary; one mapping rule.
  • .toast (new) — flash-message notification, top-right positioned. Modifiers .success, .warning, .danger. Part .toast-close. Fills a real gap — flash messages currently improvise.
  • .spinner (new) — standalone loading indicator. Modifier .small. Used independently of [aria-busy]-on-buttons.
  • .app-header with parts .app-header-inner, .brand, .nav, .account — the top-of-page banner. Replaces .site-header + .primary-nav + .account-nav (one vocabulary instead of three).
  • .heading with part .heading-actions — formal page-title pattern (h1 + actions row). Replaces the current ad-hoc .page-heading.
  • .dropdown — small helper for native <select> + any future custom menu pattern.

Variant composition pattern (the mechanism behind chained modifiers):

Each component declares Tier-3 component-scoped tokens and consumes them. Variants re-point those tokens; modifiers can read inherited values. A trimmed components/button.css example:

.button {
  --button-bg:     var(--color-surface);
  --button-fg:     var(--color-text);
  --button-border: var(--color-border);
  background: var(--button-bg);
  color:      var(--button-fg);
  border: 1px solid var(--button-border);
  /* …padding, radius, transitions… */
}
.button.primary { --button-bg: var(--color-primary); --button-fg: var(--color-on-primary); --button-border: var(--color-primary); }
.button.danger  { --button-bg: var(--color-danger);  --button-fg: var(--color-on-danger);  --button-border: var(--color-danger);  }
.button.outline { --button-bg: transparent; --button-fg: var(--button-border); }
.button.ghost   { --button-bg: transparent; --button-fg: var(--color-text); --button-border: transparent; }
.button.link    { --button-bg: transparent; --button-border: transparent; text-decoration: underline; color: var(--color-link); }
.button.small   { padding: 0.25rem 0.5rem; font-size: var(--text-sm); }

.button.danger.outline works because .outline is declared after .danger and reads the inherited --button-border (which .danger just set). The same pattern is used for .tag and .toast.

Macros — five high-leverage primitives

A new directory l4d2web/templates/ui/ adds Jinja macros for primitives where markup correctness is load-bearing — i.e., where hand-assembling gets accessibility wrong easily:

Macro file Macros Why
ui/_field.html ui.field, ui.checkbox, ui.select Owns <label for>/<input id> pairing, aria-describedby wiring for hint+error, aria-invalid on error
ui/_dialog.html ui.dialog Owns the <dialog> + .dialog-header/.dialog-body/.dialog-footer structure and the data-inline-modal-close JS hook
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/_tag.html ui.tag, ui.lifecycle_tag(state) Maps a server-lifecycle state string ("running", "starting", "drift", …) to the right .tag.<variant> — one mapping rule total

CSS-only is the default for trivial primitives — <button class="btn btn-primary">Save</button> 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/<name>.css or widgets/<name>.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. DocumentedAGENTS.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 <pre><code>{{ src|trim|e }}</code></pre> (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 <html>. Useful well beyond the style guide; included here because the style guide is the natural place to introduce it.

An entry in AGENTS.md:

## 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/<name>.css` (generic) or `widgets/<name>.css` (project-specific)
   - Style guide entry with rendered example + source + at least one ✅ DO
   - If composite or a11y-load-bearing, add a macro in `templates/ui/_<name>.html`
   - Only then use the widget on the page
3. Naming convention (no exceptions):
   - **Parts of a component use hyphenated child classes** — `.card-header`, `.field-hint`, `.dialog-body`
   - **Variant modifiers chain on the parent class** — `.button.primary`, `.tag.success`, `.dialog.wide`
   - **State stays on ARIA attributes**, not modifier classes — `[disabled]`, `[aria-busy="true"]`, `[aria-selected="true"]`, `[aria-invalid="true"]`
4. Never inline `style="…"` attributes. Never invent class names outside the system.
5. Token usage:
   - Component CSS references **only** Tier-2 semantic tokens (`var(--color-*)`, `var(--space-*)`, …)
   - Tier-3 component-scoped tokens (`--button-bg`, `--tag-fg`, etc.) are private to each component and never appear in other components' 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-<section> 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 validated the architecture@layer cascade, color-mix() in production browsers, the light/dark theme switching via data-theme, the aesthetic direction. The vocabulary used in the spike (.btn, .panel, .modal, .badge, .state-*, …) is NOT carried forward — the rewrite uses a redesigned vocabulary (.button + chained modifiers, .card, .dialog, .tag, …) chosen from first principles for the agentic-dev goal. The spike artifacts get deleted in the cleanup commit (see Migration plan).

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 <link rel="stylesheet" href=".../main.css">. Old components.css no longer loads — pages look bare. Intentional and visible.

  3. Core components — add components/button.css, components/card.css, components/table.css, components/tag.css, components/app-header.css, components/heading.css. Half the surface starts looking right again (where templates use the new class names; many still use the old ones until step 7).

  4. Composite components + macros — add components/dialog.css, components/tabs.css, components/field.css, components/dropdown.css, components/toast.css, components/spinner.css, plus templates/ui/_field.html, _dialog.html, _tabs.html, _confirm_form.html, _tag.html. The macros are available for use but templates aren't yet calling them (step 7 does that).

  5. Project widgets — move file-tree, overlay-picker, console-autocomplete, editor, logs, live-state into widgets/<name>.css with renamed class names (.overlay-picker.overlay-list, .file-tree-row.file-tree-item, etc. — full mapping in the implementation plan). Rename token references to the new semantic-token namespace.

  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. Template rewrite — walk every template in l4d2web/templates/ and update class attributes to the new vocabulary. This is a large but mechanical phase; the implementation plan splits it into reviewable chunks (one per template-family: page templates, partial templates, server-detail's complex cluster). Templates also switch to the new macros (ui.field, ui.dialog, ui.lifecycle_tag, etc.) where applicable. After this step, no template still references the old vocabulary.

  8. 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.
  • Three-tier tokens (primitives → semantic → component-scoped). The initial draft proposed two tiers; the third (component-scoped tokens like --button-bg) was added when we adopted chained-modifier variants (.button.danger.outline), because composing variants cleanly requires each variant to set local CSS custom properties that later modifiers can read. The third tier is private to each component — it doesn't bloat the global token namespace.
  • Naming convention chosen from first principles. The old vocabulary (.btn / .btn-primary / .panel / .modal / .badge / .state-* / .site-header / .primary-nav / .link-button / .danger-outline / .sr-only / .overlay-picker / …) was historically grown and inconsistent. The new vocabulary uses two rules: parts are hyphenated child classes (.card-header), variants chain on the parent (.button.primary). State stays on ARIA attributes. The agent learns the rules once and predicts the rest.
  • @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 tags don't qualify; fields and dialogs do. The fifth, ui.lifecycle_tag, encapsulates the state-string → tag-variant mapping in one place.
  • Style guide as enforcement, not just documentation. Tied to a workflow rule ("the system is closed"), referenced in AGENTS.md, with DO / DON'T blocks. Most leveraged change for the agentic-dev goal.
  • Template rewrite as a discrete migration phase. The new vocabulary means every template's class attributes change; the implementation plan dedicates a phase to this mechanical-but-extensive work rather than mixing it into the CSS commits.
  • Spike artifacts kept until cleanup commit. They seeded the architecture decision; they go away when the production system lands. The spike used the old vocabulary — only its @layer structure + token shape + dark-mode mechanism + aesthetic direction carried forward.