# 09 · Visual Design Language

> **Status:** foundational design pass. This is the _visual_ contract — the
> counterpart to `docs/spec/` (behavior) and the rest of `docs/architecture/`
> (structure). `styles/tokens.css`, `styles/base.css`, component CSS Modules, and
> `src/core/vega-themes.ts` implement _to this doc_.
>
> **Companion:** [`visual-specimen.html`](./visual-specimen.html) — a standalone,
> openable "kitchen sink" that renders every token and element with a live
> theme/accent switcher. Edit tokens there first, eyeball them, then port the
> settled values into `styles/tokens.css`.

Astrolabe's look is **inspired by the IBM Design Language / Carbon**, but Carbon is
**not a dependency** — we transcribe the values we want and reinterpret the
principles in our own words. We borrow IBM's _engineered structure_; we keep
_color and theming free_.

---

## 1. Principles

IBM's four design principles map almost exactly onto Astrolabe's SOUL ("the spec is
the star; the UI is a thin, considered shell"). Restated for us:

1. **Considered** — _remove everything gratuitous._ No decoration that isn't
   carrying meaning. Whitespace is a feature.
2. **Unified** — a _small fixed kit_ (one type family, a neutral ramp, one accent,
   a handful of components) reused systematically. Identity comes from consistency,
   not novelty per screen.
3. **Executed** — _everything communicates, including what we leave out._ Alignment,
   rhythm, and empty space are decisions, not leftovers.
4. **Progressive** — _every element reduces friction._ If it doesn't help the user
   read, edit, or find a snippet faster, it doesn't earn its place.

…plus our own, where we part ways with IBM:

5. **Structure is rigorous; color is free.** The grid, type scale, spacing, and
   square geometry are systematic and fixed. Color, accent, and theming are the
   _expressive_ layer — open, swappable, and meant to be played with.

---

## 2. Deliberate divergences from Carbon

What we **borrow** vs. where we **diverge** — recorded so future readers know these
were choices, not drift:

| Topic                           | IBM/Carbon                                          | Astrolabe                                                                                 |
| ------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Adoption                        | A framework + component lib                         | **Inspiration only.** Transcribed tokens, our own components                              |
| Structure (grid, type, spacing) | 8px mini unit, modular type scale                   | **Borrowed wholesale** — it's the rigorous part worth having                              |
| UI chrome corners               | ~0–2px (near-square)                                | **Fully square, `radius: 0`** — one notch more austere/engineered                         |
| Icons                           | Rounded exteriors, 2px soft corners + 90° interiors | **Kept rounded** (use Carbon's icon set) — the one warm, human touch                      |
| Color                           | "Blue at the core"; other hues only for purpose     | **Dropped.** Color/theming is free and expressive; accent is a token, many themes welcome |
| Neutrals                        | Carbon gray ramp                                    | **Borrowed** — accessible, well-tuned, a good legible base                                |
| Motion                          | Productive vs. expressive                           | **Productive only** — subtle, purposeful, reduced-motion-aware                            |
| Secondary button                | A dark gray **fill** (`$button-secondary`)          | **Outlined** (Carbon's _tertiary_ shape) — one filled button per region stays the rule    |

---

## 3. Tokens

All tokens are CSS custom properties on `:root`, themed by overriding them on
`[data-theme]` (and, for accent, `[data-accent]`). As of M1.5 the settled values
live in `styles/tokens.css`; the specimen remains the sandbox for trying new
tokens/themes before porting them across.

### 3.1 Typography — IBM Plex

- **Families:** `IBM Plex Sans` for UI, `IBM Plex Mono` for the editor, code,
  numeric/tabular data, and inline spec fragments. Self-hosted in production via
  `@fontsource/ibm-plex-sans` + `@fontsource/ibm-plex-mono` (offline/PWA — never a
  CDN). The specimen uses a CDN purely for preview convenience.
- **Scale (px), from Carbon's modular scale:** `12 · 14 · 16 · 18 · 20 · 24 · 28 ·
32 · 42`. Body is **14/20** (already our `--font-size-base`). Captions/labels 12.
- **Weights:** 400 regular, 600 semibold for emphasis/headings; 300 light reserved
  for large display only.
- **Breathing room:** Plex _"requires space to breathe."_ Don't over-tighten —
  body line-height ≥ 1.4, default tracking (no negative letter-spacing on text).
  Flush-left, clear hierarchy.

### 3.2 Spacing — the 8px base unit

IBM's product/web rule: _"the 8px mini unit guides everything."_ Every gap, pad,
and size is a relationship of 8 (with 2/4 as fine sub-steps):

`--space-1: 2px · --space-2: 4px · --space-3: 8px · --space-4: 12px · --space-5:
16px · --space-6: 24px · --space-7: 32px · --space-8: 48px · --space-9: 64px`.

> Note: this renumbers our current M0 scale to anchor on 8. The migration is
> mechanical (search/replace `--space-*` usages) and lands with the design pass.

### 3.3 Color — role-based, theme-free

Color is expressed as **roles**, never raw hexes, so themes can repaint the whole
UI by swapping one set of values. Borrowed from Carbon's layering model:

| Role token                                           | Meaning                                                                                |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `--bg`                                               | App canvas (lowest layer)                                                              |
| `--layer-01` / `--layer-02`                          | Raised surfaces (panels, cards, popovers) — elevation by lightness step, not shadow    |
| `--border` / `--border-strong`                       | Subtle separators / component boundaries (3:1 non-text contrast)                       |
| `--field-01` / `--field-02` (+ `--field-hover-*`)    | Field fills: one step off the canvas / off a `--layer-01` surface                      |
| `--text` / `--text-secondary` / `--text-placeholder` | Text hierarchy                                                                         |
| `--accent` / `--accent-hover` / `--accent-contrast`  | The expressive accent — **swappable**; UI must never hardcode a hue                    |
| `--accent-soft` / `--accent-soft-hover`              | Low-emphasis accent **wash** (accent mixed into `--bg`) for a tinted-but-quiet surface |
| `--focus`                                            | Focus-ring color (defaults to `--accent`)                                              |
| `--support-error / -success / -warning / -info`      | Status only — color = meaning                                                          |

- **Neutrals** use the Carbon gray ramp (`#f4f4f4 … #161616`) — accessible and
  legible. **Accent and theming are open**: the default accent is **deep teal**
  (`#0e7490` / dark `#2dd4bf`), with opt-in alternates (blue, indigo, amber, rose)
  and light/dark themes to prove the system is free, not blue-bound. Pick, add, or
  invent themes freely.
- **Status palette** (borrowed, stable): error `#da1e28`, success `#198038`,
  warning `#f1c21b`, info `#0043ce` — tuned per theme for contrast.
- **Contrast:** target WCAG AA (4.5:1 text, 3:1 large/UI). Accent-on-`--bg` and
  text-on-`--accent` must both pass for any shipped theme. **`--border-strong` is a
  component boundary, not decoration — it must hold 3:1 against the surface it
  bounds (WCAG 1.4.11)**: gray-50 `#8d8d8d` light (3.32:1 on `--bg`, 3.02:1 on
  `--layer-01`), gray-60 `#6f6f6f` dark (3.60:1 on `--bg`). Carbon's gray-30/gray-70
  "strong" values fail this; don't drift back to them.
- **Soft accent is derived, not hardcoded.** `--accent-soft` /
  `--accent-soft-hover` are `color-mix(in srgb, var(--accent) 12–20%, var(--bg))`,
  so the wash follows whatever accent + theme is active rather than carrying a
  per-accent value. Use it for a surface that should be _noticed_ without competing
  with a primary action (the header's **Support** button).
- **Field-on-layer (Carbon layering).** A field's fill is **one step off the
  surface it sits on**, alternating like Carbon's field set: `--field-01` on the
  canvas (gray on white / near-black on black), `--field-02` on a `--layer-01`
  surface (white on gray / a step lighter on dark). The alternation is what keeps
  a field visible without a box and prevents three indistinct grays from stacking
  (canvas → panel → field — the snippet-metadata-panel bug). Mechanism: components
  consume the contextual **`--field`** / `--field-hover` tokens only; an elevated
  surface sets `--field: var(--field-02)` (and the hover variant) once on its
  container — the same pattern as `--control-hover-fill`. Setters today: the modal
  chrome and body (ModalShell), the library metadata panel, the settings popover.

### 3.4 Shape & elevation

- `--radius: 0` for all chrome (buttons, fields, cards, panels). Square is the look.
- **Icons are exempt** — they keep their rounded geometry (Carbon icon set, 2px
  corners). Icons are SVG, not chrome, so `--radius` doesn't touch them.
- **Elevation is lightness, not shadow.** Stack `--bg → --layer-01 → --layer-02`.
  Shadows, if ever used, are minimal and reserved for true overlays (modals,
  popovers).
- **Borders are 1px**, `--border` subtle by default.

### 3.5 Motion

- **Durations (productive):** `--dur-fast: 70ms`, `--dur-fast-2: 110ms`,
  `--dur-moderate: 150ms`. Nothing slower in the core UI.
- **Easing:** standard productive `cubic-bezier(0.2, 0, 0.38, 0.9)`.
- **Restraint:** animate only what's vital (state changes, entrances of meaningful
  elements). No gratuitous motion. All transitions are already neutralized under
  `@media (prefers-reduced-motion: reduce)` in `base.css`.

---

## 4. Component conventions

- **How a shared look travels.** Exactly four mechanisms, in escalating order:
  **design tokens** (`styles/tokens.css`) for values; **contextual custom
  properties** set once by a surface and consumed by everything on it
  (`--control-hover-fill`, `--field`); **`base.css` element baselines** at zero
  specificity for looks every instance of an element shares (the focus ring, the
  field recipe); **React primitives** (`Button`, `IconButton`) when shared
  _behavior_ or enforced variants justify a component. Nothing else — no
  CSS-module `composes`, no utility classes, no mixin layer. A fifth mechanism is
  drift, even when it's locally cleaner. 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.
- **Color inputs go through `ColorField`** — the one home for the native
  `type="color"` chrome reset and the optional paired hex field (read/copy/retype
  a value). A bare `<input type="color">` is the code smell; the Theme Builder
  swatches (`hex`) and the Chart Builder constant-colour binding (`size="sm"`,
  inside a pill) share it. Per-context removal is the caller's, not the field's.
- **Buttons:** square, via the `Button` primitive. Variants: **primary** (filled
  `--accent`), **secondary** (1px `--border-strong`, `--bg` fill —
  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. Support), **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, 11–12px, 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.** Fields and select-like triggers are
  **not boxes** — they're the quiet-field recipe below (fill + bottom border). A
  1px `--border-strong` **box** is reserved for the few value-holding controls
  that need full enclosure: segmented controls, secondary buttons, drop targets
  (dashed), the color-swatch input. Plain actions are ghost or filled — never
  outlined boxes; passive chrome (tags, badges, type glyphs) takes `--border`,
  never `--border-strong`. 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.
- **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
  behind the work area and read as a row of equals — only the genuine call to
  action is filled. Worked examples: the **editor toolbar** (Publish is the lone
  primary; Extract/Revert are secondary, and collapse to icons when narrow — see
  [arch 10 §8](10-interaction-and-feedback.md)), and the **header** (Datasets /
  Import / Export / About are **icon-only** IconButtons per Carbon's UI-shell
  header — global actions as a right-aligned icon row — with accessible names
  that carry scope, e.g. "Export workspace" vs the preview's per-chart "Export";
  a divider then sets off the soft-accent Support, which keeps its text label —
  a solicitation needs the word — and the ghost theme toggle).
- **Hover is variant-specific:** filled buttons (primary/danger) **darken**
  (`--accent-hover` / a slight brightness drop); outlined/ghost buttons **gain a
  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). 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 1–2px). Always visible on
  keyboard focus — accessibility is non-negotiable (principle 4).
- **Fields** (text, textarea, select-trigger, search): **the quiet field** —
  square, `--field` fill (one step off the surface, §3.3), **bottom border only**
  in `--border-strong`, no box; 2px `--focus` ring hugging the box (offset −2px)
  on focus. Mono font for spec/JSON inputs. The recipe is declared **once** in
  `styles/base.css` as a zero-specificity element baseline (text-like `input`
  types + `textarea`, excluding Monaco's internal widgets); component modules add
  only idiosyncrasies — width, padding, font size — never a competing border or
  fill. Select-like triggers are `<button>`s the baseline can't reach, so
  SelectControl/SortControl restate it (they are fields, not buttons: a trigger
  holds a value); their hover/open fill is `--field-hover`. Writing `border:` on
  an input is the code smell — the field look has exactly one home. The quiet
  treatment is Carbon's; the boxed alternative (GOV.UK's canon — 2px solid
  enclosure) is equally legitimate a11y-wise but spends a box on every field,
  and in a square-chrome editor UI boxes are reserved for the few controls that
  need full enclosure. A field's boundary is carried by its fill step plus the
  3:1 underline.
- **List rows** (snippet library): compact, full-row hover (`--layer-01`),
  active row marked by an accent left-border + `--layer-01` fill, secondary
  metadata in `--text-secondary`. Row-level actions reveal on hover.
- **Status indicators:** a small dot/tag for draft vs. published; a dataset glyph
  when references exist. Status colors only.
- **Toasts:** `--layer-02`, 1px border in the support color, square, brief.
- **Dialogs (confirm / alert):** centered card on a dimmed backdrop
  (`rgb(0 0 0 / 0.5)`), `--layer-01` fill, 1px `--border`, minimal overlay shadow,
  square. A title, a `--text-secondary` message, and a right-aligned action row:
  **Cancel** (secondary) + the primary, which is a **danger** button (filled
  `--support-error`, `--on-status` label) for destructive intent. The in-app
  replacement for `window.confirm`; see [arch 03 → Confirmation & alert dialogs](03-modal-system.md#confirmation--alert-dialogs)
  for behavior (Carbon transactional rule: backdrop does **not** dismiss; Cancel
  takes focus for danger). Use a dialog only when a decision is required —
  non-blocking outcomes are **toasts**.
- **Code / editor surfaces:** `--font-mono`, `--layer-01`, generous line-height.

---

## 5. Iconography

> Geometry and the accessibility floor are set elsewhere: icons keep Carbon's
> rounded 2px geometry and are exempt from `--radius` (§3.4), and every icon-only
> control carries an accessible name with meaning never resting on colour alone
> ([arch 10 §5–6](10-interaction-and-feedback.md)). This section is the **usage**
> contract — _when_ a thing earns an icon, and how the set stays coherent.

**Stance: balanced, label-first.** Astrolabe is a tool its user returns to often
— the one context where GOV.UK concedes icons earn their place: _"Icons can be
more useful in case working systems, where users are familiar with the interface
and return to it frequently … In most cases it's still helpful to include a
visible text label alongside any icons"_ (GOV.UK, `styles/images`). So we are
neither icon-rich (Carbon's default density) nor icon-austere (GOV.UK's
public-service default): the default is **text**; an icon is added only when it
does real work, and usually _alongside_ the text, not instead of it.

### 5.1 The icon-vs-text decision

Apply in order:

1. **Default to text.** If a label alone is clear, ship the label. An icon that
   only decorates fails principle 1 (Considered) and invites the ambiguity GOV.UK
   warns of — _"people can understand a single icon to mean different things."_
2. **Add an icon when it does a job** — one of: speeds scanning of a list/row read
   repeatedly (dataset marker), signals status/type at a glance (draft dot), or
   affords a high-frequency action (delete). Carbon's rule holds: _"employ icons
   sparingly and strategically … to reduce cognitive load."_
3. **Pair icon + text by default.** In any labelled control, menu item, or row,
   the icon rides _alongside_ its text — recognition support (NN/g #6), not a
   replacement for the word.
4. **Icon-only is the exception, and a closed set.** Permitted only for the
   **universal set** (§5.2) — glyphs whose meaning is unambiguous and which recur
   everywhere. A new icon-only control is never created ad-hoc; admitting one to
   the set is a contract change, not a per-component decision.

| Form        | When                                                     | Accessible name                                   |
| ----------- | -------------------------------------------------------- | ------------------------------------------------- |
| Text only   | The default. Label is clear on its own.                  | The visible text                                  |
| Icon + text | Icon aids scanning/status; text stays the primary label. | The visible text; icon `aria-hidden` (decorative) |
| Icon only   | Universal-set glyph in a space-constrained control.      | `aria-label` on the control (APG button pattern)  |

> Accessible-name mechanics (APG button pattern): a control's name comes from its
> text content, or from `aria-label`/`aria-labelledby` when there is none. So an
> icon **beside visible text** is `aria-hidden` (the text names it — avoiding the
> duplicate screen-reader readout GOV.UK flags); an icon **alone** needs an
> `aria-label`.

### 5.2 The icon vocabulary (controlled set)

One icon = one meaning, **app-wide** — GOV.UK: _"Do not use a single icon to
represent more than one thing."_ The vocabulary is a **uniqueness ledger**, not a
hall of fame: every glyph is registered here so the same meaning always reuses its
glyph and no glyph is ever repurposed — even a one-off gets a row, so it can't be
reused for something else later. The registry lives in code at
[`src/app/components/Icon.tsx`](../../src/app/components/Icon.tsx) (the `IconName`
union + `GLYPHS` map); this table is its prose mirror. Glyphs are traced from
Carbon and drawn `fill: currentColor`.

**Core set** — recurring, cross-surface:

| Meaning              | Carbon glyph    | Form                     | Surfaces                                                                                     |
| -------------------- | --------------- | ------------------------ | -------------------------------------------------------------------------------------------- |
| Close / dismiss      | `Close` (✕)     | icon-only ⭐             | `ModalShell`, `Toaster`                                                                      |
| Theme → dark         | `Asleep` (moon) | icon-only ⭐             | `ThemeToggle` (shown when light)                                                             |
| Theme → light        | `Light` (sun)   | icon-only ⭐             | `ThemeToggle` (shown when dark)                                                              |
| References a dataset | `DataTable`     | icon + text              | Library row marker, Linked-datasets list, header **Datasets**, editor **Extract**⁴           |
| Add / create-new     | `Add`           | icon + text → icon-only⁴ | Library "Create New Snippet" (collapses to "+" when the pane is narrow), Datasets "New …"    |
| Delete               | `TrashCan`      | icon-only ⭐ (danger)    | Library row delete¹ — text "Delete" in the panel²                                            |
| Import workspace     | `Upload`        | icon + text              | Header **Import** (a file is brought into the app)                                           |
| Export workspace     | `Download`      | icon + text              | Header **Export** (the workspace is written out)                                             |
| About / information  | `Information`   | icon + text              | Header **About**                                                                             |
| Revert draft         | `Reset`         | icon + text → icon-only⁴ | Editor toolbar **Revert** (restore last published)                                           |
| Live search          | `Search`        | icon-in-field⁵           | Library search box (leading magnifier; the input's `aria-label`/placeholder names the field) |
| Settings (gear)      | `Settings`      | icon-only ⭐             | Per-pane settings disclosures (Editor, Preview, Library dates)                               |
| Unpublished draft    | (CSS dot)       | status-glyph             | Library row (paired with a hidden label)                                                     |

**Pane-toggle set** — the one **custom** sub-family (not single Carbon glyphs): a
panel frame with one of three regions filled, where the filled bar's _position_
encodes which pane it toggles. Icon-only by design — position is the meaning — each
carrying an `aria-label`:

| Meaning             | Glyph                          | Form         | Surface           |
| ------------------- | ------------------------------ | ------------ | ----------------- |
| Toggle library pane | panel frame, **left** filled   | icon-only ⭐ | `PaneToggleStrip` |
| Toggle editor pane  | panel frame, **centre** filled | icon-only ⭐ | `PaneToggleStrip` |
| Toggle preview pane | panel frame, **right** filled  | icon-only ⭐ | `PaneToggleStrip` |

**Status set** — Carbon's **filled** notification glyphs, one per severity. Unlike
the outline UI set, these are coloured **by status** (not by surrounding text) and
are a deliberate _filled_ sub-family. They add a **redundant, non-colour severity
channel** (WCAG 1.4.1): meaning never rests on the bar colour alone, and the
triangle shape-codes warning apart from the round error/success/info — so severity
survives colour-blindness. Used wherever a status is signalled (toasts today; inline
notifications/validation as they arrive):

| Meaning | Carbon glyph         | Colour                  | Surfaces                                    |
| ------- | -------------------- | ----------------------- | ------------------------------------------- |
| Error   | `ErrorFilled`        | `--support-error`       | `Toaster` (error)                           |
| Warning | `WarningAltFilled` ▲ | `--support-warning-fg`³ | `Toaster` (warning), Chart Builder warnings |
| Success | `CheckmarkFilled`    | `--support-success`     | `Toaster` (success)                         |
| Info    | `InformationFilled`  | `--support-info`        | `Toaster` (info)                            |

**Scoped set** — single-surface, glyph **reserved** in the ledger but **not yet in
the `Icon` registry**:

| Meaning          | Carbon glyph       | Form        | Surface / note                                                                                                                                                                                                                                             |
| ---------------- | ------------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Swap / transpose | `ArrowsHorizontal` | icon + text | Chart Builder "Swap X/Y" — the button **ships** today with an interim Unicode `⇄`, not a registry `Icon`. `ArrowsHorizontal` stays reserved here so the meaning is claimed; promote the button to it (add the `IconName` + glyph) when polishing the swap. |

⭐ = **icon-only set** (the glyph alone names the control, via `aria-label`): the
**universal** glyphs `close` + `theme`; the conventional disclosure/affordance
glyphs `settings` (gear) and the **pane-toggle** trio (position is the meaning);
and **delete** as a deliberate destructive-row exception — a dense, repeated list
action where a label would cost more than it gives. `search` is _not_ ⭐: its
magnifier is a decorative lead-in to a labelled input (footnote ⁵), not a control
named by the glyph. ✕ means **close only**; delete is `TrashCan`, never ✕ (that
collision is exactly what one-glyph-one-meaning forbids). Admitting a glyph to ⭐ is
a contract change (§5.1 rule 4), not a per-component call.

¹ Row delete is hover/focus-revealed and reddens on hover/focus (arch 10 — reveal &
destructive-intent rules). ² "Duplicate" and "Delete" in the detail panel stay
**text** (label-first; lower frequency, not a dense row). ³ The raw warning yellow
fails contrast on light surfaces, so the warning glyph uses `--support-warning-fg`
(darkened amber; the yellow `--toast-accent` stays on the decorative border). ⁴
**Responsive collapse**, not membership in the icon-only set: these are icon+text
that _shed the label_ under width pressure (the editor toolbar's secondary actions,
and the library's standalone Create CTA, when the pane is narrow — [arch 10
§8](10-interaction-and-feedback.md)), keeping the accessible name in
`aria-label`/`title`. A degradation that preserves the name is distinct from a
permanent icon-only control (§5.1 rule 4), so it isn't a closed-set change. ⁵
**Icon-in-field**: a decorative leading glyph _inside_ a labelled control — the
search input's magnifier is `aria-hidden`, and the input itself carries the
accessible name. Not icon-only (the control is named by its label, not the glyph).

### 5.3 Size

Carbon's icon scale, paired to our type. The tokens are live in
`styles/tokens.css`; the `Icon` component's `size` prop selects one:

| Token       | Size | Pairs with                     | Use                                         |
| ----------- | ---- | ------------------------------ | ------------------------------------------- |
| `--icon-sm` | 16px | 14px body (`--font-size-base`) | Default — inline with text, row markers     |
| `--icon-md` | 20px | 16px text                      | Slightly emphasised controls (theme toggle) |
| `--icon-lg` | 24px | —                              | When a larger icon is genuinely needed      |
| `--icon-xl` | 32px | —                              | Rare; large display only                    |

- _"16px and 20px icons are optimized to feel balanced when paired with 14pt and
  16pt IBM Plex"_ (Carbon) → **16px (`sm`) is our default**, since body is 14px.
- Use an icon **at its scale** — don't rescale a 16px glyph to 11px or 13px (the
  old 11px dataset glyph and 18px toggle were the drift this fixed).
- Carbon's glyphs are drawn on a 32-unit grid with a built-in stroke weight per
  size; because we render them **filled** (see §5.4) there is no stroke token to
  set — sizing the SVG is all that's needed.

### 5.4 Style, colour & alignment

- **Geometry & fill:** Carbon's rounded 2px corners, per §3.4. Carbon's UI icons
  are **filled** shapes (`fill: currentColor`) that read as outlines — _not_
  `stroke`-drawn. Our `Icon` primitive draws fill; the size classes set width and
  height only. (The two original hand-rolls used `stroke`; tracing the real Carbon
  glyphs moved us to fill.)
- **Colour:** monochrome, one colour, **inherits `currentColor`** so it matches
  its text — Carbon: _"match your icon colour with your text colour … don't use
  different colours for text and icons"_; must pass contrast. Two sanctioned
  recolours: **destructive intent** (delete reddens to `--support-error` on
  hover/focus — arch 10) and the **status sub-family** (§5.2), coloured by severity
  rather than by text — those are graphical status objects (WCAG 3:1), and warning
  uses the darkened `--support-warning-fg` so it clears contrast on light surfaces.
- **Alignment:** centre-align with adjacent text — never baseline-align (Carbon).
- **Sourcing:** Carbon is **not a dependency**; we transcribe the glyph's SVG
  geometry into `Icon.tsx`'s `GLYPHS` map (Carbon's third-party rule, inverted — a
  new glyph must be _"visually balanced"_ with the set). Match an existing icon's
  32-grid when adding one.
- **Hit area:** the interactive target (the button), not the glyph, owns the click
  size — our 32/40px buttons already clear comfortable targets; never shrink the
  target down to the icon.

### 5.5 Current state

The contract is implemented across the M1–M4 surfaces:

- **Infrastructure:** `--icon-*` tokens in `styles/tokens.css`; a shared
  [`Icon`](../../src/app/components/Icon.tsx) primitive + the `GLYPHS` registry as
  the single source of truth. Components import `Icon`, never inline an SVG.
- **Core set live:** `Close` (ModalShell, Toaster — replacing the bare ✕/×),
  `Asleep`/`Light` (ThemeToggle, now on-scale), `DataTable` (library row + linked
  list, replacing the 11px cylinder), `TrashCan` (row delete), `Add` (both
  "create-new" buttons). The draft dot is unchanged.
- **Status set live:** the four filled glyphs (`ErrorFilled` / `WarningAltFilled` /
  `CheckmarkFilled` / `InformationFilled`) in `Toaster`, coloured by kind; the
  Chart-Builder warnings reuse `WarningAltFilled` (replacing the old ⚠ character).
- **Deferred (scoped set):** only the Chart-Builder `ArrowsHorizontal` (swap-axes) —
  registered, not built; a one-button polish revisited with the next Chart-Builder
  pass.

No open status thread remains — the status-glyph question is settled here.

---

## 6. Charts (`src/core/vega-themes.ts`)

The chart `Config` is themed to match the app, per theme:

- `background: transparent` (inherits the surface), Plex font for titles/labels,
  axis/grid colors derived from the neutral ramp + `--text-secondary`.
- **Categorical palette** for `range.category` is part of the _free color_ layer —
  a distinct, colorblind-sequenced set (Carbon's data-viz palette is a good
  starting point, but not mandatory). Light and dark variants. This is where
  expressive color earns its keep.
- Config is applied **at embed time**, never baked into the user's stored spec.

---

## 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                   |
| `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
`vega-themes.ts`. Verify by rendering the real app, not just the specimen.

**What the specimen is (and isn't).** It is the **token sandbox** (try accents,
ramps, themes before touching `tokens.css`) and a **catalog of reusable
primitives** in both themes — buttons, fields, tabs/status/tags, the library-row
pattern, toasts, the overlay dialog, the code surface. It is **kept in sync** with
those primitives: when a primitive's canonical look changes or a new one lands
(e.g. the confirm dialog), add/update its specimen entry. It does **not** mirror
**feature surfaces** — the Datasets / Settings / Chart Builder modals, the editor,
the full shell — those are app screens, verified in the running app (headless
Chrome, both themes), not catalogued here. That primitive-vs-feature line is what
keeps the specimen finite, honest, and worth trusting.

---

## 8. Inspiration sources — where to look for more

We treat IBM/Carbon as inspiration, so we mine its **source repos**, not the live
doc sites. The sites (`carbondesignsystem.com`, `ibm.com/design/language`) are
JS-rendered and don't fetch cleanly — **clone the repo and read it locally instead.**
Convention: clone under `/Users/oleh/code/reference/` with
`git clone --depth 1 https://github.com/carbon-design-system/<repo>.git`.

| Need                                                                                                           | Repo                      | Where it lives                                                                                                                                                                                                                                                                                                                                      |
| -------------------------------------------------------------------------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Principles / the "why"** (philosophy, 2x grid, color rationale, type, motion, icon geometry)                 | `design-language-website` | `src/pages/`: `philosophy/principles.mdx`, `2x-grid.mdx`, `color.mdx`, `typography/*.mdx`, `animation/overview.mdx`, `iconography/ui-icons/{design,usage}.mdx` (~1.4 GB clone — image-heavy; the MDX is what we want). Icon **usage** rules (§5) also draw on `carbon-website/src/pages/elements/icons/usage.mdx` + GOV.UK `styles/images/index.md` |
| **Token values** (gray/blue ramps, type scale, font families, motion durations/easings, theme role→value maps) | `carbon`                  | `packages/colors/src/colors.ts`, `packages/type/src/{scale,fontFamily,fontWeight}.ts`, `packages/motion/src/index.ts`, `packages/themes/src/{white,g100}.ts`                                                                                                                                                                                        |
| **Component-level usage guidance**                                                                             | `carbon-website`          | `src/pages/**/*.mdx`                                                                                                                                                                                                                                                                                                                                |
| **Data-viz categorical chart palette** (for `vega-themes.ts` `range.category`)                                 | `carbon-charts`           | cloned in M1.5 → `packages/core/scss/_color-palette.scss` (the `'14'` pairing, white + g100); token→hex resolved against `carbon` `packages/colors/src/colors.ts`                                                                                                                                                                                   |

> The decisions we made _from_ these sources are captured above (§1–7) and in the
> specimen, so we don't need to re-derive them — only return to the repos to extend
> the research (e.g. the chart palette, or a component pattern we haven't tackled).
