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

1834 lines
69 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `@import`s 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 + `@import`s |
| 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:
```bash
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**
```bash
mkdir -p l4d2web/l4d2web/static/css/tokens
```
- [ ] **Step 2: Write `main.css`**
`l4d2web/l4d2web/static/css/main.css`:
```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`:
```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`:
```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`:
```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**
```bash
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**
```bash
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`:
```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:
```css
/* 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):
```html
<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:
```html
<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`
```bash
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:
```bash
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**
```bash
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**
```bash
mkdir -p l4d2web/l4d2web/static/css/components
```
- [ ] **Step 2: Write `components/button.css`**
`l4d2web/l4d2web/static/css/components/button.css`:
```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`:
```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`:
```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`:
```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`:
```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**
```bash
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`:
```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`:
```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`:
```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`:
```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**
```bash
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**
```bash
mkdir -p l4d2web/l4d2web/templates/ui
```
- [ ] **Step 2: Write `ui/_field.html`**
`l4d2web/l4d2web/templates/ui/_field.html`:
```jinja
{# 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`:
```jinja
{# 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`:
```jinja
{# 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`:
```jinja
{# 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`:
```jinja
{# 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`:
```python
"""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**
```bash
cd l4d2web && uv run pytest tests/test_ui_macros.py -v
```
Expected: 6 passed.
- [ ] **Step 9: Commit**
```bash
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.css``widgets/console-autocomplete.css`
- Move: `l4d2web/static/css/editor.css``widgets/editor.css`
- Move: `l4d2web/static/css/logs.css``widgets/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**
```bash
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/`**
```bash
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 mv`d from the root-level file):
```css
/* 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**
```bash
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:
```bash
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:
```bash
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 `@import`s in `main.css` resolve**
```bash
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**
```bash
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`:
```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**
```bash
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`:
```python
"""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**
```bash
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`:
```python
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):
```python
from l4d2web.routes.styleguide_routes import bp as styleguide_bp
```
And register it alongside the other `app.register_blueprint(...)` calls:
```python
app.register_blueprint(styleguide_bp)
```
- [ ] **Step 5: Write `templates/styleguide.html`**
`l4d2web/l4d2web/templates/styleguide.html`:
```jinja
{# 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**
```bash
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**
```bash
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**
```bash
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:
```markdown
## 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**
```bash
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**
```bash
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**
```bash
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):
```python
from l4d2web.routes.spike_routes import bp as spike_bp
from l4d2web.routes.spike_routes import spike_enabled
```
And remove:
```python
if spike_enabled():
app.register_blueprint(spike_bp)
```
- [ ] **Step 4: Run the full pytest suite**
```bash
cd l4d2web && uv run pytest -x
```
Expected: all green. Fix any failures in this commit before continuing.
- [ ] **Step 5: Run the Chromium e2e suite**
```bash
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**
```bash
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**
```bash
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**
```bash
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**
```bash
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 `@import`s 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