mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add shared Icon primitive and controlled glyph vocabulary (Carbon, filled)
This commit is contained in:
@@ -151,7 +151,7 @@ and the companion `visual-specimen.html`.
|
|||||||
|
|
||||||
- Align `src/core/vega-themes.ts`: chart `Config` per theme + a categorical
|
- Align `src/core/vega-themes.ts`: chart `Config` per theme + a categorical
|
||||||
`range.category` palette (clone `carbon-design-system/carbon-charts` for the
|
`range.category` palette (clone `carbon-design-system/carbon-charts` for the
|
||||||
sequence — see Architecture 09 §7).
|
sequence — see Architecture 09 §8).
|
||||||
|
|
||||||
**Tests**
|
**Tests**
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
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 |
|
| 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
|
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
|
doc sites. The sites (`carbondesignsystem.com`, `ibm.com/design/language`) are
|
||||||
@@ -221,12 +390,12 @@ Convention: clone under `/Users/oleh/code/reference/` with
|
|||||||
`git clone --depth 1 https://github.com/carbon-design-system/<repo>.git`.
|
`git clone --depth 1 https://github.com/carbon-design-system/<repo>.git`.
|
||||||
|
|
||||||
| Need | Repo | Where it lives |
|
| 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) |
|
| **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` |
|
| **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` |
|
| **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` |
|
| **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
|
> 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).
|
> the research (e.g. the chart palette, or a component pattern we haven't tackled).
|
||||||
|
|||||||
@@ -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
|
- **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
|
user-facing line**, second person, name what stopped in the title, one or two sentences
|
||||||
in the body, never flippant.
|
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
|
## 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
|
- **Reduced motion is honored globally.** Animations/transitions are neutralized under
|
||||||
`prefers-reduced-motion` (`styles/base.css`); never gate meaning on motion.
|
`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,
|
- **Colour is never the sole signal** (WCAG 1.4.1; Carbon status pattern). Pair it with a
|
||||||
icon, shape, or text — a red toast border also has a title and an `alert` role; the
|
label, icon, shape, or text — a toast carries a title, an `alert`/`status` role, **and a
|
||||||
draft dot has a `title`/`aria-label`.
|
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
|
- **Every control is labelled.** Icon-only buttons, toggles, and fields carry accessible
|
||||||
names so assistive tech can announce them.
|
names so assistive tech can announce them.
|
||||||
- **A binary toggle exposes its state, not just its action.** A theme/on-off control is a
|
- **A binary toggle exposes its state, not just its action.** A theme/on-off control is a
|
||||||
|
|||||||
@@ -995,6 +995,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ICONS -->
|
||||||
|
<section>
|
||||||
|
<h2>Iconography — Carbon glyphs, fill: currentColor</h2>
|
||||||
|
<div class="cap" style="margin-bottom: var(--space-5)">
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
<div class="row" style="gap: var(--space-7); flex-wrap: wrap; align-items: flex-start">
|
||||||
|
<span style="display: inline-flex; flex-direction: column; align-items: center; gap: var(--space-2); color: var(--text-secondary)">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor"><polygon points="17.4141 16 24 9.4141 22.5859 8 16 14.5859 9.4143 8 8 9.4141 14.5859 16 8 22.5859 9.4143 24 16 17.4141 22.5859 24 24 22.5859 17.4141 16"/></svg>
|
||||||
|
<span class="cap">close</span>
|
||||||
|
</span>
|
||||||
|
<span style="display: inline-flex; flex-direction: column; align-items: center; gap: var(--space-2); color: var(--text-secondary)">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor"><path d="M13.5025,5.4136A15.0755,15.0755,0,0,0,25.096,23.6082a11.1134,11.1134,0,0,1-7.9749,3.3893c-.1385,0-.2782.0051-.4178,0A11.0944,11.0944,0,0,1,13.5025,5.4136M14.98,3a1.0024,1.0024,0,0,0-.1746.0156A13.0959,13.0959,0,0,0,16.63,28.9973c.1641.006.3282,0,.4909,0a13.0724,13.0724,0,0,0,10.702-5.5556,1.0094,1.0094,0,0,0-.7833-1.5644A13.08,13.08,0,0,1,15.8892,4.38,1.0149,1.0149,0,0,0,14.98,3Z"/></svg>
|
||||||
|
<span class="cap">moon</span>
|
||||||
|
</span>
|
||||||
|
<span style="display: inline-flex; flex-direction: column; align-items: center; gap: var(--space-2); color: var(--text-secondary)">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor"><rect x="15" y="2" width="2" height="5"/><rect x="21.6675" y="6.8536" width="4.958" height="1.9998" transform="translate(1.5191 19.3744) rotate(-45)"/><rect x="25" y="15" width="5" height="2"/><rect x="23.1466" y="21.6675" width="1.9998" height="4.958" transform="translate(-10.0018 24.1465) rotate(-45)"/><rect x="15" y="25" width="2" height="5"/><rect x="5.3745" y="23.1466" width="4.958" height="1.9998" transform="translate(-14.7739 12.6256) rotate(-45)"/><rect x="2" y="15" width="5" height="2"/><rect x="6.8536" y="5.3745" width="1.9998" height="4.958" transform="translate(-3.253 7.8535) rotate(-45)"/><path d="M16,12a4,4,0,1,1-4,4,4.0045,4.0045,0,0,1,4-4m0-2a6,6,0,1,0,6,6,6,6,0,0,0-6-6Z"/></svg>
|
||||||
|
<span class="cap">sun</span>
|
||||||
|
</span>
|
||||||
|
<span style="display: inline-flex; flex-direction: column; align-items: center; gap: var(--space-2); color: var(--text-secondary)">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor"><rect x="8" y="18" width="4" height="2"/><rect x="14" y="18" width="4" height="2"/><rect x="8" y="14" width="4" height="2"/><rect x="14" y="22" width="4" height="2"/><rect x="20" y="14" width="4" height="2"/><rect x="20" y="22" width="4" height="2"/><path d="M27,3H5A2.0025,2.0025,0,0,0,3,5V27a2.0025,2.0025,0,0,0,2,2H27a2.0025,2.0025,0,0,0,2-2V5A2.0025,2.0025,0,0,0,27,3Zm0,2,0,4H5V5ZM5,27V11H27l0,16Z"/></svg>
|
||||||
|
<span class="cap">dataset</span>
|
||||||
|
</span>
|
||||||
|
<span style="display: inline-flex; flex-direction: column; align-items: center; gap: var(--space-2); color: var(--text-secondary)">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor"><polygon points="17,15 17,8 15,8 15,15 8,15 8,17 15,17 15,24 17,24 17,17 24,17 24,15"/></svg>
|
||||||
|
<span class="cap">add</span>
|
||||||
|
</span>
|
||||||
|
<span style="display: inline-flex; flex-direction: column; align-items: center; gap: var(--space-2); color: var(--text-secondary)">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor"><rect x="12" y="12" width="2" height="12"/><rect x="18" y="12" width="2" height="12"/><path d="M4,6V8H6V28a2,2,0,0,0,2,2H24a2,2,0,0,0,2-2V8h2V6ZM8,28V8H24V28Z"/><rect x="12" y="2" width="8" height="2"/></svg>
|
||||||
|
<span class="cap">delete</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="cap" style="margin-top: var(--space-6)">
|
||||||
|
In context: icon + text is the default; icon-only is the universal/destructive
|
||||||
|
exception. ✕ means close only — delete is the trash glyph.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- TABS + STATUS -->
|
<!-- TABS + STATUS -->
|
||||||
<section>
|
<section>
|
||||||
<h2>Tabs · status · tags</h2>
|
<h2>Tabs · status · tags</h2>
|
||||||
@@ -1010,19 +1049,8 @@
|
|||||||
<span class="tag-pill"><span class="dot draft"></span> Draft changes</span>
|
<span class="tag-pill"><span class="dot draft"></span> Draft changes</span>
|
||||||
<span class="tag-pill"><span class="dot published"></span> Published</span>
|
<span class="tag-pill"><span class="dot published"></span> Published</span>
|
||||||
<span class="tag-pill">
|
<span class="tag-pill">
|
||||||
<svg
|
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor">
|
||||||
width="14"
|
<rect x="8" y="18" width="4" height="2" /><rect x="14" y="18" width="4" height="2" /><rect x="8" y="14" width="4" height="2" /><rect x="14" y="22" width="4" height="2" /><rect x="20" y="14" width="4" height="2" /><rect x="20" y="22" width="4" height="2" /><path d="M27,3H5A2.0025,2.0025,0,0,0,3,5V27a2.0025,2.0025,0,0,0,2,2H27a2.0025,2.0025,0,0,0,2-2V5A2.0025,2.0025,0,0,0,27,3Zm0,2,0,4H5V5ZM5,27V11H27l0,16Z" />
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<ellipse cx="12" cy="6" rx="8" ry="3" />
|
|
||||||
<path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6" />
|
|
||||||
<path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" />
|
|
||||||
</svg>
|
</svg>
|
||||||
2 datasets
|
2 datasets
|
||||||
</span>
|
</span>
|
||||||
@@ -1125,11 +1153,27 @@
|
|||||||
<!-- TOASTS -->
|
<!-- TOASTS -->
|
||||||
<section>
|
<section>
|
||||||
<h2>Toasts</h2>
|
<h2>Toasts</h2>
|
||||||
|
<div class="cap" style="margin-bottom: var(--space-4)">
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
<div class="stack" style="max-width: 360px">
|
<div class="stack" style="max-width: 360px">
|
||||||
<div class="toast info">Snippet duplicated.</div>
|
<div class="toast info">
|
||||||
<div class="toast success">Published “Quarterly revenue”.</div>
|
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor; flex: none; color: var(--support-info)"><path d="M16,2A14,14,0,1,0,30,16,14,14,0,0,0,16,2Zm0,6a1.5,1.5,0,1,1-1.5,1.5A1.5,1.5,0,0,1,16,8Zm4,16.125H12v-2.25h2.875v-5.75H13v-2.25h4.125v8H20Z"/></svg>
|
||||||
<div class="toast warning">Snippet storage is 82% full.</div>
|
Snippet duplicated.
|
||||||
<div class="toast error">Could not save — storage is full.</div>
|
</div>
|
||||||
|
<div class="toast success">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor; flex: none; color: var(--support-success)"><path d="M16,2A14,14,0,1,0,30,16,14,14,0,0,0,16,2ZM14,21.5908l-5-5L10.5906,15,14,18.4092,21.41,11l1.5957,1.5859Z"/></svg>
|
||||||
|
Published “Quarterly revenue”.
|
||||||
|
</div>
|
||||||
|
<div class="toast warning">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor; flex: none; color: var(--support-warning-fg, #8a6d00)"><path d="M16.002,6.1714h-.004L4.6487,27.9966,4.6506,28H27.3494l.0019-.0034ZM14.875,12h2.25v9h-2.25ZM16,26a1.5,1.5,0,1,1,1.5-1.5A1.5,1.5,0,0,1,16,26Z"/></svg>
|
||||||
|
Snippet storage is 82% full.
|
||||||
|
</div>
|
||||||
|
<div class="toast error">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor; flex: none; color: var(--support-error)"><path d="M16,2A13.914,13.914,0,0,0,2,16,13.914,13.914,0,0,0,16,30,13.914,13.914,0,0,0,30,16,13.914,13.914,0,0,0,16,2Zm5.4449,21L9,10.5557,10.5557,9,23,21.4448Z"/></svg>
|
||||||
|
Could not save — storage is full.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -214,14 +214,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.warning {
|
.warning {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-2);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.warning::before {
|
/* Filled status-warning glyph (arch 09 §5.2), in the contrast-safe amber. Replaces
|
||||||
content: '⚠ ';
|
the old ⚠ text character so the warning matches the toast status family. */
|
||||||
color: var(--support-warning, var(--text-secondary));
|
.warningIcon {
|
||||||
|
flex: none;
|
||||||
|
margin-top: 1px;
|
||||||
|
color: var(--support-warning-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Explains the disabled Create action (contract 10: a disabled control must say
|
/* Explains the disabled Create action (contract 10: a disabled control must say
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
useChartBuilderStore,
|
useChartBuilderStore,
|
||||||
} from '../stores/ChartBuilderStore';
|
} from '../stores/ChartBuilderStore';
|
||||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||||
|
import { Icon } from './Icon';
|
||||||
import styles from './ChartBuilderModal.module.css';
|
import styles from './ChartBuilderModal.module.css';
|
||||||
|
|
||||||
const RENDER_DEBOUNCE_MS = 300;
|
const RENDER_DEBOUNCE_MS = 300;
|
||||||
@@ -460,7 +461,8 @@ export function ChartBuilderModal() {
|
|||||||
<ul className={styles.warnings}>
|
<ul className={styles.warnings}>
|
||||||
{warnings.map((w) => (
|
{warnings.map((w) => (
|
||||||
<li key={w.message} className={styles.warning}>
|
<li key={w.message} className={styles.warning}>
|
||||||
{w.message}
|
<Icon name="status-warning" className={styles.warningIcon} />
|
||||||
|
<span>{w.message}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
|
|
||||||
.newButton {
|
.newButton {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
margin: var(--space-4);
|
margin: var(--space-4);
|
||||||
height: 40px;
|
height: 40px;
|
||||||
padding: 0 var(--space-5);
|
padding: 0 var(--space-5);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { notify } from '../stores/NotificationStore';
|
|||||||
import { selectSelectedDataset, useDatasetStore, byModifiedDesc } from '../stores/DatasetStore';
|
import { selectSelectedDataset, useDatasetStore, byModifiedDesc } from '../stores/DatasetStore';
|
||||||
import { useSnippetStore } from '../stores/SnippetStore';
|
import { useSnippetStore } from '../stores/SnippetStore';
|
||||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||||
|
import { Icon } from './Icon';
|
||||||
import styles from './DatasetsModal.module.css';
|
import styles from './DatasetsModal.module.css';
|
||||||
|
|
||||||
/** Display label for a format (spec §05 → List item: JSON / CSV / TSV / TopoJSON). */
|
/** Display label for a format (spec §05 → List item: JSON / CSV / TSV / TopoJSON). */
|
||||||
@@ -76,7 +77,7 @@ export function DatasetsModal() {
|
|||||||
<div className={styles.manager}>
|
<div className={styles.manager}>
|
||||||
<div className={styles.listPane}>
|
<div className={styles.listPane}>
|
||||||
<button type="button" className={styles.newButton} onClick={handleNew}>
|
<button type="button" className={styles.newButton} onClick={handleNew}>
|
||||||
+ New Dataset
|
<Icon name="add" /> New Dataset
|
||||||
</button>
|
</button>
|
||||||
<ul className={styles.list}>
|
<ul className={styles.list}>
|
||||||
{ordered.length === 0 && (
|
{ordered.length === 0 && (
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 <svg> rendered');
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Icon', () => {
|
||||||
|
test('renders an SVG on the Carbon 32-grid, decorative and unfocusable', () => {
|
||||||
|
const svg = render(<Icon name="close" />);
|
||||||
|
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(<Icon name="close" />).querySelector('polygon')).not.toBeNull();
|
||||||
|
const del = render(<Icon name="delete" />);
|
||||||
|
expect(del.querySelectorAll('rect').length).toBe(3);
|
||||||
|
expect(del.querySelector('path')).not.toBeNull();
|
||||||
|
expect(render(<Icon name="dataset" />).querySelectorAll('rect').length).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('size maps to a single, changeable class token', () => {
|
||||||
|
const sm = render(<Icon name="add" />); // default
|
||||||
|
const smClass = sm.getAttribute('class') ?? '';
|
||||||
|
const md = render(<Icon name="add" size="md" />);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<IconName, ReactNode> = {
|
||||||
|
close: (
|
||||||
|
<polygon points="17.4141 16 24 9.4141 22.5859 8 16 14.5859 9.4143 8 8 9.4141 14.5859 16 8 22.5859 9.4143 24 16 17.4141 22.5859 24 24 22.5859 17.4141 16" />
|
||||||
|
),
|
||||||
|
add: <polygon points="17,15 17,8 15,8 15,15 8,15 8,17 15,17 15,24 17,24 17,17 24,17 24,15" />,
|
||||||
|
delete: (
|
||||||
|
<>
|
||||||
|
<rect x="12" y="12" width="2" height="12" />
|
||||||
|
<rect x="18" y="12" width="2" height="12" />
|
||||||
|
<path d="M4,6V8H6V28a2,2,0,0,0,2,2H24a2,2,0,0,0,2-2V8h2V6ZM8,28V8H24V28Z" />
|
||||||
|
<rect x="12" y="2" width="8" height="2" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
dataset: (
|
||||||
|
<>
|
||||||
|
<rect x="8" y="18" width="4" height="2" />
|
||||||
|
<rect x="14" y="18" width="4" height="2" />
|
||||||
|
<rect x="8" y="14" width="4" height="2" />
|
||||||
|
<rect x="14" y="22" width="4" height="2" />
|
||||||
|
<rect x="20" y="14" width="4" height="2" />
|
||||||
|
<rect x="20" y="22" width="4" height="2" />
|
||||||
|
<path d="M27,3H5A2.0025,2.0025,0,0,0,3,5V27a2.0025,2.0025,0,0,0,2,2H27a2.0025,2.0025,0,0,0,2-2V5A2.0025,2.0025,0,0,0,27,3Zm0,2,0,4H5V5ZM5,27V11H27l0,16Z" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
moon: (
|
||||||
|
<path d="M13.5025,5.4136A15.0755,15.0755,0,0,0,25.096,23.6082a11.1134,11.1134,0,0,1-7.9749,3.3893c-.1385,0-.2782.0051-.4178,0A11.0944,11.0944,0,0,1,13.5025,5.4136M14.98,3a1.0024,1.0024,0,0,0-.1746.0156A13.0959,13.0959,0,0,0,16.63,28.9973c.1641.006.3282,0,.4909,0a13.0724,13.0724,0,0,0,10.702-5.5556,1.0094,1.0094,0,0,0-.7833-1.5644A13.08,13.08,0,0,1,15.8892,4.38,1.0149,1.0149,0,0,0,14.98,3Z" />
|
||||||
|
),
|
||||||
|
sun: (
|
||||||
|
<>
|
||||||
|
<rect x="15" y="2" width="2" height="5" />
|
||||||
|
<rect
|
||||||
|
x="21.6675"
|
||||||
|
y="6.8536"
|
||||||
|
width="4.958"
|
||||||
|
height="1.9998"
|
||||||
|
transform="translate(1.5191 19.3744) rotate(-45)"
|
||||||
|
/>
|
||||||
|
<rect x="25" y="15" width="5" height="2" />
|
||||||
|
<rect
|
||||||
|
x="23.1466"
|
||||||
|
y="21.6675"
|
||||||
|
width="1.9998"
|
||||||
|
height="4.958"
|
||||||
|
transform="translate(-10.0018 24.1465) rotate(-45)"
|
||||||
|
/>
|
||||||
|
<rect x="15" y="25" width="2" height="5" />
|
||||||
|
<rect
|
||||||
|
x="5.3745"
|
||||||
|
y="23.1466"
|
||||||
|
width="4.958"
|
||||||
|
height="1.9998"
|
||||||
|
transform="translate(-14.7739 12.6256) rotate(-45)"
|
||||||
|
/>
|
||||||
|
<rect x="2" y="15" width="5" height="2" />
|
||||||
|
<rect
|
||||||
|
x="6.8536"
|
||||||
|
y="5.3745"
|
||||||
|
width="1.9998"
|
||||||
|
height="4.958"
|
||||||
|
transform="translate(-3.253 7.8535) rotate(-45)"
|
||||||
|
/>
|
||||||
|
<path d="M16,12a4,4,0,1,1-4,4,4.0045,4.0045,0,0,1,4-4m0-2a6,6,0,1,0,6,6,6,6,0,0,0-6-6Z" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
// 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': (
|
||||||
|
<path d="M16,2A13.914,13.914,0,0,0,2,16,13.914,13.914,0,0,0,16,30,13.914,13.914,0,0,0,30,16,13.914,13.914,0,0,0,16,2Zm5.4449,21L9,10.5557,10.5557,9,23,21.4448Z" />
|
||||||
|
),
|
||||||
|
'status-warning': (
|
||||||
|
<path d="M16.002,6.1714h-.004L4.6487,27.9966,4.6506,28H27.3494l.0019-.0034ZM14.875,12h2.25v9h-2.25ZM16,26a1.5,1.5,0,1,1,1.5-1.5A1.5,1.5,0,0,1,16,26Z" />
|
||||||
|
),
|
||||||
|
'status-success': (
|
||||||
|
<path d="M16,2A14,14,0,1,0,30,16,14,14,0,0,0,16,2ZM14,21.5908l-5-5L10.5906,15,14,18.4092,21.41,11l1.5957,1.5859Z" />
|
||||||
|
),
|
||||||
|
'status-info': (
|
||||||
|
<path d="M16,2A14,14,0,1,0,30,16,14,14,0,0,0,16,2Zm0,6a1.5,1.5,0,1,1-1.5,1.5A1.5,1.5,0,0,1,16,8Zm4,16.125H12v-2.25h2.875v-5.75H13v-2.25h4.125v8H20Z" />
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<svg
|
||||||
|
className={`${styles.icon} ${styles[size]}${className ? ` ${className}` : ''}`}
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
// Decorative: the enclosing control names itself (arch 09 §5.1).
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable={false}
|
||||||
|
>
|
||||||
|
{GLYPHS[name]}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { useAppStore } from '../stores/AppStore';
|
|||||||
import { getModalConfig, getModalTitle } from '../modals/modal-registry';
|
import { getModalConfig, getModalTitle } from '../modals/modal-registry';
|
||||||
import { closeModal } from '../modals/ModalCoordinator';
|
import { closeModal } from '../modals/ModalCoordinator';
|
||||||
import { useFocusTrap } from '../hooks/useFocusTrap';
|
import { useFocusTrap } from '../hooks/useFocusTrap';
|
||||||
|
import { Icon } from './Icon';
|
||||||
import styles from './ModalShell.module.css';
|
import styles from './ModalShell.module.css';
|
||||||
|
|
||||||
export function ModalShell() {
|
export function ModalShell() {
|
||||||
@@ -69,7 +70,7 @@ export function ModalShell() {
|
|||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
onClick={() => void closeModal()}
|
onClick={() => void closeModal()}
|
||||||
>
|
>
|
||||||
×
|
<Icon name="close" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
|
|||||||
@@ -13,45 +13,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useAppStore } from '../stores/AppStore';
|
import { useAppStore } from '../stores/AppStore';
|
||||||
|
import { Icon } from './Icon';
|
||||||
import styles from './ThemeToggle.module.css';
|
import styles from './ThemeToggle.module.css';
|
||||||
|
|
||||||
function MoonIcon() {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SunIcon() {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="4.5" />
|
|
||||||
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
const uiTheme = useAppStore((s) => s.uiTheme);
|
const uiTheme = useAppStore((s) => s.uiTheme);
|
||||||
const toggleTheme = useAppStore((s) => s.toggleTheme);
|
const toggleTheme = useAppStore((s) => s.toggleTheme);
|
||||||
@@ -69,7 +33,7 @@ export function ThemeToggle() {
|
|||||||
aria-label="Dark theme"
|
aria-label="Dark theme"
|
||||||
title={`Switch to ${target} theme`}
|
title={`Switch to ${target} theme`}
|
||||||
>
|
>
|
||||||
{uiTheme === 'dark' ? <SunIcon /> : <MoonIcon />}
|
{uiTheme === 'dark' ? <Icon name="sun" size="md" /> : <Icon name="moon" size="md" />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,18 @@
|
|||||||
--toast-accent: var(--support-info);
|
--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 {
|
@keyframes toast-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -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(<Toaster />);
|
||||||
|
});
|
||||||
|
const toast = container.querySelector('[role="alert"], [role="status"]');
|
||||||
|
expect(toast).not.toBeNull();
|
||||||
|
// The status glyph is a direct-child <svg> 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(<Toaster />);
|
||||||
|
});
|
||||||
|
expect(container.querySelector('[role="alert"]')).not.toBeNull();
|
||||||
|
expect(container.querySelector('[role="status"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type Notification,
|
type Notification,
|
||||||
type NotificationKind,
|
type NotificationKind,
|
||||||
} from '../stores/NotificationStore';
|
} from '../stores/NotificationStore';
|
||||||
|
import { Icon, type IconName } from './Icon';
|
||||||
import styles from './Toaster.module.css';
|
import styles from './Toaster.module.css';
|
||||||
|
|
||||||
/** Auto-dismiss delay for the non-critical kinds (ms). Errors/warnings persist. */
|
/** Auto-dismiss delay for the non-critical kinds (ms). Errors/warnings persist. */
|
||||||
@@ -36,6 +37,15 @@ const KIND_CLASS: Record<NotificationKind, string> = {
|
|||||||
info: styles.info,
|
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<NotificationKind, IconName> = {
|
||||||
|
error: 'status-error',
|
||||||
|
warning: 'status-warning',
|
||||||
|
success: 'status-success',
|
||||||
|
info: 'status-info',
|
||||||
|
};
|
||||||
|
|
||||||
function Toast({ notification }: { notification: Notification }) {
|
function Toast({ notification }: { notification: Notification }) {
|
||||||
const dismiss = useNotificationStore((s) => s.dismiss);
|
const dismiss = useNotificationStore((s) => s.dismiss);
|
||||||
const { id, kind, title, message, detail } = notification;
|
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.
|
// Errors/warnings interrupt assistive tech (assertive); the rest are polite.
|
||||||
role={kind === 'error' || kind === 'warning' ? 'alert' : 'status'}
|
role={kind === 'error' || kind === 'warning' ? 'alert' : 'status'}
|
||||||
>
|
>
|
||||||
|
<Icon name={KIND_ICON[kind]} size="md" className={styles.statusIcon} />
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
<p className={styles.title}>{title}</p>
|
<p className={styles.title}>{title}</p>
|
||||||
<p className={styles.message}>{message}</p>
|
<p className={styles.message}>{message}</p>
|
||||||
@@ -71,7 +82,7 @@ function Toast({ notification }: { notification: Notification }) {
|
|||||||
aria-label="Dismiss notification"
|
aria-label="Dismiss notification"
|
||||||
onClick={() => dismiss(id)}
|
onClick={() => dismiss(id)}
|
||||||
>
|
>
|
||||||
✕
|
<Icon name="close" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
* 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
|
* visually belong to the app rather than looking like stock Vega-Lite. This is
|
||||||
|
|||||||
@@ -25,6 +25,13 @@
|
|||||||
--space-8: 48px;
|
--space-8: 48px;
|
||||||
--space-9: 64px;
|
--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) */
|
/* Shape — square chrome (icons keep their own rounded geometry) */
|
||||||
--radius: 0px;
|
--radius: 0px;
|
||||||
--border-width: 1px;
|
--border-width: 1px;
|
||||||
@@ -42,6 +49,9 @@
|
|||||||
--support-error: #da1e28;
|
--support-error: #da1e28;
|
||||||
--support-success: #198038;
|
--support-success: #198038;
|
||||||
--support-warning: #f1c21b;
|
--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;
|
--support-info: #0043ce;
|
||||||
--on-status: #ffffff;
|
--on-status: #ffffff;
|
||||||
|
|
||||||
@@ -74,6 +84,7 @@
|
|||||||
--support-error: #fa4d56;
|
--support-error: #fa4d56;
|
||||||
--support-success: #42be65;
|
--support-success: #42be65;
|
||||||
--support-warning: #f1c21b;
|
--support-warning: #f1c21b;
|
||||||
|
--support-warning-fg: #f1c21b;
|
||||||
--support-info: #78a9ff;
|
--support-info: #78a9ff;
|
||||||
--on-status: #161616;
|
--on-status: #161616;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user