docs(stylesheet): redesign from first principles

Throw away the historical naming. New vocabulary chosen for clarity and
agentic-dev predictability: parts use hyphenated child classes
(.card-header), variant modifiers chain on the parent (.button.primary),
state stays on ARIA attributes. Variants compose via Tier-3
component-scoped tokens (--button-bg etc.) — .button.danger.outline is a
real outlined-danger button with no combination rule.

Adds toast, spinner, heading, app-header as first-class components.
Renames panel→card, modal→dialog, badge→tag; collapses state-* into tag
variants via ui.lifecycle_tag. Adds an explicit template-rewrite phase
in the migration plan, since every template's class attributes change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-18 00:33:47 +02:00
parent 536c3384bf
commit 308fa4eb26
No known key found for this signature in database
2 changed files with 1332 additions and 692 deletions

File diff suppressed because it is too large Load diff

View file

@ -112,7 +112,7 @@ l4d2web/static/css/
utilities.css
```
### Tokens — two tiers
### Tokens — three tiers
**Tier 1 (primitives)** in `tokens/primitives.css` is the raw palette: ~30
custom properties for the grayscale ramp, brand blue ramp, semantic state
@ -124,18 +124,29 @@ by semantic tokens.
**Tier 2 (semantic)** in `tokens/semantic.css` aliases primitives by *role*:
`--color-bg`, `--color-surface`, `--color-text`, `--color-muted`,
`--color-border`, `--color-primary`, `--color-on-primary`, `--color-danger`,
`--color-warning`, `--color-success`, `--color-focus`, plus `--space-s`,
`--space-m`, `--space-l`, `--radius-s`, `--shadow-sm`, `--shadow-md`, etc.
Dark mode lives here, in a `[data-theme="dark"]` selector — the primitives
do not change between modes; the semantic aliases re-point at different
primitives. This keeps dark mode in one file instead of duplicated inside
a `@media (prefers-color-scheme: dark)` block.
`--color-warning`, `--color-success`, `--color-info`, `--color-focus`, plus
`--space-1` through `--space-7`, `--radius-1` / `-2` / `-full`, `--shadow-sm`
/ `-md` / `-lg`, etc. Dark mode lives here, in a `[data-theme="dark"]`
selector — the primitives do not change between modes; the semantic aliases
re-point at different primitives. This keeps dark mode in one file instead
of duplicated inside a `@media (prefers-color-scheme: dark)` block.
A rule the agent can follow mechanically: **components reference only
semantic tokens (`var(--color-*)`, `var(--space-*)`, etc.). Raw hex codes
appear only in `tokens/primitives.css`.** This rule is codified in
`AGENTS.md` and is the simplest possible expression of the two-tier
contract.
**Tier 3 (component-scoped)** lives inside each component's CSS file, never
exposed beyond it. Example: `components/button.css` declares
`--button-bg`, `--button-fg`, `--button-border` on `.button`, and each variant
(`.button.primary`, `.button.danger`) re-points those local properties at
semantic tokens. Modifiers like `.button.outline` then read the inherited
component-scoped values, which is what lets variants **compose**
`.button.danger.outline` is a real outlined-danger button without a single
rule mentioning the combination. Same pattern for `.tag` (`--tag-bg`,
`--tag-fg`, `--tag-border`) and `.toast` (`--toast-bg`, etc.).
A rule the agent can follow mechanically: **components reference Tier 2
semantic tokens** (`var(--color-*)`, `var(--space-*)`, …). **Tier 3
component-scoped tokens are private** to each component and never appear
in other components' CSS. **Raw hex codes appear only in
`tokens/primitives.css`.** These three rules are codified in `AGENTS.md`
and constitute the entire token contract.
Dark mode is opted into by setting `data-theme="dark"` on the `<html>`
element. The initial value comes from `prefers-color-scheme` via a small
@ -146,47 +157,89 @@ this redesign — the redesign only ensures the mechanism is in place.
### Components — class-based, CSS-only
Vocabulary (the components layer in full):
**Naming convention** (two rules, no exceptions):
- **`.btn`** with variants `.btn-primary`, `.btn-secondary`, `.btn-danger`,
`.btn-outline`, `.btn-link`, plus `.btn-sm`. States: `[disabled]`,
`[aria-busy="true"]` (loading). Loading state shows a spinner via a CSS
animation; no extra markup required.
- **`.button-row`** — flex+gap container for groups of buttons.
- **`.field`** — wrapper containing `.field-label`, `input` / `select` /
`textarea`, optional `.field-hint`, optional `.field-error`. Error state
uses native `[aria-invalid="true"]` on the input — no separate
`.field-input-error` class. Hint/error text is associated via
`aria-describedby` (enforced by the macro; see below).
- **`.inline-save`** — flex layout for the input + submit pair used several
places today (`.inline-save` exists already; conventions preserved).
- **`.table`** — bordered, slight zebra optional via `.table-striped`.
- **`.panel`** — bordered card with optional `.panel-heading`, `.panel-body`,
`.panel-footer`. Subsumes the current `.panel` / `.card` aliases.
- **`.modal`** — styles `<dialog>`. Optional `.modal-wide` for the wider
variant already used today. Header / body / footer subdivisions. The
existing `modals.js` open/close machinery is unchanged.
- **`.tabs`** with `.tab` and `.tab-panel`. State is encoded as
`[aria-selected="true"]` on the tab — no `.tab-active` class. The existing
`tabs.js` reads and writes those ARIA attributes already; no JS changes
needed.
- **`.badge`** with semantic variants (`.badge-success`, `.badge-warning`,
`.badge-danger`, `.badge-muted`) and state variants
(`.state-running`, `.state-stopped`, `.state-unknown`, `.state-transient`,
`.state-drift`). The state variants exist today; preserved for templating
continuity.
- **`.site-header`** + **`.primary-nav`** + **`.account-nav`** + **`.brand`**
— already present, restyled on the new token base.
- **`.dropdown`** — styling around native `<select>` and a small custom
menu pattern if needed.
1. **Parts of a component use hyphenated child classes.** `.card-header`,
`.field-hint`, `.dialog-body`. The part belongs to the parent; the
hyphen makes the relationship visible.
2. **Variant modifiers chain on the parent class.** `.button.primary`,
`.tag.success`, `.button.outline`, `.dialog.wide`. A modifier alone
is meaningless — it must be read together with the component class.
State as ARIA attributes, not modifier classes: `[disabled]`,
State stays on ARIA attributes, not modifier classes: `[disabled]`,
`[aria-busy="true"]`, `[aria-selected="true"]`, `[aria-invalid="true"]`.
This avoids the class-vs-aria drift that produces broken accessibility.
Variant naming convention is uniform: `<component> <component>-<variant>
<component>-<size>`. No exceptions. New variants must be added to the
component's file *and* to the style guide entry in the same commit.
Vocabulary (the components layer in full):
- **`.button`** with modifiers `.primary`, `.outline`, `.ghost`, `.danger`,
`.link`, `.small`. Modifiers compose: `.button.danger.outline` is an
outlined-danger button, no new rule required. States: `[disabled]`,
`[aria-busy="true"]` (loading; spinner via CSS animation, no extra markup).
- **`.field`** — wrapper containing `.field-label`, `input` / `select` /
`textarea`, optional `.field-hint`, optional `.field-error`. Error state
uses native `[aria-invalid="true"]` on the input. Hint/error text wired
to the input via `aria-describedby` (enforced by the `ui.field` macro).
`.field-checkbox` handles the checkbox-with-label layout.
- **`.card`** — bordered surface with `.card-header`, `.card-body`,
`.card-footer`. Replaces the historically duplicated `.panel` / `.card`
pair.
- **`.table`** — data table. Modifier `.striped` for zebra rows.
- **`.dialog`** — styles `<dialog>`. Parts: `.dialog-header`, `.dialog-body`,
`.dialog-footer`, `.dialog-close`. Modifier `.wide` for the wider variant
used by editor-style modals. The existing `modals.js` open/close machinery
is unchanged; the close button keeps its `data-inline-modal-close`
attribute (the JS hook).
- **`.tabs`** with parts `.tab` and `.tab-panel`. State is `[aria-selected]`
on each `.tab`; the existing `tabs.js` reads/writes the attribute.
- **`.tag`** with modifiers `.success`, `.warning`, `.danger`, `.info`,
`.muted`. Replaces the current `.badge` vocabulary AND the project-specific
`.state-running` / `.state-stopped` / `.state-unknown` / `.state-transient`
/ `.state-drift` classes — lifecycle states map to semantic tag variants
via the `ui.lifecycle_tag(state)` macro (`"running"` → `.tag.success`,
`"stopped"``.tag.muted`, `"unknown"``.tag.muted`, `"starting"` /
`"stopping"` / `"resetting"` etc. → `.tag.warning`, `"drift"`
`.tag.danger`). One vocabulary; one mapping rule.
- **`.toast`** (new) — flash-message notification, top-right positioned.
Modifiers `.success`, `.warning`, `.danger`. Part `.toast-close`. Fills
a real gap — flash messages currently improvise.
- **`.spinner`** (new) — standalone loading indicator. Modifier `.small`.
Used independently of `[aria-busy]`-on-buttons.
- **`.app-header`** with parts `.app-header-inner`, `.brand`, `.nav`,
`.account` — the top-of-page banner. Replaces `.site-header` +
`.primary-nav` + `.account-nav` (one vocabulary instead of three).
- **`.heading`** with part `.heading-actions` — formal page-title pattern
(h1 + actions row). Replaces the current ad-hoc `.page-heading`.
- **`.dropdown`** — small helper for native `<select>` + any future custom
menu pattern.
**Variant composition pattern** (the mechanism behind chained modifiers):
Each component declares Tier-3 component-scoped tokens and consumes them.
Variants re-point those tokens; modifiers can read inherited values. A
trimmed `components/button.css` example:
```css
.button {
--button-bg: var(--color-surface);
--button-fg: var(--color-text);
--button-border: var(--color-border);
background: var(--button-bg);
color: var(--button-fg);
border: 1px solid var(--button-border);
/* …padding, radius, transitions… */
}
.button.primary { --button-bg: var(--color-primary); --button-fg: var(--color-on-primary); --button-border: var(--color-primary); }
.button.danger { --button-bg: var(--color-danger); --button-fg: var(--color-on-danger); --button-border: var(--color-danger); }
.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; text-decoration: underline; color: var(--color-link); }
.button.small { padding: 0.25rem 0.5rem; font-size: var(--text-sm); }
```
`.button.danger.outline` works because `.outline` is declared *after*
`.danger` and reads the inherited `--button-border` (which `.danger`
just set). The same pattern is used for `.tag` and `.toast`.
### Macros — five high-leverage primitives
@ -195,12 +248,12 @@ where markup correctness is load-bearing — i.e., where hand-assembling
gets accessibility wrong easily:
| Macro file | Macros | Why |
|-------------------------|----------------------------------------------|---------------------------------------------------------------------------------------------------------------|
|-------------------------|----------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
| `ui/_field.html` | `ui.field`, `ui.checkbox`, `ui.select` | Owns `<label for>`/`<input id>` pairing, `aria-describedby` wiring for hint+error, `aria-invalid` on error |
| `ui/_modal.html` | `ui.modal` | Owns the `<dialog>` + `modal-header`/`modal-body`/`modal-footer` structure and the close-button + JS hooks |
| `ui/_dialog.html` | `ui.dialog` | Owns the `<dialog>` + `.dialog-header`/`.dialog-body`/`.dialog-footer` structure and the `data-inline-modal-close` JS hook |
| `ui/_tabs.html` | `ui.tabs`, `ui.tab_panel` | Owns the `role="tablist"` / `role="tab"` / `role="tabpanel"` + `aria-controls` / `aria-selected` wiring |
| `ui/_confirm_form.html` | `ui.confirm_form` | Owns CSRF token, POST action, and standard button row for destructive actions |
| `ui/_badge.html` | `ui.badge_state(state)` | Maps a server-state string to the correct `state-*` class — encapsulates a real source of inconsistency today |
| `ui/_tag.html` | `ui.tag`, `ui.lifecycle_tag(state)` | Maps a server-lifecycle state string (`"running"`, `"starting"`, `"drift"`, …) to the right `.tag.<variant>` — one mapping rule total |
CSS-only is the default for trivial primitives — `<button class="btn
btn-primary">Save</button>` is its own canonical form and does not need a
@ -301,14 +354,19 @@ Before adding any UI markup:
with a canonical example.
2. If your change needs a widget that isn't in the style guide, add it to
the system FIRST:
- CSS goes in `components/<name>.css` or `widgets/<name>.css`
- Style guide entry with rendered example + source + at least one do/don't
- If composite/a11y-bearing, add a macro in `templates/ui/_<name>.html`
- CSS goes in `components/<name>.css` (generic) or `widgets/<name>.css` (project-specific)
- Style guide entry with rendered example + source + at least one ✅ DO
- If composite or a11y-load-bearing, add a macro in `templates/ui/_<name>.html`
- Only then use the widget on the page
3. Never inline `style="…"` attributes.
4. Never invent class names outside the system.
5. Use only `var(--color-*)`, `var(--space-*)`, etc. in component CSS —
raw hex codes appear only in `tokens/primitives.css`.
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`
- **State stays on ARIA attributes**, not modifier classes — `[disabled]`, `[aria-busy="true"]`, `[aria-selected="true"]`, `[aria-invalid="true"]`
4. Never inline `style="…"` attributes. Never invent class names outside the system.
5. 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`
```
## What the spike validated
@ -337,9 +395,14 @@ Findings:
writing new component CSS doesn't have to think about specificity at all.
Pure custom won on the criterion you weighted highest ("bad code feel
leads to incoherent styling later"). The spike's `custom.css` is the seed
for the production stylesheet; it is not the final form, but the layer
ordering, the token shape, and the component vocabulary are validated.
leads to incoherent styling later"). The spike validated the
**architecture** — `@layer` cascade, color-mix() in production browsers,
the light/dark theme switching via `data-theme`, the aesthetic direction.
The **vocabulary used in the spike (`.btn`, `.panel`, `.modal`, `.badge`,
`.state-*`, …) is NOT carried forward** — the rewrite uses a redesigned
vocabulary (`.button` + chained modifiers, `.card`, `.dialog`, `.tag`, …)
chosen from first principles for the agentic-dev goal. The spike artifacts
get deleted in the cleanup commit (see Migration plan).
## Migration plan
@ -356,32 +419,45 @@ may be visible mid-series, which is fine):
`<link rel="stylesheet" href=".../main.css">`. Old `components.css`
no longer loads — pages look bare. Intentional and visible.
3. **Core components** — add `components/button.css`,
`components/panel.css`, `components/table.css`, `components/badge.css`,
`components/nav.css`. Half the surface starts looking right again.
3. **Core components** — add `components/button.css`, `components/card.css`,
`components/table.css`, `components/tag.css`, `components/app-header.css`,
`components/heading.css`. Half the surface starts looking right again
(where templates use the new class names; many still use the old ones
until step 7).
4. **Composite components + macros** — add `components/modal.css`,
4. **Composite components + macros** — add `components/dialog.css`,
`components/tabs.css`, `components/field.css`, `components/dropdown.css`,
plus `templates/ui/_field.html`, `_modal.html`, `_tabs.html`,
`_confirm_form.html`, `_badge.html`. Update templates that use these
primitives to invoke the macros where appropriate.
`components/toast.css`, `components/spinner.css`, plus
`templates/ui/_field.html`, `_dialog.html`, `_tabs.html`,
`_confirm_form.html`, `_tag.html`. The macros are available for use but
templates aren't yet calling them (step 7 does that).
5. **Project widgets** — move file-tree, overlay-picker,
console-autocomplete, editor, logs, live-state into `widgets/<name>.css`.
This is largely content-preserving — rename token references to the
semantic-token namespace, otherwise the rules carry over.
5. **Project widgets** — move file-tree, overlay-picker, console-autocomplete,
editor, logs, live-state into `widgets/<name>.css` with renamed class
names (`.overlay-picker` → `.overlay-list`, `.file-tree-row`
`.file-tree-item`, etc. — full mapping in the implementation plan).
Rename token references to the new semantic-token namespace.
6. **Style guide** — add `routes/styleguide_routes.py`,
`templates/styleguide.html` with every primitive + do/don't blocks,
token reference table, dark-mode toggle button. Update `AGENTS.md`
with the UI workflow section.
`templates/styleguide.html` with every primitive + ✅ DO / ❌ DON'T
blocks, token reference table, dark-mode toggle button. Update
`AGENTS.md` with the UI workflow section.
7. **Cleanup** — delete the old `components.css`, the old `tokens.css`,
the old root-level `logs.css` / `console-autocomplete.css` / `editor.css`
(now under `widgets/`). Delete the spike artifacts in the same commit:
`l4d2web/static/css/spike/`, `l4d2web/templates/spike.html`,
`l4d2web/routes/spike_routes.py`, `l4d2web/static/vendor/css/`, and the
`if spike_enabled(): app.register_blueprint(spike_bp)` block in
7. **Template rewrite** — walk every template in `l4d2web/templates/`
and update class attributes to the new vocabulary. This is a large but
mechanical phase; the implementation plan splits it into reviewable
chunks (one per template-family: page templates, partial templates,
server-detail's complex cluster). Templates also switch to the new
macros (`ui.field`, `ui.dialog`, `ui.lifecycle_tag`, etc.) where
applicable. After this step, no template still references the old
vocabulary.
8. **Cleanup** — delete the old `components.css`, the old `tokens.css`,
the old root-level `logs.css` / `console-autocomplete.css` /
`editor.css` (now under `widgets/`). Delete the spike artifacts in
the same commit: `l4d2web/static/css/spike/`, `l4d2web/templates/spike.html`,
`l4d2web/routes/spike_routes.py`, `l4d2web/static/vendor/css/`, and
the `if spike_enabled(): app.register_blueprint(spike_bp)` block in
`app.py` (plus the `spike_routes` import). Run the existing Chromium
e2e suite and the pytest suite. Walk the major pages (dashboard,
servers, server detail, overlay detail, blueprint detail, profile,
@ -412,20 +488,39 @@ revisit them.
- **No build step.** No Sass, no PostCSS, no bundler. Modern CSS
(`@layer`, `color-mix()`, custom properties) suffices and matches the
project's zero-build philosophy.
- **Two-tier tokens, not three.** Three-tier (primitive / semantic /
component-scoped, as in Material 3 or Adobe Spectrum) is over-engineered
for a project this size. Two tiers buy clean dark mode and unambiguous
agent rules without the indirection cost.
- **Three-tier tokens** (primitives → semantic → component-scoped). The
initial draft proposed two tiers; the third (component-scoped tokens
like `--button-bg`) was added when we adopted chained-modifier variants
(`.button.danger.outline`), because composing variants cleanly requires
each variant to set local CSS custom properties that later modifiers
can read. The third tier is *private to each component* — it doesn't
bloat the global token namespace.
- **Naming convention chosen from first principles.** The old vocabulary
(`.btn` / `.btn-primary` / `.panel` / `.modal` / `.badge` / `.state-*` /
`.site-header` / `.primary-nav` / `.link-button` / `.danger-outline` /
`.sr-only` / `.overlay-picker` / …) was historically grown and
inconsistent. The new vocabulary uses two rules: **parts are hyphenated
child classes** (`.card-header`), **variants chain on the parent**
(`.button.primary`). State stays on ARIA attributes. The agent learns
the rules once and predicts the rest.
- **`@layer` for cascade.** Underused, well-supported since 2022, exactly
fits the "layered design system" mental model. Replaces specificity
hacks with declared layer ordering.
- **Macros for five high-leverage primitives, CSS-only for the rest.**
The macro tier exists where forgetting a piece of markup silently
breaks accessibility. Buttons and panels don't qualify; fields and
modals do.
breaks accessibility. Buttons and tags don't qualify; fields and
dialogs do. The fifth, `ui.lifecycle_tag`, encapsulates the
state-string → tag-variant mapping in one place.
- **Style guide as enforcement, not just documentation.** Tied to a
workflow rule ("the system is closed"), referenced in `AGENTS.md`,
with do/don't anti-pattern blocks. Most leveraged change for the
with ✅ DO / ❌ DON'T blocks. Most leveraged change for the
agentic-dev goal.
- **Spike artifacts kept until cleanup commit.** They seeded the decision;
they go away when the production system lands.
- **Template rewrite as a discrete migration phase.** The new vocabulary
means every template's class attributes change; the implementation
plan dedicates a phase to this mechanical-but-extensive work rather
than mixing it into the CSS commits.
- **Spike artifacts kept until cleanup commit.** They seeded the
architecture decision; they go away when the production system lands.
The spike used the *old* vocabulary — only its `@layer` structure +
token shape + dark-mode mechanism + aesthetic direction carried
forward.