Control scale: Button/IconButton primitives, two-height tokens, app-wide migration

This commit is contained in:
2026-06-12 23:13:13 +03:00
parent af9cc8a716
commit 7d4906ec38
41 changed files with 660 additions and 904 deletions
+11
View File
@@ -55,6 +55,17 @@ Review all changes in scope. If changes span multiple patterns below, apply all
5. **Styles and UI**: When altering CSS or layout, follow or generalize existing patterns 5. **Styles and UI**: When altering CSS or layout, follow or generalize existing patterns
(CSS Modules + design tokens in `styles/tokens.css`) rather than writing from scratch. Don't (CSS Modules + design tokens in `styles/tokens.css`) rather than writing from scratch. Don't
fix whitespace/formatting (trailing newlines etc.) — Prettier owns that. fix whitespace/formatting (trailing newlines etc.) — Prettier owns that.
- **Control primitives & the two-height scale** (arch 09 §4): action buttons are the
`Button` component, icon-only buttons are `IconButton` — never a freshly styled
`<button>`. Interactive controls are `var(--control-height)` (32px) or
`var(--control-height-lg)` (40px); a hardcoded control height (28px, 36px, …) in a
diff is a finding. A freshly written bordered-field recipe is the same finding —
fields share one look (1px `--border-strong`, `--bg` fill, `--control-height`); a
new input should copy an existing field class, not re-derive it. Call-site classes composed onto a primitive may only do layout
(flex, margins, reveal) or a documented state accent (outlined-danger, pressed) —
restyling the primitive's box from a call site is a finding. Borders mark function:
value-holding controls get `--border-strong` boxes; plain actions are ghost or
filled; list rows are flat with dividers, not stacked boxes.
6. **Code Comments**: Comments should not duplicate what the code already says. Remove 6. **Code Comments**: Comments should not duplicate what the code already says. Remove
parroting comments. Ensure comments capture non-obvious _why_ — design decisions, parroting comments. Ensure comments capture non-obvious _why_ — design decisions,
+44 -8
View File
@@ -149,11 +149,43 @@ UI by swapping one set of values. Borrowed from Carbon's layering model:
## 4. Component conventions ## 4. Component conventions
- **Buttons:** square, 32px (compact) / 40px (default) tall. Variants: **primary** - **The control scale (two heights, enforced by tokens + primitives).** Every
(filled `--accent`), **secondary** (bordered), **ghost** (text-only, borderless), interactive control is `--control-height` (32px) or `--control-height-lg`
**soft-accent** (ghost on an `--accent-soft` wash — a low-emphasis solicitation, (40px) — tokens.css. 32px is THE control height: buttons, fields, selects,
e.g. Donate), **danger** (filled `--support-error`). 600-weight label. Clear segmented controls, icon buttons, anything in a toolbar, form row, or dialog
hover/active and a visible focus ring. action row. 40px is reserved for standalone primary CTAs (the library's Build
Chart, a modal list-pane's New X) and modal footers. A third height is drift —
the pre-token codebase accumulated 26/28/30/36/42px variants one component at a
time, which read as "shaky" the moment controls shared a row. The rule is
enforced mechanically: action buttons are the **`Button`** primitive, icon-only
buttons are **`IconButton`** (24px `sm` exists solely for controls nested
_inside_ a 32px control — a search field's clear, a toast's dismiss); writing
`height:` on a new ad-hoc button is the code smell.
- **Buttons:** square, via the `Button` primitive. Variants: **primary** (filled
`--accent`), **secondary** (1px `--border-strong`, `--bg` fill — the same
field-on-layer treatment as inputs, so a bordered control on a gray panel goes
white, never a darker gray), **ghost** (text-only, borderless; a transparent
border holds the box size), **soft-accent** (ghost on an `--accent-soft` wash —
a low-emphasis solicitation, e.g. Donate), **danger** (filled
`--support-error` — the confirm step), **danger-outline** (secondary geometry,
red label, filling solid red on hover/focus — a destructive action sitting
among peers, e.g. a detail view's Delete). 13px 600-weight label. Clear hover/active and a visible
focus ring. **Inline link-style actions are a separate kind, not a Button
variant**: small accent-text actions embedded in content ("+ Add filter",
"Swap X/Y", "Use a constant", a popover's Reset) deliberately sit below the
control scale — content-sized, 1112px, 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.** A 1px `--border-strong` box belongs
to things that _hold or receive a value_: fields, selects/disclosure triggers,
segmented controls, secondary buttons, drop targets (dashed). Plain actions are
ghost or filled — never outlined boxes. 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. (Carbon goes further — fields
are bottom-border-only and its secondary button is a gray fill; our fully-boxed
fields are a recorded divergence, §2, which is exactly why box discipline
matters more here, not less.)
- **Emphasis hierarchy (Carbon button/usage).** A region carries **one** - **Emphasis hierarchy (Carbon button/usage).** A region carries **one**
high-emphasis (primary) button at most; everything else is lower emphasis. In high-emphasis (primary) button at most; everything else is lower emphasis. In
toolbars/headers full of utilities, the utilities go **ghost** so they recede toolbars/headers full of utilities, the utilities go **ghost** so they recede
@@ -168,8 +200,11 @@ UI by swapping one set of values. Borrowed from Carbon's layering model:
fill one elevation step above their surface** — on `--bg` → `--layer-01`, on a fill one elevation step above their surface** — on `--bg` → `--layer-01`, on a
`--layer-01` surface (dialogs, panels) → `--layer-02`. Filling to the _same_ `--layer-01` surface (dialogs, panels) → `--layer-02`. Filling to the _same_
layer as the surface reads as no hover at all (the collision that left the layer as the surface reads as no hover at all (the collision that left the
confirm dialog's Cancel looking dead). Any button placed on an elevated surface confirm dialog's Cancel looking dead). The step is mechanical: Button/IconButton
must step its hover fill up to match. hover with `var(--control-hover-fill, var(--layer-01))`, and an elevated surface
sets `--control-hover-fill: var(--layer-02)` once on its container (the header,
the modal chrome, the confirm card, the library's metadata panel) — controls
inherit the right step instead of each re-encoding it.
- **Focus ring:** a 2px `--focus` outline (offset 12px). Always visible on - **Focus ring:** a 2px `--focus` outline (offset 12px). Always visible on
keyboard focus — accessibility is non-negotiable (principle 4). keyboard focus — accessibility is non-negotiable (principle 4).
- **Fields** (text, textarea, select, search): square, 1px `--border-strong`, - **Fields** (text, textarea, select, search): square, 1px `--border-strong`,
@@ -412,10 +447,11 @@ The chart `Config` is themed to match the app, per theme:
## 7. Implementation map ## 7. Implementation map
| Artifact | Role | | Artifact | Role |
| ------------------------------------------------ | ----------------------------------------------------- | | -------------------------------------------------- | -------------------------------------------------------------------- |
| [`visual-specimen.html`](./visual-specimen.html) | Living preview + token sandbox. Iterate here first | | [`visual-specimen.html`](./visual-specimen.html) | Living preview + token sandbox. Iterate here first |
| `styles/tokens.css` | The settled tokens — ported from the specimen in M1.5 | | `styles/tokens.css` | The settled tokens — ported from the specimen in M1.5 |
| `styles/base.css` | Font wiring (`@fontsource`), reset, reduced-motion | | `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 | | component `*.module.css` | Consume tokens only; no raw hexes, no hardcoded hue |
| `src/core/vega-themes.ts` | Chart `Config` per theme; categorical palettes | | `src/core/vega-themes.ts` | Chart `Config` per theme; categorical palettes |
+25 -21
View File
@@ -44,6 +44,12 @@
--radius: 0px; --radius: 0px;
--border-width: 1px; --border-width: 1px;
/* Control scale — every interactive control is one of exactly two
heights (arch 09 §4): 32px standard, 40px for standalone primary
CTAs and modal footers. */
--control-height: 32px;
--control-height-lg: 40px;
/* Motion — productive only */ /* Motion — productive only */
--dur-fast: 70ms; --dur-fast: 70ms;
--dur-fast-2: 110ms; --dur-fast-2: 110ms;
@@ -399,26 +405,25 @@
padding: var(--space-5); padding: var(--space-5);
} }
/* ----- Buttons ----- */ /* ----- Buttons (mirrors src/app/components/Button.module.css) ----- */
.btn { .btn {
font: inherit; font: inherit;
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 13px;
cursor: pointer; cursor: pointer;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-3); gap: var(--space-2);
height: 40px; height: var(--control-height);
padding: 0 var(--space-5); padding: 0 var(--space-3);
border: var(--border-width) solid transparent; border: var(--border-width) solid transparent;
border-radius: var(--radius); border-radius: var(--radius);
transition: transition:
background var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease),
border-color var(--dur-fast) var(--ease); border-color var(--dur-fast) var(--ease);
} }
.btn.sm { .btn.lg {
height: 32px; height: var(--control-height-lg);
font-size: 13px;
padding: 0 var(--space-4); padding: 0 var(--space-4);
} }
.btn-primary { .btn-primary {
@@ -429,7 +434,7 @@
background: var(--accent-hover); background: var(--accent-hover);
} }
.btn-secondary { .btn-secondary {
background: transparent; background: var(--bg);
color: var(--text); color: var(--text);
border-color: var(--border-strong); border-color: var(--border-strong);
} }
@@ -947,7 +952,7 @@
<!-- BUTTONS --> <!-- BUTTONS -->
<section> <section>
<h2>Buttons — square, 600 weight</h2> <h2>Buttons — square, 600 weight, 32px standard / 40px lg</h2>
<div class="row"> <div class="row">
<button class="btn btn-primary">Create snippet</button> <button class="btn btn-primary">Create snippet</button>
<button class="btn btn-secondary">Duplicate</button> <button class="btn btn-secondary">Duplicate</button>
@@ -956,9 +961,8 @@
<button class="btn btn-primary" disabled>Disabled</button> <button class="btn btn-primary" disabled>Disabled</button>
</div> </div>
<div class="row" style="margin-top: var(--space-5)"> <div class="row" style="margin-top: var(--space-5)">
<button class="btn btn-primary sm">Compact</button> <button class="btn btn-primary lg">Build Chart</button>
<button class="btn btn-secondary sm">Compact</button> <button class="btn btn-secondary lg">Cancel</button>
<button class="btn btn-ghost sm">Compact</button>
</div> </div>
<p class="cap" style="margin-top: var(--space-4)"> <p class="cap" style="margin-top: var(--space-4)">
Tab through to see the focus ring (2px accent outline). Tab through to see the focus ring (2px accent outline).
@@ -972,7 +976,7 @@
emphasis.) emphasis.)
</p> </p>
<div class="row" style="align-items: center"> <div class="row" style="align-items: center">
<button class="btn btn-ghost-util sm"> <button class="btn btn-ghost-util">
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor"> <svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor">
<rect x="8" y="18" width="4" height="2" /> <rect x="8" y="18" width="4" height="2" />
<rect x="14" y="18" width="4" height="2" /> <rect x="14" y="18" width="4" height="2" />
@@ -985,19 +989,19 @@
/></svg /></svg
>Datasets >Datasets
</button> </button>
<button class="btn btn-ghost-util sm"> <button class="btn btn-ghost-util">
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor"> <svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor">
<path d="M26,24v4H6V24H4v4a2,2,0,0,0,2,2H26a2,2,0,0,0,2-2V24Z" /> <path d="M26,24v4H6V24H4v4a2,2,0,0,0,2,2H26a2,2,0,0,0,2-2V24Z" />
<path d="M6,12l1.41,1.41L15,5.83V24h2V5.83l7.59,7.59L26,12,16,2Z" /></svg <path d="M6,12l1.41,1.41L15,5.83V24h2V5.83l7.59,7.59L26,12,16,2Z" /></svg
>Import >Import
</button> </button>
<button class="btn btn-ghost-util sm"> <button class="btn btn-ghost-util">
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor"> <svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor">
<path d="M26,24v4H6V24H4v4a2,2,0,0,0,2,2H26a2,2,0,0,0,2-2V24Z" /> <path d="M26,24v4H6V24H4v4a2,2,0,0,0,2,2H26a2,2,0,0,0,2-2V24Z" />
<path d="M26,14l-1.41-1.41L17,20.17V2H15V20.17l-7.59-7.58L6,14l10,10Z" /></svg <path d="M26,14l-1.41-1.41L17,20.17V2H15V20.17l-7.59-7.58L6,14l10,10Z" /></svg
>Export >Export
</button> </button>
<button class="btn btn-ghost-util sm"> <button class="btn btn-ghost-util">
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor"> <svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor">
<path d="M17,22V14H13v2h2v6H12v2h8V22Z" /> <path d="M17,22V14H13v2h2v6H12v2h8V22Z" />
<path d="M16,8a1.5,1.5,0,1,0,1.5,1.5A1.5,1.5,0,0,0,16,8Z" /> <path d="M16,8a1.5,1.5,0,1,0,1.5,1.5A1.5,1.5,0,0,0,16,8Z" />
@@ -1007,7 +1011,7 @@
>About >About
</button> </button>
<span class="hdr-divider"></span> <span class="hdr-divider"></span>
<button class="btn btn-soft sm">Donate</button> <button class="btn btn-soft">Donate</button>
</div> </div>
</section> </section>
@@ -1495,8 +1499,8 @@
><span><span class="dot draft"></span> Draft changes</span> ><span><span class="dot draft"></span> Draft changes</span>
</div> </div>
<div class="row" style="margin-top: var(--space-5)"> <div class="row" style="margin-top: var(--space-5)">
<button class="btn btn-primary sm">Publish</button> <button class="btn btn-primary">Publish</button>
<button class="btn btn-ghost sm">Revert</button> <button class="btn btn-ghost">Revert</button>
</div> </div>
</div> </div>
</div> </div>
@@ -1611,7 +1615,7 @@
<div class="motion-box"></div> <div class="motion-box"></div>
</div> </div>
<div class="row" style="margin-top: var(--space-5)"> <div class="row" style="margin-top: var(--space-5)">
<button class="btn btn-secondary sm" id="motionBtn">Play transition</button> <button class="btn btn-secondary" id="motionBtn">Play transition</button>
<span class="cap" <span class="cap"
>Neutralized automatically under <code>prefers-reduced-motion</code>.</span >Neutralized automatically under <code>prefers-reduced-motion</code>.</span
> >
+6 -52
View File
@@ -13,6 +13,9 @@
border-bottom: var(--border-width) solid var(--border); border-bottom: var(--border-width) solid var(--border);
background: var(--layer-01); background: var(--layer-01);
flex: 0 0 auto; flex: 0 0 auto;
/* The header sits on --layer-01, so ghost/secondary controls step their hover
fill up to --layer-02 (arch 09 §4; consumed by Button/IconButton). */
--control-hover-fill: var(--layer-02);
} }
.title { .title {
@@ -61,42 +64,9 @@
flex: 1; flex: 1;
} }
/* /* Vertical rule separating the utilities from Donate + the theme toggle.
* Header utility buttons (Datasets / Import / Export / About) — low-emphasis (The header buttons themselves are the shared Button primitive — ghost
* ghost buttons: borderless, icon + label, so they recede behind the work area utilities, soft-accent Donate; arch 09 §4.) */
* and read as a row of equals (Carbon: utilities in toolbars/dashboards belong at
* low emphasis). The transparent border holds the box size so hover doesn't shift
* neighbours. Matches the ThemeToggle's ghost icon button beside them.
*/
.headerButton {
display: inline-flex;
align-items: center;
gap: var(--space-2);
height: 32px;
padding: 0 var(--space-3);
border: var(--border-width) solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--text);
font: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.headerButton:hover {
background: var(--layer-02);
}
.headerButton:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
/* Vertical rule separating the utilities from Donate + the theme toggle. */
.headerDivider { .headerDivider {
flex: 0 0 auto; flex: 0 0 auto;
width: var(--border-width); width: var(--border-width);
@@ -105,22 +75,6 @@
background: var(--border-strong); background: var(--border-strong);
} }
/*
* Donate — the one solicitation in the header. A soft-accent wash lifts it just
* above the ghost utilities without competing with a primary action (it carries
* no border; the tint alone distinguishes it). Placed after .headerButton:hover
* so its own hover state wins the equal-specificity tie.
*/
.donate {
background: var(--accent-soft);
color: var(--accent-hover);
}
.donate:hover {
background: var(--accent-soft-hover);
color: var(--accent-hover);
}
/* The Import file picker is driven programmatically by its header button. */ /* The Import file picker is driven programmatically by its header button. */
.hiddenInput { .hiddenInput {
display: none; display: none;
+18 -23
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { Button } from './components/Button';
import { ConfirmDialog } from './components/ConfirmDialog'; import { ConfirmDialog } from './components/ConfirmDialog';
import { LivePreview } from './components/LivePreview'; import { LivePreview } from './components/LivePreview';
import { ModalShell } from './components/ModalShell'; import { ModalShell } from './components/ModalShell';
@@ -80,52 +81,46 @@ export function App() {
better at low emphasis). The utilities are ghost (borderless) buttons better at low emphasis). The utilities are ghost (borderless) buttons
with a leading icon for scanning; a divider then sets off Donate — the with a leading icon for scanning; a divider then sets off Donate — the
one solicitation, given a soft-accent wash — and the theme toggle. */} one solicitation, given a soft-accent wash — and the theme toggle. */}
<button <Button
type="button" variant="ghost"
className={styles.headerButton}
onClick={() => openModal('datasets')} onClick={() => openModal('datasets')}
aria-keyshortcuts="Meta+K Control+K" aria-keyshortcuts="Meta+K Control+K"
title="Datasets (⌘/Ctrl+K)" title="Datasets (⌘/Ctrl+K)"
> >
<Icon name="dataset" /> <Icon name="dataset" />
Datasets Datasets
</button> </Button>
<button <Button
type="button" variant="ghost"
className={styles.headerButton}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
title="Import a workspace JSON file" title="Import a workspace JSON file"
> >
<Icon name="import" /> <Icon name="import" />
Import Import
</button> </Button>
<button {/* TODO: two controls labelled "Export" are visible at once (this
type="button" workspace export + the preview pane's per-chart export) — rename or
className={styles.headerButton} differentiate (docs/ux-second-pass.md). */}
<Button
variant="ghost"
onClick={() => exportWorkspace()} onClick={() => exportWorkspace()}
title="Export your workspace to a JSON file" title="Export your workspace to a JSON file"
> >
<Icon name="export" /> <Icon name="export" />
Export Export
</button> </Button>
<button <Button
type="button" variant="ghost"
className={styles.headerButton}
onClick={() => openModal('about')} onClick={() => openModal('about')}
title="About, keyboard shortcuts, and privacy information" title="About, keyboard shortcuts, and privacy information"
> >
<Icon name="info" /> <Icon name="info" />
About About
</button> </Button>
<span className={styles.headerDivider} aria-hidden="true" /> <span className={styles.headerDivider} aria-hidden="true" />
<button <Button variant="soft-accent" onClick={() => openModal('donate')} title="Support Astrolabe">
type="button"
className={`${styles.headerButton} ${styles.donate}`}
onClick={() => openModal('donate')}
title="Support Astrolabe"
>
Donate Donate
</button> </Button>
<ThemeToggle /> <ThemeToggle />
{/* Hidden picker for Import; restricted to JSON (spec §08). */} {/* Hidden picker for Import; restricted to JSON (spec §08). */}
<input <input
+107
View File
@@ -0,0 +1,107 @@
/*
* Button — shared look for every textual action button (arch 09 §4). Owned here,
* once. Heights come from the control scale in tokens.css; no other button
* heights exist in the app.
*/
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
height: var(--control-height);
padding: 0 var(--space-3);
border: var(--border-width) solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--text);
font: inherit;
font-size: 13px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.lg {
height: var(--control-height-lg);
padding: 0 var(--space-4);
}
.button:disabled {
cursor: default;
opacity: 0.45;
}
/* --- primary: filled accent; darkens on hover (filled buttons darken, they
don't fill — arch 09 §4) --- */
.primary {
background: var(--accent);
color: var(--accent-contrast);
}
.primary:hover:not(:disabled) {
background: var(--accent-hover);
}
/* --- secondary: bordered with a --bg fill — the same field-on-layer treatment
as inputs/selects (arch 09 §4: a bordered control on a gray panel goes
white, never a darker gray); hover steps one elevation above the surface
(see --control-hover-fill in Button.tsx header) --- */
.secondary {
border-color: var(--border-strong);
background: var(--bg);
}
.secondary:hover:not(:disabled) {
background: var(--control-hover-fill, var(--layer-01));
}
/* --- ghost: borderless utility; recedes until hover --- */
.ghost:hover:not(:disabled) {
background: var(--control-hover-fill, var(--layer-01));
}
/* A ghost acting as an open disclosure trigger holds the pressed fill. */
.ghost[aria-expanded='true'] {
background: var(--layer-02);
}
/* --- soft-accent: tinted-but-quiet solicitation (e.g. Donate) --- */
.softAccent {
background: var(--accent-soft);
color: var(--accent-hover);
}
.softAccent:hover:not(:disabled) {
background: var(--accent-soft-hover);
color: var(--accent-hover);
}
/* --- danger: filled error for destructive confirm actions --- */
.danger {
background: var(--support-error);
color: var(--on-status);
}
.danger:hover:not(:disabled) {
filter: brightness(0.92);
}
/* --- danger-outline: secondary geometry, red label; fills solid red on
hover/focus so the destructive intent is signalled before it acts --- */
.dangerOutline {
border-color: var(--border-strong);
background: var(--bg);
color: var(--support-error);
}
.dangerOutline:hover:not(:disabled),
.dangerOutline:focus-visible {
background: var(--support-error);
border-color: transparent;
color: var(--on-status);
}
+79
View File
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Button } from './Button';
import { IconButton } from './IconButton';
(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 button = container.querySelector('button');
if (!button) throw new Error('no <button> rendered');
return button;
}
describe('Button', () => {
test('defaults to type="button" so it never submits an enclosing form', () => {
expect(render(<Button>Save</Button>).getAttribute('type')).toBe('button');
expect(render(<Button type="submit">Save</Button>).getAttribute('type')).toBe('submit');
});
test('each variant resolves to a distinct class string', () => {
// Whatever the CSS-module hashing, the variants must produce different
// classes so the arch 09 §4 taxonomy is actually applied, not ignored.
const classes = (
['primary', 'secondary', 'ghost', 'soft-accent', 'danger', 'danger-outline'] as const
).map((variant) => render(<Button variant={variant}>X</Button>).className);
expect(new Set(classes).size).toBe(classes.length);
});
test('size and call-site classes compose onto the base class', () => {
// Capture the string before the next render — re-rendering reuses the node.
const mdClass = render(<Button>X</Button>).className;
const lg = render(
<Button size="lg" className="layout">
X
</Button>,
);
expect(lg.className).not.toBe(mdClass);
expect(lg.classList.contains('layout')).toBe(true);
});
});
describe('IconButton', () => {
test('label provides the accessible name and the default tooltip', () => {
const btn = render(<IconButton label="Close" />);
expect(btn.getAttribute('aria-label')).toBe('Close');
expect(btn.getAttribute('title')).toBe('Close');
});
test('an explicit title overrides the tooltip but not the accessible name', () => {
const btn = render(<IconButton label="Datasets" title="Datasets (⌘/Ctrl+K)" />);
expect(btn.getAttribute('aria-label')).toBe('Datasets');
expect(btn.getAttribute('title')).toBe('Datasets (⌘/Ctrl+K)');
});
test('defaults to type="button" and composes size/call-site classes', () => {
const md = render(<IconButton label="X" />);
expect(md.getAttribute('type')).toBe('button');
const mdClass = md.className;
const sm = render(<IconButton label="X" size="sm" className="reveal" />);
expect(sm.className).not.toBe(mdClass);
expect(sm.classList.contains('reveal')).toBe(true);
});
});
+74
View File
@@ -0,0 +1,74 @@
/**
* Button — the shared action-button primitive (arch 09 §4).
*
* Every textual action button in the app is one of these. The component owns the
* base look (Button.module.css) so instances can't drift; call sites compose
* `className` for layout-specific additions only (flex, margins), never to
* restyle the control.
*
* Variants are the arch 09 §4 taxonomy:
* - **primary** — filled `--accent`; at most one per region (emphasis hierarchy).
* - **secondary** — bordered, `--bg` fill (the field-on-layer treatment).
* - **ghost** — borderless; utilities in toolbars/headers that should recede.
* (A transparent border still holds the box size so hover doesn't shift
* neighbours.)
* - **soft-accent** — ghost on an `--accent-soft` wash; low-emphasis solicitation.
* - **danger** — filled `--support-error`; destructive confirm actions.
* - **danger-outline** — secondary geometry with a red label, filling solid red
* on hover/focus (arch 10 — a destructive control signals danger before it
* acts). For destructive actions that sit among peers (a detail view's
* Delete); the filled `danger` stays reserved for the confirm step.
*
* Sizes are the control scale (tokens.css): `md` = `--control-height` (32px, the
* default — toolbars, forms, dialog action rows), `lg` = `--control-height-lg`
* (40px — standalone primary CTAs).
*
* Hover on secondary/ghost fills one elevation step above the surface (arch 09
* §4). The step is resolved by the `--control-hover-fill` custom property:
* defaults to `--layer-01` (controls on the `--bg` canvas); elevated surfaces
* (the header, modal cards) set `--control-hover-fill: var(--layer-02)` once and
* every Button/IconButton on them steps up automatically.
*/
import type { ComponentPropsWithRef } from 'react';
import styles from './Button.module.css';
type ButtonVariant =
| 'primary'
| 'secondary'
| 'ghost'
| 'soft-accent'
| 'danger'
| 'danger-outline';
export interface ButtonProps extends ComponentPropsWithRef<'button'> {
variant?: ButtonVariant;
size?: 'md' | 'lg';
}
const VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: styles.primary,
secondary: styles.secondary,
ghost: styles.ghost,
'soft-accent': styles.softAccent,
danger: styles.danger,
'danger-outline': styles.dangerOutline,
};
export function Button({
variant = 'secondary',
size = 'md',
className,
type = 'button',
...rest
}: ButtonProps) {
return (
<button
type={type}
className={[styles.button, VARIANT_CLASS[variant], size === 'lg' && styles.lg, className]
.filter(Boolean)
.join(' ')}
{...rest}
/>
);
}
+1 -42
View File
@@ -5,48 +5,7 @@
display: inline-flex; display: inline-flex;
} }
/* Trigger — a compact ghost button (icon + label), matching the header utilities. */ /* The trigger is the shared Button primitive (ghost; arch 09 §4). */
.trigger {
display: inline-flex;
align-items: center;
gap: var(--space-2);
height: 28px;
padding: 0 var(--space-3);
border: var(--border-width) solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: 13px;
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.trigger:hover:not(:disabled) {
background: var(--layer-01);
color: var(--text);
}
.trigger[aria-expanded='true'] {
background: var(--layer-02);
color: var(--text);
}
.trigger:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
.trigger:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.triggerIcon {
color: currentColor;
}
/* The disclosed panel — portaled to <body>, positioned `fixed` (top/right set /* The disclosed panel — portaled to <body>, positioned `fixed` (top/right set
inline) so it escapes the panes' overflow clipping. Mirrors SettingsPopover. */ inline) so it escapes the panes' overflow clipping. Mirrors SettingsPopover. */
+5 -5
View File
@@ -39,6 +39,7 @@ import { usePopover } from '../hooks/usePopover';
import { useDatasetStore } from '../stores/DatasetStore'; import { useDatasetStore } from '../stores/DatasetStore';
import { notify } from '../stores/NotificationStore'; import { notify } from '../stores/NotificationStore';
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore'; import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { Button } from './Button';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { SettingRow } from './SettingsPopover'; import { SettingRow } from './SettingsPopover';
@@ -183,19 +184,18 @@ export function ChartExport({ chartReady, getImageUrl }: ChartExportProps) {
return ( return (
<div className={styles.wrap}> <div className={styles.wrap}>
<button <Button
ref={triggerRef} ref={triggerRef}
type="button" variant="ghost"
className={styles.trigger}
aria-expanded={open} aria-expanded={open}
aria-controls={POP_ID} aria-controls={POP_ID}
disabled={!hasSpec} disabled={!hasSpec}
title={hasSpec ? 'Export this chart' : 'Select a snippet to export'} title={hasSpec ? 'Export this chart' : 'Select a snippet to export'}
onClick={toggle} onClick={toggle}
> >
<Icon name="export" className={styles.triggerIcon} /> <Icon name="export" />
<span>Export</span> <span>Export</span>
</button> </Button>
{open && {open &&
createPortal( createPortal(
<div <div
+5 -41
View File
@@ -21,6 +21,9 @@
border-radius: var(--radius); border-radius: var(--radius);
/* Minimal shadow, reserved for true overlays (doc §09). */ /* Minimal shadow, reserved for true overlays (doc §09). */
box-shadow: 0 2px 12px rgb(0 0 0 / 0.3); box-shadow: 0 2px 12px rgb(0 0 0 / 0.3);
/* The card sits on --layer-01, so its controls' hover fill steps up (the
collision that once left Cancel looking dead — arch 09 §4). */
--control-hover-fill: var(--layer-02);
} }
.title { .title {
@@ -44,49 +47,10 @@
margin-top: var(--space-2); margin-top: var(--space-2);
} }
.cancel, /* The actions are shared Buttons (secondary / primary|danger, lg). These two
.confirm { classes are pure markers — the focus trap's initial-focus selectors. */
height: 40px;
padding: 0 var(--space-5);
border: var(--border-width) solid transparent;
border-radius: var(--radius);
font: inherit;
font-weight: 600;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.cancel { .cancel {
background: transparent;
border-color: var(--border-strong);
color: var(--text);
}
.cancel:hover {
background: var(--layer-02);
} }
.confirm { .confirm {
background: var(--accent);
color: var(--accent-contrast);
}
.confirm:hover {
background: var(--accent-hover);
}
.danger {
background: var(--support-error);
color: var(--on-status);
}
.danger:hover {
/* Slightly darken; the error token already carries the meaning. */
filter: brightness(0.92);
}
.cancel:focus-visible,
.confirm:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
} }
+8 -6
View File
@@ -1,5 +1,6 @@
import { useConfirmStore } from '../stores/ConfirmStore'; import { useConfirmStore } from '../stores/ConfirmStore';
import { useFocusTrap } from '../hooks/useFocusTrap'; import { useFocusTrap } from '../hooks/useFocusTrap';
import { Button } from './Button';
import styles from './ConfirmDialog.module.css'; import styles from './ConfirmDialog.module.css';
/** /**
@@ -42,16 +43,17 @@ export function ConfirmDialog() {
{message} {message}
</p> </p>
<div className={styles.actions}> <div className={styles.actions}>
<button type="button" className={styles.cancel} onClick={() => resolve(false)}> <Button size="lg" className={styles.cancel} onClick={() => resolve(false)}>
{cancelLabel ?? 'Cancel'} {cancelLabel ?? 'Cancel'}
</button> </Button>
<button <Button
type="button" size="lg"
className={`${styles.confirm} ${danger ? styles.danger : ''}`} variant={danger ? 'danger' : 'primary'}
className={styles.confirm}
onClick={() => resolve(true)} onClick={() => resolve(true)}
> >
{confirmLabel ?? 'Confirm'} {confirmLabel ?? 'Confirm'}
</button> </Button>
</div> </div>
</div> </div>
</div> </div>
+2 -68
View File
@@ -15,27 +15,10 @@
border-right: var(--border-width) solid var(--border); border-right: var(--border-width) solid var(--border);
} }
/* A shared primary Button (lg); locally just pinned with a margin. */
.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;
padding: 0 var(--space-5);
border: var(--border-width) solid transparent;
border-radius: var(--radius);
background: var(--accent);
color: var(--accent-contrast);
font: inherit;
font-weight: 600;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.newButton:hover {
background: var(--accent-hover);
} }
.list { .list {
@@ -177,56 +160,7 @@
justify-content: flex-end; justify-content: flex-end;
} }
.action { /* Actions are shared Buttons (secondary / primary / danger-outline). */
height: 32px;
padding: 0 var(--space-4);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: transparent;
color: var(--text);
font: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.action:hover:not(:disabled) {
background: var(--layer-01);
}
.action:disabled {
color: var(--text-placeholder);
border-color: var(--border);
cursor: not-allowed;
}
.action:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.primary {
background: var(--accent);
border-color: transparent;
color: var(--accent-contrast);
font-weight: 600;
}
.primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.danger {
border-color: var(--border-strong);
color: var(--support-error);
}
.danger:hover:not(:disabled) {
background: var(--support-error);
color: var(--on-status);
border-color: transparent;
}
.comment { .comment {
margin: 0; margin: 0;
+16 -43
View File
@@ -37,6 +37,7 @@ import {
} from '../stores/DatasetStore'; } 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 { Button } from './Button';
import { Icon } from './Icon'; import { Icon } from './Icon';
import styles from './DatasetsModal.module.css'; import styles from './DatasetsModal.module.css';
@@ -73,9 +74,9 @@ export function DatasetsModal() {
return ( return (
<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 variant="primary" size="lg" className={styles.newButton} onClick={handleNew}>
<Icon name="add" /> New Dataset <Icon name="add" /> New Dataset
</button> </Button>
<ul className={styles.list}> <ul className={styles.list}>
{ordered.length === 0 && ( {ordered.length === 0 && (
<li className={styles.empty}> <li className={styles.empty}>
@@ -239,9 +240,7 @@ function DatasetDetail({
<div className={styles.detailHead}> <div className={styles.detailHead}>
<h3 className={styles.detailName}>{dataset.name}</h3> <h3 className={styles.detailName}>{dataset.name}</h3>
<div className={styles.detailActions}> <div className={styles.detailActions}>
<button type="button" className={styles.action} onClick={() => void handleCopy()}> <Button onClick={() => void handleCopy()}>{copied ? 'Copied' : 'Copy Reference'}</Button>
{copied ? 'Copied' : 'Copy Reference'}
</button>
{/* The clipboard write is invisible, so the success is confirmed inline {/* The clipboard write is invisible, so the success is confirmed inline
("Copied") rather than by a toast (docs/architecture/10 → Toast copy). ("Copied") rather than by a toast (docs/architecture/10 → Toast copy).
This polite live region announces it to assistive tech, which the This polite live region announces it to assistive tech, which the
@@ -250,35 +249,18 @@ function DatasetDetail({
{copied ? 'Reference copied to clipboard' : ''} {copied ? 'Reference copied to clipboard' : ''}
</span> </span>
{dataset.source === 'url' && ( {dataset.source === 'url' && (
<button <Button onClick={() => void handleRefresh()} disabled={refreshing}>
type="button"
className={styles.action}
onClick={() => void handleRefresh()}
disabled={refreshing}
>
{refreshing ? 'Refreshing…' : 'Refresh'} {refreshing ? 'Refreshing…' : 'Refresh'}
</button> </Button>
)} )}
<button type="button" className={styles.action} onClick={handleEdit}> <Button onClick={handleEdit}>Edit</Button>
Edit
</button>
{/* Build Chart (spec §05 → §06) — opens the Chart Builder on this dataset. {/* Build Chart (spec §05 → §06) — opens the Chart Builder on this dataset.
Replaces the Datasets modal (one modal at a time, §01C); detail view has Replaces the Datasets modal (one modal at a time, §01C); detail view has
no transient form state, so no discard prompt. */} no transient form state, so no discard prompt. */}
<button <Button onClick={() => openModal('chartBuilder', String(dataset.id))}>Build Chart</Button>
type="button" <Button variant="danger-outline" onClick={() => void handleDelete()}>
className={styles.action}
onClick={() => openModal('chartBuilder', String(dataset.id))}
>
Build Chart
</button>
<button
type="button"
className={`${styles.action} ${styles.danger}`}
onClick={() => void handleDelete()}
>
Delete Delete
</button> </Button>
</div> </div>
</div> </div>
@@ -574,27 +556,18 @@ function DatasetFormView({ editing }: { editing: boolean }) {
{/* A failed fetch is recoverable by pasting the data inline (the user's choice {/* A failed fetch is recoverable by pasting the data inline (the user's choice
when CORS or offline blocks the URL) — offered as a direct one-click path. */} when CORS or offline blocks the URL) — offered as a direct one-click path. */}
{fetchError && ( {fetchError && (
<button <Button className={styles.fallback} onClick={handlePasteInline}>
type="button"
className={`${styles.action} ${styles.fallback}`}
onClick={handlePasteInline}
>
Paste data inline instead Paste data inline instead
</button> </Button>
)} )}
<div className={styles.formActions}> <div className={styles.formActions}>
<button type="button" className={styles.action} onClick={handleCancel} disabled={fetching}> <Button onClick={handleCancel} disabled={fetching}>
Cancel Cancel
</button> </Button>
<button <Button variant="primary" disabled={!canSave || fetching} onClick={() => void handleSave()}>
type="button"
className={`${styles.action} ${styles.primary}`}
disabled={!canSave || fetching}
onClick={() => void handleSave()}
>
{fetching ? 'Fetching…' : editing ? 'Save changes' : 'Create dataset'} {fetching ? 'Fetching…' : editing ? 'Save changes' : 'Create dataset'}
</button> </Button>
</div> </div>
</div> </div>
); );
+1 -1
View File
@@ -26,7 +26,7 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 36px; height: var(--control-height-lg);
padding: 0 var(--space-6); padding: 0 var(--space-6);
background: var(--accent); background: var(--accent);
border: var(--border-width) solid transparent; border: var(--border-width) solid transparent;
+1 -33
View File
@@ -74,41 +74,9 @@
color: var(--support-error); color: var(--support-error);
} }
/* Cancel / Create are shared Buttons (secondary / primary, lg — modal footer). */
.actions { .actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: var(--space-3); gap: var(--space-3);
} }
.action {
height: 36px;
padding: 0 var(--space-5);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: transparent;
color: var(--text);
font: inherit;
font-weight: 500;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.action:hover {
background: var(--layer-01);
}
.action:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.primary {
background: var(--accent);
border-color: transparent;
color: var(--accent-contrast);
font-weight: 600;
}
.primary:hover {
background: var(--accent-hover);
}
+5 -8
View File
@@ -11,6 +11,7 @@
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { closeModal } from '../modals/ModalCoordinator'; import { closeModal } from '../modals/ModalCoordinator';
import { useExtractStore } from '../stores/ExtractStore'; import { useExtractStore } from '../stores/ExtractStore';
import { Button } from './Button';
import styles from './ExtractModal.module.css'; import styles from './ExtractModal.module.css';
const MAX_PREVIEW = 1500; const MAX_PREVIEW = 1500;
@@ -74,16 +75,12 @@ export function ExtractModal() {
)} )}
<div className={styles.actions}> <div className={styles.actions}>
<button type="button" className={styles.action} onClick={() => void closeModal()}> <Button size="lg" onClick={() => void closeModal()}>
Cancel Cancel
</button> </Button>
<button <Button variant="primary" size="lg" onClick={handleCreate}>
type="button"
className={`${styles.action} ${styles.primary}`}
onClick={handleCreate}
>
Create dataset Create dataset
</button> </Button>
</div> </div>
</div> </div>
); );
+48
View File
@@ -0,0 +1,48 @@
/*
* IconButton — shared look for every icon-only button (arch 09 §4 + §5).
* Square, sized by the control scale in tokens.css.
*/
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: var(--control-height);
height: var(--control-height);
padding: 0;
border: var(--border-width) solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.sm {
width: 24px;
height: 24px;
}
.lg {
width: var(--control-height-lg);
height: var(--control-height-lg);
}
.iconButton:hover:not(:disabled) {
background: var(--control-hover-fill, var(--layer-01));
color: var(--text);
}
/* An open disclosure trigger (gear popovers, export menu) holds the fill. */
.iconButton[aria-expanded='true'] {
background: var(--layer-02);
color: var(--text);
}
.iconButton:disabled {
cursor: default;
opacity: 0.45;
}
+53
View File
@@ -0,0 +1,53 @@
/**
* IconButton — the shared icon-only button primitive (arch 09 §4 + §5).
*
* A square button holding a single `<Icon>` (the controlled-vocabulary glyph
* primitive) and nothing else. Because there is no visible text, an accessible
* name is required: `label` becomes `aria-label` (APG button pattern) and, unless
* overridden via `title`, the hover tooltip.
*
* Always ghost — borderless toolbar utility; the transparent border holds the
* box size. Sizes are the control scale: `md` = `--control-height` (32px
* square, the default), `lg` = `--control-height-lg` (40px — beside a lg
* Button), `sm` = 24px (only nested inside another 32px control, e.g. a search
* field's clear button).
*
* Hover follows the same one-elevation-step rule as Button, via the same
* `--control-hover-fill` custom property; an open disclosure trigger
* (`aria-expanded="true"`) holds the `--layer-02` fill.
*/
import type { ComponentPropsWithRef } from 'react';
import styles from './IconButton.module.css';
export interface IconButtonProps extends ComponentPropsWithRef<'button'> {
/** Accessible name (required — there is no visible text). */
label: string;
size?: 'sm' | 'md' | 'lg';
}
export function IconButton({
label,
size = 'md',
className,
type = 'button',
title,
...rest
}: IconButtonProps) {
return (
<button
type={type}
aria-label={label}
title={title ?? label}
className={[
styles.iconButton,
size === 'sm' && styles.sm,
size === 'lg' && styles.lg,
className,
]
.filter(Boolean)
.join(' ')}
{...rest}
/>
);
}
+5 -26
View File
@@ -20,6 +20,9 @@
border: var(--border-width) solid var(--border); border: var(--border-width) solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
box-shadow: 0 2px 12px rgb(0 0 0 / 0.3); box-shadow: 0 2px 12px rgb(0 0 0 / 0.3);
/* Modal chrome sits on --layer-01, so ghost/secondary controls in the header
step their hover fill up (arch 09 §4). The body resets to --bg below. */
--control-hover-fill: var(--layer-02);
} }
/* Large: the two-pane managers (Datasets). Definite height so inner panes scroll. */ /* Large: the two-pane managers (Datasets). Definite height so inner panes scroll. */
@@ -66,32 +69,7 @@
outline: none; outline: none;
} }
.close { /* The close affordance is the shared IconButton. */
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: none;
color: var(--text-secondary);
font-size: 22px;
line-height: 1;
cursor: pointer;
border-radius: var(--radius);
transition: background var(--dur-fast) var(--ease);
}
.close:hover {
background: var(--layer-02);
color: var(--text);
}
.close:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
/* The modal body. Background steps to --bg so the panes read as the work surface /* The modal body. Background steps to --bg so the panes read as the work surface
against the --layer-01 chrome. Fills the remaining height; inner content scrolls. */ against the --layer-01 chrome. Fills the remaining height; inner content scrolls. */
@@ -100,4 +78,5 @@
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
background: var(--bg); background: var(--bg);
--control-hover-fill: var(--layer-01);
} }
+3 -7
View File
@@ -27,6 +27,7 @@ import { DonateModal } from './DonateModal';
import { ExtractModal } from './ExtractModal'; import { ExtractModal } from './ExtractModal';
import { ThemeBuilderModal } from './ThemeBuilderModal'; import { ThemeBuilderModal } from './ThemeBuilderModal';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { IconButton } from './IconButton';
import styles from './ModalShell.module.css'; import styles from './ModalShell.module.css';
/** The body rendered for each modal name (metadata stays in the registry). */ /** The body rendered for each modal name (metadata stays in the registry). */
@@ -95,14 +96,9 @@ export function ModalShell() {
<h2 id="modal-title" className={styles.title} tabIndex={-1}> <h2 id="modal-title" className={styles.title} tabIndex={-1}>
{getModalTitle(name)} {getModalTitle(name)}
</h2> </h2>
<button <IconButton label="Close" onClick={() => void closeModal()}>
type="button"
className={styles.close}
aria-label="Close"
onClick={() => void closeModal()}
>
<Icon name="close" /> <Icon name="close" />
</button> </IconButton>
</header> </header>
<div className={styles.body}> <div className={styles.body}>
<Body /> <Body />
+2 -88
View File
@@ -35,53 +35,7 @@
gap: var(--space-3); gap: var(--space-3);
} }
/* Primary call to action — the accent button, matching the library's Create. */ /* The two CTAs and the per-example Adds are shared Buttons (arch 09 §4). */
.primary {
display: inline-flex;
align-items: center;
gap: var(--space-2);
height: 40px;
padding: 0 var(--space-5);
border: var(--border-width) solid transparent;
border-radius: var(--radius);
background: var(--accent);
color: var(--accent-contrast);
font: inherit;
font-weight: 600;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.primary:hover {
background: var(--accent-hover);
}
.primary:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
/* The data-first door — bordered ghost, same height as the primary beside it. */
.buildCta {
display: inline-flex;
align-items: center;
gap: var(--space-2);
height: 40px;
padding: 0 var(--space-5);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: transparent;
color: var(--text);
font: inherit;
font-weight: 600;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.buildCta:hover {
background: var(--layer-01);
}
.buildCta:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
/* The "or start from an example" header row, with Add all pushed to the end. */ /* The "or start from an example" header row, with Add all pushed to the end. */
.galleryHead { .galleryHead {
@@ -101,27 +55,8 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
/* Secondary button — bordered, transparent (like the header actions). */
.addAll { .addAll {
flex: 0 0 auto; flex: 0 0 auto;
height: 32px;
padding: 0 var(--space-4);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: transparent;
color: var(--text);
font: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.addAll:hover {
background: var(--layer-01);
}
.addAll:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
} }
/* Responsive gallery: cards as wide as ~240px, filling the column. */ /* Responsive gallery: cards as wide as ~240px, filling the column. */
@@ -174,28 +109,7 @@
line-height: 1.4; line-height: 1.4;
} }
/* Per-example Add — small bordered action, aligned to the card's start. */ /* Per-example Add — a shared secondary Button, aligned to the card's start. */
.add { .add {
align-self: flex-start; align-self: flex-start;
display: inline-flex;
align-items: center;
gap: var(--space-1);
height: 30px;
padding: 0 var(--space-4);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.add:hover {
background: var(--layer-02, var(--layer-01));
}
.add:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
} }
+9 -9
View File
@@ -24,6 +24,7 @@ import { renderSpec, type RenderHandle } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore'; import { useAppStore } from '../stores/AppStore';
import { usePanesStore } from '../stores/PanesStore'; import { usePanesStore } from '../stores/PanesStore';
import { useSnippetStore } from '../stores/SnippetStore'; import { useSnippetStore } from '../stores/SnippetStore';
import { Button } from './Button';
import { Icon } from './Icon'; import { Icon } from './Icon';
import styles from './Onboarding.module.css'; import styles from './Onboarding.module.css';
@@ -141,19 +142,19 @@ export function Onboarding() {
</p> </p>
<div className={styles.ctaRow}> <div className={styles.ctaRow}>
<button type="button" className={styles.primary} onClick={handleCreate}> <Button variant="primary" size="lg" onClick={handleCreate}>
<Icon name="add" /> Create your first snippet <Icon name="add" /> Create your first snippet
</button> </Button>
<button type="button" className={styles.buildCta} onClick={handleBuild}> <Button size="lg" onClick={handleBuild}>
<Icon name="chart" /> Build a chart from your data <Icon name="chart" /> Build a chart from your data
</button> </Button>
</div> </div>
<div className={styles.galleryHead}> <div className={styles.galleryHead}>
<h3 className={styles.galleryTitle}>Or start from an example</h3> <h3 className={styles.galleryTitle}>Or start from an example</h3>
<button type="button" className={styles.addAll} onClick={handleAddAll}> <Button className={styles.addAll} onClick={handleAddAll}>
Add all Add all
</button> </Button>
</div> </div>
<ul className={styles.gallery}> <ul className={styles.gallery}>
@@ -164,14 +165,13 @@ export function Onboarding() {
<span className={styles.cardName}>{example.name}</span> <span className={styles.cardName}>{example.name}</span>
<span className={styles.cardDesc}>{example.description}</span> <span className={styles.cardDesc}>{example.description}</span>
</div> </div>
<button <Button
type="button"
className={styles.add} className={styles.add}
aria-label={`Add ${example.name}`} aria-label={`Add ${example.name}`}
onClick={() => handleAdd(example)} onClick={() => handleAdd(example)}
> >
<Icon name="add" /> Add <Icon name="add" /> Add
</button> </Button>
</li> </li>
))} ))}
</ul> </ul>
+9 -31
View File
@@ -12,41 +12,19 @@
padding: var(--space-3) var(--space-2); padding: var(--space-3) var(--space-2);
border-right: var(--border-width) solid var(--border); border-right: var(--border-width) solid var(--border);
background: var(--layer-01); background: var(--layer-01);
/* The strip sits on --layer-01, so the IconButtons' hover fill steps up. */
--control-hover-fill: var(--layer-02);
} }
.button { /* The controls are shared IconButtons; only the toggle-on state is local.
display: flex; Shown pane: the toggle is "on" — full-strength glyph on a filled chip.
align-items: center; (.strip prefix outranks the primitive's base background/color.) */
justify-content: center; /* TODO: the pressed chip (--layer-02 fill) is identical to the IconButton hover
width: 32px; fill on this surface, so hovering an off toggle looks the same as an on toggle
height: 32px; at rest — the pressed state needs its own cue (docs/ux-second-pass.md). */
padding: 0; .strip .pressed {
border: var(--border-width) solid transparent;
border-radius: var(--radius);
background: transparent;
/* Not-pressed (hidden pane) reads as muted; pressed (shown) lifts to full text. */
color: var(--text-secondary);
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.button:hover {
background: var(--layer-02);
color: var(--text);
}
.button:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
/* Shown pane: the toggle is "on" — full-strength glyph on a filled chip. */
.pressed {
color: var(--text); color: var(--text);
background: var(--layer-02); background: var(--layer-02);
border-color: var(--border-strong);
} }
.divider { .divider {
+8 -12
View File
@@ -23,6 +23,7 @@ import { useRef, useState } from 'react';
import { openModal } from '../modals/ModalCoordinator'; import { openModal } from '../modals/ModalCoordinator';
import { usePanesStore, type PaneName } from '../stores/PanesStore'; import { usePanesStore, type PaneName } from '../stores/PanesStore';
import { Icon, type IconName } from './Icon'; import { Icon, type IconName } from './Icon';
import { IconButton } from './IconButton';
import styles from './PaneToggleStrip.module.css'; import styles from './PaneToggleStrip.module.css';
interface PaneItem { interface PaneItem {
@@ -105,14 +106,13 @@ export function PaneToggleStrip() {
className={styles.strip} className={styles.strip}
> >
{PANES.map((item, i) => ( {PANES.map((item, i) => (
<button <IconButton
key={item.pane} key={item.pane}
ref={(el) => { ref={(el) => {
refs.current[i] = el; refs.current[i] = el;
}} }}
type="button"
aria-pressed={visible[item.pane]} aria-pressed={visible[item.pane]}
aria-label={item.label} label={item.label}
// Only point at the pane while it's actually mounted: App unmounts a // Only point at the pane while it's actually mounted: App unmounts a
// hidden pane's <section>, so emitting aria-controls then would leave a // hidden pane's <section>, so emitting aria-controls then would leave a
// dangling IDREF (invalid, even if AT ignores it). The association is // dangling IDREF (invalid, even if AT ignores it). The association is
@@ -124,9 +124,7 @@ export function PaneToggleStrip() {
// stable (aria-label) per APG's toggle-button rule. // stable (aria-label) per APG's toggle-button rule.
title={`${visible[item.pane] ? 'Hide' : 'Show'} ${item.label.toLowerCase()}`} title={`${visible[item.pane] ? 'Hide' : 'Show'} ${item.label.toLowerCase()}`}
tabIndex={focusIndex === i ? 0 : -1} tabIndex={focusIndex === i ? 0 : -1}
className={[styles.button, visible[item.pane] && styles.pressed] className={visible[item.pane] ? styles.pressed : undefined}
.filter(Boolean)
.join(' ')}
onClick={() => { onClick={() => {
togglePane(item.pane, panesInner()); togglePane(item.pane, panesInner());
setFocusIndex(i); setFocusIndex(i);
@@ -134,21 +132,19 @@ export function PaneToggleStrip() {
onKeyDown={(e) => onKeyDown(e, i)} onKeyDown={(e) => onKeyDown(e, i)}
> >
<Icon name={item.icon} /> <Icon name={item.icon} />
</button> </IconButton>
))} ))}
<div className={styles.divider} aria-hidden="true" /> <div className={styles.divider} aria-hidden="true" />
<button <IconButton
ref={(el) => { ref={(el) => {
refs.current[datasetsIndex] = el; refs.current[datasetsIndex] = el;
}} }}
type="button" label="Datasets"
aria-label="Datasets"
aria-keyshortcuts="Meta+K Control+K" aria-keyshortcuts="Meta+K Control+K"
title="Datasets (⌘/Ctrl+K)" title="Datasets (⌘/Ctrl+K)"
tabIndex={focusIndex === datasetsIndex ? 0 : -1} tabIndex={focusIndex === datasetsIndex ? 0 : -1}
className={styles.button}
onClick={() => { onClick={() => {
openModal('datasets'); openModal('datasets');
setFocusIndex(datasetsIndex); setFocusIndex(datasetsIndex);
@@ -156,7 +152,7 @@ export function PaneToggleStrip() {
onKeyDown={(e) => onKeyDown(e, datasetsIndex)} onKeyDown={(e) => onKeyDown(e, datasetsIndex)}
> >
<Icon name="dataset" /> <Icon name="dataset" />
</button> </IconButton>
</div> </div>
); );
} }
@@ -8,18 +8,22 @@
*/ */
.group { .group {
display: inline-flex; display: inline-flex;
/* The control scale (tokens.css): the 1px borders live inside the 32px box. */
height: var(--control-height);
border: var(--border-width) solid var(--border-strong); border: var(--border-width) solid var(--border-strong);
} }
.option { .option {
appearance: none; appearance: none;
display: inline-flex;
align-items: center;
border: none; border: none;
background: var(--bg); background: var(--bg);
color: var(--text-secondary); color: var(--text-secondary);
font: inherit; font: inherit;
font-size: 12px; font-size: 13px;
line-height: 1; line-height: 1;
padding: var(--space-2) var(--space-3); padding: 0 var(--space-3);
cursor: pointer; cursor: pointer;
transition: transition:
background var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease),
+1 -1
View File
@@ -6,7 +6,7 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
height: 32px; height: var(--control-height);
padding: 0 var(--space-3); padding: 0 var(--space-3);
border: var(--border-width) solid var(--border-strong); border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius); border-radius: var(--radius);
+5 -34
View File
@@ -5,38 +5,7 @@
display: inline-flex; display: inline-flex;
} }
/* Gear trigger — a subtle icon button matching the modal-shell close affordance. */ /* The gear trigger is the shared IconButton primitive (arch 09 §4). */
.gear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: var(--border-width) solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.gear:hover {
background: var(--layer-01);
color: var(--text);
}
.gear[aria-expanded='true'] {
background: var(--layer-02);
color: var(--text);
}
.gear:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
/* The disclosed panel — portaled to <body>, positioned `fixed` from the gear's /* The disclosed panel — portaled to <body>, positioned `fixed` from the gear's
rect (top/left|right set inline) so it escapes the panes' overflow clipping. */ rect (top/left|right set inline) so it escapes the panes' overflow clipping. */
@@ -66,7 +35,8 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--space-4); gap: var(--space-4);
min-height: 32px; /* Rows hold 32px controls; the min keeps control-less rows the same height. */
min-height: var(--control-height);
} }
.row + .row { .row + .row {
@@ -109,7 +79,8 @@
.number, .number,
.text { .text {
padding: var(--space-2) var(--space-3); height: var(--control-height);
padding: 0 var(--space-3);
border: var(--border-width) solid var(--border-strong); border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--bg); background: var(--bg);
+4 -6
View File
@@ -23,6 +23,7 @@ import { type ReactNode } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { usePopover } from '../hooks/usePopover'; import { usePopover } from '../hooks/usePopover';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { IconButton } from './IconButton';
import styles from './SettingsPopover.module.css'; import styles from './SettingsPopover.module.css';
/** Focus the first interactive control on open (any kind — these are forms). */ /** Focus the first interactive control on open (any kind — these are forms). */
@@ -55,18 +56,15 @@ export function SettingsPopover({
return ( return (
<div className={styles.wrap}> <div className={styles.wrap}>
<button <IconButton
ref={triggerRef} ref={triggerRef}
type="button" label={label}
className={styles.gear}
aria-expanded={open} aria-expanded={open}
aria-controls={id} aria-controls={id}
aria-label={label}
title={label}
onClick={toggle} onClick={toggle}
> >
<Icon name="settings" /> <Icon name="settings" />
</button> </IconButton>
{open && {open &&
createPortal( createPortal(
<div ref={setPopNode} id={id} className={styles.pop} role="group" aria-label={title}> <div ref={setPopNode} id={id} className={styles.pop} role="group" aria-label={title}>
+18 -106
View File
@@ -37,55 +37,14 @@
margin: var(--space-4); margin: var(--space-4);
} }
/* Both creators are the shared Button primitive (primary / secondary, lg);
locally they only divide the row's width. */
.createNew { .createNew {
flex: 1 1 auto; flex: 1 1 auto;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
height: 40px;
padding: 0 var(--space-4);
border: var(--border-width) solid transparent;
border-radius: var(--radius);
background: var(--accent);
color: var(--accent-contrast);
font: inherit;
font-weight: 600;
/* The label never wraps: above the collapse width the pane is wide enough for one
line; below it, only the icon shows. */
white-space: nowrap;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
} }
.createNew:hover {
background: var(--accent-hover);
}
/* The expert accelerator: a quiet bordered ghost beside the primary. */
.createGhost { .createGhost {
flex: 0 1 auto; flex: 0 1 auto;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
height: 40px;
padding: 0 var(--space-3);
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
background: transparent;
color: var(--text-secondary);
font: inherit;
white-space: nowrap;
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.createGhost:hover {
background: var(--layer-01);
color: var(--text);
} }
/* Two-stage label collapse as the pane narrows: the long ghost label goes first /* Two-stage label collapse as the pane narrows: the long ghost label goes first
@@ -135,7 +94,7 @@
intrinsic content width (~20ch), which otherwise overflowed the slot and slid intrinsic content width (~20ch), which otherwise overflowed the slot and slid
over the Sort trigger at the pane minimum. */ over the Sort trigger at the pane minimum. */
min-width: 0; min-width: 0;
height: 32px; height: var(--control-height);
/* Room for the leading magnifier and the trailing clear button. */ /* Room for the leading magnifier and the trailing clear button. */
padding: 0 32px 0 32px; padding: 0 32px 0 32px;
border: var(--border-width) solid var(--border-strong); border: var(--border-width) solid var(--border-strong);
@@ -161,33 +120,11 @@
appearance: none; appearance: none;
} }
/* The clear affordance is a shared IconButton (sm — nested inside the 32px
field); locally it is only pinned to the input's trailing edge. */
.searchClear { .searchClear {
position: absolute; position: absolute;
right: var(--space-2); right: var(--space-2);
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: var(--radius);
background: none;
color: var(--text-secondary);
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.searchClear:hover {
background: var(--layer-01);
color: var(--text);
}
.searchClear:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
} }
/* Visually-hidden live region for the search result count (council SEARCH). */ /* Visually-hidden live region for the search result count (council SEARCH). */
@@ -341,33 +278,27 @@
color: var(--text-placeholder); color: var(--text-placeholder);
} }
/* Row trash — a shared IconButton (sm); locally it only handles the row-hover
reveal and the destructive hover colour. */
.delete { .delete {
flex: 0 0 auto;
align-self: center; align-self: center;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: none;
color: var(--text-secondary);
cursor: pointer;
padding: var(--space-2);
border-radius: var(--radius);
opacity: 0; opacity: 0;
transition: transition:
opacity var(--dur-fast) var(--ease), opacity var(--dur-fast) var(--ease),
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease); color var(--dur-fast) var(--ease);
} }
.item:hover .delete, .item:hover .delete,
.delete:focus-visible { .item .delete:focus-visible {
opacity: 1; opacity: 1;
} }
/* Destructive intent reddens on hover AND keyboard focus, not colour-by-mouse-only /* Destructive intent reddens on hover AND keyboard focus, not colour-by-mouse-only
(arch 10 — destructive controls signal danger on hover/focus). */ (arch 10 — destructive controls signal danger on hover/focus). The .item prefix
.delete:hover, outranks the IconButton primitive's own hover colour. */
.delete:focus-visible { .item .delete:hover,
.item .delete:focus-visible {
color: var(--support-error); color: var(--support-error);
} }
@@ -385,6 +316,8 @@
padding: var(--space-4); padding: var(--space-4);
border-top: var(--border-width) solid var(--border); border-top: var(--border-width) solid var(--border);
background: var(--layer-01); background: var(--layer-01);
/* The panel sits on --layer-01, so its controls' hover fill steps up. */
--control-hover-fill: var(--layer-02);
} }
.metaField { .metaField {
@@ -418,6 +351,8 @@
} }
.metaName { .metaName {
height: var(--control-height);
padding: 0 var(--space-3);
font-weight: 500; font-weight: 500;
} }
@@ -495,30 +430,7 @@
margin-top: var(--space-1); margin-top: var(--space-1);
} }
/* Shared Buttons (secondary / danger-outline); locally they only split the row. */
.metaAction { .metaAction {
flex: 1 1 auto; flex: 1 1 auto;
height: 32px;
padding: 0 var(--space-3);
/* White on the gray panel, same field-on-layer treatment as the inputs above. */
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
border-color var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.metaAction:hover {
background: var(--layer-02);
}
.metaDanger:hover {
color: var(--support-error);
border-color: var(--support-error);
} }
+21 -20
View File
@@ -25,7 +25,9 @@ import { confirm } from '../stores/ConfirmStore';
import { notify } from '../stores/NotificationStore'; import { notify } from '../stores/NotificationStore';
import { selectActiveSnippet, useSnippetStore } from '../stores/SnippetStore'; import { selectActiveSnippet, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore'; import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { Button } from './Button';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { IconButton } from './IconButton';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { SettingRow, SettingsPopover, TextControl } from './SettingsPopover'; import { SettingRow, SettingsPopover, TextControl } from './SettingsPopover';
import { SortControl } from './SortControl'; import { SortControl } from './SortControl';
@@ -162,16 +164,12 @@ function SnippetMeta({
)} )}
<div className={styles.metaActions}> <div className={styles.metaActions}>
<button type="button" className={styles.metaAction} onClick={onDuplicate}> <Button className={styles.metaAction} onClick={onDuplicate}>
Duplicate Duplicate
</button> </Button>
<button <Button variant="danger-outline" className={styles.metaAction} onClick={onDelete}>
type="button"
className={`${styles.metaAction} ${styles.metaDanger}`}
onClick={onDelete}
>
Delete Delete
</button> </Button>
</div> </div>
</section> </section>
); );
@@ -260,7 +258,9 @@ export function SnippetLibrary() {
pane is dragged narrow the labels collapse to icons (a @container pane is dragged narrow the labels collapse to icons (a @container
query); `aria-label` keeps each accessible name. */} query); `aria-label` keeps each accessible name. */}
<div className={styles.createRow}> <div className={styles.createRow}>
<button <Button
variant="primary"
size="lg"
className={styles.createNew} className={styles.createNew}
onClick={() => openModal('chartBuilder')} onClick={() => openModal('chartBuilder')}
aria-label="Build Chart" aria-label="Build Chart"
@@ -268,8 +268,9 @@ export function SnippetLibrary() {
> >
<Icon name="chart" /> <Icon name="chart" />
<span className={styles.createNewLabel}>Build Chart</span> <span className={styles.createNewLabel}>Build Chart</span>
</button> </Button>
<button <Button
size="lg"
className={styles.createGhost} className={styles.createGhost}
onClick={() => createSnippet()} onClick={() => createSnippet()}
aria-label="New JSON snippet" aria-label="New JSON snippet"
@@ -277,7 +278,7 @@ export function SnippetLibrary() {
> >
<Icon name="add" /> <Icon name="add" />
<span className={styles.createNewLabel}>New JSON snippet</span> <span className={styles.createNewLabel}>New JSON snippet</span>
</button> </Button>
</div> </div>
{/* Search + Sort controls, pinned above the list (spec §02; council {/* Search + Sort controls, pinned above the list (spec §02; council
@@ -300,15 +301,14 @@ export function SnippetLibrary() {
spellCheck={false} spellCheck={false}
/> />
{searching && ( {searching && (
<button <IconButton
type="button" size="sm"
className={styles.searchClear} className={styles.searchClear}
aria-label="Clear search" label="Clear search"
title="Clear search"
onClick={clearSearch} onClick={clearSearch}
> >
<Icon name="close" /> <Icon name="close" />
</button> </IconButton>
)} )}
</div> </div>
<SortControl /> <SortControl />
@@ -381,14 +381,15 @@ export function SnippetLibrary() {
)} )}
</span> </span>
</button> </button>
<button <IconButton
size="sm"
className={styles.delete} className={styles.delete}
aria-label={`Delete ${s.name}`} label={`Delete ${s.name}`}
title="Delete snippet" title="Delete snippet"
onClick={() => void handleDelete(s.id, s.name)} onClick={() => void handleDelete(s.id, s.name)}
> >
<Icon name="delete" /> <Icon name="delete" />
</button> </IconButton>
</li> </li>
); );
})} })}
+2 -3
View File
@@ -10,13 +10,12 @@
} }
/* Trigger — shows the current field + direction for recognition (council SORT). /* Trigger — shows the current field + direction for recognition (council SORT).
32px tall to match the search input it sits beside (they shared a row at The standard control height, matching the search input it sits beside. */
mismatched 28/32px heights before). */
.trigger { .trigger {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-1); gap: var(--space-1);
height: 32px; height: var(--control-height);
padding: 0 var(--space-3); padding: 0 var(--space-3);
border: var(--border-width) solid var(--border-strong); border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius); border-radius: var(--radius);
+6 -47
View File
@@ -23,44 +23,12 @@
flex: 1 1 auto; flex: 1 1 auto;
} }
/* Draft/Published toggle styling now lives in SegmentedControl.module.css. */ /* Toolbar controls are the shared primitives: Draft/Published is a
SegmentedControl, Publish/Revert/Extract are Buttons (arch 09 §4). */
/* Publish / Revert buttons. */
.action {
appearance: none;
height: 28px;
padding: 0 var(--space-4);
border: var(--border-width) solid var(--border-strong);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 12px;
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
border-color var(--dur-fast) var(--ease),
opacity var(--dur-fast) var(--ease);
}
.action:hover:not(:disabled) {
background: var(--layer-01);
}
.action:disabled {
opacity: 0.4;
cursor: default;
}
/* Collapsible secondary actions (Extract, Revert): label by default, icon-only /* Collapsible secondary actions (Extract, Revert): label by default, icon-only
when the pane is narrow. Both children live in the DOM always; the container when the pane is narrow. Both children live in the DOM always; the container
query swaps which one shows, so there's no reflow cost beyond display. */ query swaps which one shows, so there's no reflow cost beyond display. */
.collapsible {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
}
.actionIcon { .actionIcon {
display: none; display: none;
} }
@@ -73,10 +41,11 @@
/* Below this pane width the labels would crowd Publish / wrap, so the secondary /* Below this pane width the labels would crowd Publish / wrap, so the secondary
actions shed their text and become square icon buttons (the accessible name + actions shed their text and become square icon buttons (the accessible name +
title keep them identifiable). 480px is comfortably above the 384px pane title keep them identifiable). 480px is comfortably above the 384px pane
minimum, so the collapse is in effect across the whole narrow range. */ minimum, so the collapse is in effect across the whole narrow range. The
.toolbar prefix outranks the Button primitive's own padding. */
@container (max-width: 480px) { @container (max-width: 480px) {
.collapsible { .toolbar .collapsible {
width: 28px; width: var(--control-height);
padding: 0; padding: 0;
} }
.collapsible .actionLabel { .collapsible .actionLabel {
@@ -87,16 +56,6 @@
} }
} }
.publish {
border-color: transparent;
background: var(--accent);
color: var(--accent-contrast);
}
.publish:hover:not(:disabled) {
background: var(--accent-hover);
}
.editorWrap { .editorWrap {
position: relative; position: relative;
flex: 1 1 auto; flex: 1 1 auto;
+10 -13
View File
@@ -40,6 +40,7 @@ import { usePreviewStore } from '../stores/PreviewStore';
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore'; import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore'; import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { Button } from './Button';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { SelectControl } from './SelectControl'; import { SelectControl } from './SelectControl';
import { import {
@@ -239,16 +240,15 @@ function EditorToolbar({
`title` (and the accessible name) when only the icon shows. Publish — the `title` (and the accessible name) when only the icon shows. Publish — the
one primary action — keeps its label at every width. */} one primary action — keeps its label at every width. */}
{canExtract && ( {canExtract && (
<button <Button
type="button" className={styles.collapsible}
className={`${styles.action} ${styles.collapsible}`}
onClick={() => openModal('extract')} onClick={() => openModal('extract')}
title="Extract inline data into a reusable dataset" title="Extract inline data into a reusable dataset"
aria-label="Extract to Dataset" aria-label="Extract to Dataset"
> >
<Icon name="dataset" className={styles.actionIcon} /> <Icon name="dataset" className={styles.actionIcon} />
<span className={styles.actionLabel}>Extract to Dataset</span> <span className={styles.actionLabel}>Extract to Dataset</span>
</button> </Button>
)} )}
<SelectControl <SelectControl
id="editor-config-actions" id="editor-config-actions"
@@ -256,14 +256,12 @@ function EditorToolbar({
heading="Spec config" heading="Spec config"
options={CONFIG_ACTIONS} options={CONFIG_ACTIONS}
onSelect={handleConfigAction} onSelect={handleConfigAction}
triggerClassName={styles.action}
triggerContent="Config" triggerContent="Config"
triggerTitle="Spec config actions — merge the chart theme in, or extract the config out" triggerTitle="Spec config actions — merge the chart theme in, or extract the config out"
disabled={activeId === null || editorView === 'published'} disabled={activeId === null || editorView === 'published'}
/> />
<button <Button
type="button" className={styles.collapsible}
className={`${styles.action} ${styles.collapsible}`}
onClick={() => void handleRevert()} onClick={() => void handleRevert()}
disabled={activeId === null || !dirty} disabled={activeId === null || !dirty}
title="Revert draft to the last published version" title="Revert draft to the last published version"
@@ -271,17 +269,16 @@ function EditorToolbar({
> >
<Icon name="revert" className={styles.actionIcon} /> <Icon name="revert" className={styles.actionIcon} />
<span className={styles.actionLabel}>Revert</span> <span className={styles.actionLabel}>Revert</span>
</button> </Button>
<button <Button
type="button" variant="primary"
className={`${styles.action} ${styles.publish}`}
onClick={handlePublish} onClick={handlePublish}
disabled={activeId === null} disabled={activeId === null}
title="Publish (⌘/Ctrl+S)" title="Publish (⌘/Ctrl+S)"
aria-keyshortcuts="Meta+S Control+S" aria-keyshortcuts="Meta+S Control+S"
> >
Publish Publish
</button> </Button>
<EditorSettings /> <EditorSettings />
</div> </div>
); );
@@ -18,27 +18,10 @@
border-right: var(--border-width) solid var(--border); border-right: var(--border-width) solid var(--border);
} }
/* A shared primary Button (lg); locally just pinned with a margin. */
.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;
padding: 0 var(--space-5);
border: var(--border-width) solid transparent;
border-radius: var(--radius);
background: var(--accent);
color: var(--accent-contrast);
font: inherit;
font-weight: 600;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.newButton:hover {
background: var(--accent-hover);
} }
.list { .list {
@@ -136,7 +119,7 @@
.input { .input {
width: 100%; width: 100%;
height: 32px; height: var(--control-height);
padding: 0 var(--space-3); padding: 0 var(--space-3);
border: var(--border-width) solid var(--border-strong); border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius); border-radius: var(--radius);
@@ -157,60 +140,7 @@
gap: var(--space-3); gap: var(--space-3);
} }
.action { /* Actions are shared Buttons (danger-outline / primary). */
height: 32px;
padding: 0 var(--space-4);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: transparent;
color: var(--text);
font: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.action:hover:not(:disabled) {
background: var(--layer-01);
}
.action:disabled {
color: var(--text-placeholder);
border-color: var(--border);
cursor: not-allowed;
}
.action:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.primary {
background: var(--accent);
border-color: transparent;
color: var(--accent-contrast);
font-weight: 600;
}
.primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.primary:disabled {
background: var(--layer-01);
}
.danger {
border-color: var(--border-strong);
color: var(--support-error);
}
.danger:hover:not(:disabled) {
background: var(--support-error);
color: var(--on-status);
border-color: transparent;
}
.errorMessage { .errorMessage {
flex: 0 0 auto; flex: 0 0 auto;
+8 -12
View File
@@ -26,6 +26,7 @@ import {
} from '../stores/CustomThemeStore'; } from '../stores/CustomThemeStore';
import { notify } from '../stores/NotificationStore'; import { notify } from '../stores/NotificationStore';
import { resnapshot } from '../modals/ModalCoordinator'; import { resnapshot } from '../modals/ModalCoordinator';
import { Button } from './Button';
import { SelectControl } from './SelectControl'; import { SelectControl } from './SelectControl';
import styles from './ThemeBuilderModal.module.css'; import styles from './ThemeBuilderModal.module.css';
@@ -152,9 +153,9 @@ export function ThemeBuilderModal() {
return ( return (
<div className={styles.builder}> <div className={styles.builder}>
<div className={styles.listPane}> <div className={styles.listPane}>
<button type="button" className={styles.newButton} onClick={handleNew}> <Button variant="primary" size="lg" className={styles.newButton} onClick={handleNew}>
New theme New theme
</button> </Button>
<ul className={styles.list}> <ul className={styles.list}>
{themes.length === 0 && ( {themes.length === 0 && (
<li className={styles.empty}> <li className={styles.empty}>
@@ -208,21 +209,16 @@ export function ThemeBuilderModal() {
triggerTitle="Write one font family into every font slot of the config" triggerTitle="Write one font family into every font slot of the config"
/> />
<div className={styles.toolbarEnd}> <div className={styles.toolbarEnd}>
<button <Button variant="danger-outline" onClick={() => void handleDelete()}>
type="button"
className={`${styles.action} ${styles.danger}`}
onClick={() => void handleDelete()}
>
Delete Delete
</button> </Button>
<button <Button
type="button" variant="primary"
className={`${styles.action} ${styles.primary}`}
disabled={!dirty || parseError !== null} disabled={!dirty || parseError !== null}
onClick={handleSave} onClick={handleSave}
> >
Save theme Save theme
</button> </Button>
</div> </div>
</div> </div>
-21
View File
@@ -1,21 +0,0 @@
.toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: var(--border-width) solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.toggle:hover {
background: var(--layer-02);
color: var(--text);
}
+7 -9
View File
@@ -7,14 +7,14 @@
* persisted `ui.theme` key directly — no separate Appearance control to sync. * persisted `ui.theme` key directly — no separate Appearance control to sync.
* *
* The button shows the icon of the theme you'll switch *to* (moon when light, * The button shows the icon of the theme you'll switch *to* (moon when light,
* sun when dark) and labels itself for screen readers. The focus ring comes from * sun when dark) and labels itself for screen readers. Chart + editor follow the
* the shared rule in base.css; chart + editor follow the theme via their own * theme via their own store subscriptions, so flipping the store repaints
* store subscriptions, so flipping the store repaints everything. * everything.
*/ */
import { useAppStore } from '../stores/AppStore'; import { useAppStore } from '../stores/AppStore';
import { Icon } from './Icon'; import { Icon } from './Icon';
import styles from './ThemeToggle.module.css'; import { IconButton } from './IconButton';
export function ThemeToggle() { export function ThemeToggle() {
const uiTheme = useAppStore((s) => s.uiTheme); const uiTheme = useAppStore((s) => s.uiTheme);
@@ -22,18 +22,16 @@ export function ThemeToggle() {
const target = uiTheme === 'dark' ? 'light' : 'dark'; const target = uiTheme === 'dark' ? 'light' : 'dark';
return ( return (
<button <IconButton
type="button"
className={styles.toggle}
onClick={toggleTheme} onClick={toggleTheme}
// Toggle button with a stable name + pressed state (APG; doc §10.6): AT // Toggle button with a stable name + pressed state (APG; doc §10.6): AT
// announces the *current* theme ("Dark theme, pressed"), at parity with the // announces the *current* theme ("Dark theme, pressed"), at parity with the
// icon a sighted user sees — not just the action. `title` keeps the hover hint. // icon a sighted user sees — not just the action. `title` keeps the hover hint.
aria-pressed={uiTheme === 'dark'} aria-pressed={uiTheme === 'dark'}
aria-label="Dark theme" label="Dark theme"
title={`Switch to ${target} theme`} title={`Switch to ${target} theme`}
> >
{uiTheme === 'dark' ? <Icon name="sun" size="md" /> : <Icon name="moon" size="md" />} {uiTheme === 'dark' ? <Icon name="sun" size="md" /> : <Icon name="moon" size="md" />}
</button> </IconButton>
); );
} }
+5 -23
View File
@@ -146,6 +146,10 @@
margin-top: var(--space-3); margin-top: var(--space-3);
} }
/* Deliberately NOT the Button primitive: the action is coloured by the toast's
kind (--toast-accent border/label) and sits below the control scale —
content-sized inside an already-compact toast, like the inline link-style
actions (arch 09 §4). */
.action { .action {
padding: var(--space-1) var(--space-3); padding: var(--space-1) var(--space-3);
font: inherit; font: inherit;
@@ -167,29 +171,7 @@
outline-offset: 2px; outline-offset: 2px;
} }
/* Dismiss is the shared IconButton (sm); the class is a layout marker only. */
.close { .close {
flex: none; flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.close:hover {
background: var(--layer-01);
color: var(--text);
}
.close:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
} }
+5 -4
View File
@@ -24,6 +24,7 @@ import {
type NotificationKind, type NotificationKind,
} from '../stores/NotificationStore'; } from '../stores/NotificationStore';
import { Icon, type IconName } from './Icon'; import { Icon, type IconName } from './Icon';
import { IconButton } from './IconButton';
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. */
@@ -115,14 +116,14 @@ function Toast({
</div> </div>
)} )}
</div> </div>
<button <IconButton
type="button" size="sm"
className={styles.close} className={styles.close}
aria-label="Dismiss notification" label="Dismiss notification"
onClick={handleDismiss} onClick={handleDismiss}
> >
<Icon name="close" /> <Icon name="close" />
</button> </IconButton>
</div> </div>
); );
} }
+8
View File
@@ -39,6 +39,14 @@
/* Layout */ /* Layout */
--header-height: 48px; --header-height: 48px;
/* Control scale (arch 09 §4) — every interactive control is one of exactly two
heights. 32px is THE control height: buttons, inputs, selects, segmented
controls, icon buttons — anything that sits in a toolbar or form row. 40px is
reserved for standalone primary CTAs (e.g. the library's Build Chart) and
modal footers. No other control heights exist; a third value is drift. */
--control-height: 32px;
--control-height-lg: 40px;
/* Motion — productive only; neutralized under prefers-reduced-motion in base.css */ /* Motion — productive only; neutralized under prefers-reduced-motion in base.css */
--dur-fast: 70ms; --dur-fast: 70ms;
--dur-fast-2: 110ms; --dur-fast-2: 110ms;