# 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 `` 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 `