docs: stylesheet redesign design
Replaces the current ~1.4k LOC stylesheet with a tiered design system: @layer-ordered cascade, two-tier tokens, budgeted component classes, five high-leverage macros, in-app style guide, and the "system is closed" workflow rule (every page element comes from the catalog). Validated by a throwaway /spike comparing Pico v2, Simple.css, and the pure-custom design; pure-custom won on the code-feel criterion the user weighted highest. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fa9acd3027
commit
a0501a20fb
1 changed files with 431 additions and 0 deletions
431
docs/superpowers/specs/2026-05-17-stylesheet-redesign-design.md
Normal file
431
docs/superpowers/specs/2026-05-17-stylesheet-redesign-design.md
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
# 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 — two 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-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.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
Vocabulary (the components layer in full):
|
||||
|
||||
- **`.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.
|
||||
|
||||
State as 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.
|
||||
|
||||
### 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/_modal.html` | `ui.modal` | Owns the `<dialog>` + `modal-header`/`modal-body`/`modal-footer` structure and the close-button + JS hooks |
|
||||
| `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 |
|
||||
|
||||
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` 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`
|
||||
- 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`.
|
||||
```
|
||||
|
||||
## 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'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.
|
||||
|
||||
## 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/panel.css`, `components/table.css`, `components/badge.css`,
|
||||
`components/nav.css`. Half the surface starts looking right again.
|
||||
|
||||
4. **Composite components + macros** — add `components/modal.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.
|
||||
|
||||
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.
|
||||
|
||||
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. **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.
|
||||
- **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.
|
||||
- **`@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.
|
||||
- **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
|
||||
agentic-dev goal.
|
||||
- **Spike artifacts kept until cleanup commit.** They seeded the decision;
|
||||
they go away when the production system lands.
|
||||
Loading…
Reference in a new issue