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>
2379 lines
88 KiB
Markdown
2379 lines
88 KiB
Markdown
# 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 `<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**
|
||
|
||
```bash
|
||
mkdir -p l4d2web/l4d2web/static/css/tokens
|
||
```
|
||
|
||
- [ ] **Step 2: Write `main.css`**
|
||
|
||
`l4d2web/l4d2web/static/css/main.css`:
|
||
|
||
```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`:
|
||
|
||
```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`:
|
||
|
||
```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`:
|
||
|
||
```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**
|
||
|
||
```bash
|
||
test -s l4d2web/l4d2web/static/css/main.css && \
|
||
test -s l4d2web/l4d2web/static/css/reset.css && \
|
||
test -s l4d2web/l4d2web/static/css/tokens/primitives.css && \
|
||
test -s l4d2web/l4d2web/static/css/tokens/semantic.css && \
|
||
echo OK
|
||
```
|
||
|
||
Expected: `OK`.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add l4d2web/l4d2web/static/css/main.css \
|
||
l4d2web/l4d2web/static/css/reset.css \
|
||
l4d2web/l4d2web/static/css/tokens/
|
||
git commit -m "feat(stylesheet): foundation — main.css, reset, 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`:
|
||
|
||
```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:
|
||
|
||
```css
|
||
.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):
|
||
|
||
```html
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/tokens.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/layout.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/logs.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/console-autocomplete.css') }}">
|
||
```
|
||
|
||
New `<head>` block — replace the above with:
|
||
|
||
```html
|
||
<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:
|
||
|
||
```html
|
||
<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:
|
||
|
||
```html
|
||
<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:
|
||
|
||
```html
|
||
<dialog id="modal-container" class="modal modal-wide">
|
||
<div id="modal-content"></div>
|
||
</dialog>
|
||
```
|
||
|
||
New modal-container:
|
||
|
||
```html
|
||
<dialog id="modal-container" class="dialog wide">
|
||
<div id="modal-content"></div>
|
||
</dialog>
|
||
```
|
||
|
||
- [ ] **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 <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`:
|
||
|
||
```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 <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`:
|
||
|
||
```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`:
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
mkdir -p l4d2web/l4d2web/templates/ui
|
||
```
|
||
|
||
- [ ] **Step 2: Write `ui/_field.html`**
|
||
|
||
`l4d2web/l4d2web/templates/ui/_field.html`:
|
||
|
||
```jinja
|
||
{# Form field with label, optional hint, optional error. Owns the
|
||
<label for> / <input id> pairing and aria-describedby wiring. #}
|
||
|
||
{% 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`:
|
||
|
||
```jinja
|
||
{# Dialog wrapping <dialog>. Owns header / body / close-button +
|
||
data-inline-modal-close hook for the existing modals.js machinery. #}
|
||
|
||
{% macro dialog(id, title, wide=False) %}
|
||
<dialog id="{{ id }}" class="dialog{% if wide %} wide{% endif %}">
|
||
<article>
|
||
<header class="dialog-header">
|
||
<h2>{{ title }}</h2>
|
||
<button class="dialog-close" type="button" aria-label="Close" data-inline-modal-close>×</button>
|
||
</header>
|
||
<div class="dialog-body">
|
||
{{ caller() }}
|
||
</div>
|
||
</article>
|
||
</dialog>
|
||
{% endmacro %}
|
||
```
|
||
|
||
- [ ] **Step 4: Write `ui/_tabs.html`**
|
||
|
||
`l4d2web/l4d2web/templates/ui/_tabs.html`:
|
||
|
||
```jinja
|
||
{# 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`:
|
||
|
||
```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") %}
|
||
<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`:
|
||
|
||
```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") %}
|
||
<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`:
|
||
|
||
```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 `<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`:
|
||
|
||
```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 %}
|
||
<style nonce="{{ g.csp_nonce }}">
|
||
.sg-section { margin-block: var(--space-6); }
|
||
.sg-example { border: 1px solid var(--color-border-soft); border-radius: var(--radius-2); margin-block: var(--space-3); overflow: hidden; }
|
||
.sg-example-title { padding: var(--space-2) var(--space-3); background: var(--color-surface-2); font-size: var(--text-sm); font-weight: 600; margin: 0; }
|
||
.sg-example-rendered { padding: var(--space-3); background: var(--color-surface); }
|
||
.sg-example-source { margin: 0; padding: var(--space-3); background: var(--color-bg); font-size: var(--text-sm); border-top: 1px solid var(--color-border-soft); white-space: pre-wrap; }
|
||
.sg-do, .sg-dont { padding: var(--space-2) var(--space-3); }
|
||
.sg-do { background: color-mix(in srgb, var(--color-success) 8%, var(--color-surface)); border-left: 4px solid var(--color-success); }
|
||
.sg-dont { background: color-mix(in srgb, var(--color-danger) 8%, var(--color-surface)); border-left: 4px solid var(--color-danger); }
|
||
.sg-token-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--space-2); }
|
||
.sg-token-swatch { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-1) var(--space-2); border: 1px solid var(--color-border-soft); border-radius: var(--radius-1); }
|
||
.sg-token-swatch > .sw { width: 1.5rem; height: 1.5rem; border-radius: var(--radius-1); border: 1px solid var(--color-border-soft); }
|
||
.sg-token-swatch > code { font-size: var(--text-xs); }
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<header class="heading">
|
||
<h1>Style guide</h1>
|
||
<div class="heading-actions">
|
||
<button type="button" class="button" id="sg-theme-toggle">Toggle dark</button>
|
||
</div>
|
||
</header>
|
||
<p class="muted">Every available widget with copy-paste source. Naming: <strong>parts use hyphens</strong> (<code>.card-header</code>), <strong>variants chain</strong> (<code>.button.primary</code>). State via ARIA attributes. If your change needs a widget that isn't here, extend the system before using it.</p>
|
||
|
||
{% macro example(title, html) %}
|
||
<section class="sg-example">
|
||
<h3 class="sg-example-title">{{ title }}</h3>
|
||
<div class="sg-example-rendered">{{ html|safe }}</div>
|
||
<pre class="sg-example-source"><code>{{ html|trim|e }}</code></pre>
|
||
</section>
|
||
{% endmacro %}
|
||
|
||
{# ============================== Tokens ============================== #}
|
||
<section class="sg-section">
|
||
<h2>Tokens</h2>
|
||
<p>Components reference <strong>Tier-2 semantic tokens only</strong> (<code>var(--color-*)</code>, <code>var(--space-*)</code>). Tier-3 component-scoped tokens (<code>--button-bg</code>, <code>--tag-fg</code>) are private. Raw hex codes live in <code>tokens/primitives.css</code>.</p>
|
||
<h3>Colors</h3>
|
||
<div class="sg-token-grid">
|
||
{% for name in ["bg", "surface", "surface-2", "text", "text-strong", "muted", "border", "border-soft", "primary", "primary-hover", "on-primary", "danger", "on-danger", "warning", "success", "info", "focus", "link"] %}
|
||
<div class="sg-token-swatch">
|
||
<span class="sw" style="background: var(--color-{{ name }})"></span>
|
||
<code>--color-{{ name }}</code>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
<h3>Spacing</h3>
|
||
<p class="mono">--space-1 (0.25rem) … --space-7 (3rem)</p>
|
||
<h3>Type scale</h3>
|
||
<p class="mono">--text-xs / sm / base / lg / xl / 2xl</p>
|
||
</section>
|
||
|
||
{# ============================== Button ============================== #}
|
||
<section class="sg-section">
|
||
<h2>Button</h2>
|
||
<p>Variants chain on <code>.button</code>. Modifiers compose: <code>.button.danger.outline</code> is a real outlined-danger button.</p>
|
||
{% set src %}
|
||
<button type="button" class="button">Default</button>
|
||
<button type="button" class="button primary">Primary</button>
|
||
<button type="button" class="button danger">Danger</button>
|
||
<button type="button" class="button outline">Outline</button>
|
||
<button type="button" class="button ghost">Ghost</button>
|
||
<button type="button" class="button link">Link</button>
|
||
<button type="button" class="button small">Small</button>
|
||
<button type="button" class="button primary" disabled>Disabled</button>
|
||
<button type="button" class="button primary" aria-busy="true">Loading…</button>
|
||
{% endset %}
|
||
{{ example("Variants + states + size", src) }}
|
||
|
||
{% set src %}
|
||
<button type="button" class="button danger outline">Delete (outlined)</button>
|
||
<button type="button" class="button primary small">Save (small)</button>
|
||
{% endset %}
|
||
{{ example("Composed modifiers", src) }}
|
||
</section>
|
||
|
||
{# ============================== Field ============================== #}
|
||
<section class="sg-section">
|
||
<h2>Field</h2>
|
||
|
||
<div class="sg-do">
|
||
✅ <strong>DO</strong> — use <code>ui.field</code> so a11y wiring can't drift:
|
||
</div>
|
||
{% set src %}
|
||
{# In your template #}
|
||
{% raw %}{% from "ui/_field.html" import field %}
|
||
{{ field(name="hostname", label="Hostname", value="left4me",
|
||
hint="Used in master-server listings.") }}{% endraw %}
|
||
{% endset %}
|
||
{{ example("Via macro", src) }}
|
||
|
||
<div class="sg-dont">
|
||
❌ <strong>DON'T</strong> — hand-write label + input without <code>for</code>/<code>id</code> + <code>aria-describedby</code>; screen readers miss the hint.
|
||
</div>
|
||
{% set src %}
|
||
<label>Hostname</label>
|
||
<input name="hostname" value="left4me">
|
||
<p>Used in master-server listings.</p>
|
||
{% endset %}
|
||
{{ example("Anti-pattern (broken a11y)", src) }}
|
||
|
||
{% set src %}
|
||
<div class="field">
|
||
<label class="field-label" for="sg-port">Port</label>
|
||
<input id="sg-port" type="number" value="-1" aria-invalid="true" aria-describedby="sg-port-err">
|
||
<p id="sg-port-err" class="field-error">Must be between 27015 and 27115.</p>
|
||
</div>
|
||
{% endset %}
|
||
{{ example("Error state (manual reference)", src) }}
|
||
</section>
|
||
|
||
{# ============================== Table ============================== #}
|
||
<section class="sg-section">
|
||
<h2>Table</h2>
|
||
{% set src %}
|
||
<table class="table">
|
||
<thead><tr><th>Name</th><th>State</th><th>Players</th></tr></thead>
|
||
<tbody>
|
||
<tr><td>alpha</td><td><span class="tag success">running</span></td><td>4 / 8</td></tr>
|
||
<tr><td>beta</td><td><span class="tag muted">stopped</span></td><td>— / 8</td></tr>
|
||
</tbody>
|
||
</table>
|
||
{% endset %}
|
||
{{ example("Basic", src) }}
|
||
</section>
|
||
|
||
{# ============================== Card ============================== #}
|
||
<section class="sg-section">
|
||
<h2>Card</h2>
|
||
{% set src %}
|
||
<article class="card">
|
||
<header class="card-header"><h3>Recent players</h3></header>
|
||
<div class="card-body"><p>Body content.</p></div>
|
||
<footer class="card-footer">
|
||
<div class="row"><button class="button">Refresh</button></div>
|
||
</footer>
|
||
</article>
|
||
{% endset %}
|
||
{{ example("With header + body + footer", src) }}
|
||
</section>
|
||
|
||
{# ============================== Dialog ============================== #}
|
||
<section class="sg-section">
|
||
<h2>Dialog</h2>
|
||
<div class="sg-do">
|
||
✅ <strong>DO</strong>:
|
||
</div>
|
||
{% set src %}
|
||
{% raw %}{% from "ui/_dialog.html" import dialog %}
|
||
{% call dialog(id="confirm-stop", title="Stop server?") %}
|
||
<p>This will disconnect all players.</p>
|
||
<div class="row">
|
||
<button type="button" class="button" data-inline-modal-close>Cancel</button>
|
||
<button type="submit" class="button danger">Stop server</button>
|
||
</div>
|
||
{% endcall %}{% endraw %}
|
||
{% endset %}
|
||
{{ example("Via macro", src) }}
|
||
|
||
<div class="sg-dont">
|
||
❌ <strong>DON'T</strong> — hand-write the <code><dialog></code> structure; you'll forget <code>data-inline-modal-close</code> and the JS won't close it.
|
||
</div>
|
||
</section>
|
||
|
||
{# ============================== Tabs ============================== #}
|
||
<section class="sg-section">
|
||
<h2>Tabs</h2>
|
||
{% set src %}
|
||
<div class="tabs" role="tablist">
|
||
<button class="tab" role="tab" aria-selected="true" aria-controls="panel-a">Overview</button>
|
||
<button class="tab" role="tab" aria-selected="false" aria-controls="panel-b">Console</button>
|
||
</div>
|
||
<div id="panel-a" role="tabpanel" class="tab-panel">Overview content.</div>
|
||
<div id="panel-b" role="tabpanel" class="tab-panel" hidden>Console content.</div>
|
||
{% endset %}
|
||
{{ example("Underline-style tabs", src) }}
|
||
</section>
|
||
|
||
{# ============================== Tag ============================== #}
|
||
<section class="sg-section">
|
||
<h2>Tag</h2>
|
||
{% set src %}
|
||
<span class="tag">default</span>
|
||
<span class="tag success">success</span>
|
||
<span class="tag warning">warning</span>
|
||
<span class="tag danger">danger</span>
|
||
<span class="tag info">info</span>
|
||
<span class="tag muted">muted</span>
|
||
{% endset %}
|
||
{{ example("Semantic variants", src) }}
|
||
|
||
<div class="sg-do">
|
||
✅ <strong>DO</strong> — for server-lifecycle states, use <code>ui.lifecycle_tag</code> so the state→variant mapping lives in one place.
|
||
</div>
|
||
{% set src %}
|
||
{% raw %}{% from "ui/_tag.html" import lifecycle_tag %}
|
||
{{ lifecycle_tag(server.actual_state) }}{% endraw %}
|
||
{% endset %}
|
||
{{ example("Via macro", src) }}
|
||
|
||
<div class="sg-dont">
|
||
❌ <strong>DON'T</strong> — hand-pick the <code>tag.<variant></code> in templates; the next template will pick a different mapping and drift.
|
||
</div>
|
||
</section>
|
||
|
||
{# ============================== Toast ============================== #}
|
||
<section class="sg-section">
|
||
<h2>Toast</h2>
|
||
{% set src %}
|
||
<div class="toast success">
|
||
<span>Server started.</span>
|
||
<button class="toast-close" aria-label="Close">×</button>
|
||
</div>
|
||
{% endset %}
|
||
{{ example("Success", src) }}
|
||
</section>
|
||
|
||
{# ============================== Spinner ============================== #}
|
||
<section class="sg-section">
|
||
<h2>Spinner</h2>
|
||
{% set src %}
|
||
<span class="spinner"></span>
|
||
<span class="spinner small"></span>
|
||
{% endset %}
|
||
{{ example("Default and small", src) }}
|
||
</section>
|
||
|
||
{# ============================== App header ============================== #}
|
||
<section class="sg-section">
|
||
<h2>App header</h2>
|
||
{% set src %}
|
||
<header class="app-header">
|
||
<div class="app-header-inner">
|
||
<nav class="nav"><a class="brand" href="#">left4me</a><a href="#">servers</a></nav>
|
||
<nav class="account"><a class="muted" href="#">user</a></nav>
|
||
</div>
|
||
</header>
|
||
{% endset %}
|
||
{{ example("Brand + nav + account", src) }}
|
||
</section>
|
||
|
||
{# ============================== Heading ============================== #}
|
||
<section class="sg-section">
|
||
<h2>Heading</h2>
|
||
{% set src %}
|
||
<header class="heading">
|
||
<h1>Server demo</h1>
|
||
<div class="heading-actions">
|
||
<button class="button">Edit</button>
|
||
<button class="button danger outline">Delete</button>
|
||
</div>
|
||
</header>
|
||
{% endset %}
|
||
{{ example("Page heading with action row", src) }}
|
||
</section>
|
||
|
||
<script nonce="{{ g.csp_nonce }}">
|
||
document.getElementById('sg-theme-toggle').addEventListener('click', function () {
|
||
var html = document.documentElement;
|
||
html.dataset.theme = html.dataset.theme === 'dark' ? 'light' : 'dark';
|
||
});
|
||
</script>
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 6: Run the tests**
|
||
|
||
```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/<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**
|
||
|
||
```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/<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)**
|
||
|
||
```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/<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**
|
||
|
||
```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)
|