left4me/docs/superpowers/plans/2026-05-17-stylesheet-redesign.md
mwiegand 536c3384bf
docs: stylesheet redesign implementation plan
Ten tasks aligned with the spec's seven-commit migration:
foundation → elements/layout → core components → composites →
macros → widget relocation → utilities → styleguide → AGENTS.md
→ cleanup. Token migration table for old→new names. Pytest unit
tests for the field a11y macro and the /styleguide route.

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

69 KiB
Raw Blame History

Stylesheet Redesign Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace l4d2web/static/css/* (~1,436 LOC, 196 component classes) with a tiered design system: two-tier tokens, @layer-ordered cascade, budgeted component classes, five high-leverage macros, an in-app style guide, and the "system is closed" workflow rule enforced via AGENTS.md.

Architecture: Pure custom CSS (no framework base). Single entry stylesheet main.css declares @layer reset, tokens, elements, layout, components, widgets, utilities; and @imports the rest. Tokens split into primitives.css (raw palette) + semantic.css (role aliases + dark-mode branch). Component classes are CSS-only by default; macros under templates/ui/ exist only for five composites where markup correctness is load-bearing (field, modal, tabs, confirm_form, badge_state). A new public route /styleguide is the canonical reference contributors and agents read before producing new UI.

Tech Stack: Plain CSS using modern features (@layer, color-mix(), custom properties). Jinja macros for the five composites. Flask blueprint for /styleguide. No Sass, no PostCSS, no bundler.

Spec: docs/superpowers/specs/2026-05-17-stylesheet-redesign-design.md


File structure

Action Path Contents
Create l4d2web/static/css/main.css Entry. Declares @layer order + @imports
Create l4d2web/static/css/reset.css Modern reset (~30 LOC)
Create l4d2web/static/css/tokens/primitives.css Raw palette: grays, blue, red/green/amber, spacing, type scale, radii, shadows, motion, fonts
Create l4d2web/static/css/tokens/semantic.css Aliases: --color-*, --space-*, plus [data-theme="dark"] branch
Create l4d2web/static/css/elements.css Bare HTML defaults: body, h1-h6, a, code, kbd, pre, hr, form elements, tables
Rewrite l4d2web/static/css/layout.css .container, .stack, .stack-h, section spacing
Create l4d2web/static/css/components/button.css .btn + variants/sizes + states
Create l4d2web/static/css/components/field.css .field + label / hint / error
Create l4d2web/static/css/components/table.css .table
Create l4d2web/static/css/components/panel.css .panel + heading / body / footer
Create l4d2web/static/css/components/modal.css .modal (styles <dialog>) + header / body / footer / close / .modal-wide
Create l4d2web/static/css/components/tabs.css .tabs + .tab[aria-selected] + .tab-panel
Create l4d2web/static/css/components/badge.css .badge + semantic + .state-*
Create l4d2web/static/css/components/nav.css .site-header, .primary-nav, .account-nav, .brand
Create l4d2web/static/css/components/dropdown.css <select> styling + custom dropdown helper
Move l4d2web/static/css/widgets/file-tree.css From extracted components.css
Move l4d2web/static/css/widgets/overlay-picker.css From extracted components.css
Move l4d2web/static/css/widgets/console-autocomplete.css From current root
Move l4d2web/static/css/widgets/editor.css From current root
Move l4d2web/static/css/widgets/logs.css From current root
Move l4d2web/static/css/widgets/live-state.css From extracted components.css (if present there) or pulled from _live_state.html's scoped style
Create l4d2web/static/css/utilities.css .muted, .mono, .truncate, .sr-only
Create l4d2web/templates/ui/_field.html ui.field, ui.checkbox, ui.select
Create l4d2web/templates/ui/_modal.html ui.modal
Create l4d2web/templates/ui/_tabs.html ui.tabs, ui.tab_panel
Create l4d2web/templates/ui/_confirm_form.html ui.confirm_form
Create l4d2web/templates/ui/_badge.html ui.badge_state
Modify l4d2web/templates/base.html Swap 5 <link> tags for 1; add inline theme-init script
Create l4d2web/templates/styleguide.html Style guide page with every primitive + do/don't blocks
Create l4d2web/routes/styleguide_routes.py Public route /styleguide
Modify l4d2web/l4d2web/app.py Register styleguide blueprint; remove spike registration (commit 7)
Modify AGENTS.md Add "UI work" section codifying the workflow rule
Delete l4d2web/static/css/components.css (commit 7)
Delete l4d2web/static/css/tokens.css (commit 7)
Delete l4d2web/static/css/logs.css (root) (commit 7) — moved to widgets/
Delete l4d2web/static/css/console-autocomplete.css (root) (commit 7) — moved to widgets/
Delete l4d2web/static/css/editor.css (root) (commit 7) — moved to widgets/
Delete l4d2web/static/css/spike/ (commit 7) — scaffolding
Delete l4d2web/templates/spike.html (commit 7)
Delete l4d2web/routes/spike_routes.py (commit 7)
Delete l4d2web/static/vendor/css/ (commit 7) — only used by spike

Reference: spike artifacts

The pre-validated CSS lives in l4d2web/static/css/spike/custom.css. It is organized exactly by the @layer blocks the production stylesheet uses. Each task below extracts a layer (or part of a layer) into its production file.

Read the spike file as needed:

cat l4d2web/l4d2web/static/css/spike/custom.css | sed -n '/^@layer reset/,/^}/p'

Or open it in an editor and copy the @layer X { … } blocks one at a time.

The spike file's @layer declarations and @layer X { … } wrappers stay in the file when copied into the production files; the @layer wrapper around each block is what gives the production cascade its ordering.

The spike template l4d2web/templates/spike.html is the reference for the style-guide page's markup vocabulary (every widget appears there at least once).


Task 1: Foundation — entry + reset + tokens

Files:

  • Create: l4d2web/static/css/main.css
  • Create: l4d2web/static/css/reset.css
  • Create: l4d2web/static/css/tokens/primitives.css
  • Create: l4d2web/static/css/tokens/semantic.css

Nothing else changes; base.html still loads the old CSS. The new files exist but are not yet referenced.

  • Step 1: Create static/css/tokens/ directory
mkdir -p l4d2web/l4d2web/static/css/tokens
  • Step 2: Write main.css

l4d2web/l4d2web/static/css/main.css:

/* Entry stylesheet. Declares the @layer order, then @imports each layer's
   file(s). Cascade specificity is determined by the @layer order — never
   by selector specificity or file order. */

@layer reset, tokens, elements, layout, components, widgets, utilities;

@import url("./reset.css")                           layer(reset);
@import url("./tokens/primitives.css")               layer(tokens);
@import url("./tokens/semantic.css")                 layer(tokens);
@import url("./elements.css")                        layer(elements);
@import url("./layout.css")                          layer(layout);

@import url("./components/button.css")               layer(components);
@import url("./components/field.css")                layer(components);
@import url("./components/table.css")                layer(components);
@import url("./components/panel.css")                layer(components);
@import url("./components/modal.css")                layer(components);
@import url("./components/tabs.css")                 layer(components);
@import url("./components/badge.css")                layer(components);
@import url("./components/nav.css")                  layer(components);
@import url("./components/dropdown.css")             layer(components);

@import url("./widgets/file-tree.css")               layer(widgets);
@import url("./widgets/overlay-picker.css")          layer(widgets);
@import url("./widgets/console-autocomplete.css")    layer(widgets);
@import url("./widgets/editor.css")                  layer(widgets);
@import url("./widgets/logs.css")                    layer(widgets);
@import url("./widgets/live-state.css")              layer(widgets);

@import url("./utilities.css")                       layer(utilities);
  • Step 3: Write reset.css

l4d2web/l4d2web/static/css/reset.css:

/* Modern reset. Mirrors the Andy-Bell / Josh-Comeau style. Intentional
   choices: keep button looking like a button (no all:unset), preserve
   focus rings via :focus-visible later in elements.css. */

*, *::before, *::after { box-sizing: border-box; }
body, h1, h2, h3, h4, p, figure, blockquote, dl, dd { margin: 0; }
ul[role="list"], ol[role="list"] { list-style: none; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100vh; line-height: 1.5; -webkit-font-smoothing: antialiased; }
img, picture { max-width: 100%; display: block; }
input, button, textarea, select { font: inherit; color: inherit; }
p, li, figcaption { text-wrap: pretty; }
h1, h2, h3, h4 { text-wrap: balance; }
:target { scroll-margin-block: 5ex; }
  • Step 4: Write tokens/primitives.css

Extract from static/css/spike/custom.css — the :root { … } block inside @layer tokens that contains the primitive palette (lines beginning --gray-50:, --blue-200:, --red-200:, etc., through the font stacks). The content is:

l4d2web/l4d2web/static/css/tokens/primitives.css:

/* Tier 1 — primitives. Raw palette. No semantic meaning. Consumed only
   by tokens/semantic.css, never by component CSS directly. */

:root {
  /* grayscale */
  --gray-50:  #fafafa; --gray-100: #f4f4f5; --gray-200: #e4e4e7;
  --gray-300: #d4d4d8; --gray-400: #a1a1aa; --gray-500: #71717a;
  --gray-600: #52525b; --gray-700: #3f3f46; --gray-800: #27272a;
  --gray-900: #18181b; --gray-950: #09090b;

  /* brand blue */
  --blue-200: #bfdbfe; --blue-300: #93c5fd; --blue-500: #3b82f6;
  --blue-600: #2563eb; --blue-700: #1d4ed8; --blue-800: #1e40af;

  /* state colors */
  --red-200: #fecaca;  --red-400: #fca5a5;  --red-700: #b42318;
  --green-300: #86efac; --green-700: #067647;
  --amber-300: #fcd34d; --amber-700: #a15c07;

  /* spacing */
  --space-1: 0.25rem; --space-2: 0.5rem; --space-3: 0.75rem;
  --space-4: 1rem;    --space-5: 1.5rem; --space-6: 2rem;
  --space-7: 3rem;

  /* radii */
  --radius-1: 0.25rem; --radius-2: 0.5rem; --radius-3: 0.75rem;
  --radius-full: 9999px;

  /* type scale */
  --text-xs:   0.75rem;  --text-sm:   0.875rem; --text-base: 1rem;
  --text-lg:   1.125rem; --text-xl:   1.25rem;  --text-2xl:  1.5rem;

  /* line heights */
  --leading-tight: 1.2;
  --leading-normal: 1.5;

  /* shadows */
  --shadow-sm: 0 1px 2px rgba(0,0,0,0.08);
  --shadow-md: 0 4px 12px rgba(0,0,0,0.10);
  --shadow-lg: 0 16px 32px rgba(0,0,0,0.18);

  /* motion */
  --duration-fast: 120ms;
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);

  /* fonts */
  --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
  • Step 5: Write tokens/semantic.css

l4d2web/l4d2web/static/css/tokens/semantic.css:

/* Tier 2 — semantic tokens. Aliases for use cases. Components consume
   ONLY these (var(--color-*), var(--space-*), etc.) — never primitives
   directly. Dark mode lives here: [data-theme="dark"] redefines the
   semantic aliases to point at different primitives. */

:root,
:root[data-theme="light"] {
  --color-bg:           var(--gray-100);
  --color-surface:      #ffffff;
  --color-surface-2:    var(--gray-50);
  --color-text:         var(--gray-900);
  --color-text-strong:  var(--gray-950);
  --color-muted:        var(--gray-500);
  --color-border:       var(--gray-300);
  --color-border-soft:  var(--gray-200);

  --color-link:          var(--blue-700);
  --color-primary:       var(--blue-700);
  --color-primary-hover: var(--blue-800);
  --color-on-primary:    #ffffff;

  --color-danger:        var(--red-700);
  --color-on-danger:     #ffffff;
  --color-warning:       var(--amber-700);
  --color-success:       var(--green-700);
  --color-focus:         var(--blue-600);

  --ring: 0 0 0 3px color-mix(in srgb, var(--color-focus) 30%, transparent);

  color-scheme: light;
}

:root[data-theme="dark"] {
  --color-bg:           var(--gray-900);
  --color-surface:      var(--gray-800);
  --color-surface-2:    var(--gray-700);
  --color-text:         var(--gray-100);
  --color-text-strong:  #ffffff;
  --color-muted:        var(--gray-400);
  --color-border:       var(--gray-700);
  --color-border-soft:  var(--gray-700);

  --color-link:          var(--blue-300);
  --color-primary:       var(--blue-300);
  --color-primary-hover: var(--blue-200);
  --color-on-primary:    var(--gray-950);

  --color-danger:        var(--red-400);
  --color-on-danger:     var(--gray-950);
  --color-warning:       var(--amber-300);
  --color-success:       var(--green-300);
  --color-focus:         var(--blue-200);

  color-scheme: dark;
}
  • Step 6: Verify the files exist and are syntactically valid CSS
test -s l4d2web/l4d2web/static/css/main.css && \
test -s l4d2web/l4d2web/static/css/reset.css && \
test -s l4d2web/l4d2web/static/css/tokens/primitives.css && \
test -s l4d2web/l4d2web/static/css/tokens/semantic.css && \
echo OK

Expected: OK

(There is no CSS linter in this project. Syntactic validity is verified visually in Task 2 when main.css is loaded by the browser and DevTools would report parse errors.)

  • Step 7: Commit
git add l4d2web/l4d2web/static/css/main.css \
        l4d2web/l4d2web/static/css/reset.css \
        l4d2web/l4d2web/static/css/tokens/
git commit -m "feat(stylesheet): foundation — main.css, reset, two-tier tokens"

Task 2: Elements + layout + base.html switch

Files:

  • Create: l4d2web/static/css/elements.css
  • Rewrite: l4d2web/static/css/layout.css
  • Modify: l4d2web/templates/base.html — collapse 5 <link> tags into 1, add inline theme-init script

After this commit the site loads the new system. Pages will look bare in places where component classes aren't yet styled. That is intentional and visible.

  • Step 1: Write elements.css

l4d2web/l4d2web/static/css/elements.css:

/* Bare HTML defaults. Consumes semantic tokens only. Anything that styles
   a tag selector lives here; class selectors live in components/. */

html, body {
  font-family: var(--font-sans);
  font-size: var(--text-base);
  line-height: var(--leading-normal);
  background: var(--color-bg);
  color: var(--color-text);
}
h1 { font-size: var(--text-2xl); line-height: var(--leading-tight); font-weight: 700; }
h2 { font-size: var(--text-xl);  line-height: var(--leading-tight); font-weight: 600; }
h3 { font-size: var(--text-lg);  line-height: var(--leading-tight); font-weight: 600; }
h4 { font-size: var(--text-base);line-height: var(--leading-tight); font-weight: 600; }
p  { margin: 0; }
a  { color: var(--color-link); text-decoration: underline; text-underline-offset: 2px; }
a:hover { text-decoration-thickness: 2px; }
code, kbd, samp, pre { font-family: var(--font-mono); font-size: 0.95em; }
kbd {
  background: var(--color-surface-2); border: 1px solid var(--color-border);
  border-radius: var(--radius-1); padding: 0.05rem 0.35rem; font-size: var(--text-xs);
}
pre {
  background: var(--color-surface-2); border: 1px solid var(--color-border);
  border-radius: var(--radius-1); padding: var(--space-3); overflow-x: auto;
  font-size: var(--text-sm);
}
hr { border: 0; border-top: 1px solid var(--color-border); margin-block: var(--space-4); }

:focus-visible { outline: none; box-shadow: var(--ring); }

input, select, textarea {
  background: var(--color-surface); color: var(--color-text);
  border: 1px solid var(--color-border); border-radius: var(--radius-1);
  padding: 0.45rem 0.6rem; font-size: var(--text-base);
  transition: border-color var(--duration-fast) var(--ease-out),
              box-shadow   var(--duration-fast) var(--ease-out);
}
input:focus, select:focus, textarea:focus { border-color: var(--color-focus); }
textarea { min-height: 4em; resize: vertical; }
input[type="checkbox"], input[type="radio"] {
  padding: 0; width: 1rem; height: 1rem; vertical-align: middle;
  accent-color: var(--color-primary);
}
fieldset { border: 1px solid var(--color-border); border-radius: var(--radius-1); padding: var(--space-2) var(--space-3); }
legend   { padding-inline: var(--space-1); font-weight: 600; }

button { background: none; border: 0; cursor: pointer; padding: 0; }

table { width: 100%; border-collapse: collapse; }
th, td { padding: var(--space-2) var(--space-3); border-bottom: 1px solid var(--color-border-soft); text-align: left; vertical-align: top; }
th { background: var(--color-surface-2); font-weight: 600; font-size: var(--text-sm); }
  • Step 2: Rewrite layout.css

Overwrite l4d2web/l4d2web/static/css/layout.css with:

/* Composition objects. Don't paint, only arrange. */

.container { max-width: 72rem; margin-inline: auto; padding-inline: var(--space-4); }
.stack > * + * { margin-top: var(--space-3); }
.stack-h { display: flex; gap: var(--space-3); align-items: center; }

main > section { margin-block: var(--space-6); }
main > section > h2:first-child {
  margin-bottom: var(--space-3);
  padding-bottom: var(--space-1);
  border-bottom: 1px solid var(--color-border-soft);
}
  • Step 3: Update base.html

In l4d2web/l4d2web/templates/base.html, replace the five separate <link> tags with a single <link> for main.css, and add an inline theme-init script that reads prefers-color-scheme before paint (uses the existing g.csp_nonce for CSP compliance).

Edit l4d2web/l4d2web/templates/base.html:

Old <head> block (the five stylesheet links):

    <link rel="stylesheet" href="{{ url_for('static', filename='css/tokens.css') }}">
    <link rel="stylesheet" href="{{ url_for('static', filename='css/layout.css') }}">
    <link rel="stylesheet" href="{{ url_for('static', filename='css/components.css') }}">
    <link rel="stylesheet" href="{{ url_for('static', filename='css/logs.css') }}">
    <link rel="stylesheet" href="{{ url_for('static', filename='css/console-autocomplete.css') }}">

New <head> block:

    <script nonce="{{ g.csp_nonce }}">
      // Set data-theme before paint so the first frame matches the user's OS preference.
      // Future user-preference override (localStorage) plugs in here.
      (function () {
        try {
          var dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
          document.documentElement.dataset.theme = dark ? 'dark' : 'light';
        } catch (e) {}
      })();
    </script>
    <link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
  • Step 4: Verify the dev server still boots and /dashboard returns 200

If the dev server isn't running, start it: LEFT4ME_SPIKE=1 scripts/dev-server.py --port 5051

curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:5051/dashboard

Expected: 302 (redirect to login) or 200. NOT 500.

If the dev server reloaded the changes, also fetch /static/css/main.css to confirm it serves:

curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:5051/static/css/main.css

Expected: 200

  • Step 5: Open /dashboard (or /login) in a browser, confirm typography and base elements render

The page will look bare — no buttons styled yet, no panels, no tables. That is correct for this checkpoint. Verify:

  • Body bg is light gray (--gray-100)

  • Typography uses system sans

  • Links are blue and underlined

  • Dark mode toggled via DevTools (document.documentElement.dataset.theme = 'dark') flips the page correctly

  • Step 6: Commit

git add l4d2web/l4d2web/static/css/elements.css \
        l4d2web/l4d2web/static/css/layout.css \
        l4d2web/l4d2web/templates/base.html
git commit -m "feat(stylesheet): elements + layout layers; base.html loads main.css"

Task 3: Core components — button, panel, table, badge, nav

Files:

  • Create: l4d2web/static/css/components/button.css
  • Create: l4d2web/static/css/components/panel.css
  • Create: l4d2web/static/css/components/table.css
  • Create: l4d2web/static/css/components/badge.css
  • Create: l4d2web/static/css/components/nav.css

Half the surface starts looking right after this commit. The contents below are extracted from static/css/spike/custom.css's @layer components { … } block, with the spike-only .spike-toolbar rule excluded.

  • Step 1: Create components directory
mkdir -p l4d2web/l4d2web/static/css/components
  • Step 2: Write components/button.css

l4d2web/l4d2web/static/css/components/button.css:

.btn {
  display: inline-flex; align-items: center; gap: var(--space-1);
  padding: 0.45rem 0.9rem;
  border: 1px solid transparent; border-radius: var(--radius-1);
  font-size: var(--text-base); line-height: 1.2;
  cursor: pointer; text-decoration: none; user-select: none;
  transition: background var(--duration-fast) var(--ease-out),
              border-color var(--duration-fast) var(--ease-out),
              color var(--duration-fast) var(--ease-out);
}
.btn-primary {
  background: var(--color-primary); border-color: var(--color-primary);
  color: var(--color-on-primary);
}
.btn-primary:hover:not([disabled]) {
  background: var(--color-primary-hover); border-color: var(--color-primary-hover);
}
.btn-secondary {
  background: var(--color-surface); border-color: var(--color-border);
  color: var(--color-text);
}
.btn-secondary:hover:not([disabled]) {
  background: var(--color-surface-2);
}
.btn-danger {
  background: var(--color-danger); border-color: var(--color-danger);
  color: var(--color-on-danger);
}
.btn-outline {
  background: transparent; border-color: var(--color-primary);
  color: var(--color-primary);
}
.btn-outline:hover:not([disabled]) {
  background: color-mix(in srgb, var(--color-primary) 8%, transparent);
}
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
.btn[aria-busy="true"]::before { content: "⟳"; margin-right: var(--space-1); animation: btn-spin 1s linear infinite; }
@keyframes btn-spin { to { transform: rotate(360deg); } }
.btn-sm { padding: 0.25rem 0.6rem; font-size: var(--text-sm); }

.button-row { display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center; }

.link-button {
  background: none; border: 0; padding: 0;
  color: var(--color-link); text-decoration: underline; cursor: pointer;
  font: inherit;
}
  • Step 3: Write components/panel.css

l4d2web/l4d2web/static/css/components/panel.css:

.panel {
  border: 1px solid var(--color-border);
  border-radius: var(--radius-2);
  background: var(--color-surface);
  box-shadow: var(--shadow-sm);
  overflow: hidden;
}
.panel-heading { border-bottom: 1px solid var(--color-border-soft); padding: var(--space-3) var(--space-4); }
.panel-heading h3 { margin: 0; }
.panel-body    { padding: var(--space-3) var(--space-4); }
.panel-footer  { border-top: 1px solid var(--color-border-soft); padding: var(--space-3) var(--space-4); background: var(--color-surface-2); }
  • Step 4: Write components/table.css

l4d2web/l4d2web/static/css/components/table.css:

/* Table element styling is in elements.css; this layer only adds the
   .table class hook for project-specific borders/striping. */

.table {
  border: 1px solid var(--color-border-soft);
  border-radius: var(--radius-1);
  overflow: hidden;
}
.table-striped tbody tr:nth-child(odd) td {
  background: color-mix(in srgb, var(--color-text) 3%, var(--color-surface));
}
  • Step 5: Write components/badge.css

l4d2web/l4d2web/static/css/components/badge.css:

.badge {
  display: inline-block;
  padding: 0.1rem 0.55rem;
  font-size: var(--text-xs); line-height: 1.4;
  border-radius: var(--radius-full);
  border: 1px solid var(--color-border);
  background: var(--color-surface); color: var(--color-text);
  font-weight: 500;
}
.badge-success { background: color-mix(in srgb, var(--color-success) 16%, var(--color-surface)); border-color: color-mix(in srgb, var(--color-success) 40%, var(--color-border)); color: var(--color-success); }
.badge-warning { background: color-mix(in srgb, var(--color-warning) 16%, var(--color-surface)); border-color: color-mix(in srgb, var(--color-warning) 40%, var(--color-border)); color: var(--color-warning); }
.badge-danger  { background: color-mix(in srgb, var(--color-danger)  16%, var(--color-surface)); border-color: color-mix(in srgb, var(--color-danger)  40%, var(--color-border)); color: var(--color-danger);  }
.badge-muted   { color: var(--color-muted); }

/* Server-lifecycle state pills */
.state-running   { background: color-mix(in srgb, var(--color-success) 16%, var(--color-surface)); border-color: color-mix(in srgb, var(--color-success) 40%, var(--color-border)); color: var(--color-success); }
.state-stopped   { background: color-mix(in srgb, var(--color-muted)   16%, var(--color-surface)); border-color: color-mix(in srgb, var(--color-muted)   40%, var(--color-border)); color: var(--color-muted);   }
.state-unknown   { color: var(--color-muted); }
.state-transient { background: color-mix(in srgb, var(--color-warning) 16%, var(--color-surface)); border-color: color-mix(in srgb, var(--color-warning) 40%, var(--color-border)); color: var(--color-warning); }
.state-drift     { background: color-mix(in srgb, var(--color-danger)  16%, var(--color-surface)); border-color: color-mix(in srgb, var(--color-danger)  40%, var(--color-border)); color: var(--color-danger);  }
  • Step 6: Write components/nav.css

l4d2web/l4d2web/static/css/components/nav.css:

.site-header {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-2);
  padding: var(--space-2) var(--space-3);
  margin-block: var(--space-3);
}
.site-header-inner { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); }
.primary-nav, .account-nav { display: flex; gap: var(--space-3); align-items: center; }
.primary-nav .brand { font-weight: 700; }
.inline-form { display: inline; margin: 0; }
  • Step 7: Verify in browser

Open /dashboard or /servers. Buttons should be styled, the site header should look like a bordered card with the brand left-aligned and account links right-aligned, state pills in the server list should be colored.

  • Step 8: Commit
git add l4d2web/l4d2web/static/css/components/button.css \
        l4d2web/l4d2web/static/css/components/panel.css \
        l4d2web/l4d2web/static/css/components/table.css \
        l4d2web/l4d2web/static/css/components/badge.css \
        l4d2web/l4d2web/static/css/components/nav.css
git commit -m "feat(stylesheet): core components — button, panel, table, badge, nav"

Task 4: Composite components — modal, tabs, field, dropdown

Files:

  • Create: l4d2web/static/css/components/modal.css

  • Create: l4d2web/static/css/components/tabs.css

  • Create: l4d2web/static/css/components/field.css

  • Create: l4d2web/static/css/components/dropdown.css

  • Step 1: Write components/modal.css

l4d2web/l4d2web/static/css/components/modal.css:

.modal {
  border: 1px solid var(--color-border); border-radius: var(--radius-2);
  background: var(--color-surface); color: var(--color-text);
  box-shadow: var(--shadow-lg);
  padding: 0;
  max-width: min(420px, 92vw);
}
.modal-wide { max-width: min(720px, 95vw); }

.modal::backdrop {
  background: rgba(0,0,0,0.45);
  backdrop-filter: blur(2px);
}

/* Inner article (used by current _modal_partial pattern) shouldn't add extra chrome */
.modal > article { padding: 0; margin: 0; background: transparent; border: 0; box-shadow: none; }

.modal-header {
  display: flex; justify-content: space-between; align-items: center;
  padding: var(--space-3) var(--space-4);
  border-bottom: 1px solid var(--color-border-soft);
}
.modal-header h2 { margin: 0; font-size: var(--text-lg); }
.modal-body   { padding: var(--space-3) var(--space-4); }
.modal-footer { padding: var(--space-3) var(--space-4); border-top: 1px solid var(--color-border-soft); }

.modal-close {
  background: none; border: 0; font-size: 1.25rem;
  color: var(--color-muted); cursor: pointer; padding: 0 var(--space-1);
}
.modal-close:hover { color: var(--color-text); }
  • Step 2: Write components/tabs.css

l4d2web/l4d2web/static/css/components/tabs.css:

.tabs {
  display: flex; gap: 0;
  border-bottom: 1px solid var(--color-border);
  margin-bottom: var(--space-3);
}
.tabs .tab {
  background: transparent; border: 0;
  border-bottom: 2px solid transparent;
  padding: var(--space-2) var(--space-3);
  color: var(--color-muted); font-size: var(--text-sm);
  cursor: pointer;
  transition: color var(--duration-fast) var(--ease-out),
              border-color var(--duration-fast) var(--ease-out);
}
.tabs .tab:hover { color: var(--color-text); }
.tabs .tab[aria-selected="true"] {
  color: var(--color-text-strong);
  border-bottom-color: var(--color-primary);
}
.tab-panel { padding: var(--space-2) 0; }
  • Step 3: Write components/field.css

l4d2web/l4d2web/static/css/components/field.css:

.field { display: flex; flex-direction: column; gap: var(--space-1); }
.field-label { font-weight: 600; font-size: var(--text-sm); }
.field-hint  { font-size: var(--text-xs); color: var(--color-muted); }
.field-error { font-size: var(--text-xs); color: var(--color-danger); }

input[aria-invalid="true"],
select[aria-invalid="true"],
textarea[aria-invalid="true"] {
  border-color: var(--color-danger);
}

.inline-save { display: flex; gap: var(--space-2); align-items: stretch; }
.inline-save > input { margin: 0; }
  • Step 4: Write components/dropdown.css

l4d2web/l4d2web/static/css/components/dropdown.css:

/* Native <select> styling is already done in elements.css; this layer
   exists as a hook for any custom dropdown markup we add later. Keep
   minimal until a real custom-dropdown widget is needed. */

.dropdown { position: relative; display: inline-block; }
.dropdown > select { width: 100%; }
  • Step 5: Verify in browser

Open a page with a modal (e.g. /servers/1 then click any action that opens one). Open server_detail.html and click between the tab buttons — the active one should show a primary-colored underline. Fields in forms (e.g. profile page) should display label / input / hint stacking cleanly.

  • Step 6: Commit
git add l4d2web/l4d2web/static/css/components/modal.css \
        l4d2web/l4d2web/static/css/components/tabs.css \
        l4d2web/l4d2web/static/css/components/field.css \
        l4d2web/l4d2web/static/css/components/dropdown.css
git commit -m "feat(stylesheet): composite components — modal, tabs, field, dropdown"

Task 5: Macros — five high-leverage primitives

Files:

  • Create: l4d2web/templates/ui/_field.html
  • Create: l4d2web/templates/ui/_modal.html
  • Create: l4d2web/templates/ui/_tabs.html
  • Create: l4d2web/templates/ui/_confirm_form.html
  • Create: l4d2web/templates/ui/_badge.html

These are import-only files. No template uses them yet — adoption happens organically as templates are touched. This commit only adds the API surface.

  • Step 1: Create templates/ui/ directory
mkdir -p l4d2web/l4d2web/templates/ui
  • Step 2: Write ui/_field.html

l4d2web/l4d2web/templates/ui/_field.html:

{# Form field with label, optional hint, optional error. Owns the
   <label for> / <input id> pairing and aria-describedby wiring.

   Usage:
       {% from "ui/_field.html" import field, checkbox, select %}

       {{ field(name="hostname", label="Hostname", value=server.hostname,
                hint="Used in master-server listings.") }}

       {{ field(name="port", label="Port", type="number", value=server.port,
                error="Must be between 27015 and 27115." if invalid_port else None) }}
#}

{% macro field(name, label, value="", type="text", hint=None, error=None, required=False, placeholder=None, autofocus=False, id=None) %}
  {%- set fid = id or ("f-" ~ name) -%}
  {%- set descby = [] -%}
  {%- if hint  %}{%- set _ = descby.append(fid ~ "-hint")  %}{%- endif -%}
  {%- if error %}{%- set _ = descby.append(fid ~ "-error") %}{%- endif -%}
  <div class="field">
    <label class="field-label" for="{{ fid }}">{{ label }}</label>
    <input id="{{ fid }}" name="{{ name }}" type="{{ type }}"
           value="{{ value }}"
           {% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
           {% if required    %}required{% endif %}
           {% if autofocus   %}autofocus{% endif %}
           {% if error       %}aria-invalid="true"{% endif %}
           {% if descby      %}aria-describedby="{{ descby|join(' ') }}"{% endif %}>
    {% if hint  %}<p id="{{ fid }}-hint"  class="field-hint">{{ hint }}</p>{% endif %}
    {% if error %}<p id="{{ fid }}-error" class="field-error">{{ error }}</p>{% endif %}
  </div>
{% endmacro %}

{% macro checkbox(name, label, checked=False, disabled=False, id=None) %}
  {%- set fid = id or ("c-" ~ name) -%}
  <label class="field-checkbox" for="{{ fid }}">
    <input id="{{ fid }}" name="{{ name }}" type="checkbox"
           {% if checked  %}checked{% endif %}
           {% if disabled %}disabled{% endif %}>
    {{ label }}
  </label>
{% endmacro %}

{% macro select(name, label, options, value="", hint=None, id=None) %}
  {#- options: list of {value, label} dicts, OR list of strings -#}
  {%- set fid = id or ("s-" ~ name) -%}
  <div class="field">
    <label class="field-label" for="{{ fid }}">{{ label }}</label>
    <select id="{{ fid }}" name="{{ name }}"
            {% if hint %}aria-describedby="{{ fid }}-hint"{% endif %}>
      {% for opt in options %}
        {%- if opt is mapping -%}
          <option value="{{ opt.value }}" {% if opt.value == value %}selected{% endif %}>{{ opt.label }}</option>
        {%- else -%}
          <option value="{{ opt }}" {% if opt == value %}selected{% endif %}>{{ opt }}</option>
        {%- endif -%}
      {% endfor %}
    </select>
    {% if hint %}<p id="{{ fid }}-hint" class="field-hint">{{ hint }}</p>{% endif %}
  </div>
{% endmacro %}
  • Step 3: Write ui/_modal.html

l4d2web/l4d2web/templates/ui/_modal.html:

{# Modal dialog. Wraps <dialog> with header / body / footer slots and the
   close-button wiring. The existing modals.js machinery is unchanged.

   Usage:
       {% from "ui/_modal.html" import modal %}

       {% call modal(id="confirm-stop", title="Stop server?") %}
           <p>This will disconnect all players.</p>
           <div class="button-row">
               <button type="button" class="btn btn-secondary" data-inline-modal-close>Cancel</button>
               <button type="submit" class="btn btn-danger">Stop server</button>
           </div>
       {% endcall %}
#}

{% macro modal(id, title, wide=False) %}
  <dialog id="{{ id }}" class="modal{% if wide %} modal-wide{% endif %}">
    <article>
      <header class="modal-header">
        <h2>{{ title }}</h2>
        <button class="modal-close" type="button" aria-label="Close" data-inline-modal-close>&times;</button>
      </header>
      <div class="modal-body">
        {{ caller() }}
      </div>
    </article>
  </dialog>
{% endmacro %}
  • Step 4: Write ui/_tabs.html

l4d2web/l4d2web/templates/ui/_tabs.html:

{# Tab bar + tabpanels. Owns role="tablist" / role="tab" / role="tabpanel"
   wiring and aria-controls / aria-selected linkage. The existing tabs.js
   handles click-to-switch via these ARIA attributes.

   Usage:
       {% from "ui/_tabs.html" import tabs, tab_panel %}

       {{ tabs([
           {"id": "overview",  "label": "Overview", "selected": True},
           {"id": "console",   "label": "Console"},
           {"id": "files",     "label": "Files"},
       ]) }}

       {% call tab_panel("overview", selected=True) %} ... {% endcall %}
       {% call tab_panel("console") %}                ... {% endcall %}
       {% call tab_panel("files") %}                  ... {% endcall %}
#}

{% macro tabs(items) %}
  <div class="tabs" role="tablist">
    {% for it in items %}
      <button class="tab" role="tab"
              id="tab-{{ it.id }}"
              aria-selected="{{ 'true' if it.selected else 'false' }}"
              aria-controls="panel-{{ it.id }}"
              {% if not it.selected %}tabindex="-1"{% endif %}>
        {{ it.label }}
      </button>
    {% endfor %}
  </div>
{% endmacro %}

{% macro tab_panel(id, selected=False) %}
  <div id="panel-{{ id }}" role="tabpanel"
       aria-labelledby="tab-{{ id }}"
       class="tab-panel"
       {% if not selected %}hidden{% endif %}>
    {{ caller() }}
  </div>
{% endmacro %}
  • Step 5: Write ui/_confirm_form.html

l4d2web/l4d2web/templates/ui/_confirm_form.html:

{# Confirm-and-submit form. CSRF token + button row + POST action.

   Usage:
       {% from "ui/_confirm_form.html" import confirm_form %}

       {{ confirm_form(action="/servers/1/delete",
                       submit_label="Delete server",
                       submit_variant="danger",
                       cancel_label="Cancel") }}
#}

{% macro confirm_form(action, submit_label="Confirm", submit_variant="primary", cancel_label="Cancel", cancel_attrs="data-inline-modal-close") %}
  <form method="post" action="{{ action }}">
    <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
    <div class="button-row">
      <button type="button" class="btn btn-secondary" {{ cancel_attrs|safe }}>{{ cancel_label }}</button>
      <button type="submit" class="btn btn-{{ submit_variant }}">{{ submit_label }}</button>
    </div>
  </form>
{% endmacro %}
  • Step 6: Write ui/_badge.html

l4d2web/l4d2web/templates/ui/_badge.html:

{# Badges and state pills. badge_state encapsulates the state-string →
   variant-class mapping that's open-coded today.

   Usage:
       {% from "ui/_badge.html" import badge, badge_state %}

       {{ badge_state(server.actual_state) }}
       {{ badge("ok", variant="success") }}
#}

{% macro badge(label, variant="muted") %}
  <span class="badge badge-{{ variant }}">{{ label }}</span>
{% endmacro %}

{% macro badge_state(state) %}
  {#- Canonical mapping of server-lifecycle state strings to .state-* classes.
      Keep in sync with components/badge.css. -#}
  {%- set known = {
      "running":    "state-running",
      "stopped":    "state-stopped",
      "unknown":    "state-unknown",
      "starting":   "state-transient",
      "stopping":   "state-transient",
      "resetting":  "state-transient",
      "initializing": "state-transient",
      "deleting":   "state-transient",
      "drift":      "state-drift",
  } -%}
  {%- set cls = known.get(state, "state-unknown") -%}
  <span class="badge {{ cls }}">{{ state or "unknown" }}</span>
{% endmacro %}
  • Step 7: Add a pytest unit test for the field macro's a11y wiring

l4d2web/tests/test_ui_macros.py:

"""Unit tests for templates/ui/ macros. These guard the a11y wiring that
the macros own — if these regress, accessibility silently breaks."""

import pytest
from jinja2 import Environment, FileSystemLoader
from pathlib import Path


@pytest.fixture
def jinja_env():
    templates_dir = Path(__file__).parent.parent / "l4d2web" / "templates"
    return Environment(loader=FileSystemLoader(str(templates_dir)))


def test_field_pairs_label_for_with_input_id(jinja_env):
    tmpl = jinja_env.from_string(
        '{% from "ui/_field.html" import field %}'
        '{{ field(name="hostname", label="Hostname") }}'
    )
    html = tmpl.render()
    assert 'for="f-hostname"' in html
    assert 'id="f-hostname"' in html


def test_field_wires_aria_describedby_for_hint(jinja_env):
    tmpl = jinja_env.from_string(
        '{% from "ui/_field.html" import field %}'
        '{{ field(name="port", label="Port", hint="2701527115") }}'
    )
    html = tmpl.render()
    assert 'aria-describedby="f-port-hint"' in html
    assert 'id="f-port-hint"' in html
    assert 'class="field-hint"' in html


def test_field_combines_aria_describedby_when_hint_and_error_both_present(jinja_env):
    tmpl = jinja_env.from_string(
        '{% from "ui/_field.html" import field %}'
        '{{ field(name="port", label="Port", hint="info", error="too low") }}'
    )
    html = tmpl.render()
    assert 'aria-describedby="f-port-hint f-port-error"' in html
    assert 'aria-invalid="true"' in html


def test_badge_state_maps_known_state(jinja_env):
    tmpl = jinja_env.from_string(
        '{% from "ui/_badge.html" import badge_state %}'
        '{{ badge_state("running") }}'
    )
    html = tmpl.render()
    assert 'class="badge state-running"' in html
    assert ">running<" in html


def test_badge_state_falls_back_to_unknown(jinja_env):
    tmpl = jinja_env.from_string(
        '{% from "ui/_badge.html" import badge_state %}'
        '{{ badge_state("weird-new-state") }}'
    )
    html = tmpl.render()
    assert 'class="badge state-unknown"' in html


def test_badge_state_none_renders_unknown_label(jinja_env):
    tmpl = jinja_env.from_string(
        '{% from "ui/_badge.html" import badge_state %}'
        '{{ badge_state(None) }}'
    )
    html = tmpl.render()
    assert "unknown" in html
  • Step 8: Run the macro tests, verify they pass
cd l4d2web && uv run pytest tests/test_ui_macros.py -v

Expected: 6 passed.

  • Step 9: Commit
git add l4d2web/l4d2web/templates/ui/ l4d2web/tests/test_ui_macros.py
git commit -m "feat(stylesheet): ui macros — field, modal, tabs, confirm_form, badge_state"

Task 6: Project widgets relocation

Files:

  • Create: l4d2web/static/css/widgets/file-tree.css (extracted from current components.css)
  • Create: l4d2web/static/css/widgets/overlay-picker.css (extracted from current components.css)
  • Move: l4d2web/static/css/console-autocomplete.csswidgets/console-autocomplete.css
  • Move: l4d2web/static/css/editor.csswidgets/editor.css
  • Move: l4d2web/static/css/logs.csswidgets/logs.css
  • Create: l4d2web/static/css/widgets/live-state.css (extracted from current components.css if .live-state-* rules exist there; otherwise leave as empty placeholder with a comment that the live-state component currently has no scoped CSS)

Each widget file is moved/extracted content-preserving — same rules, same selectors — but token names must be migrated. The old tokens.css and new tokens/semantic.css agree on most color names but differ on spacing, radii, and a few colors. Use this table when copying.

Token migration table (apply to every line copied from the old files):

Old name New name Notes
--color-bg, --color-text, --color-muted, --color-border, --color-link, --color-primary, --color-danger, --color-warning, --color-success, --color-focus, --color-surface (same) Unchanged.
--color-surface-muted --color-surface-2
--color-border-muted --color-border-soft
--color-button-primary --color-primary
--color-button-danger --color-danger
--color-log-bg --color-surface-2
--color-log-text --color-text
--space-xs --space-1 0.25rem
--space-s --space-2 0.5rem
--space-m --space-3 0.75rem
--space-l --space-4 1rem
--space-xl --space-5 1.5rem (was 1.5rem)
--space-2xl --space-6 2rem
--radius-base, --radius-s --radius-1 0.25rem
--radius-m --radius-2 0.5rem
--line 1px solid var(--color-border) Inline expansion — --line is no longer in the system.
--line-soft 1px solid var(--color-border-soft) Inline expansion.
--font-mono (same) Unchanged.

CodeMirror editor tokens (--cm-*, --syntax-*, --editor-rows) live with the editor widget. Step 5 below relocates them.

  • Step 1: Create widgets directory
mkdir -p l4d2web/l4d2web/static/css/widgets
  • Step 2: Extract file-tree rules from components.css into widgets/file-tree.css

Read l4d2web/l4d2web/static/css/components.css and identify every selector beginning with .file-tree (including descendants like .file-tree-row, .file-tree-toggle, .file-tree-children, .file-tree-badge, .file-tree-row-truncated, etc.). Copy those rules verbatim into l4d2web/l4d2web/static/css/widgets/file-tree.css.

Rename any old token references to the new names where needed. The existing token names in tokens.css are largely identical to the new semantic tokens; spot-check after copying.

  • Step 3: Extract overlay-picker rules from components.css into widgets/overlay-picker.css

Same process for .overlay-picker* selectors.

  • Step 4: Move root-level widget files into widgets/
git mv l4d2web/l4d2web/static/css/console-autocomplete.css \
       l4d2web/l4d2web/static/css/widgets/console-autocomplete.css
git mv l4d2web/l4d2web/static/css/editor.css \
       l4d2web/l4d2web/static/css/widgets/editor.css
git mv l4d2web/l4d2web/static/css/logs.css \
       l4d2web/l4d2web/static/css/widgets/logs.css
  • Step 4b: Relocate CodeMirror tokens into widgets/editor.css

The current tokens.css defines CodeMirror palette tokens (--cm-bg, --cm-fg, --cm-keyword, --cm-string, --cm-comment, --cm-number, --cm-selection, plus the --syntax-* aliases and --editor-rows) at the :root level so they're globally available. After this redesign they are widget-scoped — move them into widgets/editor.css, scoped to the editor's container if possible (.cm-editor or the wrapper used by editor.bundle.js); fall back to :root if scoping breaks the editor.

Prepend the following block to widgets/editor.css (above the existing editor rules, which were just git mvd from the root-level file):

/* CodeMirror palette tokens. Were at :root in the old tokens.css; now
   live with the widget that consumes them. Light + dark in one place. */

:root {
  --syntax-keyword: #cc4488;
  --syntax-string:  #2f8b3a;
  --syntax-comment: #888;
  --syntax-number:  #884488;
  --cm-bg:        var(--color-surface);
  --cm-fg:        var(--color-text);
  --cm-selection: rgba(60, 130, 220, 0.2);
  --cm-keyword:   var(--syntax-keyword);
  --cm-string:    var(--syntax-string);
  --cm-comment:   var(--syntax-comment);
  --cm-number:    var(--syntax-number);
  --editor-rows:  16;
}
:root[data-theme="dark"] {
  --syntax-keyword: #ff80c0;
  --syntax-string:  #87d96a;
  --syntax-comment: #888;
  --syntax-number:  #c890ff;
  --cm-selection:   rgba(120, 170, 255, 0.25);
}

(The --cm-bg / --cm-fg cascade through --color-surface / --color-text automatically, so they don't need re-definition in the dark block.)

  • Step 5: Check if live-state has scoped CSS that needs a widget file
grep -nE "live-state|\.player-(card|avatar|name|meta)" l4d2web/l4d2web/static/css/components.css

If the grep finds rules, extract them into l4d2web/l4d2web/static/css/widgets/live-state.css. If not, create a placeholder file so main.css's @import "./widgets/live-state.css" doesn't 404:

test -f l4d2web/l4d2web/static/css/widgets/live-state.css || cat > l4d2web/l4d2web/static/css/widgets/live-state.css <<'EOF'
/* Live-state widget. No scoped rules yet — markup currently relies on
   element defaults plus component badges. Reserved for future per-widget
   styling. */
EOF
  • Step 5b: Grep for un-migrated old token names in the new widget files

After all widget files are in place, sanity-check that no old token references survive:

grep -nE 'var\(--(space-(xs|s|m|l|xl|2xl)|radius-(base|s|m)|line|line-soft|color-(surface-muted|border-muted|button-primary|button-danger|log-bg|log-text))' \
  l4d2web/l4d2web/static/css/widgets/*.css l4d2web/l4d2web/static/css/components/*.css 2>/dev/null

Expected: no output. If anything matches, apply the migration table above and re-run.

  • Step 6: Verify all @imports in main.css resolve
for f in $(grep -oE '\./[a-z0-9./-]+\.css' l4d2web/l4d2web/static/css/main.css); do
  full=l4d2web/l4d2web/static/css/${f#./}
  test -f "$full" || echo "MISSING: $full"
done
echo "done"

Expected: only done printed (no MISSING lines).

  • Step 7: Open server detail / overlay detail in browser; verify file-tree and overlay-picker render

The dev server should auto-reload static files. Navigate to a page containing each widget and confirm rendering matches pre-redesign.

  • Step 8: Commit
git add l4d2web/l4d2web/static/css/widgets/
git commit -m "refactor(stylesheet): relocate project widgets to widgets/"

Task 7: Utilities

Files:

  • Create: l4d2web/static/css/utilities.css

  • Step 1: Write utilities.css

l4d2web/l4d2web/static/css/utilities.css:

/* Utility classes. In the utilities layer — wins over components by
   layer order, not selector specificity. Keep this list short and
   purposeful; if a utility starts being used to override component
   styling regularly, that's a signal the component is wrong. */

.muted    { color: var(--color-muted); }
.mono     { font-family: var(--font-mono); }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sr-only  {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
  • Step 2: Commit
git add l4d2web/l4d2web/static/css/utilities.css
git commit -m "feat(stylesheet): utilities layer"

Task 8: Style guide route + template

Files:

  • Create: l4d2web/routes/styleguide_routes.py

  • Create: l4d2web/templates/styleguide.html

  • Modify: l4d2web/l4d2web/app.py — register the styleguide blueprint

  • Step 1: Write the test first

l4d2web/tests/test_styleguide.py:

"""The /styleguide route is the canonical reference for every available
widget. Test that it renders, returns 200 publicly, and contains at
least the major primitive class names."""

import pytest
from l4d2web.app import create_app


@pytest.fixture
def client(monkeypatch, tmp_path):
    db = tmp_path / "sg.db"
    monkeypatch.setenv("DATABASE_URL", f"sqlite:///{db}")
    monkeypatch.setenv("SECRET_KEY", "test-only-not-a-secret")
    app = create_app({"TESTING": True})
    return app.test_client()


def test_styleguide_returns_200(client):
    resp = client.get("/styleguide")
    assert resp.status_code == 200


def test_styleguide_does_not_require_login(client):
    # No session set up; should still serve the page.
    resp = client.get("/styleguide")
    assert resp.status_code == 200


def test_styleguide_contains_every_primitive(client):
    resp = client.get("/styleguide")
    body = resp.get_data(as_text=True)
    # Each canonical primitive must appear somewhere on the page.
    for token in [
        "btn-primary", "btn-secondary", "btn-danger", "btn-outline",
        "field-label", "field-hint", "field-error",
        "table", "panel", "panel-heading",
        "modal", "modal-header",
        "tabs", "tab-panel",
        "badge-success", "state-running",
        "site-header",
    ]:
        assert token in body, f"styleguide missing primitive: {token}"


def test_styleguide_includes_do_dont_blocks(client):
    resp = client.get("/styleguide")
    body = resp.get_data(as_text=True)
    assert "sg-do" in body
    assert "sg-dont" in body
  • Step 2: Run the test, verify it fails
cd l4d2web && uv run pytest tests/test_styleguide.py -v

Expected: FAIL — /styleguide returns 404 because the route doesn't exist yet.

  • Step 3: Write routes/styleguide_routes.py

l4d2web/l4d2web/routes/styleguide_routes.py:

from flask import Blueprint, render_template


bp = Blueprint("styleguide", __name__)


@bp.get("/styleguide")
def styleguide_index() -> str:
    return render_template("styleguide.html")
  • Step 4: Register the blueprint in app.py

Edit l4d2web/l4d2web/app.py. Add the import alongside the others (alphabetical):

from l4d2web.routes.styleguide_routes import bp as styleguide_bp

And register it alongside the other app.register_blueprint(...) calls:

    app.register_blueprint(styleguide_bp)
  • Step 5: Write templates/styleguide.html

l4d2web/l4d2web/templates/styleguide.html:

{# Style guide page. Source of truth for every available primitive.
   Each example uses {% set src %}...{% endset %} so the rendered widget
   and the displayed source HTML can't drift. #}
{% extends "base.html" %}
{% block title %}Styleguide | left4me{% endblock %}

{% block extra_head %}
<style nonce="{{ g.csp_nonce }}">
  .sg-section   { margin-block: var(--space-6); }
  .sg-example   { border: 1px solid var(--color-border-soft); border-radius: var(--radius-2); margin-block: var(--space-3); overflow: hidden; }
  .sg-example-title    { padding: var(--space-2) var(--space-3); background: var(--color-surface-2); font-size: var(--text-sm); font-weight: 600; margin: 0; }
  .sg-example-rendered { padding: var(--space-3); background: var(--color-surface); }
  .sg-example-source   { margin: 0; padding: var(--space-3); background: var(--color-bg); font-size: var(--text-sm); border-top: 1px solid var(--color-border-soft); white-space: pre-wrap; }
  .sg-do, .sg-dont     { padding: var(--space-2) var(--space-3); }
  .sg-do   { background: color-mix(in srgb, var(--color-success) 8%, var(--color-surface)); border-left: 4px solid var(--color-success); }
  .sg-dont { background: color-mix(in srgb, var(--color-danger)  8%, var(--color-surface)); border-left: 4px solid var(--color-danger);  }
  .sg-token-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--space-2); }
  .sg-token-swatch { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-1) var(--space-2); border: 1px solid var(--color-border-soft); border-radius: var(--radius-1); }
  .sg-token-swatch > .sw { width: 1.5rem; height: 1.5rem; border-radius: var(--radius-1); border: 1px solid var(--color-border-soft); }
  .sg-token-swatch > code { font-size: var(--text-xs); }
</style>
{% endblock %}

{% block content %}
<header class="page-heading">
  <h1>Style guide</h1>
  <p class="muted">Every available widget, with copy-paste source. If your change needs a widget that isn't here, extend the system before using it.</p>
  <button type="button" class="btn btn-secondary" id="sg-theme-toggle">Toggle dark</button>
</header>

{# ============================ Helper macro ============================ #}
{% macro example(title, html) %}
  <section class="sg-example">
    <h3 class="sg-example-title">{{ title }}</h3>
    <div class="sg-example-rendered">{{ html|safe }}</div>
    <pre class="sg-example-source"><code>{{ html|trim|e }}</code></pre>
  </section>
{% endmacro %}

{# ============================== Tokens ============================== #}
<section class="sg-section">
  <h2>Tokens</h2>
  <p>Component CSS uses semantic tokens only (<code>var(--color-*)</code>, <code>var(--space-*)</code>). Primitive hex codes live in <code>tokens/primitives.css</code> and are not referenced directly by components.</p>
  <h3>Colors</h3>
  <div class="sg-token-grid">
    {% for name in ["bg", "surface", "surface-2", "text", "text-strong", "muted", "border", "border-soft", "primary", "primary-hover", "on-primary", "danger", "on-danger", "warning", "success", "focus", "link"] %}
      <div class="sg-token-swatch">
        <span class="sw" style="background: var(--color-{{ name }})"></span>
        <code>--color-{{ name }}</code>
      </div>
    {% endfor %}
  </div>
  <h3>Spacing scale</h3>
  <ul class="mono">
    {% for n in [1,2,3,4,5,6,7] %}<li>--space-{{ n }}</li>{% endfor %}
  </ul>
  <h3>Type scale</h3>
  <ul class="mono">
    <li>--text-xs / sm / base / lg / xl / 2xl</li>
  </ul>
</section>

{# ============================== Buttons ============================== #}
<section class="sg-section">
  <h2>Button</h2>
  {% set src %}
<button type="button" class="btn btn-primary">Primary</button>
<button type="button" class="btn btn-secondary">Secondary</button>
<button type="button" class="btn btn-danger">Danger</button>
<button type="button" class="btn btn-outline">Outline</button>
<button type="button" class="btn btn-primary" disabled>Disabled</button>
<button type="button" class="btn btn-primary" aria-busy="true">Loading…</button>
<button type="button" class="btn btn-primary btn-sm">Small</button>
  {% endset %}
  {{ example("Variants + states + sizes", src) }}

  {% set src %}
<div class="button-row">
  <button type="button" class="btn btn-secondary">Cancel</button>
  <button type="submit"  class="btn btn-primary">Save</button>
</div>
  {% endset %}
  {{ example("Button row", src) }}
</section>

{# ============================== Fields ============================== #}
<section class="sg-section">
  <h2>Field</h2>
  <p>Use the <code>ui.field</code> macro — it owns the <code>for</code>/<code>id</code> pair and <code>aria-describedby</code> wiring.</p>

  <div class="sg-do">
    ✅ <strong>DO</strong> — use <code>ui.field</code> so a11y attributes can't drift:
  </div>
  {% set src %}
{# In your template #}
{% raw %}{% from "ui/_field.html" import field %}
{{ field(name="hostname", label="Hostname", value="left4me",
         hint="Used in master-server listings.") }}{% endraw %}
  {% endset %}
  {{ example("Field via macro", src) }}

  <div class="sg-dont">
    ❌ <strong>DON'T</strong> — hand-assemble a label + input without <code>for</code>/<code>id</code> + <code>aria-describedby</code>; screen readers will miss the hint.
  </div>
  {% set src %}
<label>Hostname</label>
<input name="hostname" value="left4me">
<p>Used in master-server listings.</p>
  {% endset %}
  {{ example("Bare label + input (broken a11y)", src) }}

  {% set src %}
<div class="field">
  <label class="field-label" for="sg-port">Port</label>
  <input id="sg-port" type="number" value="-1" aria-invalid="true" aria-describedby="sg-port-err">
  <p id="sg-port-err" class="field-error">Must be between 27015 and 27115.</p>
</div>
  {% endset %}
  {{ example("Error state (raw source — for reference)", src) }}
</section>

{# ============================== Tables ============================== #}
<section class="sg-section">
  <h2>Table</h2>
  {% set src %}
<table class="table">
  <thead><tr><th>Name</th><th>State</th><th>Players</th></tr></thead>
  <tbody>
    <tr><td>alpha</td><td><span class="badge state-running">running</span></td><td>4 / 8</td></tr>
    <tr><td>beta</td><td><span class="badge state-stopped">stopped</span></td><td>— / 8</td></tr>
  </tbody>
</table>
  {% endset %}
  {{ example("Basic", src) }}
</section>

{# ============================== Panel ============================== #}
<section class="sg-section">
  <h2>Panel</h2>
  {% set src %}
<article class="panel">
  <header class="panel-heading"><h3>Recent players</h3></header>
  <div class="panel-body"><p>Body content.</p></div>
  <footer class="panel-footer">
    <div class="button-row"><button class="btn btn-secondary">Refresh</button></div>
  </footer>
</article>
  {% endset %}
  {{ example("With heading + body + footer", src) }}
</section>

{# ============================== Modal ============================== #}
<section class="sg-section">
  <h2>Modal</h2>
  <p>Use the <code>ui.modal</code> macro — it owns the <code>&lt;dialog&gt;</code> structure and the close-button wiring.</p>

  <div class="sg-do">
    ✅ <strong>DO</strong>:
  </div>
  {% set src %}
{% raw %}{% from "ui/_modal.html" import modal %}
{% call modal(id="confirm-stop", title="Stop server?") %}
  <p>This will disconnect all players.</p>
  <div class="button-row">
    <button type="button" class="btn btn-secondary" data-inline-modal-close>Cancel</button>
    <button type="submit"  class="btn btn-danger">Stop server</button>
  </div>
{% endcall %}{% endraw %}
  {% endset %}
  {{ example("Modal via macro", src) }}

  <div class="sg-dont">
    ❌ <strong>DON'T</strong> — hand-write the <code>&lt;dialog&gt;</code> + header markup; you'll lose the close-button or modal-close hook.
  </div>
</section>

{# ============================== Tabs ============================== #}
<section class="sg-section">
  <h2>Tabs</h2>
  {% set src %}
<div class="tabs" role="tablist">
  <button class="tab" role="tab" aria-selected="true"  aria-controls="panel-a">Overview</button>
  <button class="tab" role="tab" aria-selected="false" aria-controls="panel-b">Console</button>
</div>
<div id="panel-a" role="tabpanel" class="tab-panel">Overview content.</div>
<div id="panel-b" role="tabpanel" class="tab-panel" hidden>Console content.</div>
  {% endset %}
  {{ example("Underline-style tabs", src) }}
</section>

{# ============================== Badges ============================== #}
<section class="sg-section">
  <h2>Badge</h2>
  {% set src %}
<span class="badge badge-success">success</span>
<span class="badge badge-warning">warning</span>
<span class="badge badge-danger">danger</span>
<span class="badge badge-muted">muted</span>
  {% endset %}
  {{ example("Semantic", src) }}

  {% set src %}
<span class="badge state-running">running</span>
<span class="badge state-stopped">stopped</span>
<span class="badge state-unknown">unknown</span>
<span class="badge state-transient">starting…</span>
<span class="badge state-drift">drift</span>
  {% endset %}
  {{ example("Server lifecycle (use ui.badge_state to render from a state string)", src) }}

  <div class="sg-do">
    ✅ <strong>DO</strong> — let <code>ui.badge_state</code> map state strings to classes; mappings live in one place.
  </div>
  <div class="sg-dont">
    ❌ <strong>DON'T</strong> — hand-pick <code>state-*</code> classes inline; one template will get it wrong and drift will start.
  </div>
</section>

{# ============================== Site nav ============================== #}
<section class="sg-section">
  <h2>Site nav</h2>
  {% set src %}
<header class="site-header">
  <div class="site-header-inner">
    <nav class="primary-nav"><a class="brand" href="#">left4me</a><a href="#">servers</a></nav>
    <nav class="account-nav"><a class="muted" href="#">user</a></nav>
  </div>
</header>
  {% endset %}
  {{ example("Header + brand + nav links", src) }}
</section>

<script nonce="{{ g.csp_nonce }}">
  document.getElementById('sg-theme-toggle').addEventListener('click', function () {
    var html = document.documentElement;
    html.dataset.theme = html.dataset.theme === 'dark' ? 'light' : 'dark';
  });
</script>
{% endblock %}
  • Step 6: Run the test, verify it passes
cd l4d2web && uv run pytest tests/test_styleguide.py -v

Expected: 4 passed.

  • Step 7: Open /styleguide in a browser, walk it in light and dark mode

Confirm every primitive section renders, the do/don't blocks are color-coded, and the dark-mode toggle button at the top flips the page.

  • Step 8: Commit
git add l4d2web/l4d2web/routes/styleguide_routes.py \
        l4d2web/l4d2web/templates/styleguide.html \
        l4d2web/l4d2web/app.py \
        l4d2web/tests/test_styleguide.py
git commit -m "feat(styleguide): in-app /styleguide as canonical widget reference"

Task 9: AGENTS.md — codify the workflow rule

Files:

  • Modify: AGENTS.md

  • Step 1: Read current AGENTS.md to find the right insertion point

wc -l /Users/mwiegand/Projekte/left4me/AGENTS.md
grep -n "^##" /Users/mwiegand/Projekte/left4me/AGENTS.md

Pick a heading after which to insert the UI work section (probably after a top-level section that talks about working in this repo; before a less-related section like deployment).

  • Step 2: Insert the UI work section

Add this section to AGENTS.md at the chosen location:

## UI work

Before adding any UI markup:

1. Read `l4d2web/templates/styleguide.html` (or open `/styleguide` in the
   running app) — it lists every available widget with a canonical example
   and do/don't notes.
2. If your change needs a widget that isn't in the style guide, **extend
   the system first**, then use the widget:
   - CSS in `l4d2web/static/css/components/<name>.css` (generic) or
     `l4d2web/static/css/widgets/<name>.css` (project-specific).
   - Style-guide entry with rendered example + escaped source + at least
     one ✅ DO; add ❌ DON'T for foreseeable wrong uses.
   - If composite or a11y-load-bearing, add a macro in
     `l4d2web/templates/ui/_<name>.html`.
   - Only then use the widget on the page.
3. **Never** use inline `style="…"` attributes.
4. **Never** invent class names off-system.
5. Component CSS references **only** semantic tokens
   (`var(--color-*)`, `var(--space-*)`, …). Raw hex codes appear only in
   `tokens/primitives.css`.
6. State as ARIA attributes, not modifier classes: prefer
   `[disabled]`, `[aria-busy="true"]`, `[aria-selected="true"]`,
   `[aria-invalid="true"]` over `.is-disabled` / `.is-loading` etc.
  • Step 3: Commit
git add AGENTS.md
git commit -m "docs(agents): codify UI workflow rule — the system is closed"

Task 10: Cleanup

Files:

  • Delete: l4d2web/static/css/components.css
  • Delete: l4d2web/static/css/tokens.css
  • Delete: l4d2web/static/css/spike/ (whole directory)
  • Delete: l4d2web/templates/spike.html
  • Delete: l4d2web/routes/spike_routes.py
  • Delete: l4d2web/static/vendor/css/ (whole directory; only used by spike)
  • Modify: l4d2web/l4d2web/app.py — remove the spike_routes import + the if spike_enabled(): app.register_blueprint(spike_bp) block

Note: the root-level logs.css, console-autocomplete.css, editor.css were already git mv'd in Task 6.

  • Step 1: Confirm the old files are no longer referenced
grep -rn "components.css\|tokens.css" l4d2web/l4d2web/templates/ l4d2web/l4d2web/static/css/ 2>/dev/null | grep -v spike

Expected: no matches outside the spike/ directory.

  • Step 2: Delete the old stylesheets and spike artifacts
git rm l4d2web/l4d2web/static/css/components.css \
       l4d2web/l4d2web/static/css/tokens.css \
       l4d2web/l4d2web/templates/spike.html \
       l4d2web/l4d2web/routes/spike_routes.py

git rm -r l4d2web/l4d2web/static/css/spike \
          l4d2web/l4d2web/static/vendor/css
  • Step 3: Remove spike registration from app.py

Edit l4d2web/l4d2web/app.py:

Remove these lines (they were added in the spike commit):

from l4d2web.routes.spike_routes import bp as spike_bp
from l4d2web.routes.spike_routes import spike_enabled

And remove:

    if spike_enabled():
        app.register_blueprint(spike_bp)
  • Step 4: Run the full pytest suite
cd l4d2web && uv run pytest -x

Expected: all green. Fix any failures in this commit before continuing.

  • Step 5: Run the Chromium e2e suite
cd l4d2web && uv run pytest tests/e2e/ -x

Expected: all green. The e2e suite hits real pages; if any class-name expectation needs updating, do it here.

  • Step 6: Walk the major pages manually in the dev server

Start the dev server: scripts/dev-server.py

Visit each in light and dark (toggle via DevTools: document.documentElement.dataset.theme = 'dark'):

  • /login
  • /dashboard
  • /servers
  • /servers/1
  • /servers/1/jobs
  • /blueprints
  • /blueprints/1
  • /overlays
  • /overlays/1
  • /overlays/2
  • /profile
  • /admin (if seeded with admin user)
  • /admin/users
  • /admin/jobs
  • /styleguide

For each, check: buttons styled, forms readable, tables clean, modals open and look right, tabs switch, badges colored, file-tree and overlay-picker render. Fix regressions in this commit.

  • Step 7: Commit
git add -A
git commit -m "chore(stylesheet): delete old components.css/tokens.css + spike artifacts

Old stylesheet is fully replaced by the layered system rooted at main.css.
Spike route, template, vendor framework CSS, and the conditional spike
blueprint registration are removed; the spike's job (validating the
custom-CSS direction) is complete."

Final verification

After Task 10, the new system is fully in place. A last sanity sweep:

  • Step 1: Search the repo for any remaining references to deleted files
grep -rn "css/spike\|spike_routes\|spike_bp\|spike_enabled\|components\.css\|css/tokens\.css\|css/logs\.css\|css/console-autocomplete\.css\|css/editor\.css" l4d2web/ AGENTS.md docs/ 2>/dev/null

Expected: matches only inside docs/superpowers/specs/ and docs/superpowers/plans/ (documentation that references the redesign).

  • Step 2: Confirm main.css is the only stylesheet base.html loads
grep -E "rel=\"stylesheet\"" l4d2web/l4d2web/templates/base.html

Expected: exactly one match, pointing at main.css.

  • Step 3: Confirm the @layer order is intact
head -5 l4d2web/l4d2web/static/css/main.css

Expected: the first non-comment line is the @layer reset, tokens, elements, layout, components, widgets, utilities; declaration.

  • Step 4: Final commit (if anything was fixed up)

If the sanity sweep surfaced anything, fix it and commit with a short follow-up message. Otherwise, the rewrite is complete.


Definition of done

  • base.html loads exactly one stylesheet: main.css
  • main.css declares the seven-layer cascade order and @imports all sub-files
  • Tokens are split into tokens/primitives.css + tokens/semantic.css
  • All nine component CSS files exist under components/
  • All six widget CSS files exist under widgets/
  • Five macros exist under templates/ui/
  • /styleguide returns 200, renders every primitive, has do/don't blocks
  • AGENTS.md has the "UI work" section codifying the closed-system rule
  • All pytest tests pass (uv run pytest)
  • All e2e tests pass (uv run pytest tests/e2e/)
  • Major pages walk cleanly in light and dark mode
  • Old components.css, tokens.css, and the three root-level widget files are deleted
  • Spike artifacts (css/spike/, vendor/css/, spike.html, spike_routes.py, the app.py registration) are deleted