38 KiB
09 · Visual Design Language
Status: foundational design pass. This is the visual contract — the counterpart to
docs/spec/(behavior) and the rest ofdocs/architecture/(structure).styles/tokens.css,styles/base.css, component CSS Modules, andsrc/core/vega-themes.tsimplement to this doc.Companion:
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 intostyles/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:
- Considered — remove everything gratuitous. No decoration that isn't carrying meaning. Whitespace is a feature.
- 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.
- Executed — everything communicates, including what we leave out. Alignment, rhythm, and empty space are decisions, not leftovers.
- 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:
- 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 Sansfor UI,IBM Plex Monofor 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-
--bgand text-on---accentmust both pass for any shipped theme.--border-strongis a component boundary, not decoration — it must hold 3:1 against the surface it bounds (WCAG 1.4.11): gray-50#8d8d8dlight (3.32:1 on--bg, 3.02:1 on--layer-01), gray-60#6f6f6fdark (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-hoverarecolor-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-01on the canvas (gray on white / near-black on black),--field-02on a--layer-01surface (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-hovertokens 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: 0for 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
--radiusdoesn'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,
--bordersubtle 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)inbase.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.csselement 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-modulecomposes, 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 theButtonprimitive, icon-only buttons areIconButton(24pxsmexists solely for controls nested inside a 32px control — a search field's clear, a toast's dismiss); writingheight:on a new ad-hoc button is the code smell. - Buttons: square, via the
Buttonprimitive. Variants: primary (filled--accent), secondary (1px--border-strong,--bgfill — 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-softwash — 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-strongbox 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), and the header (Datasets / Import / Export / About are ghost; a divider then sets off the soft-accent Donate 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-01surface (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 withvar(--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
--focusoutline (offset 1–2px). Always visible on keyboard focus — accessibility is non-negotiable (principle 4). - Fields (text, textarea, select-trigger, search): the quiet field —
square,
--fieldfill (one step off the surface, §3.3), bottom border only in--border-strong, no box; 2px--focusring hugging the box (offset −2px) on focus. Mono font for spec/JSON inputs. The recipe is declared once instyles/base.cssas a zero-specificity element baseline (text-likeinputtypes +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. Writingborder: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-01fill, 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-01fill, 1px--border, minimal overlay shadow, square. A title, a--text-secondarymessage, and a right-aligned action row: Cancel (secondary) + the primary, which is a danger button (filled--support-error,--on-statuslabel) for destructive intent. The in-app replacement forwindow.confirm; see arch 03 → 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). 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:
- 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."
- 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."
- 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.
- 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-labelledbywhen there is none. So an icon beside visible text isaria-hidden(the text names it — avoiding the duplicate screen-reader readout GOV.UK flags); an icon alone needs anaria-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 (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), 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 — notstroke-drawn. OurIconprimitive draws fill; the size classes set width and height only. (The two original hand-rolls usedstroke; tracing the real Carbon glyphs moved us to fill.) - Colour: monochrome, one colour, inherits
currentColorso 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-erroron 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-fgso 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'sGLYPHSmap (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 instyles/tokens.css; a sharedIconprimitive + theGLYPHSregistry as the single source of truth. Components importIcon, 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) inToaster, coloured by kind; the Chart-Builder warnings reuseWarningAltFilled(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.categoryis 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 |
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).