# 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 | --- ## 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 and prominent separators | | `--text` / `--text-secondary` / `--text-placeholder` | Text hierarchy | | `--accent` / `--accent-hover` / `--accent-contrast` | The expressive accent — **swappable**; UI must never hardcode a hue | | `--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. ### 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 - **Buttons:** square, 32px (compact) / 40px (default) tall. Variants: **primary** (filled `--accent`), **secondary** (bordered), **ghost** (text-only), **danger** (filled `--support-error`). 600-weight label. Clear hover/active and a visible focus ring. - **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). Any button placed on an elevated surface must step its hover fill up to match. - **Focus ring:** a 2px `--focus` outline (offset 1–2px). Always visible on keyboard focus — accessibility is non-negotiable (principle 4). - **Fields** (text, textarea, select, search): square, 1px `--border`, `--layer-01` fill, accent border + focus ring on focus. Mono font for spec/JSON inputs. - **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 | | Add / create-new | `Add` | icon + text | Library "Create New Snippet", Datasets "New …" | | Delete | `TrashCan` | icon-only ⭐ (danger) | Library row delete¹ — text "Delete" in the panel² | | Unpublished draft | (CSS dot) | status-glyph | Library row (paired with a hidden label) | **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** — registered (glyph reserved) but single-surface and **not yet implemented**: | Meaning | Carbon glyph | Form | Surface / note | | ---------------- | ------------------ | ----------- | ----------------------------------------------------- | | Swap / transpose | `ArrowsHorizontal` | icon + text | Chart Builder "Swap X/Y" — one button; icon is polish | ⭐ = **icon-only set**: close + theme (truly universal), plus **delete** as a deliberate destructive-row exception — a dense, repeated list action where a label would cost more than it gives. ✕ means **close only**; delete is `TrashCan`, never ✕ (that collision is exactly what one-glyph-one-meaning forbids). ¹ 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). ### 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 | | 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/.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).