Control scale: Button/IconButton primitives, two-height tokens, app-wide migration

This commit is contained in:
2026-06-12 23:13:13 +03:00
parent af9cc8a716
commit 7d4906ec38
41 changed files with 660 additions and 904 deletions
+50 -14
View File
@@ -149,11 +149,43 @@ UI by swapping one set of values. Borrowed from Carbon's layering model:
## 4. Component conventions
- **Buttons:** square, 32px (compact) / 40px (default) tall. Variants: **primary**
(filled `--accent`), **secondary** (bordered), **ghost** (text-only, borderless),
**soft-accent** (ghost on an `--accent-soft` wash — a low-emphasis solicitation,
e.g. Donate), **danger** (filled `--support-error`). 600-weight label. Clear
hover/active and a visible focus ring.
- **The control scale (two heights, enforced by tokens + primitives).** Every
interactive control is `--control-height` (32px) or `--control-height-lg`
(40px) — tokens.css. 32px is THE control height: buttons, fields, selects,
segmented controls, icon buttons, anything in a toolbar, form row, or dialog
action row. 40px is reserved for standalone primary CTAs (the library's Build
Chart, a modal list-pane's New X) and modal footers. A third height is drift —
the pre-token codebase accumulated 26/28/30/36/42px variants one component at a
time, which read as "shaky" the moment controls shared a row. The rule is
enforced mechanically: action buttons are the **`Button`** primitive, icon-only
buttons are **`IconButton`** (24px `sm` exists solely for controls nested
_inside_ a 32px control — a search field's clear, a toast's dismiss); writing
`height:` on a new ad-hoc button is the code smell.
- **Buttons:** square, via the `Button` primitive. Variants: **primary** (filled
`--accent`), **secondary** (1px `--border-strong`, `--bg` fill — the same
field-on-layer treatment as inputs, so a bordered control on a gray panel goes
white, never a darker gray), **ghost** (text-only, borderless; a transparent
border holds the box size), **soft-accent** (ghost on an `--accent-soft` wash —
a low-emphasis solicitation, e.g. Donate), **danger** (filled
`--support-error` — the confirm step), **danger-outline** (secondary geometry,
red label, filling solid red on hover/focus — a destructive action sitting
among peers, e.g. a detail view's Delete). 13px 600-weight label. Clear hover/active and a visible
focus ring. **Inline link-style actions are a separate kind, not a Button
variant**: small accent-text actions embedded in content ("+ Add filter",
"Swap X/Y", "Use a constant", a popover's Reset) deliberately sit below the
control scale — content-sized, 1112px, no box — so they read as part of the
prose/panel they act on, not as toolbar controls. Don't "promote" them to
Buttons; their smallness is the emphasis level.
- **Borders mark function, not decoration.** A 1px `--border-strong` box belongs
to things that _hold or receive a value_: fields, selects/disclosure triggers,
segmented controls, secondary buttons, drop targets (dashed). Plain actions are
ghost or filled — never outlined boxes. List rows are flat (hairline dividers +
hover fill), not stacked boxes. With square chrome, every box makes alignment
errors visible, so each border must earn its place; when a region looks
"busy", remove boxes before shrinking anything. (Carbon goes further — fields
are bottom-border-only and its secondary button is a gray fill; our fully-boxed
fields are a recorded divergence, §2, which is exactly why box discipline
matters more here, not less.)
- **Emphasis hierarchy (Carbon button/usage).** A region carries **one**
high-emphasis (primary) button at most; everything else is lower emphasis. In
toolbars/headers full of utilities, the utilities go **ghost** so they recede
@@ -168,8 +200,11 @@ UI by swapping one set of values. Borrowed from Carbon's layering model:
fill one elevation step above their surface** — on `--bg` → `--layer-01`, on a
`--layer-01` surface (dialogs, panels) → `--layer-02`. Filling to the _same_
layer as the surface reads as no hover at all (the collision that left the
confirm dialog's Cancel looking dead). Any button placed on an elevated surface
must step its hover fill up to match.
confirm dialog's Cancel looking dead). The step is mechanical: Button/IconButton
hover with `var(--control-hover-fill, var(--layer-01))`, and an elevated surface
sets `--control-hover-fill: var(--layer-02)` once on its container (the header,
the modal chrome, the confirm card, the library's metadata panel) — controls
inherit the right step instead of each re-encoding it.
- **Focus ring:** a 2px `--focus` outline (offset 12px). Always visible on
keyboard focus — accessibility is non-negotiable (principle 4).
- **Fields** (text, textarea, select, search): square, 1px `--border-strong`,
@@ -411,13 +446,14 @@ The chart `Config` is themed to match the app, per theme:
## 7. Implementation map
| Artifact | Role |
| ------------------------------------------------ | ----------------------------------------------------- |
| [`visual-specimen.html`](./visual-specimen.html) | Living preview + token sandbox. Iterate here first |
| `styles/tokens.css` | The settled tokens — ported from the specimen in M1.5 |
| `styles/base.css` | Font wiring (`@fontsource`), reset, reduced-motion |
| component `*.module.css` | Consume tokens only; no raw hexes, no hardcoded hue |
| `src/core/vega-themes.ts` | Chart `Config` per theme; categorical palettes |
| Artifact | Role |
| -------------------------------------------------- | -------------------------------------------------------------------- |
| [`visual-specimen.html`](./visual-specimen.html) | Living preview + token sandbox. Iterate here first |
| `styles/tokens.css` | The settled tokens — ported from the specimen in M1.5 |
| `styles/base.css` | Font wiring (`@fontsource`), reset, reduced-motion |
| `src/app/components/Button.tsx` / `IconButton.tsx` | The shared control primitives (§4) — every action / icon-only button |
| component `*.module.css` | Consume tokens only; no raw hexes, no hardcoded hue |
| `src/core/vega-themes.ts` | Chart `Config` per theme; categorical palettes |
**Order of work:** settle the specimen → port tokens to `tokens.css` → self-host
Plex in `base.css` → restyle existing M1 components against the tokens → align