left4me/docs/superpowers/plans/2026-05-17-stylesheet-redesign.md
mwiegand 308fa4eb26
docs(stylesheet): redesign from first principles
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>
2026-05-18 00:33:47 +02:00

88 KiB
Raw Blame History

Stylesheet Redesign Implementation Plan

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

Goal: Replace 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-header block), 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, /login returns 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 /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
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>&times;</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="2701527115") }}'
    )
    html = tmpl.render()
    assert 'aria-describedby="f-port-hint"' in html
    assert 'id="f-port-hint"' in html
    assert 'class="field-hint"' in html


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


def test_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.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
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 > ul selector OR .overlay-list > .overlay-list if 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 in main.css resolve
for f in $(grep -oE '\./[a-z0-9./-]+\.css' l4d2web/l4d2web/static/css/main.css); do
  full=l4d2web/l4d2web/static/css/${f#./}
  test -f "$full" || echo "MISSING: $full"
done
echo "done"

Expected: only done printed.

  • 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>&lt;dialog&gt;</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.&lt;variant&gt;</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">&times;</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 /styleguide in 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.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-primaryprimary, btn-dangerdanger, 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)

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)

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

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 the spike_routes import + 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.html loads exactly one stylesheet
grep -E "rel=\"stylesheet\"" l4d2web/l4d2web/templates/base.html

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

  • Step 3: @layer order 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.html loads exactly one stylesheet: main.css
  • main.css declares 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/
  • /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)