From 3e89d9a53183fd5e0b6745de4d0a995c1744bb80 Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Sat, 6 Jun 2026 23:51:47 +0300 Subject: [PATCH] Add shared Icon primitive and controlled glyph vocabulary (Carbon, filled) --- docs/IMPLEMENTATION-PLAN.md | 2 +- docs/architecture/09-visual-design.md | 189 +++++++++++++++++- .../10-interaction-and-feedback.md | 11 +- docs/architecture/visual-specimen.html | 78 ++++++-- .../components/ChartBuilderModal.module.css | 12 +- src/app/components/ChartBuilderModal.tsx | 4 +- src/app/components/DatasetsModal.module.css | 4 + src/app/components/DatasetsModal.tsx | 3 +- src/app/components/Icon.module.css | 30 +++ src/app/components/Icon.test.tsx | 57 ++++++ src/app/components/Icon.tsx | 142 +++++++++++++ src/app/components/ModalShell.tsx | 3 +- src/app/components/ThemeToggle.tsx | 40 +--- src/app/components/Toaster.module.css | 12 ++ src/app/components/Toaster.test.tsx | 51 +++++ src/app/components/Toaster.tsx | 13 +- src/core/vega-themes.ts | 2 +- styles/tokens.css | 11 + 18 files changed, 587 insertions(+), 77 deletions(-) create mode 100644 src/app/components/Icon.module.css create mode 100644 src/app/components/Icon.test.tsx create mode 100644 src/app/components/Icon.tsx create mode 100644 src/app/components/Toaster.test.tsx diff --git a/docs/IMPLEMENTATION-PLAN.md b/docs/IMPLEMENTATION-PLAN.md index b130eaa..f2a932c 100644 --- a/docs/IMPLEMENTATION-PLAN.md +++ b/docs/IMPLEMENTATION-PLAN.md @@ -151,7 +151,7 @@ and the companion `visual-specimen.html`. - Align `src/core/vega-themes.ts`: chart `Config` per theme + a categorical `range.category` palette (clone `carbon-design-system/carbon-charts` for the - sequence — see Architecture 09 §7). + sequence — see Architecture 09 §8). **Tests** diff --git a/docs/architecture/09-visual-design.md b/docs/architecture/09-visual-design.md index f25fe42..317798c 100644 --- a/docs/architecture/09-visual-design.md +++ b/docs/architecture/09-visual-design.md @@ -171,7 +171,176 @@ UI by swapping one set of values. Borrowed from Carbon's layering model: --- -## 5. Charts (`src/core/vega-themes.ts`) +## 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: @@ -185,7 +354,7 @@ The chart `Config` is themed to match the app, per theme: --- -## 6. Implementation map +## 7. Implementation map | Artifact | Role | | ------------------------------------------------ | ----------------------------------------------------- | @@ -212,7 +381,7 @@ keeps the specimen finite, honest, and worth trusting. --- -## 7. Inspiration sources — where to look for more +## 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 @@ -220,13 +389,13 @@ JS-rendered and don't fetch cleanly — **clone the repo and read it locally ins 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.mdx` (~1.4 GB clone — image-heavy; the MDX is what we want) | -| **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` | +| 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–6) and in the +> 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). diff --git a/docs/architecture/10-interaction-and-feedback.md b/docs/architecture/10-interaction-and-feedback.md index 0be59bd..04f9ac5 100644 --- a/docs/architecture/10-interaction-and-feedback.md +++ b/docs/architecture/10-interaction-and-feedback.md @@ -110,6 +110,11 @@ states are part of the feature, not an afterthought (Carbon empty-states, GOV.UK - **Wording** (NN/g #9, GOV.UK, Carbon content): plain language, **no error codes in the user-facing line**, second person, name what stopped in the title, one or two sentences in the body, never flippant. +- **Status carries a glyph, not only colour** (arch 09 §5.2 status set; Carbon notification + taxonomy). Error/warning/success/info surfaces (toasts, inline warnings) lead with the + **filled status icon**, coloured by severity — a redundant non-colour channel so severity + reads under colour-blindness (WCAG 1.4.1), with the triangle shape-coding warning apart + from the round error/success/info. Colour + icon + title together; never colour alone. ## 4. Recovery & data-safety contract @@ -229,9 +234,9 @@ Not features to add later — the baseline every surface is built on. - **Reduced motion is honored globally.** Animations/transitions are neutralized under `prefers-reduced-motion` (`styles/base.css`); never gate meaning on motion. -- **Colour is never the sole signal** (WCAG; Carbon status pattern). Pair it with a label, - icon, shape, or text — a red toast border also has a title and an `alert` role; the - draft dot has a `title`/`aria-label`. +- **Colour is never the sole signal** (WCAG 1.4.1; Carbon status pattern). Pair it with a + label, icon, shape, or text — a toast carries a title, an `alert`/`status` role, **and a + filled status glyph** coloured by severity (§3); the draft dot has a `title`/`aria-label`. - **Every control is labelled.** Icon-only buttons, toggles, and fields carry accessible names so assistive tech can announce them. - **A binary toggle exposes its state, not just its action.** A theme/on-off control is a diff --git a/docs/architecture/visual-specimen.html b/docs/architecture/visual-specimen.html index 59a73e9..083dcc1 100644 --- a/docs/architecture/visual-specimen.html +++ b/docs/architecture/visual-specimen.html @@ -995,6 +995,45 @@ + +
+

Iconography — Carbon glyphs, fill: currentColor

+
+ The controlled vocabulary (arch 09 §5). One glyph per meaning; 16px default, paired + to 14px body. Filled (not stroked) — they inherit text colour and theme. +
+
+ + + close + + + + moon + + + + sun + + + + dataset + + + + add + + + + delete + +
+
+ In context: icon + text is the default; icon-only is the universal/destructive + exception. ✕ means close only — delete is the trash glyph. +
+
+

Tabs · status · tags

@@ -1010,19 +1049,8 @@ Draft changes Published - - - - + + 2 datasets @@ -1125,11 +1153,27 @@

Toasts

+
+ Filled status glyph + colour + text — a non-colour severity channel (arch 09 §5.2 / + 10 §3). The triangle shape-codes warning apart from the round error/success/info. +
-
Snippet duplicated.
-
Published “Quarterly revenue”.
-
Snippet storage is 82% full.
-
Could not save — storage is full.
+
+ + Snippet duplicated. +
+
+ + Published “Quarterly revenue”. +
+
+ + Snippet storage is 82% full. +
+
+ + Could not save — storage is full. +
diff --git a/src/app/components/ChartBuilderModal.module.css b/src/app/components/ChartBuilderModal.module.css index 1be91a0..ad7a137 100644 --- a/src/app/components/ChartBuilderModal.module.css +++ b/src/app/components/ChartBuilderModal.module.css @@ -214,14 +214,20 @@ } .warning { + display: flex; + align-items: flex-start; + gap: var(--space-2); font-size: 12px; line-height: 1.4; color: var(--text-secondary); } -.warning::before { - content: '⚠ '; - color: var(--support-warning, var(--text-secondary)); +/* Filled status-warning glyph (arch 09 §5.2), in the contrast-safe amber. Replaces + the old ⚠ text character so the warning matches the toast status family. */ +.warningIcon { + flex: none; + margin-top: 1px; + color: var(--support-warning-fg); } /* Explains the disabled Create action (contract 10: a disabled control must say diff --git a/src/app/components/ChartBuilderModal.tsx b/src/app/components/ChartBuilderModal.tsx index 7c0beef..b04a4f3 100644 --- a/src/app/components/ChartBuilderModal.tsx +++ b/src/app/components/ChartBuilderModal.tsx @@ -53,6 +53,7 @@ import { useChartBuilderStore, } from '../stores/ChartBuilderStore'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; +import { Icon } from './Icon'; import styles from './ChartBuilderModal.module.css'; const RENDER_DEBOUNCE_MS = 300; @@ -460,7 +461,8 @@ export function ChartBuilderModal() {
    {warnings.map((w) => (
  • - {w.message} + + {w.message}
  • ))}
diff --git a/src/app/components/DatasetsModal.module.css b/src/app/components/DatasetsModal.module.css index 4f4bde3..053241e 100644 --- a/src/app/components/DatasetsModal.module.css +++ b/src/app/components/DatasetsModal.module.css @@ -17,6 +17,10 @@ .newButton { flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); margin: var(--space-4); height: 40px; padding: 0 var(--space-5); diff --git a/src/app/components/DatasetsModal.tsx b/src/app/components/DatasetsModal.tsx index 3aa2672..0c85820 100644 --- a/src/app/components/DatasetsModal.tsx +++ b/src/app/components/DatasetsModal.tsx @@ -22,6 +22,7 @@ import { notify } from '../stores/NotificationStore'; import { selectSelectedDataset, useDatasetStore, byModifiedDesc } from '../stores/DatasetStore'; import { useSnippetStore } from '../stores/SnippetStore'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; +import { Icon } from './Icon'; import styles from './DatasetsModal.module.css'; /** Display label for a format (spec §05 → List item: JSON / CSV / TSV / TopoJSON). */ @@ -76,7 +77,7 @@ export function DatasetsModal() {
    {ordered.length === 0 && ( diff --git a/src/app/components/Icon.module.css b/src/app/components/Icon.module.css new file mode 100644 index 0000000..0c3c873 --- /dev/null +++ b/src/app/components/Icon.module.css @@ -0,0 +1,30 @@ +/* Shared icon primitive (arch 09 §5). Icons are fill: currentColor SVG, so they + inherit the surrounding text colour and theme automatically. The size classes + map to the Carbon scale tokens; the glyph keeps its own rounded geometry and is + exempt from --radius (square chrome applies to chrome, not icons). */ +.icon { + display: inline-block; + flex: 0 0 auto; + fill: currentColor; + vertical-align: middle; +} + +.sm { + width: var(--icon-sm); + height: var(--icon-sm); +} + +.md { + width: var(--icon-md); + height: var(--icon-md); +} + +.lg { + width: var(--icon-lg); + height: var(--icon-lg); +} + +.xl { + width: var(--icon-xl); + height: var(--icon-xl); +} diff --git a/src/app/components/Icon.test.tsx b/src/app/components/Icon.test.tsx new file mode 100644 index 0000000..3d661af --- /dev/null +++ b/src/app/components/Icon.test.tsx @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { Icon } from './Icon'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +function render(node: React.ReactNode) { + act(() => root.render(node)); + const svg = container.querySelector('svg'); + if (!svg) throw new Error('no rendered'); + return svg; +} + +describe('Icon', () => { + test('renders an SVG on the Carbon 32-grid, decorative and unfocusable', () => { + const svg = render(); + expect(svg.getAttribute('viewBox')).toBe('0 0 32 32'); + // Decorative by default: the enclosing control carries the accessible name. + expect(svg.getAttribute('aria-hidden')).toBe('true'); + expect(svg.getAttribute('focusable')).toBe('false'); + }); + + test('each name draws a distinct glyph from the registry', () => { + // close is a single polygon; delete (TrashCan) is rects + a path; dataset + // (DataTable) is six cells + a path. Distinct geometry ⇒ distinct meaning. + expect(render().querySelector('polygon')).not.toBeNull(); + const del = render(); + expect(del.querySelectorAll('rect').length).toBe(3); + expect(del.querySelector('path')).not.toBeNull(); + expect(render().querySelectorAll('rect').length).toBe(6); + }); + + test('size maps to a single, changeable class token', () => { + const sm = render(); // default + const smClass = sm.getAttribute('class') ?? ''; + const md = render(); + const mdClass = md.getAttribute('class') ?? ''; + // Whatever the CSS-module hashing, the two sizes must resolve to different + // class strings so the 16/20/24/32 scale is actually applied, not ignored. + expect(smClass).not.toBe(mdClass); + }); +}); diff --git a/src/app/components/Icon.tsx b/src/app/components/Icon.tsx new file mode 100644 index 0000000..cefce76 --- /dev/null +++ b/src/app/components/Icon.tsx @@ -0,0 +1,142 @@ +/** + * Icon — the shared icon primitive and controlled vocabulary (arch 09 §5). + * + * Astrolabe is label-first: an icon is added only when it does real work, usually + * alongside text (arch 09 §5.1). This module is the single source of truth for the + * icon set — one glyph per meaning, app-wide (§5.2). Adding an icon means adding a + * `IconName` + a registry entry here, never inlining an SVG in a component. + * + * The glyphs are traced from IBM Carbon's icon set (32×32 grid, fill-based — they + * read as outlines but are filled shapes). Carbon is inspiration, not a dependency. + * Every glyph draws with `fill: currentColor`, so it inherits its text colour and + * themes for free. + * + * Icons are decorative by default (`aria-hidden`): the control around them carries + * the accessible name (text label, or an `aria-label` on an icon-only button — APG + * button pattern). Never rely on an icon alone to name a control. + */ + +import type { ReactNode } from 'react'; +import styles from './Icon.module.css'; + +/** The controlled icon vocabulary (arch 09 §5.2). One entry = one meaning. */ +export type IconName = + | 'close' // close / dismiss (universal, icon-only) + | 'moon' // theme: switch to dark (universal, icon-only) + | 'sun' // theme: switch to light (universal, icon-only) + | 'dataset' // "references a dataset" — Carbon DataTable + | 'delete' // delete — Carbon TrashCan + | 'add' // add / create-new — Carbon Add + // Status sub-family (arch 09 §5.2) — Carbon's FILLED notification glyphs, coloured + // by status (not text). A redundant non-colour severity channel (WCAG 1.4.1): the + // triangle shape-codes warning apart from the round error/success/info. + | 'status-error' // Carbon ErrorFilled + | 'status-warning' // Carbon WarningAltFilled (triangle) + | 'status-success' // Carbon CheckmarkFilled + | 'status-info'; // Carbon InformationFilled + +/** Carbon icon scale (arch 09 §5.3). 16px (sm) is the default, paired to 14px body. */ +export type IconSize = 'sm' | 'md' | 'lg' | 'xl'; + +/** Inner SVG geometry per glyph, on Carbon's 0 0 32 32 grid. fill comes from CSS. */ +const GLYPHS: Record = { + close: ( + + ), + add: , + delete: ( + <> + + + + + + ), + dataset: ( + <> + + + + + + + + + ), + moon: ( + + ), + sun: ( + <> + + + + + + + + + + + ), + // Filled status glyphs: the symbol is a winding-rule knockout in the solid shape, + // so it shows the surface colour through (Carbon's filled notification family). + 'status-error': ( + + ), + 'status-warning': ( + + ), + 'status-success': ( + + ), + 'status-info': ( + + ), +}; + +export interface IconProps { + name: IconName; + /** Carbon scale; defaults to `sm` (16px) — the body-text default. */ + size?: IconSize; + /** Extra class for layout-level overrides (margins, colour). */ + className?: string; +} + +export function Icon({ name, size = 'sm', className }: IconProps) { + return ( + + ); +} diff --git a/src/app/components/ModalShell.tsx b/src/app/components/ModalShell.tsx index b7f23b1..bca4507 100644 --- a/src/app/components/ModalShell.tsx +++ b/src/app/components/ModalShell.tsx @@ -16,6 +16,7 @@ import { useAppStore } from '../stores/AppStore'; import { getModalConfig, getModalTitle } from '../modals/modal-registry'; import { closeModal } from '../modals/ModalCoordinator'; import { useFocusTrap } from '../hooks/useFocusTrap'; +import { Icon } from './Icon'; import styles from './ModalShell.module.css'; export function ModalShell() { @@ -69,7 +70,7 @@ export function ModalShell() { aria-label="Close" onClick={() => void closeModal()} > - × +
    diff --git a/src/app/components/ThemeToggle.tsx b/src/app/components/ThemeToggle.tsx index b01993f..63e9c5b 100644 --- a/src/app/components/ThemeToggle.tsx +++ b/src/app/components/ThemeToggle.tsx @@ -13,45 +13,9 @@ */ import { useAppStore } from '../stores/AppStore'; +import { Icon } from './Icon'; import styles from './ThemeToggle.module.css'; -function MoonIcon() { - return ( - - ); -} - -function SunIcon() { - return ( - - ); -} - export function ThemeToggle() { const uiTheme = useAppStore((s) => s.uiTheme); const toggleTheme = useAppStore((s) => s.toggleTheme); @@ -69,7 +33,7 @@ export function ThemeToggle() { aria-label="Dark theme" title={`Switch to ${target} theme`} > - {uiTheme === 'dark' ? : } + {uiTheme === 'dark' ? : } ); } diff --git a/src/app/components/Toaster.module.css b/src/app/components/Toaster.module.css index c9d5bfb..3c96109 100644 --- a/src/app/components/Toaster.module.css +++ b/src/app/components/Toaster.module.css @@ -47,6 +47,18 @@ --toast-accent: var(--support-info); } +/* Leading status glyph (arch 09 §5.2): coloured by kind, top-aligned with the + title. The accent IS the icon colour, except warning, whose raw yellow fails + contrast on the light surface — it uses the darkened fg token. */ +.statusIcon { + flex: none; + margin-top: 1px; + color: var(--toast-accent); +} +.warning .statusIcon { + color: var(--support-warning-fg); +} + @keyframes toast-in { from { opacity: 0; diff --git a/src/app/components/Toaster.test.tsx b/src/app/components/Toaster.test.tsx new file mode 100644 index 0000000..0dbfd4b --- /dev/null +++ b/src/app/components/Toaster.test.tsx @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { useNotificationStore, type NotificationKind } from '../stores/NotificationStore'; +import { Toaster } from './Toaster'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + useNotificationStore.getState().clear(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + useNotificationStore.getState().clear(); +}); + +const KINDS: NotificationKind[] = ['error', 'warning', 'success', 'info']; + +describe('Toaster status glyphs', () => { + // The point of the status icon is a non-colour severity channel (WCAG 1.4.1) — so + // every kind must render a leading glyph, not lean on the border colour alone. + test.each(KINDS)('a %s toast renders a leading status glyph', (kind) => { + act(() => { + useNotificationStore.getState().notify({ kind, title: `${kind} title`, message: 'body' }); + root.render(); + }); + const toast = container.querySelector('[role="alert"], [role="status"]'); + expect(toast).not.toBeNull(); + // The status glyph is a direct-child of the toast (the close button's svg + // is nested inside the button, so :scope > svg isolates the status icon). + expect(toast!.querySelector(':scope > svg')).not.toBeNull(); + }); + + test('error and warning are assertive; success and info are polite', () => { + act(() => { + useNotificationStore.getState().notify({ kind: 'error', title: 'e', message: 'b' }); + useNotificationStore.getState().notify({ kind: 'success', title: 's', message: 'b' }); + root.render(); + }); + expect(container.querySelector('[role="alert"]')).not.toBeNull(); + expect(container.querySelector('[role="status"]')).not.toBeNull(); + }); +}); diff --git a/src/app/components/Toaster.tsx b/src/app/components/Toaster.tsx index e785c57..0201fdc 100644 --- a/src/app/components/Toaster.tsx +++ b/src/app/components/Toaster.tsx @@ -16,6 +16,7 @@ import { type Notification, type NotificationKind, } from '../stores/NotificationStore'; +import { Icon, type IconName } from './Icon'; import styles from './Toaster.module.css'; /** Auto-dismiss delay for the non-critical kinds (ms). Errors/warnings persist. */ @@ -36,6 +37,15 @@ const KIND_CLASS: Record = { info: styles.info, }; +/** Filled status glyph per kind (arch 09 §5.2) — a non-colour severity channel, + * decorative (the title names the toast); coloured by kind in the CSS. */ +const KIND_ICON: Record = { + error: 'status-error', + warning: 'status-warning', + success: 'status-success', + info: 'status-info', +}; + function Toast({ notification }: { notification: Notification }) { const dismiss = useNotificationStore((s) => s.dismiss); const { id, kind, title, message, detail } = notification; @@ -54,6 +64,7 @@ function Toast({ notification }: { notification: Notification }) { // Errors/warnings interrupt assistive tech (assertive); the rest are polite. role={kind === 'error' || kind === 'warning' ? 'alert' : 'status'} > +

    {title}

    {message}

    @@ -71,7 +82,7 @@ function Toast({ notification }: { notification: Notification }) { aria-label="Dismiss notification" onClick={() => dismiss(id)} > - ✕ +
    ); diff --git a/src/core/vega-themes.ts b/src/core/vega-themes.ts index 3c4df43..e9d0a18 100644 --- a/src/core/vega-themes.ts +++ b/src/core/vega-themes.ts @@ -1,5 +1,5 @@ /** - * Vega-Lite chart config per UI theme (docs/architecture/05 §3, 09 §5). + * Vega-Lite chart config per UI theme (docs/architecture/05 §3, 09 §6). * * Portable core: a Vega-Lite `Config` styles every chart globally so charts * visually belong to the app rather than looking like stock Vega-Lite. This is diff --git a/styles/tokens.css b/styles/tokens.css index 2843c7f..a65ae0d 100644 --- a/styles/tokens.css +++ b/styles/tokens.css @@ -25,6 +25,13 @@ --space-8: 48px; --space-9: 64px; + /* Iconography — Carbon icon scale (arch 09 §5.3). Icons are SVG drawn with + fill: currentColor; 16px is the default, paired to 14px body text. */ + --icon-sm: 16px; + --icon-md: 20px; + --icon-lg: 24px; + --icon-xl: 32px; + /* Shape — square chrome (icons keep their own rounded geometry) */ --radius: 0px; --border-width: 1px; @@ -42,6 +49,9 @@ --support-error: #da1e28; --support-success: #198038; --support-warning: #f1c21b; + /* Warning foreground: the raw yellow fails contrast on light surfaces, so status + glyphs/text use a darkened amber. Dark theme keeps the yellow (legible on dark). */ + --support-warning-fg: #8a6d00; --support-info: #0043ce; --on-status: #ffffff; @@ -74,6 +84,7 @@ --support-error: #fa4d56; --support-success: #42be65; --support-warning: #f1c21b; + --support-warning-fg: #f1c21b; --support-info: #78a9ff; --on-status: #161616; }