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>
526 lines
29 KiB
Markdown
526 lines
29 KiB
Markdown
# Stylesheet redesign
|
|
|
|
## Context
|
|
|
|
The current stylesheet is ~1,436 LOC across six files in `l4d2web/static/css/`:
|
|
`tokens.css` (84 LOC), `layout.css` (42), `components.css` (1,177; ~196 class
|
|
selectors), `logs.css` (13), `console-autocomplete.css` (88), `editor.css` (32).
|
|
Tokens are real (CSS custom properties, light + dark via `prefers-color-scheme`),
|
|
but `components.css` grew organically: 196 classes accumulated without a budget,
|
|
specificity is uneven, and the same idea ("state pill") is expressed several
|
|
ways in different places.
|
|
|
|
The result reads as "developer wrote some CSS" rather than "this is a system."
|
|
That is a problem for human readers and a bigger problem for agentic
|
|
development — when an LLM agent adds a feature, the cheapest path is to
|
|
re-implement whichever pattern happens to be in its context window, which
|
|
re-introduces the inconsistency the next agent then has to imitate. The
|
|
incoherence compounds.
|
|
|
|
This redesign replaces the current stylesheet with a small, explicitly-tiered
|
|
design system: two-tier tokens, an `@layer`-ordered cascade, a budgeted set of
|
|
component classes, a handful of macros where markup correctness is load-bearing,
|
|
and a live style guide that is the single canonical reference contributors (and
|
|
agents) read before producing new UI.
|
|
|
|
## Goals
|
|
|
|
- **Coherent visual** — every page in `l4d2web` reads as one system, in light
|
|
and dark mode.
|
|
- **Agentic-development friendly** — an agent given a UI task can produce
|
|
styled output by reading one file (the style guide) and following the
|
|
conventions there. No tribal knowledge required.
|
|
- **Smaller surface area** — `components.css`'s 1,177 LOC + 196 classes
|
|
collapses to a deliberate ~400 LOC budget covering exactly the primitives
|
|
used across the app; project-specific widgets live in their own files.
|
|
- **Enforced cascade** — specificity is determined by `@layer` order, not
|
|
selector specificity, so adding a component never accidentally beats
|
|
utilities or fights element defaults.
|
|
- **Closed system** — every page element comes from the catalog. New widget?
|
|
Extend the system, then use it. No inline `style=""`, no inventing class
|
|
names off-system.
|
|
|
|
Non-goals:
|
|
|
|
- Adopting any CSS framework as a base. Pico v2, Simple.css, Bulma, and
|
|
Tailwind were considered and rejected; see "Decisions" below.
|
|
- Changing the markup of project-specific widgets (file-tree, overlay-picker,
|
|
console-autocomplete, CodeMirror skin, log viewer, live-state). They get
|
|
relocated into the new file layout but keep their existing structure.
|
|
- Changing routes, blueprints, or htmx contracts.
|
|
- Adopting Open Props or any external token library. The refined `tokens.css`
|
|
remains the single source of truth.
|
|
- Build-step tooling. No Sass, no PostCSS, no bundler. Plain CSS using modern
|
|
features (`@layer`, `color-mix()`, CSS custom properties) the production
|
|
browsers already support.
|
|
|
|
## Architecture
|
|
|
|
### The eight layers
|
|
|
|
The cascade order is declared once in the entry stylesheet:
|
|
|
|
```css
|
|
@layer reset, tokens, elements, layout, components, widgets, utilities;
|
|
```
|
|
|
|
Each layer has one responsibility:
|
|
|
|
| # | Layer | Contains | File(s) |
|
|
|---|-------------|----------------------------------------------------------------|-------------------------------------------------------------|
|
|
| 1 | `reset` | Modern reset (Andy-Bell / Josh-Comeau-style; ~30 LOC) | `reset.css` |
|
|
| 2 | `tokens` | Primitives + semantic tokens, light + dark | `tokens/primitives.css`, `tokens/semantic.css` |
|
|
| 3 | `elements` | Bare HTML defaults: `body`, `h1-h6`, `a`, `code`, `kbd`, `hr`, form elements, tables | `elements.css` |
|
|
| 4 | `layout` | Composition primitives: `.container`, `.stack`, `.stack-h` | `layout.css` |
|
|
| 5 | `components`| Named widgets: `.btn`, `.field`, `.table`, `.panel`, `.modal`, `.tabs`, `.badge`, `.nav`, `.dropdown` | `components/<name>.css` |
|
|
| 6 | `widgets` | Project-specific composites: `.file-tree`, `.overlay-picker`, `.console-autocomplete`, log viewer, editor skin | `widgets/<name>.css` |
|
|
| 7 | `utilities` | Single-purpose overrides: `.muted`, `.mono`, `.truncate`, `.sr-only` | `utilities.css` |
|
|
|
|
The single entry file (`main.css`) declares the layer order and `@import`s the
|
|
rest. `base.html` swaps its current five `<link>` tags for one
|
|
`<link rel="stylesheet" href="…/main.css">`.
|
|
|
|
### File layout
|
|
|
|
```
|
|
l4d2web/static/css/
|
|
main.css ← @layer order + @imports
|
|
reset.css
|
|
tokens/
|
|
primitives.css ← raw palette: --gray-100, --blue-700, --space-4, --text-base, …
|
|
semantic.css ← aliases: --color-primary, --color-bg, --space-m, …
|
|
plus the data-theme="dark" branch
|
|
elements.css ← bare HTML defaults
|
|
layout.css
|
|
components/
|
|
button.css
|
|
field.css
|
|
table.css
|
|
panel.css
|
|
modal.css
|
|
tabs.css
|
|
badge.css
|
|
nav.css
|
|
dropdown.css
|
|
widgets/
|
|
file-tree.css ← moved from current components.css
|
|
overlay-picker.css ← extracted from current components.css
|
|
console-autocomplete.css ← moved (currently at static/css/ root)
|
|
editor.css ← moved
|
|
logs.css ← moved
|
|
live-state.css ← extracted from components.css
|
|
utilities.css
|
|
```
|
|
|
|
### 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
|
|
colors (red / green / amber), spacing scale, type scale, radii, shadow scale,
|
|
motion durations and easings, and font stacks. No semantic meaning. The
|
|
primitives never appear in component CSS directly — they are consumed only
|
|
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-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.
|
|
|
|
**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
|
|
inline script at the top of `<head>` (avoids a flash of wrong theme on
|
|
first paint); a future user-preference toggle would write to localStorage
|
|
and override the OS default. Implementing the toggle is out of scope for
|
|
this redesign — the redesign only ensures the mechanism is in place.
|
|
|
|
### Components — class-based, CSS-only
|
|
|
|
**Naming convention** (two rules, no exceptions):
|
|
|
|
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 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.
|
|
|
|
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
|
|
|
|
A new directory `l4d2web/templates/ui/` adds Jinja macros for primitives
|
|
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/_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/_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
|
|
macro wrapper. The macro tier is reserved for "if you forget a piece of
|
|
this, the result is silently wrong."
|
|
|
|
Existing partials (`_console_line.html`, `_overlay_file_tree.html`,
|
|
`_overlay_picker_*.html`, `_recent_players_modal_body.html`,
|
|
`_server_actions.html`, `_live_state.html`) are unaffected. They are
|
|
page-scoped composites that solve a different problem from `ui/*`; they
|
|
keep their current shape and just reference the new class names.
|
|
`_macros.html` continues to host CSRF/utility helpers.
|
|
|
|
## Workflow principle — the system is closed
|
|
|
|
The redesign codifies a hard rule:
|
|
|
|
> **Every page element comes from the catalog. If the catalog lacks what
|
|
> you need, extend the catalog first, then use it.**
|
|
|
|
What this means in practice:
|
|
|
|
- **Adding a feature with existing widgets** → just use them.
|
|
- **Adding a feature that needs a new widget** → three-step extension:
|
|
1. Add the CSS to `components/<name>.css` or `widgets/<name>.css`.
|
|
2. Add a style guide entry: rendered example + escaped source HTML, with
|
|
at least one "do" example, and a "don't" entry if there's a foreseeable
|
|
wrong way to use it.
|
|
3. If the widget is composite or a11y-load-bearing, add a macro under
|
|
`templates/ui/`.
|
|
4. Only then use the widget on the page.
|
|
- **New variant of an existing widget** → add the variant class to the
|
|
widget's CSS, add a style guide entry showing the variant alongside the
|
|
existing ones, then use it.
|
|
- **Page-specific tweak that doesn't reuse** → it still gets a name and
|
|
lives under `widgets/`. Documented in the style guide as "used only on
|
|
X page." The bar is "documented" not "reused" — a singleton with a
|
|
proper home is acceptable.
|
|
- **One-off inline `style=""`** → forbidden.
|
|
|
|
Enforcement is light and layered:
|
|
|
|
1. **Cultural** — code-review checklist: "did this change add UI without
|
|
going through the system?"
|
|
2. **Documented** — `AGENTS.md` carries the rule verbatim so agents see it
|
|
first.
|
|
3. **Style guide is the source of truth** — if a widget isn't in
|
|
`templates/styleguide.html`, it doesn't exist; PRs adding widgets must
|
|
update the style guide in the same commit.
|
|
4. **Optional lint** (deferred, post-redesign) — a small script scanning
|
|
`templates/*.html` for `style="`, magic hex colors, and class names not
|
|
present in the style guide. Cheap to add later; not blocking for this
|
|
work.
|
|
|
|
The principle has an explicit cost: a feature that needs a new widget pays
|
|
"add widget first" overhead. For human contributors that can feel slow;
|
|
for agentic dev, the cost is much lower — an agent can extend the system
|
|
in the same session, in the same commit, without context-switching. This
|
|
is exactly the kind of friction that compounds positively.
|
|
|
|
## Style guide
|
|
|
|
A new public route at `/styleguide` rendering `templates/styleguide.html`.
|
|
Mounted by a small new blueprint `l4d2web/routes/styleguide_routes.py`.
|
|
Public access (no login required) — it leaks nothing sensitive and a
|
|
linkable URL is genuinely useful in PR descriptions and chat.
|
|
|
|
Each primitive gets a section showing:
|
|
|
|
1. **The rendered widget**, multiple variants if applicable.
|
|
2. **The source HTML**, captured once via `{% set src %}…{% endset %}` and
|
|
rendered twice — once as `{{ src|safe }}` (the live widget), once inside
|
|
`<pre><code>{{ src|trim|e }}</code></pre>` (copy-paste source). This
|
|
guarantees the rendered example and the displayed source cannot drift.
|
|
3. **Do / don't blocks** on at least `field`, `modal`, `tabs`, and `badge`
|
|
(the most-likely-to-be-screwed-up primitives). Format:
|
|
- ✅ DO — *correct example* + source + one sentence on why it's right
|
|
- ❌ DON'T — *anti-pattern example* + source + one sentence on what
|
|
breaks (a11y wiring missing, wrong state class, etc.)
|
|
|
|
A token reference table at the top (or in a dedicated section) shows color
|
|
swatches, spacing scale, type scale, shadow scale. Manually maintained as
|
|
a Jinja loop over a Python dict — not parsing CSS. Stays in sync via the
|
|
"add widget = update style guide" rule.
|
|
|
|
A dark-mode toggle button at the top of the style guide sets
|
|
`data-theme="dark"` on `<html>`. Useful well beyond the style guide;
|
|
included here because the style guide is the natural place to introduce it.
|
|
|
|
An entry in `AGENTS.md`:
|
|
|
|
```markdown
|
|
## UI work
|
|
|
|
Before adding any UI markup:
|
|
|
|
1. Read `l4d2web/templates/styleguide.html` — it lists every available widget
|
|
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` (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. 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
|
|
|
|
A throwaway spike at `/spike` (gated on `LEFT4ME_SPIKE=1`) compared three
|
|
options on a representative page: Pico v2 as base, Simple.css as base, and
|
|
the proposed pure-custom design with the 8-layer cascade. The spike
|
|
artifacts live at `l4d2web/templates/spike.html` and
|
|
`l4d2web/static/css/spike/{pico,simple,custom}.css`.
|
|
|
|
Findings:
|
|
|
|
- **Pico v2** required ~50 LOC to remap our semantic tokens onto its
|
|
`--pico-*` variables, plus a `width: auto` override on buttons to undo
|
|
Pico's full-width-in-form default. Every customization needs the
|
|
"find Pico's variable → look up the docs → override" dance. The custom
|
|
widgets (file-tree, overlay-picker) sit next to Pico-styled primitives
|
|
in two-dialects mode.
|
|
- **Simple.css** was smaller and less opinionated, but its blog-flavored
|
|
typography (large H1/H2/H3) and per-`<section>` top borders surprised in
|
|
ways that would keep biting. ~165 LOC custom on top.
|
|
- **Pure custom** at ~280 LOC reads top-to-bottom as a design system:
|
|
reset → primitives → semantic → elements → layout → components →
|
|
widgets → utilities, each in its own `@layer`. No translation layer,
|
|
no override-fights. The `@layer` cascade specifically means the agent
|
|
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 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
|
|
|
|
One branch, work directly on `master` (per user preference), staged as
|
|
clean commits each of which leaves the site functional (visual transition
|
|
may be visible mid-series, which is fine):
|
|
|
|
1. **Foundation** — add `main.css`, `reset.css`, `tokens/primitives.css`,
|
|
`tokens/semantic.css`. Old files still in place; `base.html` unchanged.
|
|
Nothing renders differently.
|
|
|
|
2. **Elements + layout** — add `elements.css`, restructure `layout.css`
|
|
into the new shape. Switch `base.html` to a single
|
|
`<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/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/dialog.css`,
|
|
`components/tabs.css`, `components/field.css`, `components/dropdown.css`,
|
|
`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` 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.
|
|
|
|
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,
|
|
admin) in light and dark mode, fixing any regressions in this commit.
|
|
|
|
The spike was scaffolding for the framework decision; once the design is
|
|
in `main.css`, it is no longer load-bearing.
|
|
|
|
Per-commit verification: open the dev server (`scripts/dev-server.py`,
|
|
which serves `LEFT4ME_ROOT=.tmp/dev-server` with seeded demo content),
|
|
walk through the affected pages, fix any regressions in the same commit.
|
|
Each commit should leave the *functionality* working even if mid-series
|
|
visuals are transitional.
|
|
|
|
## Decisions log
|
|
|
|
A summary of the brainstorm decisions and why, in case future-me needs to
|
|
revisit them.
|
|
|
|
- **Custom system, not a framework base.** Pico, Simple.css, and Bulma
|
|
were each considered. Pico is the only "minimal classless framework"
|
|
with admin-app component coverage; the rest are blog-scale typography
|
|
systems. The spike showed that even Pico's coverage costs ~50 LOC of
|
|
token translation + override-fights, while the pure-custom path costs
|
|
only ~+100 LOC and yields a much cleaner code surface. The "code feel
|
|
bites incoherence later" criterion tipped this decisively.
|
|
- **No Tailwind / utility-first.** Off the table per user preference.
|
|
- **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.
|
|
- **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 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 blocks. Most leveraged change for the
|
|
agentic-dev goal.
|
|
- **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.
|