mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
525 lines
39 KiB
Markdown
525 lines
39 KiB
Markdown
# 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 **Donate** 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.
|
||
- **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. 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, 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 Donate, 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).
|