Ten tasks aligned with the spec's seven-commit migration: foundation → elements/layout → core components → composites → macros → widget relocation → utilities → styleguide → AGENTS.md → cleanup. Token migration table for old→new names. Pytest unit tests for the field a11y macro and the /styleguide route. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1834 lines
69 KiB
Markdown
1834 lines
69 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 `l4d2web/static/css/*` (~1,436 LOC, 196 component classes) with a tiered design system: two-tier tokens, `@layer`-ordered cascade, budgeted component classes, five high-leverage macros, an in-app style guide, and the "system is closed" workflow rule enforced via `AGENTS.md`.
|
||
|
||
**Architecture:** Pure custom CSS (no framework base). Single entry stylesheet `main.css` declares `@layer reset, tokens, elements, layout, components, widgets, utilities;` and `@import`s the rest. Tokens split into `primitives.css` (raw palette) + `semantic.css` (role aliases + dark-mode branch). Component classes are CSS-only by default; macros under `templates/ui/` exist only for five composites where markup correctness is load-bearing (`field`, `modal`, `tabs`, `confirm_form`, `badge_state`). A new public route `/styleguide` is the canonical reference contributors and agents read before producing new UI.
|
||
|
||
**Tech Stack:** Plain CSS using modern features (`@layer`, `color-mix()`, custom properties). Jinja macros for the five composites. Flask blueprint for `/styleguide`. No Sass, no PostCSS, no bundler.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-05-17-stylesheet-redesign-design.md`
|
||
|
||
---
|
||
|
||
## File structure
|
||
|
||
| Action | Path | Contents |
|
||
|---|---|---|
|
||
| Create | `l4d2web/static/css/main.css` | Entry. Declares `@layer` order + `@import`s |
|
||
| Create | `l4d2web/static/css/reset.css` | Modern reset (~30 LOC) |
|
||
| Create | `l4d2web/static/css/tokens/primitives.css` | Raw palette: grays, blue, red/green/amber, spacing, type scale, radii, shadows, motion, fonts |
|
||
| Create | `l4d2web/static/css/tokens/semantic.css` | Aliases: `--color-*`, `--space-*`, plus `[data-theme="dark"]` branch |
|
||
| Create | `l4d2web/static/css/elements.css` | Bare HTML defaults: `body`, `h1-h6`, `a`, `code`, `kbd`, `pre`, `hr`, form elements, tables |
|
||
| Rewrite | `l4d2web/static/css/layout.css` | `.container`, `.stack`, `.stack-h`, section spacing |
|
||
| Create | `l4d2web/static/css/components/button.css` | `.btn` + variants/sizes + states |
|
||
| Create | `l4d2web/static/css/components/field.css` | `.field` + label / hint / error |
|
||
| Create | `l4d2web/static/css/components/table.css` | `.table` |
|
||
| Create | `l4d2web/static/css/components/panel.css` | `.panel` + heading / body / footer |
|
||
| Create | `l4d2web/static/css/components/modal.css` | `.modal` (styles `<dialog>`) + header / body / footer / close / `.modal-wide` |
|
||
| Create | `l4d2web/static/css/components/tabs.css` | `.tabs` + `.tab[aria-selected]` + `.tab-panel` |
|
||
| Create | `l4d2web/static/css/components/badge.css` | `.badge` + semantic + `.state-*` |
|
||
| Create | `l4d2web/static/css/components/nav.css` | `.site-header`, `.primary-nav`, `.account-nav`, `.brand` |
|
||
| Create | `l4d2web/static/css/components/dropdown.css` | `<select>` styling + custom dropdown helper |
|
||
| Move | `l4d2web/static/css/widgets/file-tree.css` | From extracted `components.css` |
|
||
| Move | `l4d2web/static/css/widgets/overlay-picker.css` | From extracted `components.css` |
|
||
| Move | `l4d2web/static/css/widgets/console-autocomplete.css` | From current root |
|
||
| Move | `l4d2web/static/css/widgets/editor.css` | From current root |
|
||
| Move | `l4d2web/static/css/widgets/logs.css` | From current root |
|
||
| Move | `l4d2web/static/css/widgets/live-state.css` | From extracted `components.css` (if present there) or pulled from `_live_state.html`'s scoped style |
|
||
| Create | `l4d2web/static/css/utilities.css` | `.muted`, `.mono`, `.truncate`, `.sr-only` |
|
||
| Create | `l4d2web/templates/ui/_field.html` | `ui.field`, `ui.checkbox`, `ui.select` |
|
||
| Create | `l4d2web/templates/ui/_modal.html` | `ui.modal` |
|
||
| Create | `l4d2web/templates/ui/_tabs.html` | `ui.tabs`, `ui.tab_panel` |
|
||
| Create | `l4d2web/templates/ui/_confirm_form.html` | `ui.confirm_form` |
|
||
| Create | `l4d2web/templates/ui/_badge.html` | `ui.badge_state` |
|
||
| Modify | `l4d2web/templates/base.html` | Swap 5 `<link>` tags for 1; add inline theme-init script |
|
||
| Create | `l4d2web/templates/styleguide.html` | Style guide page with every primitive + do/don't blocks |
|
||
| Create | `l4d2web/routes/styleguide_routes.py` | Public route `/styleguide` |
|
||
| Modify | `l4d2web/l4d2web/app.py` | Register styleguide blueprint; remove spike registration (commit 7) |
|
||
| Modify | `AGENTS.md` | Add "UI work" section codifying the workflow rule |
|
||
| Delete | `l4d2web/static/css/components.css` | (commit 7) |
|
||
| Delete | `l4d2web/static/css/tokens.css` | (commit 7) |
|
||
| Delete | `l4d2web/static/css/logs.css` (root) | (commit 7) — moved to `widgets/` |
|
||
| Delete | `l4d2web/static/css/console-autocomplete.css` (root) | (commit 7) — moved to `widgets/` |
|
||
| Delete | `l4d2web/static/css/editor.css` (root) | (commit 7) — moved to `widgets/` |
|
||
| Delete | `l4d2web/static/css/spike/` | (commit 7) — scaffolding |
|
||
| Delete | `l4d2web/templates/spike.html` | (commit 7) |
|
||
| Delete | `l4d2web/routes/spike_routes.py` | (commit 7) |
|
||
| Delete | `l4d2web/static/vendor/css/` | (commit 7) — only used by spike |
|
||
|
||
## Reference: spike artifacts
|
||
|
||
The pre-validated CSS lives in **`l4d2web/static/css/spike/custom.css`**. It is organized exactly by the `@layer` blocks the production stylesheet uses. Each task below extracts a layer (or part of a layer) into its production file.
|
||
|
||
Read the spike file as needed:
|
||
|
||
```bash
|
||
cat l4d2web/l4d2web/static/css/spike/custom.css | sed -n '/^@layer reset/,/^}/p'
|
||
```
|
||
|
||
Or open it in an editor and copy the `@layer X { … }` blocks one at a time.
|
||
|
||
The spike file's `@layer` declarations and `@layer X { … }` wrappers stay in the file when copied into the production files; the `@layer` wrapper around each block is what gives the production cascade its ordering.
|
||
|
||
The spike template `l4d2web/templates/spike.html` is the reference for the style-guide page's markup vocabulary (every widget appears there at least once).
|
||
|
||
---
|
||
|
||
## Task 1: Foundation — entry + reset + tokens
|
||
|
||
**Files:**
|
||
- Create: `l4d2web/static/css/main.css`
|
||
- Create: `l4d2web/static/css/reset.css`
|
||
- Create: `l4d2web/static/css/tokens/primitives.css`
|
||
- Create: `l4d2web/static/css/tokens/semantic.css`
|
||
|
||
Nothing else changes; `base.html` still loads the old CSS. The new files exist but are not yet referenced.
|
||
|
||
- [ ] **Step 1: Create `static/css/tokens/` directory**
|
||
|
||
```bash
|
||
mkdir -p l4d2web/l4d2web/static/css/tokens
|
||
```
|
||
|
||
- [ ] **Step 2: Write `main.css`**
|
||
|
||
`l4d2web/l4d2web/static/css/main.css`:
|
||
|
||
```css
|
||
/* Entry stylesheet. Declares the @layer order, then @imports each layer's
|
||
file(s). Cascade specificity is determined by the @layer order — never
|
||
by selector specificity or file order. */
|
||
|
||
@layer reset, tokens, elements, layout, components, widgets, utilities;
|
||
|
||
@import url("./reset.css") layer(reset);
|
||
@import url("./tokens/primitives.css") layer(tokens);
|
||
@import url("./tokens/semantic.css") layer(tokens);
|
||
@import url("./elements.css") layer(elements);
|
||
@import url("./layout.css") layer(layout);
|
||
|
||
@import url("./components/button.css") layer(components);
|
||
@import url("./components/field.css") layer(components);
|
||
@import url("./components/table.css") layer(components);
|
||
@import url("./components/panel.css") layer(components);
|
||
@import url("./components/modal.css") layer(components);
|
||
@import url("./components/tabs.css") layer(components);
|
||
@import url("./components/badge.css") layer(components);
|
||
@import url("./components/nav.css") layer(components);
|
||
@import url("./components/dropdown.css") layer(components);
|
||
|
||
@import url("./widgets/file-tree.css") layer(widgets);
|
||
@import url("./widgets/overlay-picker.css") layer(widgets);
|
||
@import url("./widgets/console-autocomplete.css") layer(widgets);
|
||
@import url("./widgets/editor.css") layer(widgets);
|
||
@import url("./widgets/logs.css") layer(widgets);
|
||
@import url("./widgets/live-state.css") layer(widgets);
|
||
|
||
@import url("./utilities.css") layer(utilities);
|
||
```
|
||
|
||
- [ ] **Step 3: Write `reset.css`**
|
||
|
||
`l4d2web/l4d2web/static/css/reset.css`:
|
||
|
||
```css
|
||
/* Modern reset. Mirrors the Andy-Bell / Josh-Comeau style. Intentional
|
||
choices: keep button looking like a button (no all:unset), preserve
|
||
focus rings via :focus-visible later in elements.css. */
|
||
|
||
*, *::before, *::after { box-sizing: border-box; }
|
||
body, h1, h2, h3, h4, p, figure, blockquote, dl, dd { margin: 0; }
|
||
ul[role="list"], ol[role="list"] { list-style: none; padding: 0; }
|
||
html { -webkit-text-size-adjust: 100%; }
|
||
body { min-height: 100vh; line-height: 1.5; -webkit-font-smoothing: antialiased; }
|
||
img, picture { max-width: 100%; display: block; }
|
||
input, button, textarea, select { font: inherit; color: inherit; }
|
||
p, li, figcaption { text-wrap: pretty; }
|
||
h1, h2, h3, h4 { text-wrap: balance; }
|
||
:target { scroll-margin-block: 5ex; }
|
||
```
|
||
|
||
- [ ] **Step 4: Write `tokens/primitives.css`**
|
||
|
||
Extract from `static/css/spike/custom.css` — the `:root { … }` block inside `@layer tokens` that contains the primitive palette (lines beginning `--gray-50:`, `--blue-200:`, `--red-200:`, etc., through the font stacks). The content is:
|
||
|
||
`l4d2web/l4d2web/static/css/tokens/primitives.css`:
|
||
|
||
```css
|
||
/* Tier 1 — primitives. Raw palette. No semantic meaning. Consumed only
|
||
by tokens/semantic.css, never by component CSS directly. */
|
||
|
||
:root {
|
||
/* grayscale */
|
||
--gray-50: #fafafa; --gray-100: #f4f4f5; --gray-200: #e4e4e7;
|
||
--gray-300: #d4d4d8; --gray-400: #a1a1aa; --gray-500: #71717a;
|
||
--gray-600: #52525b; --gray-700: #3f3f46; --gray-800: #27272a;
|
||
--gray-900: #18181b; --gray-950: #09090b;
|
||
|
||
/* brand blue */
|
||
--blue-200: #bfdbfe; --blue-300: #93c5fd; --blue-500: #3b82f6;
|
||
--blue-600: #2563eb; --blue-700: #1d4ed8; --blue-800: #1e40af;
|
||
|
||
/* state colors */
|
||
--red-200: #fecaca; --red-400: #fca5a5; --red-700: #b42318;
|
||
--green-300: #86efac; --green-700: #067647;
|
||
--amber-300: #fcd34d; --amber-700: #a15c07;
|
||
|
||
/* spacing */
|
||
--space-1: 0.25rem; --space-2: 0.5rem; --space-3: 0.75rem;
|
||
--space-4: 1rem; --space-5: 1.5rem; --space-6: 2rem;
|
||
--space-7: 3rem;
|
||
|
||
/* radii */
|
||
--radius-1: 0.25rem; --radius-2: 0.5rem; --radius-3: 0.75rem;
|
||
--radius-full: 9999px;
|
||
|
||
/* type scale */
|
||
--text-xs: 0.75rem; --text-sm: 0.875rem; --text-base: 1rem;
|
||
--text-lg: 1.125rem; --text-xl: 1.25rem; --text-2xl: 1.5rem;
|
||
|
||
/* line heights */
|
||
--leading-tight: 1.2;
|
||
--leading-normal: 1.5;
|
||
|
||
/* shadows */
|
||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.08);
|
||
--shadow-md: 0 4px 12px rgba(0,0,0,0.10);
|
||
--shadow-lg: 0 16px 32px rgba(0,0,0,0.18);
|
||
|
||
/* motion */
|
||
--duration-fast: 120ms;
|
||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||
|
||
/* fonts */
|
||
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Write `tokens/semantic.css`**
|
||
|
||
`l4d2web/l4d2web/static/css/tokens/semantic.css`:
|
||
|
||
```css
|
||
/* Tier 2 — semantic tokens. Aliases for use cases. Components consume
|
||
ONLY these (var(--color-*), var(--space-*), etc.) — never primitives
|
||
directly. Dark mode lives here: [data-theme="dark"] redefines the
|
||
semantic aliases to point at different primitives. */
|
||
|
||
:root,
|
||
:root[data-theme="light"] {
|
||
--color-bg: var(--gray-100);
|
||
--color-surface: #ffffff;
|
||
--color-surface-2: var(--gray-50);
|
||
--color-text: var(--gray-900);
|
||
--color-text-strong: var(--gray-950);
|
||
--color-muted: var(--gray-500);
|
||
--color-border: var(--gray-300);
|
||
--color-border-soft: var(--gray-200);
|
||
|
||
--color-link: var(--blue-700);
|
||
--color-primary: var(--blue-700);
|
||
--color-primary-hover: var(--blue-800);
|
||
--color-on-primary: #ffffff;
|
||
|
||
--color-danger: var(--red-700);
|
||
--color-on-danger: #ffffff;
|
||
--color-warning: var(--amber-700);
|
||
--color-success: var(--green-700);
|
||
--color-focus: var(--blue-600);
|
||
|
||
--ring: 0 0 0 3px color-mix(in srgb, var(--color-focus) 30%, transparent);
|
||
|
||
color-scheme: light;
|
||
}
|
||
|
||
:root[data-theme="dark"] {
|
||
--color-bg: var(--gray-900);
|
||
--color-surface: var(--gray-800);
|
||
--color-surface-2: var(--gray-700);
|
||
--color-text: var(--gray-100);
|
||
--color-text-strong: #ffffff;
|
||
--color-muted: var(--gray-400);
|
||
--color-border: var(--gray-700);
|
||
--color-border-soft: var(--gray-700);
|
||
|
||
--color-link: var(--blue-300);
|
||
--color-primary: var(--blue-300);
|
||
--color-primary-hover: var(--blue-200);
|
||
--color-on-primary: var(--gray-950);
|
||
|
||
--color-danger: var(--red-400);
|
||
--color-on-danger: var(--gray-950);
|
||
--color-warning: var(--amber-300);
|
||
--color-success: var(--green-300);
|
||
--color-focus: var(--blue-200);
|
||
|
||
color-scheme: dark;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Verify the files exist and are syntactically valid CSS**
|
||
|
||
```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`
|
||
|
||
(There is no CSS linter in this project. Syntactic validity is verified visually in Task 2 when `main.css` is loaded by the browser and DevTools would report parse errors.)
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```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, two-tier tokens"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Elements + layout + base.html switch
|
||
|
||
**Files:**
|
||
- Create: `l4d2web/static/css/elements.css`
|
||
- Rewrite: `l4d2web/static/css/layout.css`
|
||
- Modify: `l4d2web/templates/base.html` — collapse 5 `<link>` tags into 1, add inline theme-init script
|
||
|
||
After this commit the site loads the new system. Pages will look bare in places where component classes aren't yet styled. That is intentional and visible.
|
||
|
||
- [ ] **Step 1: Write `elements.css`**
|
||
|
||
`l4d2web/l4d2web/static/css/elements.css`:
|
||
|
||
```css
|
||
/* Bare HTML defaults. Consumes semantic tokens only. Anything that styles
|
||
a tag selector lives here; class selectors live in components/. */
|
||
|
||
html, body {
|
||
font-family: var(--font-sans);
|
||
font-size: var(--text-base);
|
||
line-height: var(--leading-normal);
|
||
background: var(--color-bg);
|
||
color: var(--color-text);
|
||
}
|
||
h1 { font-size: var(--text-2xl); line-height: var(--leading-tight); font-weight: 700; }
|
||
h2 { font-size: var(--text-xl); line-height: var(--leading-tight); font-weight: 600; }
|
||
h3 { font-size: var(--text-lg); line-height: var(--leading-tight); font-weight: 600; }
|
||
h4 { font-size: var(--text-base);line-height: var(--leading-tight); font-weight: 600; }
|
||
p { margin: 0; }
|
||
a { color: var(--color-link); text-decoration: underline; text-underline-offset: 2px; }
|
||
a:hover { text-decoration-thickness: 2px; }
|
||
code, kbd, samp, pre { font-family: var(--font-mono); font-size: 0.95em; }
|
||
kbd {
|
||
background: var(--color-surface-2); border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-1); padding: 0.05rem 0.35rem; font-size: var(--text-xs);
|
||
}
|
||
pre {
|
||
background: var(--color-surface-2); border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-1); padding: var(--space-3); overflow-x: auto;
|
||
font-size: var(--text-sm);
|
||
}
|
||
hr { border: 0; border-top: 1px solid var(--color-border); margin-block: var(--space-4); }
|
||
|
||
:focus-visible { outline: none; box-shadow: var(--ring); }
|
||
|
||
input, select, textarea {
|
||
background: var(--color-surface); color: var(--color-text);
|
||
border: 1px solid var(--color-border); border-radius: var(--radius-1);
|
||
padding: 0.45rem 0.6rem; font-size: var(--text-base);
|
||
transition: border-color var(--duration-fast) var(--ease-out),
|
||
box-shadow var(--duration-fast) var(--ease-out);
|
||
}
|
||
input:focus, select:focus, textarea:focus { border-color: var(--color-focus); }
|
||
textarea { min-height: 4em; resize: vertical; }
|
||
input[type="checkbox"], input[type="radio"] {
|
||
padding: 0; width: 1rem; height: 1rem; vertical-align: middle;
|
||
accent-color: var(--color-primary);
|
||
}
|
||
fieldset { border: 1px solid var(--color-border); border-radius: var(--radius-1); padding: var(--space-2) var(--space-3); }
|
||
legend { padding-inline: var(--space-1); font-weight: 600; }
|
||
|
||
button { background: none; border: 0; cursor: pointer; padding: 0; }
|
||
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th, td { padding: var(--space-2) var(--space-3); border-bottom: 1px solid var(--color-border-soft); text-align: left; vertical-align: top; }
|
||
th { background: var(--color-surface-2); font-weight: 600; font-size: var(--text-sm); }
|
||
```
|
||
|
||
- [ ] **Step 2: Rewrite `layout.css`**
|
||
|
||
Overwrite `l4d2web/l4d2web/static/css/layout.css` with:
|
||
|
||
```css
|
||
/* Composition objects. Don't paint, only arrange. */
|
||
|
||
.container { max-width: 72rem; margin-inline: auto; padding-inline: var(--space-4); }
|
||
.stack > * + * { margin-top: var(--space-3); }
|
||
.stack-h { display: flex; gap: var(--space-3); align-items: center; }
|
||
|
||
main > section { margin-block: var(--space-6); }
|
||
main > section > h2:first-child {
|
||
margin-bottom: var(--space-3);
|
||
padding-bottom: var(--space-1);
|
||
border-bottom: 1px solid var(--color-border-soft);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Update `base.html`**
|
||
|
||
In `l4d2web/l4d2web/templates/base.html`, replace the five separate `<link>` tags with a single `<link>` for `main.css`, and add an inline theme-init script that reads `prefers-color-scheme` before paint (uses the existing `g.csp_nonce` for CSP compliance).
|
||
|
||
Edit `l4d2web/l4d2web/templates/base.html`:
|
||
|
||
Old `<head>` block (the five stylesheet links):
|
||
|
||
```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:
|
||
|
||
```html
|
||
<script nonce="{{ g.csp_nonce }}">
|
||
// Set data-theme before paint so the first frame matches the user's OS preference.
|
||
// Future user-preference override (localStorage) plugs in here.
|
||
(function () {
|
||
try {
|
||
var dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
|
||
} catch (e) {}
|
||
})();
|
||
</script>
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
|
||
```
|
||
|
||
- [ ] **Step 4: Verify the dev server still boots and `/dashboard` returns 200**
|
||
|
||
If the dev server isn't running, start it: `LEFT4ME_SPIKE=1 scripts/dev-server.py --port 5051`
|
||
|
||
```bash
|
||
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:5051/dashboard
|
||
```
|
||
|
||
Expected: `302` (redirect to login) or `200`. NOT `500`.
|
||
|
||
If the dev server reloaded the changes, also fetch `/static/css/main.css` to confirm it serves:
|
||
|
||
```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 `/dashboard` (or `/login`) in a browser, confirm typography and base elements render**
|
||
|
||
The page will look bare — no buttons styled yet, no panels, no tables. That is correct for this checkpoint. Verify:
|
||
- Body bg is light gray (`--gray-100`)
|
||
- Typography uses system sans
|
||
- Links are blue and underlined
|
||
- Dark mode toggled via DevTools (`document.documentElement.dataset.theme = 'dark'`) flips the page correctly
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```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 loads main.css"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Core components — button, panel, table, badge, nav
|
||
|
||
**Files:**
|
||
- Create: `l4d2web/static/css/components/button.css`
|
||
- Create: `l4d2web/static/css/components/panel.css`
|
||
- Create: `l4d2web/static/css/components/table.css`
|
||
- Create: `l4d2web/static/css/components/badge.css`
|
||
- Create: `l4d2web/static/css/components/nav.css`
|
||
|
||
Half the surface starts looking right after this commit. The contents below are extracted from `static/css/spike/custom.css`'s `@layer components { … }` block, with the spike-only `.spike-toolbar` rule excluded.
|
||
|
||
- [ ] **Step 1: Create components directory**
|
||
|
||
```bash
|
||
mkdir -p l4d2web/l4d2web/static/css/components
|
||
```
|
||
|
||
- [ ] **Step 2: Write `components/button.css`**
|
||
|
||
`l4d2web/l4d2web/static/css/components/button.css`:
|
||
|
||
```css
|
||
.btn {
|
||
display: inline-flex; align-items: center; gap: var(--space-1);
|
||
padding: 0.45rem 0.9rem;
|
||
border: 1px solid transparent; border-radius: var(--radius-1);
|
||
font-size: var(--text-base); line-height: 1.2;
|
||
cursor: pointer; text-decoration: none; user-select: none;
|
||
transition: background var(--duration-fast) var(--ease-out),
|
||
border-color var(--duration-fast) var(--ease-out),
|
||
color var(--duration-fast) var(--ease-out);
|
||
}
|
||
.btn-primary {
|
||
background: var(--color-primary); border-color: var(--color-primary);
|
||
color: var(--color-on-primary);
|
||
}
|
||
.btn-primary:hover:not([disabled]) {
|
||
background: var(--color-primary-hover); border-color: var(--color-primary-hover);
|
||
}
|
||
.btn-secondary {
|
||
background: var(--color-surface); border-color: var(--color-border);
|
||
color: var(--color-text);
|
||
}
|
||
.btn-secondary:hover:not([disabled]) {
|
||
background: var(--color-surface-2);
|
||
}
|
||
.btn-danger {
|
||
background: var(--color-danger); border-color: var(--color-danger);
|
||
color: var(--color-on-danger);
|
||
}
|
||
.btn-outline {
|
||
background: transparent; border-color: var(--color-primary);
|
||
color: var(--color-primary);
|
||
}
|
||
.btn-outline:hover:not([disabled]) {
|
||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||
}
|
||
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
|
||
.btn[aria-busy="true"]::before { content: "⟳"; margin-right: var(--space-1); animation: btn-spin 1s linear infinite; }
|
||
@keyframes btn-spin { to { transform: rotate(360deg); } }
|
||
.btn-sm { padding: 0.25rem 0.6rem; font-size: var(--text-sm); }
|
||
|
||
.button-row { display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center; }
|
||
|
||
.link-button {
|
||
background: none; border: 0; padding: 0;
|
||
color: var(--color-link); text-decoration: underline; cursor: pointer;
|
||
font: inherit;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Write `components/panel.css`**
|
||
|
||
`l4d2web/l4d2web/static/css/components/panel.css`:
|
||
|
||
```css
|
||
.panel {
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-2);
|
||
background: var(--color-surface);
|
||
box-shadow: var(--shadow-sm);
|
||
overflow: hidden;
|
||
}
|
||
.panel-heading { border-bottom: 1px solid var(--color-border-soft); padding: var(--space-3) var(--space-4); }
|
||
.panel-heading h3 { margin: 0; }
|
||
.panel-body { padding: var(--space-3) var(--space-4); }
|
||
.panel-footer { border-top: 1px solid var(--color-border-soft); padding: var(--space-3) var(--space-4); background: var(--color-surface-2); }
|
||
```
|
||
|
||
- [ ] **Step 4: Write `components/table.css`**
|
||
|
||
`l4d2web/l4d2web/static/css/components/table.css`:
|
||
|
||
```css
|
||
/* Table element styling is in elements.css; this layer only adds the
|
||
.table class hook for project-specific borders/striping. */
|
||
|
||
.table {
|
||
border: 1px solid var(--color-border-soft);
|
||
border-radius: var(--radius-1);
|
||
overflow: hidden;
|
||
}
|
||
.table-striped tbody tr:nth-child(odd) td {
|
||
background: color-mix(in srgb, var(--color-text) 3%, var(--color-surface));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Write `components/badge.css`**
|
||
|
||
`l4d2web/l4d2web/static/css/components/badge.css`:
|
||
|
||
```css
|
||
.badge {
|
||
display: inline-block;
|
||
padding: 0.1rem 0.55rem;
|
||
font-size: var(--text-xs); line-height: 1.4;
|
||
border-radius: var(--radius-full);
|
||
border: 1px solid var(--color-border);
|
||
background: var(--color-surface); color: var(--color-text);
|
||
font-weight: 500;
|
||
}
|
||
.badge-success { background: color-mix(in srgb, var(--color-success) 16%, var(--color-surface)); border-color: color-mix(in srgb, var(--color-success) 40%, var(--color-border)); color: var(--color-success); }
|
||
.badge-warning { background: color-mix(in srgb, var(--color-warning) 16%, var(--color-surface)); border-color: color-mix(in srgb, var(--color-warning) 40%, var(--color-border)); color: var(--color-warning); }
|
||
.badge-danger { background: color-mix(in srgb, var(--color-danger) 16%, var(--color-surface)); border-color: color-mix(in srgb, var(--color-danger) 40%, var(--color-border)); color: var(--color-danger); }
|
||
.badge-muted { color: var(--color-muted); }
|
||
|
||
/* Server-lifecycle state pills */
|
||
.state-running { background: color-mix(in srgb, var(--color-success) 16%, var(--color-surface)); border-color: color-mix(in srgb, var(--color-success) 40%, var(--color-border)); color: var(--color-success); }
|
||
.state-stopped { background: color-mix(in srgb, var(--color-muted) 16%, var(--color-surface)); border-color: color-mix(in srgb, var(--color-muted) 40%, var(--color-border)); color: var(--color-muted); }
|
||
.state-unknown { color: var(--color-muted); }
|
||
.state-transient { background: color-mix(in srgb, var(--color-warning) 16%, var(--color-surface)); border-color: color-mix(in srgb, var(--color-warning) 40%, var(--color-border)); color: var(--color-warning); }
|
||
.state-drift { background: color-mix(in srgb, var(--color-danger) 16%, var(--color-surface)); border-color: color-mix(in srgb, var(--color-danger) 40%, var(--color-border)); color: var(--color-danger); }
|
||
```
|
||
|
||
- [ ] **Step 6: Write `components/nav.css`**
|
||
|
||
`l4d2web/l4d2web/static/css/components/nav.css`:
|
||
|
||
```css
|
||
.site-header {
|
||
background: var(--color-surface);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-2);
|
||
padding: var(--space-2) var(--space-3);
|
||
margin-block: var(--space-3);
|
||
}
|
||
.site-header-inner { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); }
|
||
.primary-nav, .account-nav { display: flex; gap: var(--space-3); align-items: center; }
|
||
.primary-nav .brand { font-weight: 700; }
|
||
.inline-form { display: inline; margin: 0; }
|
||
```
|
||
|
||
- [ ] **Step 7: Verify in browser**
|
||
|
||
Open `/dashboard` or `/servers`. Buttons should be styled, the site header should look like a bordered card with the brand left-aligned and account links right-aligned, state pills in the server list should be colored.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add l4d2web/l4d2web/static/css/components/button.css \
|
||
l4d2web/l4d2web/static/css/components/panel.css \
|
||
l4d2web/l4d2web/static/css/components/table.css \
|
||
l4d2web/l4d2web/static/css/components/badge.css \
|
||
l4d2web/l4d2web/static/css/components/nav.css
|
||
git commit -m "feat(stylesheet): core components — button, panel, table, badge, nav"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Composite components — modal, tabs, field, dropdown
|
||
|
||
**Files:**
|
||
- Create: `l4d2web/static/css/components/modal.css`
|
||
- Create: `l4d2web/static/css/components/tabs.css`
|
||
- Create: `l4d2web/static/css/components/field.css`
|
||
- Create: `l4d2web/static/css/components/dropdown.css`
|
||
|
||
- [ ] **Step 1: Write `components/modal.css`**
|
||
|
||
`l4d2web/l4d2web/static/css/components/modal.css`:
|
||
|
||
```css
|
||
.modal {
|
||
border: 1px solid var(--color-border); border-radius: var(--radius-2);
|
||
background: var(--color-surface); color: var(--color-text);
|
||
box-shadow: var(--shadow-lg);
|
||
padding: 0;
|
||
max-width: min(420px, 92vw);
|
||
}
|
||
.modal-wide { max-width: min(720px, 95vw); }
|
||
|
||
.modal::backdrop {
|
||
background: rgba(0,0,0,0.45);
|
||
backdrop-filter: blur(2px);
|
||
}
|
||
|
||
/* Inner article (used by current _modal_partial pattern) shouldn't add extra chrome */
|
||
.modal > article { padding: 0; margin: 0; background: transparent; border: 0; box-shadow: none; }
|
||
|
||
.modal-header {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
padding: var(--space-3) var(--space-4);
|
||
border-bottom: 1px solid var(--color-border-soft);
|
||
}
|
||
.modal-header h2 { margin: 0; font-size: var(--text-lg); }
|
||
.modal-body { padding: var(--space-3) var(--space-4); }
|
||
.modal-footer { padding: var(--space-3) var(--space-4); border-top: 1px solid var(--color-border-soft); }
|
||
|
||
.modal-close {
|
||
background: none; border: 0; font-size: 1.25rem;
|
||
color: var(--color-muted); cursor: pointer; padding: 0 var(--space-1);
|
||
}
|
||
.modal-close:hover { color: var(--color-text); }
|
||
```
|
||
|
||
- [ ] **Step 2: Write `components/tabs.css`**
|
||
|
||
`l4d2web/l4d2web/static/css/components/tabs.css`:
|
||
|
||
```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); }
|
||
|
||
input[aria-invalid="true"],
|
||
select[aria-invalid="true"],
|
||
textarea[aria-invalid="true"] {
|
||
border-color: var(--color-danger);
|
||
}
|
||
|
||
.inline-save { display: flex; gap: var(--space-2); align-items: stretch; }
|
||
.inline-save > input { margin: 0; }
|
||
```
|
||
|
||
- [ ] **Step 4: Write `components/dropdown.css`**
|
||
|
||
`l4d2web/l4d2web/static/css/components/dropdown.css`:
|
||
|
||
```css
|
||
/* Native <select> styling is already done in elements.css; this layer
|
||
exists as a hook for any custom dropdown markup we add later. Keep
|
||
minimal until a real custom-dropdown widget is needed. */
|
||
|
||
.dropdown { position: relative; display: inline-block; }
|
||
.dropdown > select { width: 100%; }
|
||
```
|
||
|
||
- [ ] **Step 5: Verify in browser**
|
||
|
||
Open a page with a modal (e.g. `/servers/1` then click any action that opens one). Open `server_detail.html` and click between the tab buttons — the active one should show a primary-colored underline. Fields in forms (e.g. profile page) should display label / input / hint stacking cleanly.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add l4d2web/l4d2web/static/css/components/modal.css \
|
||
l4d2web/l4d2web/static/css/components/tabs.css \
|
||
l4d2web/l4d2web/static/css/components/field.css \
|
||
l4d2web/l4d2web/static/css/components/dropdown.css
|
||
git commit -m "feat(stylesheet): composite components — modal, tabs, field, dropdown"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Macros — five high-leverage primitives
|
||
|
||
**Files:**
|
||
- Create: `l4d2web/templates/ui/_field.html`
|
||
- Create: `l4d2web/templates/ui/_modal.html`
|
||
- Create: `l4d2web/templates/ui/_tabs.html`
|
||
- Create: `l4d2web/templates/ui/_confirm_form.html`
|
||
- Create: `l4d2web/templates/ui/_badge.html`
|
||
|
||
These are import-only files. No template uses them yet — adoption happens organically as templates are touched. This commit only adds the API surface.
|
||
|
||
- [ ] **Step 1: Create `templates/ui/` directory**
|
||
|
||
```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.
|
||
|
||
Usage:
|
||
{% from "ui/_field.html" import field, checkbox, select %}
|
||
|
||
{{ field(name="hostname", label="Hostname", value=server.hostname,
|
||
hint="Used in master-server listings.") }}
|
||
|
||
{{ field(name="port", label="Port", type="number", value=server.port,
|
||
error="Must be between 27015 and 27115." if invalid_port else None) }}
|
||
#}
|
||
|
||
{% macro field(name, label, value="", type="text", hint=None, error=None, required=False, placeholder=None, autofocus=False, id=None) %}
|
||
{%- set fid = id or ("f-" ~ name) -%}
|
||
{%- set descby = [] -%}
|
||
{%- if hint %}{%- set _ = descby.append(fid ~ "-hint") %}{%- endif -%}
|
||
{%- if error %}{%- set _ = descby.append(fid ~ "-error") %}{%- endif -%}
|
||
<div class="field">
|
||
<label class="field-label" for="{{ fid }}">{{ label }}</label>
|
||
<input id="{{ fid }}" name="{{ name }}" type="{{ type }}"
|
||
value="{{ value }}"
|
||
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
|
||
{% if required %}required{% endif %}
|
||
{% if autofocus %}autofocus{% endif %}
|
||
{% if error %}aria-invalid="true"{% endif %}
|
||
{% if descby %}aria-describedby="{{ descby|join(' ') }}"{% endif %}>
|
||
{% if hint %}<p id="{{ fid }}-hint" class="field-hint">{{ hint }}</p>{% endif %}
|
||
{% if error %}<p id="{{ fid }}-error" class="field-error">{{ error }}</p>{% endif %}
|
||
</div>
|
||
{% endmacro %}
|
||
|
||
{% macro checkbox(name, label, checked=False, disabled=False, id=None) %}
|
||
{%- set fid = id or ("c-" ~ name) -%}
|
||
<label class="field-checkbox" for="{{ fid }}">
|
||
<input id="{{ fid }}" name="{{ name }}" type="checkbox"
|
||
{% if checked %}checked{% endif %}
|
||
{% if disabled %}disabled{% endif %}>
|
||
{{ label }}
|
||
</label>
|
||
{% endmacro %}
|
||
|
||
{% macro select(name, label, options, value="", hint=None, id=None) %}
|
||
{#- options: list of {value, label} dicts, OR list of strings -#}
|
||
{%- set fid = id or ("s-" ~ name) -%}
|
||
<div class="field">
|
||
<label class="field-label" for="{{ fid }}">{{ label }}</label>
|
||
<select id="{{ fid }}" name="{{ name }}"
|
||
{% if hint %}aria-describedby="{{ fid }}-hint"{% endif %}>
|
||
{% for opt in options %}
|
||
{%- if opt is mapping -%}
|
||
<option value="{{ opt.value }}" {% if opt.value == value %}selected{% endif %}>{{ opt.label }}</option>
|
||
{%- else -%}
|
||
<option value="{{ opt }}" {% if opt == value %}selected{% endif %}>{{ opt }}</option>
|
||
{%- endif -%}
|
||
{% endfor %}
|
||
</select>
|
||
{% if hint %}<p id="{{ fid }}-hint" class="field-hint">{{ hint }}</p>{% endif %}
|
||
</div>
|
||
{% endmacro %}
|
||
```
|
||
|
||
- [ ] **Step 3: Write `ui/_modal.html`**
|
||
|
||
`l4d2web/l4d2web/templates/ui/_modal.html`:
|
||
|
||
```jinja
|
||
{# Modal dialog. Wraps <dialog> with header / body / footer slots and the
|
||
close-button wiring. The existing modals.js machinery is unchanged.
|
||
|
||
Usage:
|
||
{% from "ui/_modal.html" import modal %}
|
||
|
||
{% call modal(id="confirm-stop", title="Stop server?") %}
|
||
<p>This will disconnect all players.</p>
|
||
<div class="button-row">
|
||
<button type="button" class="btn btn-secondary" data-inline-modal-close>Cancel</button>
|
||
<button type="submit" class="btn btn-danger">Stop server</button>
|
||
</div>
|
||
{% endcall %}
|
||
#}
|
||
|
||
{% macro modal(id, title, wide=False) %}
|
||
<dialog id="{{ id }}" class="modal{% if wide %} modal-wide{% endif %}">
|
||
<article>
|
||
<header class="modal-header">
|
||
<h2>{{ title }}</h2>
|
||
<button class="modal-close" type="button" aria-label="Close" data-inline-modal-close>×</button>
|
||
</header>
|
||
<div class="modal-body">
|
||
{{ caller() }}
|
||
</div>
|
||
</article>
|
||
</dialog>
|
||
{% endmacro %}
|
||
```
|
||
|
||
- [ ] **Step 4: Write `ui/_tabs.html`**
|
||
|
||
`l4d2web/l4d2web/templates/ui/_tabs.html`:
|
||
|
||
```jinja
|
||
{# Tab bar + tabpanels. Owns role="tablist" / role="tab" / role="tabpanel"
|
||
wiring and aria-controls / aria-selected linkage. The existing tabs.js
|
||
handles click-to-switch via these ARIA attributes.
|
||
|
||
Usage:
|
||
{% from "ui/_tabs.html" import tabs, tab_panel %}
|
||
|
||
{{ tabs([
|
||
{"id": "overview", "label": "Overview", "selected": True},
|
||
{"id": "console", "label": "Console"},
|
||
{"id": "files", "label": "Files"},
|
||
]) }}
|
||
|
||
{% call tab_panel("overview", selected=True) %} ... {% endcall %}
|
||
{% call tab_panel("console") %} ... {% endcall %}
|
||
{% call tab_panel("files") %} ... {% endcall %}
|
||
#}
|
||
|
||
{% macro tabs(items) %}
|
||
<div class="tabs" role="tablist">
|
||
{% for it in items %}
|
||
<button class="tab" role="tab"
|
||
id="tab-{{ it.id }}"
|
||
aria-selected="{{ 'true' if it.selected else 'false' }}"
|
||
aria-controls="panel-{{ it.id }}"
|
||
{% if not it.selected %}tabindex="-1"{% endif %}>
|
||
{{ it.label }}
|
||
</button>
|
||
{% endfor %}
|
||
</div>
|
||
{% endmacro %}
|
||
|
||
{% macro tab_panel(id, selected=False) %}
|
||
<div id="panel-{{ id }}" role="tabpanel"
|
||
aria-labelledby="tab-{{ id }}"
|
||
class="tab-panel"
|
||
{% if not selected %}hidden{% endif %}>
|
||
{{ caller() }}
|
||
</div>
|
||
{% endmacro %}
|
||
```
|
||
|
||
- [ ] **Step 5: Write `ui/_confirm_form.html`**
|
||
|
||
`l4d2web/l4d2web/templates/ui/_confirm_form.html`:
|
||
|
||
```jinja
|
||
{# Confirm-and-submit form. CSRF token + button row + POST action.
|
||
|
||
Usage:
|
||
{% from "ui/_confirm_form.html" import confirm_form %}
|
||
|
||
{{ confirm_form(action="/servers/1/delete",
|
||
submit_label="Delete server",
|
||
submit_variant="danger",
|
||
cancel_label="Cancel") }}
|
||
#}
|
||
|
||
{% macro confirm_form(action, submit_label="Confirm", submit_variant="primary", cancel_label="Cancel", cancel_attrs="data-inline-modal-close") %}
|
||
<form method="post" action="{{ action }}">
|
||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||
<div class="button-row">
|
||
<button type="button" class="btn btn-secondary" {{ cancel_attrs|safe }}>{{ cancel_label }}</button>
|
||
<button type="submit" class="btn btn-{{ submit_variant }}">{{ submit_label }}</button>
|
||
</div>
|
||
</form>
|
||
{% endmacro %}
|
||
```
|
||
|
||
- [ ] **Step 6: Write `ui/_badge.html`**
|
||
|
||
`l4d2web/l4d2web/templates/ui/_badge.html`:
|
||
|
||
```jinja
|
||
{# Badges and state pills. badge_state encapsulates the state-string →
|
||
variant-class mapping that's open-coded today.
|
||
|
||
Usage:
|
||
{% from "ui/_badge.html" import badge, badge_state %}
|
||
|
||
{{ badge_state(server.actual_state) }}
|
||
{{ badge("ok", variant="success") }}
|
||
#}
|
||
|
||
{% macro badge(label, variant="muted") %}
|
||
<span class="badge badge-{{ variant }}">{{ label }}</span>
|
||
{% endmacro %}
|
||
|
||
{% macro badge_state(state) %}
|
||
{#- Canonical mapping of server-lifecycle state strings to .state-* classes.
|
||
Keep in sync with components/badge.css. -#}
|
||
{%- set known = {
|
||
"running": "state-running",
|
||
"stopped": "state-stopped",
|
||
"unknown": "state-unknown",
|
||
"starting": "state-transient",
|
||
"stopping": "state-transient",
|
||
"resetting": "state-transient",
|
||
"initializing": "state-transient",
|
||
"deleting": "state-transient",
|
||
"drift": "state-drift",
|
||
} -%}
|
||
{%- set cls = known.get(state, "state-unknown") -%}
|
||
<span class="badge {{ cls }}">{{ state or "unknown" }}</span>
|
||
{% endmacro %}
|
||
```
|
||
|
||
- [ ] **Step 7: Add a pytest unit test for the field macro's a11y wiring**
|
||
|
||
`l4d2web/tests/test_ui_macros.py`:
|
||
|
||
```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_badge_state_maps_known_state(jinja_env):
|
||
tmpl = jinja_env.from_string(
|
||
'{% from "ui/_badge.html" import badge_state %}'
|
||
'{{ badge_state("running") }}'
|
||
)
|
||
html = tmpl.render()
|
||
assert 'class="badge state-running"' in html
|
||
assert ">running<" in html
|
||
|
||
|
||
def test_badge_state_falls_back_to_unknown(jinja_env):
|
||
tmpl = jinja_env.from_string(
|
||
'{% from "ui/_badge.html" import badge_state %}'
|
||
'{{ badge_state("weird-new-state") }}'
|
||
)
|
||
html = tmpl.render()
|
||
assert 'class="badge state-unknown"' in html
|
||
|
||
|
||
def test_badge_state_none_renders_unknown_label(jinja_env):
|
||
tmpl = jinja_env.from_string(
|
||
'{% from "ui/_badge.html" import badge_state %}'
|
||
'{{ badge_state(None) }}'
|
||
)
|
||
html = tmpl.render()
|
||
assert "unknown" in html
|
||
```
|
||
|
||
- [ ] **Step 8: Run the macro tests, verify they pass**
|
||
|
||
```bash
|
||
cd l4d2web && uv run pytest tests/test_ui_macros.py -v
|
||
```
|
||
|
||
Expected: 6 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, modal, tabs, confirm_form, badge_state"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Project widgets relocation
|
||
|
||
**Files:**
|
||
- Create: `l4d2web/static/css/widgets/file-tree.css` (extracted from current `components.css`)
|
||
- Create: `l4d2web/static/css/widgets/overlay-picker.css` (extracted from current `components.css`)
|
||
- Move: `l4d2web/static/css/console-autocomplete.css` → `widgets/console-autocomplete.css`
|
||
- Move: `l4d2web/static/css/editor.css` → `widgets/editor.css`
|
||
- Move: `l4d2web/static/css/logs.css` → `widgets/logs.css`
|
||
- Create: `l4d2web/static/css/widgets/live-state.css` (extracted from current `components.css` if `.live-state-*` rules exist there; otherwise leave as empty placeholder with a comment that the live-state component currently has no scoped CSS)
|
||
|
||
Each widget file is moved/extracted **content-preserving** — same rules, same selectors — but token names must be migrated. The old `tokens.css` and new `tokens/semantic.css` agree on most color names but differ on spacing, radii, and a few colors. Use this table when copying.
|
||
|
||
**Token migration table (apply to every line copied from the old files):**
|
||
|
||
| Old name | New name | Notes |
|
||
|---|---|---|
|
||
| `--color-bg`, `--color-text`, `--color-muted`, `--color-border`, `--color-link`, `--color-primary`, `--color-danger`, `--color-warning`, `--color-success`, `--color-focus`, `--color-surface` | (same) | Unchanged. |
|
||
| `--color-surface-muted` | `--color-surface-2` | |
|
||
| `--color-border-muted` | `--color-border-soft` | |
|
||
| `--color-button-primary` | `--color-primary` | |
|
||
| `--color-button-danger` | `--color-danger` | |
|
||
| `--color-log-bg` | `--color-surface-2` | |
|
||
| `--color-log-text` | `--color-text` | |
|
||
| `--space-xs` | `--space-1` | 0.25rem |
|
||
| `--space-s` | `--space-2` | 0.5rem |
|
||
| `--space-m` | `--space-3` | 0.75rem |
|
||
| `--space-l` | `--space-4` | 1rem |
|
||
| `--space-xl` | `--space-5` | 1.5rem (was 1.5rem) |
|
||
| `--space-2xl` | `--space-6` | 2rem |
|
||
| `--radius-base`, `--radius-s` | `--radius-1` | 0.25rem |
|
||
| `--radius-m` | `--radius-2` | 0.5rem |
|
||
| `--line` | `1px solid var(--color-border)` | Inline expansion — `--line` is no longer in the system. |
|
||
| `--line-soft` | `1px solid var(--color-border-soft)` | Inline expansion. |
|
||
| `--font-mono` | (same) | Unchanged. |
|
||
|
||
CodeMirror editor tokens (`--cm-*`, `--syntax-*`, `--editor-rows`) live with the editor widget. Step 5 below relocates them.
|
||
|
||
- [ ] **Step 1: Create widgets directory**
|
||
|
||
```bash
|
||
mkdir -p l4d2web/l4d2web/static/css/widgets
|
||
```
|
||
|
||
- [ ] **Step 2: Extract file-tree rules from `components.css` into `widgets/file-tree.css`**
|
||
|
||
Read `l4d2web/l4d2web/static/css/components.css` and identify every selector beginning with `.file-tree` (including descendants like `.file-tree-row`, `.file-tree-toggle`, `.file-tree-children`, `.file-tree-badge`, `.file-tree-row-truncated`, etc.). Copy those rules verbatim into `l4d2web/l4d2web/static/css/widgets/file-tree.css`.
|
||
|
||
Rename any old token references to the new names where needed. The existing token names in `tokens.css` are largely identical to the new semantic tokens; spot-check after copying.
|
||
|
||
- [ ] **Step 3: Extract overlay-picker rules from `components.css` into `widgets/overlay-picker.css`**
|
||
|
||
Same process for `.overlay-picker*` selectors.
|
||
|
||
- [ ] **Step 4: Move root-level widget files into `widgets/`**
|
||
|
||
```bash
|
||
git mv l4d2web/l4d2web/static/css/console-autocomplete.css \
|
||
l4d2web/l4d2web/static/css/widgets/console-autocomplete.css
|
||
git mv l4d2web/l4d2web/static/css/editor.css \
|
||
l4d2web/l4d2web/static/css/widgets/editor.css
|
||
git mv l4d2web/l4d2web/static/css/logs.css \
|
||
l4d2web/l4d2web/static/css/widgets/logs.css
|
||
```
|
||
|
||
- [ ] **Step 4b: Relocate CodeMirror tokens into `widgets/editor.css`**
|
||
|
||
The current `tokens.css` defines CodeMirror palette tokens (`--cm-bg`, `--cm-fg`, `--cm-keyword`, `--cm-string`, `--cm-comment`, `--cm-number`, `--cm-selection`, plus the `--syntax-*` aliases and `--editor-rows`) at the `:root` level so they're globally available. After this redesign they are widget-scoped — move them into `widgets/editor.css`, scoped to the editor's container if possible (`.cm-editor` or the wrapper used by `editor.bundle.js`); fall back to `:root` if scoping breaks the editor.
|
||
|
||
Prepend the following block to `widgets/editor.css` (above the existing editor rules, which were just `git mv`d from the root-level file):
|
||
|
||
```css
|
||
/* CodeMirror palette tokens. Were at :root in the old tokens.css; now
|
||
live with the widget that consumes them. Light + dark in one place. */
|
||
|
||
:root {
|
||
--syntax-keyword: #cc4488;
|
||
--syntax-string: #2f8b3a;
|
||
--syntax-comment: #888;
|
||
--syntax-number: #884488;
|
||
--cm-bg: var(--color-surface);
|
||
--cm-fg: var(--color-text);
|
||
--cm-selection: rgba(60, 130, 220, 0.2);
|
||
--cm-keyword: var(--syntax-keyword);
|
||
--cm-string: var(--syntax-string);
|
||
--cm-comment: var(--syntax-comment);
|
||
--cm-number: var(--syntax-number);
|
||
--editor-rows: 16;
|
||
}
|
||
:root[data-theme="dark"] {
|
||
--syntax-keyword: #ff80c0;
|
||
--syntax-string: #87d96a;
|
||
--syntax-comment: #888;
|
||
--syntax-number: #c890ff;
|
||
--cm-selection: rgba(120, 170, 255, 0.25);
|
||
}
|
||
```
|
||
|
||
(The `--cm-bg` / `--cm-fg` cascade through `--color-surface` / `--color-text` automatically, so they don't need re-definition in the dark block.)
|
||
|
||
- [ ] **Step 5: Check if live-state has scoped CSS that needs a widget file**
|
||
|
||
```bash
|
||
grep -nE "live-state|\.player-(card|avatar|name|meta)" l4d2web/l4d2web/static/css/components.css
|
||
```
|
||
|
||
If the grep finds rules, extract them into `l4d2web/l4d2web/static/css/widgets/live-state.css`. If not, create a placeholder file so `main.css`'s `@import "./widgets/live-state.css"` doesn't 404:
|
||
|
||
```bash
|
||
test -f l4d2web/l4d2web/static/css/widgets/live-state.css || cat > l4d2web/l4d2web/static/css/widgets/live-state.css <<'EOF'
|
||
/* Live-state widget. No scoped rules yet — markup currently relies on
|
||
element defaults plus component badges. Reserved for future per-widget
|
||
styling. */
|
||
EOF
|
||
```
|
||
|
||
- [ ] **Step 5b: Grep for un-migrated old token names in the new widget files**
|
||
|
||
After all widget files are in place, sanity-check that no old token references survive:
|
||
|
||
```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. If anything matches, apply the migration table above and re-run.
|
||
|
||
- [ ] **Step 6: 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 (no `MISSING` lines).
|
||
|
||
- [ ] **Step 7: Open server detail / overlay detail in browser; verify file-tree and overlay-picker render**
|
||
|
||
The dev server should auto-reload static files. Navigate to a page containing each widget and confirm rendering matches pre-redesign.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add l4d2web/l4d2web/static/css/widgets/
|
||
git commit -m "refactor(stylesheet): relocate project widgets to widgets/"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Utilities
|
||
|
||
**Files:**
|
||
- Create: `l4d2web/static/css/utilities.css`
|
||
|
||
- [ ] **Step 1: Write `utilities.css`**
|
||
|
||
`l4d2web/l4d2web/static/css/utilities.css`:
|
||
|
||
```css
|
||
/* Utility classes. In the utilities layer — wins over components by
|
||
layer order, not selector specificity. Keep this list short and
|
||
purposeful; if a utility starts being used to override component
|
||
styling regularly, that's a signal the component is wrong. */
|
||
|
||
.muted { color: var(--color-muted); }
|
||
.mono { font-family: var(--font-mono); }
|
||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.sr-only {
|
||
position: absolute; width: 1px; height: 1px;
|
||
padding: 0; margin: -1px; overflow: hidden;
|
||
clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```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 available
|
||
widget. Test that it renders, returns 200 publicly, and contains at
|
||
least the major primitive class names."""
|
||
|
||
import pytest
|
||
from l4d2web.app import create_app
|
||
|
||
|
||
@pytest.fixture
|
||
def client(monkeypatch, tmp_path):
|
||
db = tmp_path / "sg.db"
|
||
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{db}")
|
||
monkeypatch.setenv("SECRET_KEY", "test-only-not-a-secret")
|
||
app = create_app({"TESTING": True})
|
||
return app.test_client()
|
||
|
||
|
||
def test_styleguide_returns_200(client):
|
||
resp = client.get("/styleguide")
|
||
assert resp.status_code == 200
|
||
|
||
|
||
def test_styleguide_does_not_require_login(client):
|
||
# No session set up; should still serve the page.
|
||
resp = client.get("/styleguide")
|
||
assert resp.status_code == 200
|
||
|
||
|
||
def test_styleguide_contains_every_primitive(client):
|
||
resp = client.get("/styleguide")
|
||
body = resp.get_data(as_text=True)
|
||
# Each canonical primitive must appear somewhere on the page.
|
||
for token in [
|
||
"btn-primary", "btn-secondary", "btn-danger", "btn-outline",
|
||
"field-label", "field-hint", "field-error",
|
||
"table", "panel", "panel-heading",
|
||
"modal", "modal-header",
|
||
"tabs", "tab-panel",
|
||
"badge-success", "state-running",
|
||
"site-header",
|
||
]:
|
||
assert token in body, f"styleguide missing primitive: {token}"
|
||
|
||
|
||
def test_styleguide_includes_do_dont_blocks(client):
|
||
resp = client.get("/styleguide")
|
||
body = resp.get_data(as_text=True)
|
||
assert "sg-do" in body
|
||
assert "sg-dont" in body
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test, verify it fails**
|
||
|
||
```bash
|
||
cd l4d2web && uv run pytest tests/test_styleguide.py -v
|
||
```
|
||
|
||
Expected: FAIL — `/styleguide` returns 404 because the route doesn't exist yet.
|
||
|
||
- [ ] **Step 3: Write `routes/styleguide_routes.py`**
|
||
|
||
`l4d2web/l4d2web/routes/styleguide_routes.py`:
|
||
|
||
```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 (alphabetical):
|
||
|
||
```python
|
||
from l4d2web.routes.styleguide_routes import bp as styleguide_bp
|
||
```
|
||
|
||
And register it 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
|
||
{# Style guide page. Source of truth for every available primitive.
|
||
Each example uses {% set src %}...{% endset %} so the rendered widget
|
||
and the displayed source HTML can't drift. #}
|
||
{% extends "base.html" %}
|
||
{% block title %}Styleguide | left4me{% endblock %}
|
||
|
||
{% block extra_head %}
|
||
<style nonce="{{ g.csp_nonce }}">
|
||
.sg-section { margin-block: var(--space-6); }
|
||
.sg-example { border: 1px solid var(--color-border-soft); border-radius: var(--radius-2); margin-block: var(--space-3); overflow: hidden; }
|
||
.sg-example-title { padding: var(--space-2) var(--space-3); background: var(--color-surface-2); font-size: var(--text-sm); font-weight: 600; margin: 0; }
|
||
.sg-example-rendered { padding: var(--space-3); background: var(--color-surface); }
|
||
.sg-example-source { margin: 0; padding: var(--space-3); background: var(--color-bg); font-size: var(--text-sm); border-top: 1px solid var(--color-border-soft); white-space: pre-wrap; }
|
||
.sg-do, .sg-dont { padding: var(--space-2) var(--space-3); }
|
||
.sg-do { background: color-mix(in srgb, var(--color-success) 8%, var(--color-surface)); border-left: 4px solid var(--color-success); }
|
||
.sg-dont { background: color-mix(in srgb, var(--color-danger) 8%, var(--color-surface)); border-left: 4px solid var(--color-danger); }
|
||
.sg-token-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--space-2); }
|
||
.sg-token-swatch { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-1) var(--space-2); border: 1px solid var(--color-border-soft); border-radius: var(--radius-1); }
|
||
.sg-token-swatch > .sw { width: 1.5rem; height: 1.5rem; border-radius: var(--radius-1); border: 1px solid var(--color-border-soft); }
|
||
.sg-token-swatch > code { font-size: var(--text-xs); }
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<header class="page-heading">
|
||
<h1>Style guide</h1>
|
||
<p class="muted">Every available widget, with copy-paste source. If your change needs a widget that isn't here, extend the system before using it.</p>
|
||
<button type="button" class="btn btn-secondary" id="sg-theme-toggle">Toggle dark</button>
|
||
</header>
|
||
|
||
{# ============================ Helper macro ============================ #}
|
||
{% macro example(title, html) %}
|
||
<section class="sg-example">
|
||
<h3 class="sg-example-title">{{ title }}</h3>
|
||
<div class="sg-example-rendered">{{ html|safe }}</div>
|
||
<pre class="sg-example-source"><code>{{ html|trim|e }}</code></pre>
|
||
</section>
|
||
{% endmacro %}
|
||
|
||
{# ============================== Tokens ============================== #}
|
||
<section class="sg-section">
|
||
<h2>Tokens</h2>
|
||
<p>Component CSS uses semantic tokens only (<code>var(--color-*)</code>, <code>var(--space-*)</code>). Primitive hex codes live in <code>tokens/primitives.css</code> and are not referenced directly by components.</p>
|
||
<h3>Colors</h3>
|
||
<div class="sg-token-grid">
|
||
{% for name in ["bg", "surface", "surface-2", "text", "text-strong", "muted", "border", "border-soft", "primary", "primary-hover", "on-primary", "danger", "on-danger", "warning", "success", "focus", "link"] %}
|
||
<div class="sg-token-swatch">
|
||
<span class="sw" style="background: var(--color-{{ name }})"></span>
|
||
<code>--color-{{ name }}</code>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
<h3>Spacing scale</h3>
|
||
<ul class="mono">
|
||
{% for n in [1,2,3,4,5,6,7] %}<li>--space-{{ n }}</li>{% endfor %}
|
||
</ul>
|
||
<h3>Type scale</h3>
|
||
<ul class="mono">
|
||
<li>--text-xs / sm / base / lg / xl / 2xl</li>
|
||
</ul>
|
||
</section>
|
||
|
||
{# ============================== Buttons ============================== #}
|
||
<section class="sg-section">
|
||
<h2>Button</h2>
|
||
{% set src %}
|
||
<button type="button" class="btn btn-primary">Primary</button>
|
||
<button type="button" class="btn btn-secondary">Secondary</button>
|
||
<button type="button" class="btn btn-danger">Danger</button>
|
||
<button type="button" class="btn btn-outline">Outline</button>
|
||
<button type="button" class="btn btn-primary" disabled>Disabled</button>
|
||
<button type="button" class="btn btn-primary" aria-busy="true">Loading…</button>
|
||
<button type="button" class="btn btn-primary btn-sm">Small</button>
|
||
{% endset %}
|
||
{{ example("Variants + states + sizes", src) }}
|
||
|
||
{% set src %}
|
||
<div class="button-row">
|
||
<button type="button" class="btn btn-secondary">Cancel</button>
|
||
<button type="submit" class="btn btn-primary">Save</button>
|
||
</div>
|
||
{% endset %}
|
||
{{ example("Button row", src) }}
|
||
</section>
|
||
|
||
{# ============================== Fields ============================== #}
|
||
<section class="sg-section">
|
||
<h2>Field</h2>
|
||
<p>Use the <code>ui.field</code> macro — it owns the <code>for</code>/<code>id</code> pair and <code>aria-describedby</code> wiring.</p>
|
||
|
||
<div class="sg-do">
|
||
✅ <strong>DO</strong> — use <code>ui.field</code> so a11y attributes can't drift:
|
||
</div>
|
||
{% set src %}
|
||
{# In your template #}
|
||
{% raw %}{% from "ui/_field.html" import field %}
|
||
{{ field(name="hostname", label="Hostname", value="left4me",
|
||
hint="Used in master-server listings.") }}{% endraw %}
|
||
{% endset %}
|
||
{{ example("Field via macro", src) }}
|
||
|
||
<div class="sg-dont">
|
||
❌ <strong>DON'T</strong> — hand-assemble a label + input without <code>for</code>/<code>id</code> + <code>aria-describedby</code>; screen readers will miss the hint.
|
||
</div>
|
||
{% set src %}
|
||
<label>Hostname</label>
|
||
<input name="hostname" value="left4me">
|
||
<p>Used in master-server listings.</p>
|
||
{% endset %}
|
||
{{ example("Bare label + input (broken a11y)", src) }}
|
||
|
||
{% set src %}
|
||
<div class="field">
|
||
<label class="field-label" for="sg-port">Port</label>
|
||
<input id="sg-port" type="number" value="-1" aria-invalid="true" aria-describedby="sg-port-err">
|
||
<p id="sg-port-err" class="field-error">Must be between 27015 and 27115.</p>
|
||
</div>
|
||
{% endset %}
|
||
{{ example("Error state (raw source — for reference)", src) }}
|
||
</section>
|
||
|
||
{# ============================== Tables ============================== #}
|
||
<section class="sg-section">
|
||
<h2>Table</h2>
|
||
{% set src %}
|
||
<table class="table">
|
||
<thead><tr><th>Name</th><th>State</th><th>Players</th></tr></thead>
|
||
<tbody>
|
||
<tr><td>alpha</td><td><span class="badge state-running">running</span></td><td>4 / 8</td></tr>
|
||
<tr><td>beta</td><td><span class="badge state-stopped">stopped</span></td><td>— / 8</td></tr>
|
||
</tbody>
|
||
</table>
|
||
{% endset %}
|
||
{{ example("Basic", src) }}
|
||
</section>
|
||
|
||
{# ============================== Panel ============================== #}
|
||
<section class="sg-section">
|
||
<h2>Panel</h2>
|
||
{% set src %}
|
||
<article class="panel">
|
||
<header class="panel-heading"><h3>Recent players</h3></header>
|
||
<div class="panel-body"><p>Body content.</p></div>
|
||
<footer class="panel-footer">
|
||
<div class="button-row"><button class="btn btn-secondary">Refresh</button></div>
|
||
</footer>
|
||
</article>
|
||
{% endset %}
|
||
{{ example("With heading + body + footer", src) }}
|
||
</section>
|
||
|
||
{# ============================== Modal ============================== #}
|
||
<section class="sg-section">
|
||
<h2>Modal</h2>
|
||
<p>Use the <code>ui.modal</code> macro — it owns the <code><dialog></code> structure and the close-button wiring.</p>
|
||
|
||
<div class="sg-do">
|
||
✅ <strong>DO</strong>:
|
||
</div>
|
||
{% set src %}
|
||
{% raw %}{% from "ui/_modal.html" import modal %}
|
||
{% call modal(id="confirm-stop", title="Stop server?") %}
|
||
<p>This will disconnect all players.</p>
|
||
<div class="button-row">
|
||
<button type="button" class="btn btn-secondary" data-inline-modal-close>Cancel</button>
|
||
<button type="submit" class="btn btn-danger">Stop server</button>
|
||
</div>
|
||
{% endcall %}{% endraw %}
|
||
{% endset %}
|
||
{{ example("Modal via macro", src) }}
|
||
|
||
<div class="sg-dont">
|
||
❌ <strong>DON'T</strong> — hand-write the <code><dialog></code> + header markup; you'll lose the close-button or modal-close hook.
|
||
</div>
|
||
</section>
|
||
|
||
{# ============================== Tabs ============================== #}
|
||
<section class="sg-section">
|
||
<h2>Tabs</h2>
|
||
{% set src %}
|
||
<div class="tabs" role="tablist">
|
||
<button class="tab" role="tab" aria-selected="true" aria-controls="panel-a">Overview</button>
|
||
<button class="tab" role="tab" aria-selected="false" aria-controls="panel-b">Console</button>
|
||
</div>
|
||
<div id="panel-a" role="tabpanel" class="tab-panel">Overview content.</div>
|
||
<div id="panel-b" role="tabpanel" class="tab-panel" hidden>Console content.</div>
|
||
{% endset %}
|
||
{{ example("Underline-style tabs", src) }}
|
||
</section>
|
||
|
||
{# ============================== Badges ============================== #}
|
||
<section class="sg-section">
|
||
<h2>Badge</h2>
|
||
{% set src %}
|
||
<span class="badge badge-success">success</span>
|
||
<span class="badge badge-warning">warning</span>
|
||
<span class="badge badge-danger">danger</span>
|
||
<span class="badge badge-muted">muted</span>
|
||
{% endset %}
|
||
{{ example("Semantic", src) }}
|
||
|
||
{% set src %}
|
||
<span class="badge state-running">running</span>
|
||
<span class="badge state-stopped">stopped</span>
|
||
<span class="badge state-unknown">unknown</span>
|
||
<span class="badge state-transient">starting…</span>
|
||
<span class="badge state-drift">drift</span>
|
||
{% endset %}
|
||
{{ example("Server lifecycle (use ui.badge_state to render from a state string)", src) }}
|
||
|
||
<div class="sg-do">
|
||
✅ <strong>DO</strong> — let <code>ui.badge_state</code> map state strings to classes; mappings live in one place.
|
||
</div>
|
||
<div class="sg-dont">
|
||
❌ <strong>DON'T</strong> — hand-pick <code>state-*</code> classes inline; one template will get it wrong and drift will start.
|
||
</div>
|
||
</section>
|
||
|
||
{# ============================== Site nav ============================== #}
|
||
<section class="sg-section">
|
||
<h2>Site nav</h2>
|
||
{% set src %}
|
||
<header class="site-header">
|
||
<div class="site-header-inner">
|
||
<nav class="primary-nav"><a class="brand" href="#">left4me</a><a href="#">servers</a></nav>
|
||
<nav class="account-nav"><a class="muted" href="#">user</a></nav>
|
||
</div>
|
||
</header>
|
||
{% endset %}
|
||
{{ example("Header + brand + nav links", src) }}
|
||
</section>
|
||
|
||
<script nonce="{{ g.csp_nonce }}">
|
||
document.getElementById('sg-theme-toggle').addEventListener('click', function () {
|
||
var html = document.documentElement;
|
||
html.dataset.theme = html.dataset.theme === 'dark' ? 'light' : 'dark';
|
||
});
|
||
</script>
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 6: Run the test, verify it passes**
|
||
|
||
```bash
|
||
cd l4d2web && uv run pytest tests/test_styleguide.py -v
|
||
```
|
||
|
||
Expected: 4 passed.
|
||
|
||
- [ ] **Step 7: Open `/styleguide` in a browser, walk it in light and dark mode**
|
||
|
||
Confirm every primitive section renders, the do/don't blocks are color-coded, and the dark-mode toggle button at the top flips the page.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```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 the workflow rule
|
||
|
||
**Files:**
|
||
- Modify: `AGENTS.md`
|
||
|
||
- [ ] **Step 1: Read current `AGENTS.md` to find the right insertion point**
|
||
|
||
```bash
|
||
wc -l /Users/mwiegand/Projekte/left4me/AGENTS.md
|
||
grep -n "^##" /Users/mwiegand/Projekte/left4me/AGENTS.md
|
||
```
|
||
|
||
Pick a heading after which to insert the UI work section (probably after a top-level section that talks about working in this repo; before a less-related section like deployment).
|
||
|
||
- [ ] **Step 2: Insert the UI work section**
|
||
|
||
Add this section to `AGENTS.md` at the chosen location:
|
||
|
||
```markdown
|
||
## UI work
|
||
|
||
Before adding any UI markup:
|
||
|
||
1. Read `l4d2web/templates/styleguide.html` (or open `/styleguide` in the
|
||
running app) — it lists every available widget with a canonical example
|
||
and do/don't notes.
|
||
2. If your change needs a widget that isn't in the style guide, **extend
|
||
the system first**, then use the widget:
|
||
- CSS in `l4d2web/static/css/components/<name>.css` (generic) or
|
||
`l4d2web/static/css/widgets/<name>.css` (project-specific).
|
||
- Style-guide entry with rendered example + escaped source + at least
|
||
one ✅ DO; add ❌ DON'T for foreseeable wrong uses.
|
||
- If composite or a11y-load-bearing, add a macro in
|
||
`l4d2web/templates/ui/_<name>.html`.
|
||
- Only then use the widget on the page.
|
||
3. **Never** use inline `style="…"` attributes.
|
||
4. **Never** invent class names off-system.
|
||
5. Component CSS references **only** semantic tokens
|
||
(`var(--color-*)`, `var(--space-*)`, …). Raw hex codes appear only in
|
||
`tokens/primitives.css`.
|
||
6. State as ARIA attributes, not modifier classes: prefer
|
||
`[disabled]`, `[aria-busy="true"]`, `[aria-selected="true"]`,
|
||
`[aria-invalid="true"]` over `.is-disabled` / `.is-loading` etc.
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add AGENTS.md
|
||
git commit -m "docs(agents): codify UI workflow rule — the system is closed"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Cleanup
|
||
|
||
**Files:**
|
||
- Delete: `l4d2web/static/css/components.css`
|
||
- Delete: `l4d2web/static/css/tokens.css`
|
||
- Delete: `l4d2web/static/css/spike/` (whole directory)
|
||
- Delete: `l4d2web/templates/spike.html`
|
||
- Delete: `l4d2web/routes/spike_routes.py`
|
||
- Delete: `l4d2web/static/vendor/css/` (whole directory; only used by spike)
|
||
- Modify: `l4d2web/l4d2web/app.py` — remove the `spike_routes` import + the `if spike_enabled(): app.register_blueprint(spike_bp)` block
|
||
|
||
Note: the root-level `logs.css`, `console-autocomplete.css`, `editor.css` were already `git mv`'d in Task 6.
|
||
|
||
- [ ] **Step 1: Confirm the old files are no longer referenced**
|
||
|
||
```bash
|
||
grep -rn "components.css\|tokens.css" l4d2web/l4d2web/templates/ l4d2web/l4d2web/static/css/ 2>/dev/null | grep -v spike
|
||
```
|
||
|
||
Expected: no matches outside the `spike/` directory.
|
||
|
||
- [ ] **Step 2: Delete the old stylesheets and spike artifacts**
|
||
|
||
```bash
|
||
git rm l4d2web/l4d2web/static/css/components.css \
|
||
l4d2web/l4d2web/static/css/tokens.css \
|
||
l4d2web/l4d2web/templates/spike.html \
|
||
l4d2web/l4d2web/routes/spike_routes.py
|
||
|
||
git rm -r l4d2web/l4d2web/static/css/spike \
|
||
l4d2web/l4d2web/static/vendor/css
|
||
```
|
||
|
||
- [ ] **Step 3: Remove spike registration from `app.py`**
|
||
|
||
Edit `l4d2web/l4d2web/app.py`:
|
||
|
||
Remove these lines (they were added in the spike commit):
|
||
|
||
```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. Fix any failures in this commit before continuing.
|
||
|
||
- [ ] **Step 5: Run the Chromium e2e suite**
|
||
|
||
```bash
|
||
cd l4d2web && uv run pytest tests/e2e/ -x
|
||
```
|
||
|
||
Expected: all green. The e2e suite hits real pages; if any class-name expectation needs updating, do it here.
|
||
|
||
- [ ] **Step 6: Walk the major pages manually in the dev server**
|
||
|
||
Start the dev server: `scripts/dev-server.py`
|
||
|
||
Visit each in light and dark (toggle via DevTools: `document.documentElement.dataset.theme = 'dark'`):
|
||
|
||
- `/login`
|
||
- `/dashboard`
|
||
- `/servers`
|
||
- `/servers/1`
|
||
- `/servers/1/jobs`
|
||
- `/blueprints`
|
||
- `/blueprints/1`
|
||
- `/overlays`
|
||
- `/overlays/1`
|
||
- `/overlays/2`
|
||
- `/profile`
|
||
- `/admin` (if seeded with admin user)
|
||
- `/admin/users`
|
||
- `/admin/jobs`
|
||
- `/styleguide`
|
||
|
||
For each, check: buttons styled, forms readable, tables clean, modals open and look right, tabs switch, badges colored, file-tree and overlay-picker render. Fix regressions in this commit.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "chore(stylesheet): delete old components.css/tokens.css + spike artifacts
|
||
|
||
Old stylesheet is fully replaced by the layered system rooted at main.css.
|
||
Spike route, template, vendor framework CSS, and the conditional spike
|
||
blueprint registration are removed; the spike's job (validating the
|
||
custom-CSS direction) is complete."
|
||
```
|
||
|
||
---
|
||
|
||
## Final verification
|
||
|
||
After Task 10, the new system is fully in place. A last sanity sweep:
|
||
|
||
- [ ] **Step 1: Search the repo for any remaining references to deleted files**
|
||
|
||
```bash
|
||
grep -rn "css/spike\|spike_routes\|spike_bp\|spike_enabled\|components\.css\|css/tokens\.css\|css/logs\.css\|css/console-autocomplete\.css\|css/editor\.css" l4d2web/ AGENTS.md docs/ 2>/dev/null
|
||
```
|
||
|
||
Expected: matches only inside `docs/superpowers/specs/` and `docs/superpowers/plans/` (documentation that references the redesign).
|
||
|
||
- [ ] **Step 2: Confirm `main.css` is the only stylesheet `base.html` loads**
|
||
|
||
```bash
|
||
grep -E "rel=\"stylesheet\"" l4d2web/l4d2web/templates/base.html
|
||
```
|
||
|
||
Expected: exactly one match, pointing at `main.css`.
|
||
|
||
- [ ] **Step 3: Confirm the `@layer` order is intact**
|
||
|
||
```bash
|
||
head -5 l4d2web/l4d2web/static/css/main.css
|
||
```
|
||
|
||
Expected: the first non-comment line is the `@layer reset, tokens, elements, layout, components, widgets, utilities;` declaration.
|
||
|
||
- [ ] **Step 4: Final commit (if anything was fixed up)**
|
||
|
||
If the sanity sweep surfaced anything, fix it and commit with a short follow-up message. Otherwise, the rewrite is complete.
|
||
|
||
---
|
||
|
||
## Definition of done
|
||
|
||
- [ ] `base.html` loads exactly one stylesheet: `main.css`
|
||
- [ ] `main.css` declares the seven-layer cascade order and `@import`s all sub-files
|
||
- [ ] Tokens are split into `tokens/primitives.css` + `tokens/semantic.css`
|
||
- [ ] All nine component CSS files exist under `components/`
|
||
- [ ] All six widget CSS files exist under `widgets/`
|
||
- [ ] Five macros exist under `templates/ui/`
|
||
- [ ] `/styleguide` returns 200, renders every primitive, has do/don't blocks
|
||
- [ ] `AGENTS.md` has the "UI work" section codifying the closed-system rule
|
||
- [ ] All pytest tests pass (`uv run pytest`)
|
||
- [ ] All e2e tests pass (`uv run pytest tests/e2e/`)
|
||
- [ ] Major pages walk cleanly in light and dark mode
|
||
- [ ] Old `components.css`, `tokens.css`, and the three root-level widget files are deleted
|
||
- [ ] Spike artifacts (`css/spike/`, `vendor/css/`, `spike.html`, `spike_routes.py`, the `app.py` registration) are deleted
|