# 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 + `@import`s | | 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 ``) + 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` | ` {% endif %} ``` New site header markup: ```html
{% if g.user %} {% endif %}
``` (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: ```html ``` New modal-container: ```html ``` - [ ] **Step 4: Verify the dev server boots, `/login` returns 200** ```bash curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:5051/login ``` Expected: `200`. NOT `500`. ```bash curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:5051/static/css/main.css ``` Expected: `200`. - [ ] **Step 5: Open `/login` in 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** ```bash git add l4d2web/l4d2web/static/css/elements.css \ l4d2web/l4d2web/static/css/layout.css \ l4d2web/l4d2web/templates/base.html git commit -m "feat(stylesheet): elements + layout layers; base.html 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** ```bash 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`: ```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`: ```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`: ```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`: ```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`: ```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`: ```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** ```bash 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`: ```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
; 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`: ```css .tabs { display: flex; gap: 0; border-bottom: 1px solid var(--color-border); margin-bottom: var(--space-3); } .tabs .tab { background: transparent; border: 0; border-bottom: 2px solid transparent; padding: var(--space-2) var(--space-3); color: var(--color-muted); font-size: var(--text-sm); cursor: pointer; transition: color var(--duration-fast) var(--ease-out), border-color var(--duration-fast) var(--ease-out); } .tabs .tab:hover { color: var(--color-text); } .tabs .tab[aria-selected="true"] { color: var(--color-text-strong); border-bottom-color: var(--color-primary); } .tab-panel { padding: var(--space-2) 0; } ``` - [ ] **Step 3: Write `components/field.css`** `l4d2web/l4d2web/static/css/components/field.css`: ```css .field { display: flex; flex-direction: column; gap: var(--space-1); } .field-label { font-weight: 600; font-size: var(--text-sm); } .field-hint { font-size: var(--text-xs); color: var(--color-muted); } .field-error { font-size: var(--text-xs); color: var(--color-danger); } .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`: ```css /* Native 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 -%}
{% if hint %}

{{ hint }}

{% endif %} {% if error %}

{{ error }}

{% endif %}
{% endmacro %} {% macro checkbox(name, label, checked=False, disabled=False, id=None) %} {%- set fid = id or ("c-" ~ name) -%} {% endmacro %} {% macro select(name, label, options, value="", hint=None, id=None) %} {%- set fid = id or ("s-" ~ name) -%}
{% if hint %}

{{ hint }}

{% endif %}
{% endmacro %} ``` - [ ] **Step 3: Write `ui/_dialog.html`** `l4d2web/l4d2web/templates/ui/_dialog.html`: ```jinja {# Dialog wrapping . Owns header / body / close-button + data-inline-modal-close hook for the existing modals.js machinery. #} {% macro dialog(id, title, wide=False) %}

{{ title }}

{{ caller() }}
{% endmacro %} ``` - [ ] **Step 4: Write `ui/_tabs.html`** `l4d2web/l4d2web/templates/ui/_tabs.html`: ```jinja {# Tab bar + tabpanels. Owns role / aria-controls / aria-selected. #} {% macro tabs(items) %}
{% for it in items %} {% endfor %}
{% endmacro %} {% macro tab_panel(id, selected=False) %}
{{ caller() }}
{% endmacro %} ``` - [ ] **Step 5: Write `ui/_confirm_form.html`** `l4d2web/l4d2web/templates/ui/_confirm_form.html`: ```jinja {# 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") %}
{% endmacro %} ``` - [ ] **Step 6: Write `ui/_tag.html`** `l4d2web/l4d2web/templates/ui/_tag.html`: ```jinja {# 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") %} {{ label }} {% 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") -%} {{ state or "unknown" }} {% endmacro %} ``` - [ ] **Step 7: Write the macro unit tests** `l4d2web/tests/test_ui_macros.py`: ```python """Unit tests for templates/ui/ macros. These guard the a11y wiring that the macros own — if these regress, accessibility silently breaks.""" import pytest from jinja2 import Environment, FileSystemLoader from pathlib import Path @pytest.fixture def jinja_env(): templates_dir = Path(__file__).parent.parent / "l4d2web" / "templates" return Environment(loader=FileSystemLoader(str(templates_dir))) def test_field_pairs_label_for_with_input_id(jinja_env): tmpl = jinja_env.from_string( '{% from "ui/_field.html" import field %}' '{{ field(name="hostname", label="Hostname") }}' ) html = tmpl.render() assert 'for="f-hostname"' in html assert 'id="f-hostname"' in html def test_field_wires_aria_describedby_for_hint(jinja_env): tmpl = jinja_env.from_string( '{% from "ui/_field.html" import field %}' '{{ field(name="port", label="Port", hint="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** ```bash cd l4d2web && uv run pytest tests/test_ui_macros.py -v ``` Expected: 7 passed. - [ ] **Step 9: Commit** ```bash git add l4d2web/l4d2web/templates/ui/ l4d2web/tests/test_ui_macros.py git commit -m "feat(stylesheet): ui macros — field, dialog, tabs, confirm_form, tag/lifecycle_tag" ``` --- ## Task 6: Project widgets relocation + rename **Files (all new under `widgets/`):** - `file-tree.css` - `overlay-list.css` - `console.css` (subsumes the old `console-autocomplete.css`) - `editor.css` - `logs.css` - `server-status.css` (extracted from `server_detail.html` patterns) - `player-list.css` (extracted from `_live_state.html` styling) 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** ```bash 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`: ```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 `
    `) → styled via `.overlay-list > ul` selector OR `.overlay-list > .overlay-list` if a wrapper persists; in practice the `
      ` 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`: ```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`: ```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`: ```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`: ```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`: ```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`: ```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** ```bash grep -nE 'var\(--(space-(xs|s|m|l|xl|2xl)|radius-(base|s|m)|line|line-soft|color-(surface-muted|border-muted|button-primary|button-danger|log-bg|log-text))' \ l4d2web/l4d2web/static/css/widgets/*.css l4d2web/l4d2web/static/css/components/*.css 2>/dev/null ``` Expected: no output. Fix anything that matches per the token-migration table at the top of this plan. - [ ] **Step 10: Verify all `@import`s in `main.css` resolve** ```bash for f in $(grep -oE '\./[a-z0-9./-]+\.css' l4d2web/l4d2web/static/css/main.css); do full=l4d2web/l4d2web/static/css/${f#./} test -f "$full" || echo "MISSING: $full" done echo "done" ``` Expected: only `done` printed. - [ ] **Step 11: Commit** ```bash 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`: ```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** ```bash git add l4d2web/l4d2web/static/css/utilities.css git commit -m "feat(stylesheet): utilities layer" ``` --- ## Task 8: Style guide route + template **Files:** - Create: `l4d2web/routes/styleguide_routes.py` - Create: `l4d2web/templates/styleguide.html` - Modify: `l4d2web/l4d2web/app.py` — register the styleguide blueprint - [ ] **Step 1: Write the test first** `l4d2web/tests/test_styleguide.py`: ```python """The /styleguide route is the canonical reference for every 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** ```bash 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`: ```python from flask import Blueprint, render_template bp = Blueprint("styleguide", __name__) @bp.get("/styleguide") def styleguide_index() -> str: return render_template("styleguide.html") ``` - [ ] **Step 4: Register the blueprint in `app.py`** Edit `l4d2web/l4d2web/app.py`. Add the import alongside the others: ```python from l4d2web.routes.styleguide_routes import bp as styleguide_bp ``` And register alongside the other `app.register_blueprint(...)` calls: ```python app.register_blueprint(styleguide_bp) ``` - [ ] **Step 5: Write `templates/styleguide.html`** `l4d2web/l4d2web/templates/styleguide.html`: ```jinja {% extends "base.html" %} {% block title %}Styleguide | left4me{% endblock %} {% block extra_head %} {% endblock %} {% block content %}

      Style guide

      Every available widget with copy-paste source. Naming: parts use hyphens (.card-header), variants chain (.button.primary). State via ARIA attributes. If your change needs a widget that isn't here, extend the system before using it.

      {% macro example(title, html) %}

      {{ title }}

      {{ html|safe }}
      {{ html|trim|e }}
      {% endmacro %} {# ============================== Tokens ============================== #}

      Tokens

      Components reference Tier-2 semantic tokens only (var(--color-*), var(--space-*)). Tier-3 component-scoped tokens (--button-bg, --tag-fg) are private. Raw hex codes live in tokens/primitives.css.

      Colors

      {% 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"] %}
      --color-{{ name }}
      {% endfor %}

      Spacing

      --space-1 (0.25rem) … --space-7 (3rem)

      Type scale

      --text-xs / sm / base / lg / xl / 2xl

      {# ============================== Button ============================== #}

      Button

      Variants chain on .button. Modifiers compose: .button.danger.outline is a real outlined-danger button.

      {% set src %} {% endset %} {{ example("Variants + states + size", src) }} {% set src %} {% endset %} {{ example("Composed modifiers", src) }}
      {# ============================== Field ============================== #}

      Field

      DO — use ui.field so a11y wiring can't drift:
      {% 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) }}
      DON'T — hand-write label + input without for/id + aria-describedby; screen readers miss the hint.
      {% set src %}

      Used in master-server listings.

      {% endset %} {{ example("Anti-pattern (broken a11y)", src) }} {% set src %}

      Must be between 27015 and 27115.

      {% endset %} {{ example("Error state (manual reference)", src) }}
      {# ============================== Table ============================== #}

      Table

      {% set src %}
      NameStatePlayers
      alpharunning4 / 8
      betastopped— / 8
      {% endset %} {{ example("Basic", src) }}
      {# ============================== Card ============================== #}

      Card

      {% set src %}

      Recent players

      Body content.

      {% endset %} {{ example("With header + body + footer", src) }}
      {# ============================== Dialog ============================== #}

      Dialog

      DO:
      {% set src %} {% raw %}{% from "ui/_dialog.html" import dialog %} {% call dialog(id="confirm-stop", title="Stop server?") %}

      This will disconnect all players.

      {% endcall %}{% endraw %} {% endset %} {{ example("Via macro", src) }}
      DON'T — hand-write the <dialog> structure; you'll forget data-inline-modal-close and the JS won't close it.
      {# ============================== Tabs ============================== #}

      Tabs

      {% set src %}
      Overview content.
      {% endset %} {{ example("Underline-style tabs", src) }}
      {# ============================== Tag ============================== #}

      Tag

      {% set src %} default success warning danger info muted {% endset %} {{ example("Semantic variants", src) }}
      DO — for server-lifecycle states, use ui.lifecycle_tag so the state→variant mapping lives in one place.
      {% set src %} {% raw %}{% from "ui/_tag.html" import lifecycle_tag %} {{ lifecycle_tag(server.actual_state) }}{% endraw %} {% endset %} {{ example("Via macro", src) }}
      DON'T — hand-pick the tag.<variant> in templates; the next template will pick a different mapping and drift.
      {# ============================== Toast ============================== #}

      Toast

      {% set src %}
      Server started.
      {% endset %} {{ example("Success", src) }}
      {# ============================== Spinner ============================== #}

      Spinner

      {% set src %} {% endset %} {{ example("Default and small", src) }}
      {# ============================== App header ============================== #}

      App header

      {% set src %}
      {% endset %} {{ example("Brand + nav + account", src) }}
      {# ============================== Heading ============================== #}

      Heading

      {% set src %}

      Server demo

      {% endset %} {{ example("Page heading with action row", src) }}
      {% endblock %} ``` - [ ] **Step 6: Run the tests** ```bash cd l4d2web && uv run pytest tests/test_styleguide.py -v ``` Expected: 4 passed. - [ ] **Step 7: Open `/styleguide` in a browser, walk in light + dark** Confirm every section renders, the ✅/❌ blocks are color-coded, dark-toggle flips. - [ ] **Step 8: Commit** ```bash git add l4d2web/l4d2web/routes/styleguide_routes.py \ l4d2web/l4d2web/templates/styleguide.html \ l4d2web/l4d2web/app.py \ l4d2web/tests/test_styleguide.py git commit -m "feat(styleguide): in-app /styleguide as canonical widget reference" ``` --- ## Task 9: AGENTS.md — codify naming + workflow **Files:** - Modify: `AGENTS.md` - [ ] **Step 1: Find the insertion point** ```bash 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`: ```markdown ## 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/.css` (generic) or `l4d2web/static/css/widgets/.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/_.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** ```bash 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.html` - `l4d2web/templates/dashboard.html` - `l4d2web/templates/profile.html` - `l4d2web/templates/admin.html` - `l4d2web/templates/admin_users.html` - `l4d2web/templates/admin_jobs.html` - `l4d2web/templates/servers.html` - `l4d2web/templates/server_detail.html` - `l4d2web/templates/server_jobs.html` - `l4d2web/templates/overlays.html` - `l4d2web/templates/overlay_detail.html` - `l4d2web/templates/overlay_jobs.html` - `l4d2web/templates/overlay_file_editor.html` - `l4d2web/templates/blueprints.html` - `l4d2web/templates/blueprint_detail.html` - `l4d2web/templates/job_detail.html` For each file, apply the class-name migration table. Specifically: - Replace `class="btn …"` with `class="button …"`; map all old `.btn-*` modifiers to the chained-modifier form (`btn-primary` → `primary`, `btn-danger` → `danger`, etc.) - Replace `class="panel"` and `class="card"` with `class="card"`; rename `.panel-heading` → `.card-header`, `.panel-body` → `.card-body`, etc. - Replace `class="modal …"` and `class="modal-*"` with the dialog vocabulary - Replace `class="badge …"` and `class="state-*"` with `class="tag …"` — and where a state string is in scope, use `{{ ui.lifecycle_tag(state) }}` from `ui/_tag.html` instead of hand-picking the variant - Replace `class="page-heading"` and `class="page-footer-actions"` with `class="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"` and `class="form-actions-inline"` with `class="row"` or `class="cluster"` as appropriate - Replace `class="sr-only"` with `class="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)** ```bash 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.html` - `l4d2web/templates/_editor_assets.html` - `l4d2web/templates/_job_table.html` - `l4d2web/templates/_live_state.html` - `l4d2web/templates/_modal_partial.html` (the HX-Modal layout wrapper — rename internal class refs) - `l4d2web/templates/_overlay_build_status.html` - `l4d2web/templates/_overlay_file_node.html` - `l4d2web/templates/_overlay_file_tree.html` - `l4d2web/templates/_overlay_item_table.html` - `l4d2web/templates/_recent_players_modal_body.html` - `l4d2web/templates/_server_actions.html` - `l4d2web/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)** ```bash 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.html`** into the new `.server-status` vocabulary (using the new `widgets/server-status.css`) - [ ] **Step 2: Run the full pytest suite** ```bash cd l4d2web && uv run pytest -x ``` Expected: green. Fix any failures in this commit. - [ ] **Step 3: Run the Chromium e2e suite** ```bash 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/`, `/blueprints`, `/blueprints/`, `/overlays`, `/overlays/`, `/profile`, `/admin`, `/admin/users`, `/admin/jobs`, `/styleguide`. Toggle theme via DevTools. Fix anything that looks wrong. - [ ] **Step 5: Commit (10c)** ```bash 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 the `spike_routes` import + the spike-blueprint registration - [ ] **Step 1: Confirm the old files are no longer referenced** ```bash 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/`. ```bash 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** ```bash 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: ```python from l4d2web.routes.spike_routes import bp as spike_bp from l4d2web.routes.spike_routes import spike_enabled ``` And remove: ```python if spike_enabled(): app.register_blueprint(spike_bp) ``` - [ ] **Step 4: Run the full pytest suite** ```bash cd l4d2web && uv run pytest -x ``` Expected: all green. - [ ] **Step 5: Run the e2e suite** ```bash 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/` (with overlay-list visible), `/servers//jobs`, `/blueprints`, `/blueprints/`, `/overlays`, `/overlays/` (with file-tree visible), `/overlays/` (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** ```bash 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** ```bash 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.html` loads exactly one stylesheet** ```bash grep -E "rel=\"stylesheet\"" l4d2web/l4d2web/templates/base.html ``` Expected: exactly one match, pointing at `main.css`. - [ ] **Step 3: `@layer` order is intact** ```bash 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.html` loads exactly one stylesheet: `main.css` - [ ] `main.css` declares the seven-layer cascade and `@import`s 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/` - [ ] `/styleguide` returns 200, renders every primitive, has ✅/❌ blocks - [ ] `AGENTS.md` has 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`, the `app.py` registration)