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>
69 KiB
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
/dashboardreturns 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>×</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="27015–27115") }}'
)
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 currentcomponents.css) - Create:
l4d2web/static/css/widgets/overlay-picker.css(extracted from currentcomponents.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 currentcomponents.cssif.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.cssintowidgets/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.cssintowidgets/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 inmain.cssresolve
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><dialog></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><dialog></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
/styleguidein 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.mdto 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 thespike_routesimport + theif 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.cssis the only stylesheetbase.htmlloads
grep -E "rel=\"stylesheet\"" l4d2web/l4d2web/templates/base.html
Expected: exactly one match, pointing at main.css.
- Step 3: Confirm the
@layerorder 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.htmlloads exactly one stylesheet:main.cssmain.cssdeclares 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/ /styleguidereturns 200, renders every primitive, has do/don't blocksAGENTS.mdhas 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, theapp.pyregistration) are deleted