Throw away the historical naming. New vocabulary chosen for clarity and agentic-dev predictability: parts use hyphenated child classes (.card-header), variant modifiers chain on the parent (.button.primary), state stays on ARIA attributes. Variants compose via Tier-3 component-scoped tokens (--button-bg etc.) — .button.danger.outline is a real outlined-danger button with no combination rule. Adds toast, spinner, heading, app-header as first-class components. Renames panel→card, modal→dialog, badge→tag; collapses state-* into tag variants via ui.lifecycle_tag. Adds an explicit template-rewrite phase in the migration plan, since every template's class attributes change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
88 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 the current ~1.4k LOC stylesheet (l4d2web/static/css/* + 196 component classes) with a from-scratch design system. New naming, new vocabulary, new structure — no preservation of historical names. Every template's class attributes get rewritten.
Architecture: Pure custom CSS, single entry main.css, @layer reset, tokens, elements, layout, components, widgets, utilities;. Three-tier tokens (primitives → semantic → component-scoped). Naming convention: parts use hyphenated child classes (.card-header), variant modifiers chain on the parent (.button.primary). State on ARIA attributes ([disabled], [aria-busy], [aria-selected], [aria-invalid]). Five macros under templates/ui/ for high-leverage composites. Public /styleguide is the canonical reference.
Tech Stack: Plain CSS (@layer, color-mix(), custom properties). Jinja macros. Flask blueprint. 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 |
Tier 1: raw palette |
| Create | l4d2web/static/css/tokens/semantic.css |
Tier 2: aliases + dark-mode branch |
| Create | l4d2web/static/css/elements.css |
Bare HTML defaults |
| Create | l4d2web/static/css/layout.css |
.container, .stack, .row, .cluster |
| Create | l4d2web/static/css/components/button.css |
.button + chained modifiers (Tier-3 tokens here) |
| Create | l4d2web/static/css/components/field.css |
.field, .field-label, .field-hint, .field-error, .field-checkbox |
| Create | l4d2web/static/css/components/table.css |
.table, .table.striped |
| Create | l4d2web/static/css/components/card.css |
.card, .card-header, .card-body, .card-footer |
| Create | l4d2web/static/css/components/dialog.css |
.dialog (styles <dialog>) + parts + .dialog.wide |
| Create | l4d2web/static/css/components/tabs.css |
.tabs, .tab, .tab-panel |
| Create | l4d2web/static/css/components/tag.css |
.tag + chained modifiers (Tier-3 tokens) |
| Create | l4d2web/static/css/components/toast.css |
.toast, .toast-close + variants (Tier-3 tokens) |
| Create | l4d2web/static/css/components/spinner.css |
.spinner, .spinner.small |
| Create | l4d2web/static/css/components/app-header.css |
.app-header, .app-header-inner, .brand, .nav, .account |
| Create | l4d2web/static/css/components/heading.css |
.heading, .heading-actions |
| Create | l4d2web/static/css/components/dropdown.css |
<select> helper + .dropdown |
| Create | l4d2web/static/css/widgets/file-tree.css |
.file-tree, .file-tree-item, .file-tree-toggle, .file-tree-children |
| Create | l4d2web/static/css/widgets/overlay-list.css |
.overlay-list, .overlay-list-item, .overlay-list-handle, .overlay-list-meta |
| Create | l4d2web/static/css/widgets/console.css |
.console, .console-line, .console-line.cmd, .console-line.out, .console-input |
| Create | l4d2web/static/css/widgets/editor.css |
.editor (CodeMirror wrapper) + relocated --cm-* tokens |
| Create | l4d2web/static/css/widgets/logs.css |
Log-viewer styling, retargeted to new semantic tokens |
| Create | l4d2web/static/css/widgets/server-status.css |
.server-status, .server-status-state, .server-status-actions, .server-status-meta |
| Create | l4d2web/static/css/widgets/player-list.css |
.player-list, .player-card, .player-card-avatar, .player-card-name, .player-card-meta |
| Create | l4d2web/static/css/utilities.css |
.muted, .mono, .truncate, .visually-hidden |
| Create | l4d2web/templates/ui/_field.html |
ui.field, ui.checkbox, ui.select |
| Create | l4d2web/templates/ui/_dialog.html |
ui.dialog |
| 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/_tag.html |
ui.tag, ui.lifecycle_tag |
| Modify | l4d2web/templates/base.html |
Swap 5 <link> tags for 1; rewrite header markup; add inline theme-init script |
| Create | l4d2web/templates/styleguide.html |
Style guide page + ✅/❌ blocks |
| Create | l4d2web/routes/styleguide_routes.py |
Public route /styleguide |
| Modify | l4d2web/l4d2web/app.py |
Register styleguide blueprint; remove spike registration (Task 11) |
| Modify | AGENTS.md |
Add "UI work" section codifying naming + workflow |
| Rewrite | All ~25 templates in l4d2web/templates/ |
Class attributes use new vocabulary; macros where applicable |
| Delete | l4d2web/static/css/components.css |
(Task 11) |
| Delete | l4d2web/static/css/tokens.css |
(Task 11) |
| Delete | l4d2web/static/css/logs.css (root) |
(Task 11) — replaced by widgets/logs.css |
| Delete | l4d2web/static/css/console-autocomplete.css (root) |
(Task 11) — folded into widgets/console.css |
| Delete | l4d2web/static/css/editor.css (root) |
(Task 11) — replaced by widgets/editor.css |
| Delete | l4d2web/static/css/spike/ |
(Task 11) — scaffolding |
| Delete | l4d2web/templates/spike.html |
(Task 11) |
| Delete | l4d2web/routes/spike_routes.py |
(Task 11) |
| Delete | l4d2web/static/vendor/css/ |
(Task 11) — only used by spike |
Token migration (used in Task 6 when relocating widget CSS)
The old tokens.css and the new tokens/semantic.css overlap on color names but differ on spacing, radii, and a few colors. Apply this table when copying any rule out of the old components.css or root-level widget files:
| Old | New | 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 |
--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-expanded — --line is not in the new system |
--line-soft |
1px solid var(--color-border-soft) |
Inline-expanded |
--font-mono |
(same) | Unchanged. |
CodeMirror tokens (--cm-*, --syntax-*, --editor-rows) move with the editor widget — see Task 6, Step 4b.
Class-name migration (used in Task 10 for templates)
The new vocabulary is a full rename. The mapping is dense; agents executing Task 10 should treat this as a find-replace table. Old names on the left, new on the right; modifier-style chains are space-separated multi-class additions.
| Old class | New class | Notes |
|---|---|---|
.btn |
.button |
All variants follow |
.btn-primary |
.button primary |
Chained modifier — two classes |
.btn-secondary |
.button |
Default (no modifier) is the secondary look |
.btn-danger |
.button danger |
|
.btn-outline |
.button outline |
|
.btn-link |
.button link |
(also subsumes .link-button below) |
.btn-sm |
.button small |
|
.button-secondary (old short name) |
.button |
|
.danger-outline |
.button danger outline |
Composes from existing axes |
.link-button |
.button link |
|
.button-row |
.row |
Generic horizontal flex (in layout) |
.panel, .card |
.card |
One vocabulary |
.panel-heading, .card-heading |
.card-header |
Match HTML semantics |
.panel-body |
.card-body |
|
.panel-footer |
.card-footer |
|
.modal |
.dialog |
Matches <dialog> element |
.modal-wide |
.dialog wide |
Chained modifier |
.modal-header |
.dialog-header |
|
.modal-body |
.dialog-body |
|
.modal-footer |
.dialog-footer |
|
.modal-close |
.dialog-close |
|
.badge |
.tag |
|
.badge-success |
.tag success |
Chained |
.badge-warning |
.tag warning |
|
.badge-danger |
.tag danger |
|
.badge-muted |
.tag muted |
|
.state-running |
.tag success |
Use ui.lifecycle_tag(state) instead of hand-picking |
.state-stopped |
.tag muted |
|
.state-unknown |
.tag muted |
|
.state-transient |
.tag warning |
|
.state-drift |
.tag danger |
|
.site-header |
.app-header |
|
.site-header-inner |
.app-header-inner |
|
.primary-nav |
.nav |
|
.account-nav |
.account |
|
.brand |
(same) | |
.page-heading |
.heading |
|
.page-footer-actions, .form-actions-inline, .button-row |
.row (or .cluster for wrap-friendly) |
|
.sr-only |
.visually-hidden |
|
.overlay-picker |
.overlay-list |
|
.overlay-picker-list |
.overlay-list direct child <ul> styled by selector |
|
.overlay-picker-row |
.overlay-list-item |
|
.overlay-picker-handle |
.overlay-list-handle |
|
.overlay-picker-name, .overlay-picker-expose |
.overlay-list-meta (consolidated) |
|
.overlay-picker-remove, .overlay-picker-add, .overlay-picker-empty |
.overlay-list-remove / -add / -empty |
|
.file-tree-row |
.file-tree-item |
|
.file-tree-row-file |
.file-tree-item file |
Chained modifier |
.field-input (rare) |
drop the class; style via .field > input selector |
★ Insight ─────────────────────────────────────
Search-and-replace gotcha: the chained-modifier form means a single attribute changes from class="btn btn-primary" to class="button primary". Both are two-class lists, but the order matters for the inheritance chain (component class first, modifiers after). Templates need attention to ordering when the rename is mechanical.
─────────────────────────────────────────────────
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 the tokens/ directory
mkdir -p l4d2web/l4d2web/static/css/tokens
- Step 2: Write
main.css
l4d2web/l4d2web/static/css/main.css:
/* Entry stylesheet. Declares @layer order, then @imports each layer's
file(s). Cascade specificity is determined by layer order, not 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/card.css") layer(components);
@import url("./components/dialog.css") layer(components);
@import url("./components/tabs.css") layer(components);
@import url("./components/tag.css") layer(components);
@import url("./components/toast.css") layer(components);
@import url("./components/spinner.css") layer(components);
@import url("./components/app-header.css") layer(components);
@import url("./components/heading.css") layer(components);
@import url("./components/dropdown.css") layer(components);
@import url("./widgets/file-tree.css") layer(widgets);
@import url("./widgets/overlay-list.css") layer(widgets);
@import url("./widgets/console.css") layer(widgets);
@import url("./widgets/editor.css") layer(widgets);
@import url("./widgets/logs.css") layer(widgets);
@import url("./widgets/server-status.css") layer(widgets);
@import url("./widgets/player-list.css") layer(widgets);
@import url("./utilities.css") layer(utilities);
- Step 3: Write
reset.css
l4d2web/l4d2web/static/css/reset.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
l4d2web/l4d2web/static/css/tokens/primitives.css:
/* Tier 1 — primitives. Raw palette. Consumed only by tokens/semantic.css,
never by component CSS directly. */
:root {
--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;
--blue-200: #bfdbfe; --blue-300: #93c5fd; --blue-500: #3b82f6;
--blue-600: #2563eb; --blue-700: #1d4ed8; --blue-800: #1e40af;
--red-200: #fecaca; --red-400: #fca5a5; --red-700: #b42318;
--green-300: #86efac; --green-700: #067647;
--amber-300: #fcd34d; --amber-700: #a15c07;
--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;
--radius-1: 0.25rem; --radius-2: 0.5rem; --radius-3: 0.75rem;
--radius-full: 9999px;
--text-xs: 0.75rem; --text-sm: 0.875rem; --text-base: 1rem;
--text-lg: 1.125rem; --text-xl: 1.25rem; --text-2xl: 1.5rem;
--leading-tight: 1.2;
--leading-normal: 1.5;
--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);
--duration-fast: 120ms;
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--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. Components reference only these. Dark mode
re-points the aliases in the [data-theme="dark"] block. */
: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-info: var(--blue-500);
--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-info: var(--blue-300);
--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
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.
- 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, three-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, rewrite header markup to new vocabulary (the.app-headerblock), add inline theme-init script
After this commit the site loads the new system. Existing pages still use OLD class names in their attributes, so most things look bare until Task 10 rewrites templates. The base.html header is rewritten in this task so the top of every page renders correctly from here on.
- Step 1: Write
elements.css
l4d2web/l4d2web/static/css/elements.css:
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:
.container { max-width: 72rem; margin-inline: auto; padding-inline: var(--space-4); }
.stack { display: flex; flex-direction: column; gap: var(--space-3); }
.stack > * + * { margin-top: 0; } /* flex gap supersedes ad-hoc margins */
.row { display: flex; gap: var(--space-2); align-items: center; }
.cluster { display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; }
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
Replace the entire <head> and <body> of l4d2web/l4d2web/templates/base.html to use the new vocabulary. Read the current file first to preserve the script-include block and the modal-container <dialog> at the bottom.
Edit l4d2web/l4d2web/templates/base.html:
Old <head> block (the five stylesheet links + meta tags):
<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 — replace the above with:
<script nonce="{{ g.csp_nonce }}">
// Set data-theme before paint so the first frame matches OS preference.
(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') }}">
Old <body> site header markup:
<header class="site-header">
<div class="site-header-inner">
<nav class="primary-nav" aria-label="Main navigation">
<a class="brand" href="{{ '/dashboard' if g.user else '/login' }}">left4me</a>
{% if g.user %}
<a href="/servers">servers</a>
<a href="/blueprints">blueprints</a>
<a href="/overlays">overlays</a>
{% endif %}
</nav>
{% if g.user %}
<nav class="account-nav" aria-label="Account navigation">
{% if g.user.admin %}<a href="/admin">admin</a>{% endif %}
<a class="muted" href="/profile">{{ g.user.username }}</a>
<form method="post" action="/logout" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="link-button" type="submit">logout</button>
</form>
</nav>
{% endif %}
</div>
</header>
New site header markup:
<header class="app-header">
<div class="app-header-inner">
<nav class="nav" aria-label="Main navigation">
<a class="brand" href="{{ '/dashboard' if g.user else '/login' }}">left4me</a>
{% if g.user %}
<a href="/servers">servers</a>
<a href="/blueprints">blueprints</a>
<a href="/overlays">overlays</a>
{% endif %}
</nav>
{% if g.user %}
<nav class="account" aria-label="Account navigation">
{% if g.user.admin %}<a href="/admin">admin</a>{% endif %}
<a class="muted" href="/profile">{{ g.user.username }}</a>
<form method="post" action="/logout" style="display:inline; margin:0">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="button link" type="submit">logout</button>
</form>
</nav>
{% endif %}
</div>
</header>
(The inline style="display:inline; margin:0" on the logout form is acceptable as a single-purpose layout exception for a one-line inline form. If the pattern recurs, promote to a .inline-form utility later — but check first.)
Old modal-container at the bottom:
<dialog id="modal-container" class="modal modal-wide">
<div id="modal-content"></div>
</dialog>
New modal-container:
<dialog id="modal-container" class="dialog wide">
<div id="modal-content"></div>
</dialog>
- Step 4: Verify the dev server boots,
/loginreturns 200
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:5051/login
Expected: 200. NOT 500.
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:5051/static/css/main.css
Expected: 200.
- Step 5: Open
/loginin a browser, confirm typography and the new header render
The header should be a single bordered card with the brand left-aligned. Inside pages, content will look bare (no .card, .button styling yet) — that's correct for this checkpoint.
- 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 switches to main.css and new app-header markup"
Task 3: Core components — button, card, table, tag, app-header, heading
Files:
-
Create:
l4d2web/static/css/components/button.css -
Create:
l4d2web/static/css/components/card.css -
Create:
l4d2web/static/css/components/table.css -
Create:
l4d2web/static/css/components/tag.css -
Create:
l4d2web/static/css/components/app-header.css -
Create:
l4d2web/static/css/components/heading.css -
Step 1: Create components directory
mkdir -p l4d2web/l4d2web/static/css/components
- Step 2: Write
components/button.css
This is the canonical example of the Tier-3 component-scoped-token pattern. All variants compose because they re-point --button-bg, --button-fg, --button-border and the base rule consumes those local properties.
l4d2web/l4d2web/static/css/components/button.css:
.button {
/* Tier-3 component-scoped tokens (private). Variants re-point these. */
--button-bg: var(--color-surface);
--button-fg: var(--color-text);
--button-border: var(--color-border);
display: inline-flex; align-items: center; gap: var(--space-1);
padding: 0.45rem 0.9rem;
border: 1px solid var(--button-border); border-radius: var(--radius-1);
background: var(--button-bg); color: var(--button-fg);
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);
}
.button:hover:not([disabled]) {
background: color-mix(in srgb, var(--button-fg) 6%, var(--button-bg));
}
/* Color variants set the Tier-3 trio. */
.button.primary {
--button-bg: var(--color-primary);
--button-fg: var(--color-on-primary);
--button-border: var(--color-primary);
}
.button.primary:hover:not([disabled]) {
background: var(--color-primary-hover); border-color: var(--color-primary-hover);
}
.button.danger {
--button-bg: var(--color-danger);
--button-fg: var(--color-on-danger);
--button-border: var(--color-danger);
}
/* Shape modifiers read the inherited border color — they compose with
.primary / .danger / etc. .outline reads --button-border, .ghost zeroes
it, .link drops the surface entirely. */
.button.outline {
--button-bg: transparent;
--button-fg: var(--button-border);
}
.button.ghost {
--button-bg: transparent;
--button-fg: var(--color-text);
--button-border: transparent;
}
.button.link {
--button-bg: transparent;
--button-border: transparent;
color: var(--color-link);
text-decoration: underline;
text-underline-offset: 2px;
padding-inline: 0;
}
/* States via ARIA attributes. */
.button[disabled] { opacity: 0.5; cursor: not-allowed; }
.button[aria-busy="true"]::before {
content: "⟳"; margin-right: var(--space-1);
animation: button-spin 1s linear infinite;
}
@keyframes button-spin { to { transform: rotate(360deg); } }
/* Size modifier. */
.button.small {
padding: 0.25rem 0.6rem;
font-size: var(--text-sm);
}
- Step 3: Write
components/card.css
l4d2web/l4d2web/static/css/components/card.css:
.card {
border: 1px solid var(--color-border);
border-radius: var(--radius-2);
background: var(--color-surface);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.card-header { border-bottom: 1px solid var(--color-border-soft); padding: var(--space-3) var(--space-4); }
.card-header h2, .card-header h3 { margin: 0; }
.card-body { padding: var(--space-3) var(--space-4); }
.card-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 {
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/tag.css
Same Tier-3 pattern as .button.
l4d2web/l4d2web/static/css/components/tag.css:
.tag {
--tag-bg: var(--color-surface);
--tag-fg: var(--color-text);
--tag-border: var(--color-border);
display: inline-block;
padding: 0.1rem 0.55rem;
font-size: var(--text-xs); line-height: 1.4;
border: 1px solid var(--tag-border);
border-radius: var(--radius-full);
background: var(--tag-bg); color: var(--tag-fg);
font-weight: 500;
}
.tag.success {
--tag-bg: color-mix(in srgb, var(--color-success) 16%, var(--color-surface));
--tag-fg: var(--color-success);
--tag-border: color-mix(in srgb, var(--color-success) 40%, var(--color-border));
}
.tag.warning {
--tag-bg: color-mix(in srgb, var(--color-warning) 16%, var(--color-surface));
--tag-fg: var(--color-warning);
--tag-border: color-mix(in srgb, var(--color-warning) 40%, var(--color-border));
}
.tag.danger {
--tag-bg: color-mix(in srgb, var(--color-danger) 16%, var(--color-surface));
--tag-fg: var(--color-danger);
--tag-border: color-mix(in srgb, var(--color-danger) 40%, var(--color-border));
}
.tag.info {
--tag-bg: color-mix(in srgb, var(--color-info) 16%, var(--color-surface));
--tag-fg: var(--color-info);
--tag-border: color-mix(in srgb, var(--color-info) 40%, var(--color-border));
}
.tag.muted {
--tag-fg: var(--color-muted);
}
- Step 6: Write
components/app-header.css
l4d2web/l4d2web/static/css/components/app-header.css:
.app-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);
}
.app-header-inner { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); }
.nav, .account { display: flex; gap: var(--space-3); align-items: center; }
.brand { font-weight: 700; }
- Step 7: Write
components/heading.css
l4d2web/l4d2web/static/css/components/heading.css:
.heading {
display: flex; justify-content: space-between; align-items: flex-start;
gap: var(--space-3); flex-wrap: wrap;
margin-block: var(--space-3);
}
.heading h1, .heading h2 { margin: 0; }
.heading-actions { display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center; }
- Step 8: Verify in browser
Open /dashboard or /login. The app header (top bar) should render as a bordered card. .button-classed buttons (after Task 10 templates) will look styled; pre-Task-10 pages still use old class names so they look bare.
- Step 9: Commit
git add l4d2web/l4d2web/static/css/components/button.css \
l4d2web/l4d2web/static/css/components/card.css \
l4d2web/l4d2web/static/css/components/table.css \
l4d2web/l4d2web/static/css/components/tag.css \
l4d2web/l4d2web/static/css/components/app-header.css \
l4d2web/l4d2web/static/css/components/heading.css
git commit -m "feat(stylesheet): core components — button, card, table, tag, app-header, heading"
Task 4: Composite components — dialog, tabs, field, dropdown, toast, spinner
Files:
-
Create:
l4d2web/static/css/components/dialog.css -
Create:
l4d2web/static/css/components/tabs.css -
Create:
l4d2web/static/css/components/field.css -
Create:
l4d2web/static/css/components/dropdown.css -
Create:
l4d2web/static/css/components/toast.css -
Create:
l4d2web/static/css/components/spinner.css -
Step 1: Write
components/dialog.css
l4d2web/l4d2web/static/css/components/dialog.css:
.dialog {
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);
}
.dialog.wide { max-width: min(720px, 95vw); }
.dialog::backdrop {
background: rgba(0,0,0,0.45);
backdrop-filter: blur(2px);
}
/* The HX-Modal pattern wraps content in <article>; strip that wrapper's
default chrome so the dialog's own borders win. */
.dialog > article { padding: 0; margin: 0; background: transparent; border: 0; box-shadow: none; }
.dialog-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);
}
.dialog-header h2 { margin: 0; font-size: var(--text-lg); }
.dialog-body { padding: var(--space-3) var(--space-4); }
.dialog-footer { padding: var(--space-3) var(--space-4); border-top: 1px solid var(--color-border-soft); }
.dialog-close {
background: none; border: 0; font-size: 1.25rem;
color: var(--color-muted); cursor: pointer; padding: 0 var(--space-1);
}
.dialog-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); }
.field-checkbox {
display: inline-flex; gap: var(--space-2); align-items: center;
font-size: var(--text-base);
}
input[aria-invalid="true"],
select[aria-invalid="true"],
textarea[aria-invalid="true"] {
border-color: var(--color-danger);
}
- Step 4: Write
components/dropdown.css
l4d2web/l4d2web/static/css/components/dropdown.css:
/* Native <select> already styled in elements.css. .dropdown is a hook
for custom-menu patterns added later. */
.dropdown { position: relative; display: inline-block; }
.dropdown > select { width: 100%; }
- Step 5: Write
components/toast.css
Same Tier-3 pattern as .button/.tag.
l4d2web/l4d2web/static/css/components/toast.css:
.toast {
--toast-bg: var(--color-surface);
--toast-fg: var(--color-text);
--toast-border: var(--color-border);
position: fixed; top: var(--space-4); right: var(--space-4);
z-index: 1000;
background: var(--toast-bg); color: var(--toast-fg);
border: 1px solid var(--toast-border); border-radius: var(--radius-2);
padding: var(--space-3) var(--space-4);
box-shadow: var(--shadow-md);
max-width: 24rem;
display: flex; gap: var(--space-3); align-items: flex-start;
}
.toast.success {
--toast-bg: color-mix(in srgb, var(--color-success) 12%, var(--color-surface));
--toast-border: color-mix(in srgb, var(--color-success) 40%, var(--color-border));
}
.toast.warning {
--toast-bg: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface));
--toast-border: color-mix(in srgb, var(--color-warning) 40%, var(--color-border));
}
.toast.danger {
--toast-bg: color-mix(in srgb, var(--color-danger) 12%, var(--color-surface));
--toast-border: color-mix(in srgb, var(--color-danger) 40%, var(--color-border));
}
.toast-close {
background: none; border: 0; color: var(--color-muted);
cursor: pointer; padding: 0 var(--space-1); font-size: 1.1rem;
margin-left: auto;
}
- Step 6: Write
components/spinner.css
l4d2web/l4d2web/static/css/components/spinner.css:
.spinner {
display: inline-block;
width: 1.2rem; height: 1.2rem;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: var(--radius-full);
animation: spinner-spin 0.8s linear infinite;
}
.spinner.small { width: 0.8rem; height: 0.8rem; border-width: 1px; }
@keyframes spinner-spin { to { transform: rotate(360deg); } }
- Step 7: Verify in browser
Open /styleguide once Task 8 is done — for now, navigate to a page that has a modal and click it (the dialog will render with new styling after Task 10 updates the template's class attributes). For Task 4 alone, visual verification is limited because most templates still use old class names.
- Step 8: Commit
git add l4d2web/l4d2web/static/css/components/dialog.css \
l4d2web/l4d2web/static/css/components/tabs.css \
l4d2web/l4d2web/static/css/components/field.css \
l4d2web/l4d2web/static/css/components/dropdown.css \
l4d2web/l4d2web/static/css/components/toast.css \
l4d2web/l4d2web/static/css/components/spinner.css
git commit -m "feat(stylesheet): composite components — dialog, tabs, field, dropdown, toast, spinner"
Task 5: Macros — five high-leverage primitives
Files:
- Create:
l4d2web/templates/ui/_field.html - Create:
l4d2web/templates/ui/_dialog.html - Create:
l4d2web/templates/ui/_tabs.html - Create:
l4d2web/templates/ui/_confirm_form.html - Create:
l4d2web/templates/ui/_tag.html
Macros emit the NEW class vocabulary. Templates start calling them in Task 10.
- 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. #}
{% 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) %}
{%- 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/_dialog.html
l4d2web/l4d2web/templates/ui/_dialog.html:
{# Dialog wrapping <dialog>. Owns header / body / close-button +
data-inline-modal-close hook for the existing modals.js machinery. #}
{% macro dialog(id, title, wide=False) %}
<dialog id="{{ id }}" class="dialog{% if wide %} wide{% endif %}">
<article>
<header class="dialog-header">
<h2>{{ title }}</h2>
<button class="dialog-close" type="button" aria-label="Close" data-inline-modal-close>×</button>
</header>
<div class="dialog-body">
{{ caller() }}
</div>
</article>
</dialog>
{% endmacro %}
- Step 4: Write
ui/_tabs.html
l4d2web/l4d2web/templates/ui/_tabs.html:
{# Tab bar + tabpanels. Owns role / aria-controls / aria-selected. #}
{% 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 + row + POST. #}
{% 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="row">
<button type="button" class="button" {{ cancel_attrs|safe }}>{{ cancel_label }}</button>
<button type="submit" class="button {{ submit_variant }}">{{ submit_label }}</button>
</div>
</form>
{% endmacro %}
- Step 6: Write
ui/_tag.html
l4d2web/l4d2web/templates/ui/_tag.html:
{# Tag rendering. lifecycle_tag encapsulates state-string → variant
mapping (the single source of truth — keep in sync with components/tag.css). #}
{% macro tag(label, variant="muted") %}
<span class="tag {{ variant }}">{{ label }}</span>
{% endmacro %}
{% macro lifecycle_tag(state) %}
{%- set mapping = {
"running": "success",
"stopped": "muted",
"unknown": "muted",
"starting": "warning",
"stopping": "warning",
"resetting": "warning",
"initializing": "warning",
"deleting": "warning",
"drift": "danger",
} -%}
{%- set variant = mapping.get(state, "muted") -%}
<span class="tag {{ variant }}">{{ state or "unknown" }}</span>
{% endmacro %}
- Step 7: Write the macro unit tests
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_lifecycle_tag_maps_running_to_success(jinja_env):
tmpl = jinja_env.from_string(
'{% from "ui/_tag.html" import lifecycle_tag %}'
'{{ lifecycle_tag("running") }}'
)
html = tmpl.render()
assert 'class="tag success"' in html
assert ">running<" in html
def test_lifecycle_tag_maps_drift_to_danger(jinja_env):
tmpl = jinja_env.from_string(
'{% from "ui/_tag.html" import lifecycle_tag %}'
'{{ lifecycle_tag("drift") }}'
)
html = tmpl.render()
assert 'class="tag danger"' in html
def test_lifecycle_tag_falls_back_to_muted_for_unknown_states(jinja_env):
tmpl = jinja_env.from_string(
'{% from "ui/_tag.html" import lifecycle_tag %}'
'{{ lifecycle_tag("weird-new-state") }}'
)
html = tmpl.render()
assert 'class="tag muted"' in html
def test_lifecycle_tag_handles_none(jinja_env):
tmpl = jinja_env.from_string(
'{% from "ui/_tag.html" import lifecycle_tag %}'
'{{ lifecycle_tag(None) }}'
)
html = tmpl.render()
assert "unknown" in html
- Step 8: Run the macro tests
cd l4d2web && uv run pytest tests/test_ui_macros.py -v
Expected: 7 passed.
- Step 9: Commit
git add l4d2web/l4d2web/templates/ui/ l4d2web/tests/test_ui_macros.py
git commit -m "feat(stylesheet): ui macros — field, dialog, tabs, confirm_form, tag/lifecycle_tag"
Task 6: Project widgets relocation + rename
Files (all new under widgets/):
file-tree.cssoverlay-list.cssconsole.css(subsumes the oldconsole-autocomplete.css)editor.csslogs.cssserver-status.css(extracted fromserver_detail.htmlpatterns)player-list.css(extracted from_live_state.htmlstyling)
Apply both the token migration (table at top of plan) AND the class-name migration (table at top of plan) when copying rules.
- Step 1: Create the widgets/ directory
mkdir -p l4d2web/l4d2web/static/css/widgets
- Step 2: Write
widgets/file-tree.css
Read the existing .file-tree* rules from l4d2web/static/css/components.css, copy them into a new file, applying both migration tables. The rename for this widget is:
.file-tree-row→.file-tree-item.file-tree-row-file→.file-tree-item.file(chained).file-tree-row-truncated→.file-tree-truncated(part — relate to whole tree)- Other
.file-tree-*names stay (they were already proper parts)
l4d2web/l4d2web/static/css/widgets/file-tree.css:
.file-tree {
border: 1px solid var(--color-border);
border-radius: var(--radius-1);
padding: var(--space-1) var(--space-2);
background: var(--color-surface);
}
.file-tree .file-tree {
/* Nested trees — no extra chrome */
border: 0; padding: 0; background: transparent;
}
.file-tree-item {
display: flex; align-items: center; gap: var(--space-2);
padding: 0.2rem 0;
}
.file-tree-item.file {
padding-left: 1.25rem;
}
.file-tree-toggle {
background: none; border: 0; cursor: pointer;
display: inline-flex; gap: 0.25rem; align-items: center;
padding: 0; color: var(--color-text); font: inherit;
}
.file-tree-toggle .chevron { width: 1ch; display: inline-block; }
.file-tree-name-button {
font-family: var(--font-mono); font-size: var(--text-sm);
background: none; border: 0; padding: 0; color: var(--color-text);
cursor: pointer; text-align: left;
}
.file-tree-name-button:hover,
.file-tree-name-button:focus-visible { text-decoration: underline; }
.file-tree-toggle[aria-expanded="true"] .chevron { transform: rotate(0deg); }
.file-tree-children {
padding-left: var(--space-3);
border-left: 1px dashed var(--color-border-soft);
margin-left: var(--space-2);
}
.file-tree-children[hidden] { display: none; }
.file-tree-badge {
font-size: var(--text-xs); color: var(--color-muted);
margin-left: auto;
}
.file-tree-badge-warn { color: var(--color-warning); }
.file-tree-truncated {
font-style: italic; color: var(--color-muted);
padding: var(--space-1) 0;
}
- Step 3: Write
widgets/overlay-list.css
Rename mapping:
.overlay-picker→.overlay-list.overlay-picker-list(the<ul>) → styled via.overlay-list > ulselector OR.overlay-list > .overlay-listif a wrapper persists; in practice the<ul>itself was redundant — drop the inner list class.overlay-picker-row→.overlay-list-item.overlay-picker-handle→.overlay-list-handle.overlay-picker-name+.overlay-picker-expose→ consolidated into.overlay-list-meta.overlay-picker-remove→.overlay-list-remove.overlay-picker-add→.overlay-list-add.overlay-picker-empty→.overlay-list-empty- Drag states (
.is-dragging,.drop-before,.drop-after) — keep as ARIA-style data attributes if possible, otherwise as classes on.overlay-list-item:.overlay-list-item.dragging,.overlay-list-item.drop-before,.overlay-list-item.drop-after
l4d2web/l4d2web/static/css/widgets/overlay-list.css:
.overlay-list {
border: 1px solid var(--color-border);
border-radius: var(--radius-1);
padding: var(--space-2);
background: var(--color-surface);
}
.overlay-list > ul { list-style: none; padding: 0; margin: 0 0 var(--space-2); }
.overlay-list-item {
display: grid;
grid-template-columns: auto 1fr auto auto;
gap: var(--space-2); align-items: center;
padding: var(--space-1); border: 1px solid transparent;
border-radius: var(--radius-1);
transition: background var(--duration-fast) var(--ease-out);
}
.overlay-list-item:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); }
.overlay-list-item.dragging { opacity: 0.6; }
.overlay-list-item.drop-before { border-top-color: var(--color-primary); }
.overlay-list-item.drop-after { border-bottom-color: var(--color-primary); }
.overlay-list-handle {
background: none; border: 0; cursor: grab; color: var(--color-muted);
padding: 0 var(--space-1); font: inherit;
}
.overlay-list-meta {
display: flex; flex-direction: column; gap: 2px;
}
.overlay-list-meta strong { font-weight: 500; }
.overlay-list-meta code {
background: color-mix(in srgb, var(--color-text) 6%, transparent);
padding: 0.05rem 0.3rem; border-radius: var(--radius-1);
font-size: var(--text-xs);
}
.overlay-list-remove {
background: none; border: 0; color: var(--color-muted);
cursor: pointer; font-size: 1.1rem; padding: 0 var(--space-1);
}
.overlay-list-remove:hover { color: var(--color-danger); }
.overlay-list-add { display: flex; gap: var(--space-2); }
.overlay-list-add select { margin: 0; }
.overlay-list-empty {
text-align: center; color: var(--color-muted);
padding: var(--space-3); font-size: var(--text-sm);
}
- Step 4: Write
widgets/console.css
Subsumes the old console-autocomplete.css (file deleted in Task 11) and adds first-class .console / .console-line / .console-input styling.
Read the existing console-autocomplete.css for the autocomplete-popup specifics and merge them in under the .console namespace. New class structure:
.console— outer wrapper.console-line— one line of console output.console-line.cmd— user-entered command (input echo).console-line.out— server output line (default; can be omitted).console-input— the input field.console-autocomplete— popup container (existing).console-autocomplete-item— popup row (existing).console-autocomplete-item.active— keyboard-highlighted row
l4d2web/l4d2web/static/css/widgets/console.css:
.console {
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-1);
padding: var(--space-2) var(--space-3);
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--color-text);
overflow-y: auto;
max-height: 24rem;
}
.console-line { white-space: pre-wrap; word-break: break-word; padding: 1px 0; }
.console-line.cmd { color: var(--color-primary); }
.console-line.cmd::before { content: "> "; opacity: 0.6; }
.console-input {
width: 100%;
font-family: var(--font-mono);
background: var(--color-surface);
margin-top: var(--space-2);
}
/* Autocomplete popup (preserved from console-autocomplete.css; classnames renamed). */
.console-autocomplete {
position: absolute;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-1);
box-shadow: var(--shadow-md);
max-height: 16rem;
overflow-y: auto;
z-index: 50;
min-width: 20rem;
}
.console-autocomplete-item {
padding: var(--space-1) var(--space-2);
cursor: pointer;
font-family: var(--font-mono);
font-size: var(--text-sm);
display: flex; gap: var(--space-2); align-items: baseline;
}
.console-autocomplete-item.active,
.console-autocomplete-item:hover {
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface));
}
.console-autocomplete-item .name { color: var(--color-text-strong); }
.console-autocomplete-item .help { color: var(--color-muted); font-size: var(--text-xs); margin-left: auto; }
(If the existing console-autocomplete.css has popup details not captured here — read it before starting Step 4 and adapt. Apply the token migration table.)
- Step 5: Write
widgets/editor.css
Relocate the root-level editor.css AND lift the CodeMirror --cm-* tokens out of the old tokens.css into this file (they're widget-private, not globally semantic).
l4d2web/l4d2web/static/css/widgets/editor.css:
/* CodeMirror palette tokens — widget-private. Were :root in the old
tokens.css; localized here. */
: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);
}
/* Editor wrapper — read existing rules in current static/css/editor.css and
relocate verbatim, applying the token migration table. */
.editor { /* …existing rules… */ }
.cm-editor { /* …existing rules… */ }
Read l4d2web/l4d2web/static/css/editor.css (the current root-level file) for the existing rules. Copy them under .editor and .cm-editor namespaces, applying token migration.
- Step 6: Write
widgets/logs.css
Read the existing root-level logs.css. Apply the token migration table. Resulting widgets/logs.css:
.log-viewer {
background: var(--color-surface-2);
color: var(--color-text);
font-family: var(--font-mono); font-size: var(--text-sm);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-1);
white-space: pre-wrap;
overflow: auto;
max-height: 30rem;
}
/* … if the existing logs.css has more rules, copy them here with the migration table applied … */
- Step 7: Write
widgets/server-status.css
Extract the server_detail.html state-cluster pattern. The relevant existing classes likely include .server-info, .server-actions, .last-job. Consolidate under .server-status:
l4d2web/l4d2web/static/css/widgets/server-status.css:
.server-status {
border: 1px solid var(--color-border);
border-radius: var(--radius-2);
background: var(--color-surface);
box-shadow: var(--shadow-sm);
padding: var(--space-3) var(--space-4);
display: grid; gap: var(--space-3);
}
.server-status-state {
display: flex; align-items: center; gap: var(--space-3);
flex-wrap: wrap;
}
.server-status-actions { display: flex; gap: var(--space-2); flex-wrap: wrap; }
.server-status-meta {
display: grid; gap: var(--space-1);
font-size: var(--text-sm);
}
.server-status-meta dt {
color: var(--color-muted); font-weight: 500;
}
.server-status-meta dd { margin: 0; }
(Read the current .server-info / .server-actions rules in components.css and merge their styling needs into .server-status-* here.)
- Step 8: Write
widgets/player-list.css
Extract the live-state player-card styling. The current vocabulary is something like .player-card, .player-avatar, .player-name, .player-meta (in _live_state.html or its scoped styles).
l4d2web/l4d2web/static/css/widgets/player-list.css:
.player-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
gap: var(--space-2);
}
.player-card {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-2);
background: var(--color-surface);
border: 1px solid var(--color-border-soft);
border-radius: var(--radius-1);
padding: var(--space-2);
}
.player-card-avatar {
width: 3rem; height: 100%; /* full card height per design memory */
border-radius: var(--radius-1);
background: var(--color-surface-2);
overflow: hidden;
}
.player-card-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.player-card-name {
font-weight: 500;
color: var(--color-text);
}
.player-card-meta {
font-size: var(--text-xs); color: var(--color-muted);
}
(Verify against current _live_state.html markup and update class attributes there in Task 10.)
- Step 9: Grep for un-migrated token names in the new widget files
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. Fix anything that matches per the token-migration table at the top of this plan.
- Step 10: 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.
- Step 11: Commit
git add l4d2web/l4d2web/static/css/widgets/
git commit -m "feat(stylesheet): project widgets — renamed and retargeted onto new tokens"
Task 7: Utilities
Files:
-
Create:
l4d2web/static/css/utilities.css -
Step 1: Write
utilities.css
l4d2web/l4d2web/static/css/utilities.css:
.muted { color: var(--color-muted); }
.mono { font-family: var(--font-mono); }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.visually-hidden {
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 widget.
Test it renders publicly and contains every primitive class name."""
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):
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)
for token in [
"button", "primary", "outline", "ghost", "danger",
"field", "field-label", "field-hint", "field-error",
"card", "card-header", "card-body",
"dialog", "dialog-header", "dialog-body",
"tabs", "tab-panel",
"tag", "success", "warning", "info",
"toast", "spinner",
"app-header", "heading",
]:
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.
- 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:
from l4d2web.routes.styleguide_routes import bp as styleguide_bp
And register alongside the other app.register_blueprint(...) calls:
app.register_blueprint(styleguide_bp)
- Step 5: Write
templates/styleguide.html
l4d2web/l4d2web/templates/styleguide.html:
{% 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="heading">
<h1>Style guide</h1>
<div class="heading-actions">
<button type="button" class="button" id="sg-theme-toggle">Toggle dark</button>
</div>
</header>
<p class="muted">Every available widget with copy-paste source. Naming: <strong>parts use hyphens</strong> (<code>.card-header</code>), <strong>variants chain</strong> (<code>.button.primary</code>). State via ARIA attributes. If your change needs a widget that isn't here, extend the system before using it.</p>
{% 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>Components reference <strong>Tier-2 semantic tokens only</strong> (<code>var(--color-*)</code>, <code>var(--space-*)</code>). Tier-3 component-scoped tokens (<code>--button-bg</code>, <code>--tag-fg</code>) are private. Raw hex codes live in <code>tokens/primitives.css</code>.</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", "info", "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</h3>
<p class="mono">--space-1 (0.25rem) … --space-7 (3rem)</p>
<h3>Type scale</h3>
<p class="mono">--text-xs / sm / base / lg / xl / 2xl</p>
</section>
{# ============================== Button ============================== #}
<section class="sg-section">
<h2>Button</h2>
<p>Variants chain on <code>.button</code>. Modifiers compose: <code>.button.danger.outline</code> is a real outlined-danger button.</p>
{% set src %}
<button type="button" class="button">Default</button>
<button type="button" class="button primary">Primary</button>
<button type="button" class="button danger">Danger</button>
<button type="button" class="button outline">Outline</button>
<button type="button" class="button ghost">Ghost</button>
<button type="button" class="button link">Link</button>
<button type="button" class="button small">Small</button>
<button type="button" class="button primary" disabled>Disabled</button>
<button type="button" class="button primary" aria-busy="true">Loading…</button>
{% endset %}
{{ example("Variants + states + size", src) }}
{% set src %}
<button type="button" class="button danger outline">Delete (outlined)</button>
<button type="button" class="button primary small">Save (small)</button>
{% endset %}
{{ example("Composed modifiers", src) }}
</section>
{# ============================== Field ============================== #}
<section class="sg-section">
<h2>Field</h2>
<div class="sg-do">
✅ <strong>DO</strong> — use <code>ui.field</code> so a11y wiring 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("Via macro", src) }}
<div class="sg-dont">
❌ <strong>DON'T</strong> — hand-write label + input without <code>for</code>/<code>id</code> + <code>aria-describedby</code>; screen readers miss the hint.
</div>
{% set src %}
<label>Hostname</label>
<input name="hostname" value="left4me">
<p>Used in master-server listings.</p>
{% endset %}
{{ example("Anti-pattern (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 (manual reference)", src) }}
</section>
{# ============================== Table ============================== #}
<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="tag success">running</span></td><td>4 / 8</td></tr>
<tr><td>beta</td><td><span class="tag muted">stopped</span></td><td>— / 8</td></tr>
</tbody>
</table>
{% endset %}
{{ example("Basic", src) }}
</section>
{# ============================== Card ============================== #}
<section class="sg-section">
<h2>Card</h2>
{% set src %}
<article class="card">
<header class="card-header"><h3>Recent players</h3></header>
<div class="card-body"><p>Body content.</p></div>
<footer class="card-footer">
<div class="row"><button class="button">Refresh</button></div>
</footer>
</article>
{% endset %}
{{ example("With header + body + footer", src) }}
</section>
{# ============================== Dialog ============================== #}
<section class="sg-section">
<h2>Dialog</h2>
<div class="sg-do">
✅ <strong>DO</strong>:
</div>
{% set src %}
{% raw %}{% from "ui/_dialog.html" import dialog %}
{% call dialog(id="confirm-stop", title="Stop server?") %}
<p>This will disconnect all players.</p>
<div class="row">
<button type="button" class="button" data-inline-modal-close>Cancel</button>
<button type="submit" class="button danger">Stop server</button>
</div>
{% endcall %}{% endraw %}
{% endset %}
{{ example("Via macro", src) }}
<div class="sg-dont">
❌ <strong>DON'T</strong> — hand-write the <code><dialog></code> structure; you'll forget <code>data-inline-modal-close</code> and the JS won't close it.
</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>
{# ============================== Tag ============================== #}
<section class="sg-section">
<h2>Tag</h2>
{% set src %}
<span class="tag">default</span>
<span class="tag success">success</span>
<span class="tag warning">warning</span>
<span class="tag danger">danger</span>
<span class="tag info">info</span>
<span class="tag muted">muted</span>
{% endset %}
{{ example("Semantic variants", src) }}
<div class="sg-do">
✅ <strong>DO</strong> — for server-lifecycle states, use <code>ui.lifecycle_tag</code> so the state→variant mapping lives in one place.
</div>
{% set src %}
{% raw %}{% from "ui/_tag.html" import lifecycle_tag %}
{{ lifecycle_tag(server.actual_state) }}{% endraw %}
{% endset %}
{{ example("Via macro", src) }}
<div class="sg-dont">
❌ <strong>DON'T</strong> — hand-pick the <code>tag.<variant></code> in templates; the next template will pick a different mapping and drift.
</div>
</section>
{# ============================== Toast ============================== #}
<section class="sg-section">
<h2>Toast</h2>
{% set src %}
<div class="toast success">
<span>Server started.</span>
<button class="toast-close" aria-label="Close">×</button>
</div>
{% endset %}
{{ example("Success", src) }}
</section>
{# ============================== Spinner ============================== #}
<section class="sg-section">
<h2>Spinner</h2>
{% set src %}
<span class="spinner"></span>
<span class="spinner small"></span>
{% endset %}
{{ example("Default and small", src) }}
</section>
{# ============================== App header ============================== #}
<section class="sg-section">
<h2>App header</h2>
{% set src %}
<header class="app-header">
<div class="app-header-inner">
<nav class="nav"><a class="brand" href="#">left4me</a><a href="#">servers</a></nav>
<nav class="account"><a class="muted" href="#">user</a></nav>
</div>
</header>
{% endset %}
{{ example("Brand + nav + account", src) }}
</section>
{# ============================== Heading ============================== #}
<section class="sg-section">
<h2>Heading</h2>
{% set src %}
<header class="heading">
<h1>Server demo</h1>
<div class="heading-actions">
<button class="button">Edit</button>
<button class="button danger outline">Delete</button>
</div>
</header>
{% endset %}
{{ example("Page heading with action row", 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 tests
cd l4d2web && uv run pytest tests/test_styleguide.py -v
Expected: 4 passed.
- Step 7: Open
/styleguidein a browser, walk in light + dark
Confirm every section renders, the ✅/❌ blocks are color-coded, dark-toggle flips.
- 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 naming + workflow
Files:
-
Modify:
AGENTS.md -
Step 1: Find the insertion point
grep -n "^##" /Users/mwiegand/Projekte/left4me/AGENTS.md
Pick a heading after which to insert the UI section.
- Step 2: Insert the UI work section
Add to AGENTS.md:
## UI work
Before adding any UI markup:
1. Read `l4d2web/templates/styleguide.html` (or open `/styleguide`) — every
available widget has 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**:
- CSS in `l4d2web/static/css/components/<name>.css` (generic) or
`l4d2web/static/css/widgets/<name>.css` (project-specific).
- Style-guide entry: rendered example + escaped source + at least one ✅ DO.
- If composite or a11y-load-bearing, add a macro in
`l4d2web/templates/ui/_<name>.html`.
- Only then use the widget on the page.
3. Naming convention (no exceptions):
- **Parts of a component use hyphenated child classes** — `.card-header`,
`.field-hint`, `.dialog-body`.
- **Variant modifiers chain on the parent class** — `.button.primary`,
`.tag.success`, `.dialog.wide`, `.button.danger.outline`.
- **State stays on ARIA attributes**, not modifier classes — `[disabled]`,
`[aria-busy="true"]`, `[aria-selected="true"]`, `[aria-invalid="true"]`.
4. **Never** use inline `style="…"` attributes.
5. **Never** invent class names off-system.
6. Token usage:
- Component CSS references **only** Tier-2 semantic tokens
(`var(--color-*)`, `var(--space-*)`, …).
- Tier-3 component-scoped tokens (`--button-bg`, `--tag-fg`, etc.) are
private to each component and never appear in other components' CSS.
- Raw hex codes appear **only** in `tokens/primitives.css`.
- Step 3: Commit
git add AGENTS.md
git commit -m "docs(agents): codify UI naming convention and workflow rule"
Task 10: Template rewrite — class attributes throughout
Files: Every template in l4d2web/templates/. Split into reviewable sub-tasks. Apply the class-name migration table at the top of this plan.
This is the largest mechanical commit family. To keep diffs reviewable, split into three commits — one per template family.
Task 10a: Page templates
Files:
l4d2web/templates/login.htmll4d2web/templates/dashboard.htmll4d2web/templates/profile.htmll4d2web/templates/admin.htmll4d2web/templates/admin_users.htmll4d2web/templates/admin_jobs.htmll4d2web/templates/servers.htmll4d2web/templates/server_detail.htmll4d2web/templates/server_jobs.htmll4d2web/templates/overlays.htmll4d2web/templates/overlay_detail.htmll4d2web/templates/overlay_jobs.htmll4d2web/templates/overlay_file_editor.htmll4d2web/templates/blueprints.htmll4d2web/templates/blueprint_detail.htmll4d2web/templates/job_detail.html
For each file, apply the class-name migration table. Specifically:
- Replace
class="btn …"withclass="button …"; map all old.btn-*modifiers to the chained-modifier form (btn-primary→primary,btn-danger→danger, etc.) - Replace
class="panel"andclass="card"withclass="card"; rename.panel-heading→.card-header,.panel-body→.card-body, etc. - Replace
class="modal …"andclass="modal-*"with the dialog vocabulary - Replace
class="badge …"andclass="state-*"withclass="tag …"— and where a state string is in scope, use{{ ui.lifecycle_tag(state) }}fromui/_tag.htmlinstead of hand-picking the variant - Replace
class="page-heading"andclass="page-footer-actions"withclass="heading"/class="heading-actions"(wrap the action area) - Replace
class="link-button",class="danger-outline",class="button-secondary"with the appropriate.button+ modifier form - Replace
class="button-row"andclass="form-actions-inline"withclass="row"orclass="cluster"as appropriate - Replace
class="sr-only"withclass="visually-hidden" - Where a
_modal_partial-style modal is used inline, switch to{% from "ui/_dialog.html" import dialog %}+{% call dialog(...) %} - Where a form field is hand-assembled, switch to
{% from "ui/_field.html" import field %}+{{ field(...) }}
Per file:
-
Step 1: Read the template
-
Step 2: Edit all
class="…"attributes per the migration table -
Step 3: Replace hand-assembled field / dialog patterns with macro calls where it's a clear win (fields with hint+error, modals with header+body+footer)
-
Step 4: Visual check via the dev server
-
Commit (10a)
git add l4d2web/l4d2web/templates/*.html
git commit -m "refactor(templates): page templates — new class vocabulary + ui/* macros"
Task 10b: Partial templates
Files:
l4d2web/templates/_console_line.htmll4d2web/templates/_editor_assets.htmll4d2web/templates/_job_table.htmll4d2web/templates/_live_state.htmll4d2web/templates/_modal_partial.html(the HX-Modal layout wrapper — rename internal class refs)l4d2web/templates/_overlay_build_status.htmll4d2web/templates/_overlay_file_node.htmll4d2web/templates/_overlay_file_tree.htmll4d2web/templates/_overlay_item_table.htmll4d2web/templates/_recent_players_modal_body.htmll4d2web/templates/_server_actions.htmll4d2web/templates/_macros.html(preserve macros, update class names emitted)
Same migration as 10a. The _live_state.html template specifically uses player-card markup — update to the new .player-card-* vocabulary. _overlay_file_tree.html uses file-tree markup — update to .file-tree-item (chained .file modifier on file entries).
-
Step 1-4 per file (same as 10a)
-
Commit (10b)
git add l4d2web/l4d2web/templates/_*.html
git commit -m "refactor(templates): partials — new class vocabulary"
Task 10c: Server-detail state cluster + verification
The server detail page's state cluster gets a structural cleanup: the current .server-info + .server-actions + .last-job siblings collapse into one .server-status with the .server-status-state / .server-status-actions / .server-status-meta parts.
-
Step 1: Restructure the state-cluster region of
server_detail.htmlinto the new.server-statusvocabulary (using the newwidgets/server-status.css) -
Step 2: Run the full pytest suite
cd l4d2web && uv run pytest -x
Expected: green. Fix any failures in this commit.
- Step 3: Run the Chromium e2e suite
cd l4d2web && uv run pytest tests/e2e/ -x
Expected: green. Some assertions on old class names may need updating; do that here.
- Step 4: Walk every major page in light + dark
Visit /login, /dashboard, /servers, /servers/<id>, /blueprints, /blueprints/<id>, /overlays, /overlays/<id>, /profile, /admin, /admin/users, /admin/jobs, /styleguide. Toggle theme via DevTools. Fix anything that looks wrong.
- Step 5: Commit (10c)
git add l4d2web/l4d2web/templates/server_detail.html l4d2web/l4d2web/tests/
git commit -m "refactor(server-detail): consolidate state cluster into server-status widget"
Task 11: Cleanup
Files:
-
Delete:
l4d2web/static/css/components.css -
Delete:
l4d2web/static/css/tokens.css -
Delete:
l4d2web/static/css/logs.css(root) -
Delete:
l4d2web/static/css/console-autocomplete.css(root) -
Delete:
l4d2web/static/css/editor.css(root) -
Delete:
l4d2web/static/css/spike/(whole dir) -
Delete:
l4d2web/templates/spike.html -
Delete:
l4d2web/routes/spike_routes.py -
Delete:
l4d2web/static/vendor/css/(whole dir) -
Modify:
l4d2web/l4d2web/app.py— remove thespike_routesimport + the spike-blueprint registration -
Step 1: Confirm the old files are no longer referenced
grep -rn "components.css\|tokens.css\b\|css/logs.css\b\|css/console-autocomplete.css\|css/editor.css\b" \
l4d2web/l4d2web/templates/ l4d2web/l4d2web/static/css/ 2>/dev/null | grep -v spike
Expected: no matches outside spike/.
grep -rn "\.btn\|\.panel\|\.modal\b\|\.badge\|\.state-running\|\.state-stopped\|\.state-unknown\|\.state-transient\|\.state-drift\|\.site-header\|\.primary-nav\|\.account-nav\|\.page-heading\|\.link-button\|\.danger-outline\|\.sr-only\|\.overlay-picker\|\.file-tree-row\|\.button-row" \
l4d2web/l4d2web/templates/ 2>/dev/null
Expected: no matches. If any remain, fix them before continuing.
- 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/static/css/logs.css \
l4d2web/l4d2web/static/css/console-autocomplete.css \
l4d2web/l4d2web/static/css/editor.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:
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.
- Step 5: Run the e2e suite
cd l4d2web && uv run pytest tests/e2e/ -x
Expected: all green.
- Step 6: Final manual walk-through in dev server, light + dark
/login, /dashboard, /servers, /servers/<id> (with overlay-list visible), /servers/<id>/jobs, /blueprints, /blueprints/<id>, /overlays, /overlays/<id> (with file-tree visible), /overlays/<id> (script overlay — editor visible), /profile, /admin, /admin/users, /admin/jobs, /styleguide. Confirm: buttons styled, forms readable, tables clean, dialogs open and look right, tabs switch, tags colored, file-tree and overlay-list render, dark mode works on every page.
- Step 7: Commit
git add -A
git commit -m "chore(stylesheet): delete old CSS + spike artifacts
Old stylesheet fully replaced by the layered system at main.css. The
spike validated the architecture (layer cascade + tokens + dark mode);
the production system uses a different vocabulary (.button + chained
modifiers, .card, .dialog, .tag, ...). Spike artifacts removed."
Final verification
- Step 1: Repo-wide search for any stragglers
grep -rn "css/spike\|spike_routes\|spike_bp\|spike_enabled\|css/components\.css\|css/tokens\.css\|class=\"btn\b\|class=\"panel\b\|class=\"modal\b\|class=\"badge\b\|\.state-running\|\.state-stopped\|\.state-drift\|\.site-header\|\.primary-nav\|\.account-nav\|\.page-heading\|\.link-button\|\.sr-only\|\.overlay-picker\|\.file-tree-row" \
l4d2web/ AGENTS.md docs/ 2>/dev/null
Expected: matches only inside docs/superpowers/specs/ and docs/superpowers/plans/ (this plan and the spec reference the old names while explaining the migration).
- Step 2:
base.htmlloads exactly one stylesheet
grep -E "rel=\"stylesheet\"" l4d2web/l4d2web/templates/base.html
Expected: exactly one match, pointing at main.css.
- Step 3:
@layerorder is intact
head -5 l4d2web/l4d2web/static/css/main.css
Expected: first non-comment line is the @layer reset, tokens, elements, layout, components, widgets, utilities; declaration.
- Step 4: Any final fixups
If anything was caught, fix and commit with a short follow-up message.
Definition of done
base.htmlloads exactly one stylesheet:main.cssmain.cssdeclares the seven-layer cascade and@imports every sub-file- Tokens split into
primitives.css+semantic.css; component-scoped tokens live inside their component files - All twelve component CSS files exist under
components/ - All seven widget CSS files exist under
widgets/ - Five macros under
templates/ui/ /styleguidereturns 200, renders every primitive, has ✅/❌ blocksAGENTS.mdhas the "UI work" section with naming + workflow rules- Every template uses the new class vocabulary; no template references
.btn,.panel,.modal,.badge,.state-*,.site-header,.primary-nav,.account-nav,.page-heading,.link-button,.danger-outline,.sr-only,.overlay-picker,.file-tree-row, or.button-row - 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, root-level widget files deleted - Spike artifacts deleted (
css/spike/,vendor/css/,spike.html,spike_routes.py, theapp.pyregistration)