Replaces the current ~1.4k LOC stylesheet with a tiered design system: @layer-ordered cascade, two-tier tokens, budgeted component classes, five high-leverage macros, in-app style guide, and the "system is closed" workflow rule (every page element comes from the catalog). Validated by a throwaway /spike comparing Pico v2, Simple.css, and the pure-custom design; pure-custom won on the code-feel criterion the user weighted highest. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
22 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
l4d2webreads 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
@layerorder, 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.cssremains 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 — 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 <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
Vocabulary (the components layer in full):
.btnwith 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-errorclass. Hint/error text is associated viaaria-describedby(enforced by the macro; see below)..inline-save— flex layout for the input + submit pair used several places today (.inline-saveexists 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/.cardaliases..modal— styles<dialog>. Optional.modal-widefor the wider variant already used today. Header / body / footer subdivisions. The existingmodals.jsopen/close machinery is unchanged..tabswith.taband.tab-panel. State is encoded as[aria-selected="true"]on the tab — no.tab-activeclass. The existingtabs.jsreads and writes those ARIA attributes already; no JS changes needed..badgewith 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<select>and a small custom menu pattern if needed.
State as 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.
Variant naming convention is uniform: <component> <component>-<variant> <component>-<size>. No exceptions. New variants must be added to the
component's file and to the style guide entry in the same commit.
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/_modal.html |
ui.modal |
Owns the <dialog> + 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 — <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:
- Add the CSS to
components/<name>.cssorwidgets/<name>.css. - 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.
- If the widget is composite or a11y-load-bearing, add a macro under
templates/ui/. - Only then use the widget on the page.
- Add the CSS to
- 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:
- Cultural — code-review checklist: "did this change add UI without going through the system?"
- Documented —
AGENTS.mdcarries the rule verbatim so agents see it first. - 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. - Optional lint (deferred, post-redesign) — a small script scanning
templates/*.htmlforstyle=", 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:
- The rendered widget, multiple variants if applicable.
- 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. - Do / don't blocks on at least
field,modal,tabs, andbadge(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` or `widgets/<name>.css`
- Style guide entry with rendered example + source + at least one do/don't
- If composite/a11y-bearing, add a macro in `templates/ui/_<name>.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 awidth: autooverride 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@layercascade 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):
-
Foundation — add
main.css,reset.css,tokens/primitives.css,tokens/semantic.css. Old files still in place;base.htmlunchanged. Nothing renders differently. -
Elements + layout — add
elements.css, restructurelayout.cssinto the new shape. Switchbase.htmlto a single<link rel="stylesheet" href=".../main.css">. Oldcomponents.cssno longer loads — pages look bare. Intentional and visible. -
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. -
Composite components + macros — add
components/modal.css,components/tabs.css,components/field.css,components/dropdown.css, plustemplates/ui/_field.html,_modal.html,_tabs.html,_confirm_form.html,_badge.html. Update templates that use these primitives to invoke the macros where appropriate. -
Project widgets — move file-tree, overlay-picker, console-autocomplete, editor, logs, live-state into
widgets/<name>.css. This is largely content-preserving — rename token references to the semantic-token namespace, otherwise the rules carry over. -
Style guide — add
routes/styleguide_routes.py,templates/styleguide.htmlwith every primitive + do/don't blocks, token reference table, dark-mode toggle button. UpdateAGENTS.mdwith the UI workflow section. -
Cleanup — delete the old
components.css, the oldtokens.css, the old root-levellogs.css/console-autocomplete.css/editor.css(now underwidgets/). 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 theif spike_enabled(): app.register_blueprint(spike_bp)block inapp.py(plus thespike_routesimport). 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.
@layerfor 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.