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
(CSS Modules + design tokens in `styles/tokens.css`) rather than writing from scratch. Don't
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
parroting comments. Ensure comments capture non-obvious _why_ — design decisions,
+50 -14
View File
@@ -149,11 +149,43 @@ UI by swapping one set of values. Borrowed from Carbon's layering model:
## 4. Component conventions
- **Buttons:** square, 32px (compact) / 40px (default) tall. Variants: **primary**
(filled `--accent`), **secondary** (bordered), **ghost** (text-only, borderless),
**soft-accent** (ghost on an `--accent-soft` wash — a low-emphasis solicitation,
e.g. Donate), **danger** (filled `--support-error`). 600-weight label. Clear
hover/active and a visible focus ring.
- **The control scale (two heights, enforced by tokens + primitives).** Every
interactive control is `--control-height` (32px) or `--control-height-lg`
(40px) — tokens.css. 32px is THE control height: buttons, fields, selects,
segmented controls, icon buttons, anything in a toolbar, form row, or dialog
action row. 40px is reserved for standalone primary CTAs (the library's Build
Chart, a modal list-pane's New X) and modal footers. A third height is drift —
the pre-token codebase accumulated 26/28/30/36/42px variants one component at a
time, which read as "shaky" the moment controls shared a row. The rule is
enforced mechanically: action buttons are the **`Button`** primitive, icon-only
buttons are **`IconButton`** (24px `sm` exists solely for controls nested
_inside_ a 32px control — a search field's clear, a toast's dismiss); writing
`height:` on a new ad-hoc button is the code smell.
- **Buttons:** square, via the `Button` primitive. Variants: **primary** (filled
`--accent`), **secondary** (1px `--border-strong`, `--bg` fill — 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**
high-emphasis (primary) button at most; everything else is lower emphasis. In
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
`--layer-01` surface (dialogs, panels) → `--layer-02`. Filling to the _same_
layer as the surface reads as no hover at all (the collision that left the
confirm dialog's Cancel looking dead). Any button placed on an elevated surface
must step its hover fill up to match.
confirm dialog's Cancel looking dead). The step is mechanical: Button/IconButton
hover with `var(--control-hover-fill, var(--layer-01))`, and an elevated surface
sets `--control-hover-fill: var(--layer-02)` once on its container (the header,
the modal chrome, the confirm card, the library's metadata panel) — controls
inherit the right step instead of each re-encoding it.
- **Focus ring:** a 2px `--focus` outline (offset 12px). Always visible on
keyboard focus — accessibility is non-negotiable (principle 4).
- **Fields** (text, textarea, select, search): square, 1px `--border-strong`,
@@ -411,13 +446,14 @@ The chart `Config` is themed to match the app, per theme:
## 7. Implementation map
| Artifact | Role |
| ------------------------------------------------ | ----------------------------------------------------- |
| [`visual-specimen.html`](./visual-specimen.html) | Living preview + token sandbox. Iterate here first |
| `styles/tokens.css` | The settled tokens — ported from the specimen in M1.5 |
| `styles/base.css` | Font wiring (`@fontsource`), reset, reduced-motion |
| component `*.module.css` | Consume tokens only; no raw hexes, no hardcoded hue |
| `src/core/vega-themes.ts` | Chart `Config` per theme; categorical palettes |
| Artifact | Role |
| -------------------------------------------------- | -------------------------------------------------------------------- |
| [`visual-specimen.html`](./visual-specimen.html) | Living preview + token sandbox. Iterate here first |
| `styles/tokens.css` | The settled tokens — ported from the specimen in M1.5 |
| `styles/base.css` | Font wiring (`@fontsource`), reset, reduced-motion |
| `src/app/components/Button.tsx` / `IconButton.tsx` | The shared control primitives (§4) — every action / icon-only button |
| component `*.module.css` | Consume tokens only; no raw hexes, no hardcoded hue |
| `src/core/vega-themes.ts` | Chart `Config` per theme; categorical palettes |
**Order of work:** settle the specimen → port tokens to `tokens.css` → self-host
Plex in `base.css` → restyle existing M1 components against the tokens → align
+25 -21
View File
@@ -44,6 +44,12 @@
--radius: 0px;
--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 */
--dur-fast: 70ms;
--dur-fast-2: 110ms;
@@ -399,26 +405,25 @@
padding: var(--space-5);
}
/* ----- Buttons ----- */
/* ----- Buttons (mirrors src/app/components/Button.module.css) ----- */
.btn {
font: inherit;
font-weight: 600;
font-size: 14px;
font-size: 13px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: var(--space-3);
height: 40px;
padding: 0 var(--space-5);
gap: var(--space-2);
height: var(--control-height);
padding: 0 var(--space-3);
border: var(--border-width) solid transparent;
border-radius: var(--radius);
transition:
background var(--dur-fast) var(--ease),
border-color var(--dur-fast) var(--ease);
}
.btn.sm {
height: 32px;
font-size: 13px;
.btn.lg {
height: var(--control-height-lg);
padding: 0 var(--space-4);
}
.btn-primary {
@@ -429,7 +434,7 @@
background: var(--accent-hover);
}
.btn-secondary {
background: transparent;
background: var(--bg);
color: var(--text);
border-color: var(--border-strong);
}
@@ -947,7 +952,7 @@
<!-- BUTTONS -->
<section>
<h2>Buttons — square, 600 weight</h2>
<h2>Buttons — square, 600 weight, 32px standard / 40px lg</h2>
<div class="row">
<button class="btn btn-primary">Create snippet</button>
<button class="btn btn-secondary">Duplicate</button>
@@ -956,9 +961,8 @@
<button class="btn btn-primary" disabled>Disabled</button>
</div>
<div class="row" style="margin-top: var(--space-5)">
<button class="btn btn-primary sm">Compact</button>
<button class="btn btn-secondary sm">Compact</button>
<button class="btn btn-ghost sm">Compact</button>
<button class="btn btn-primary lg">Build Chart</button>
<button class="btn btn-secondary lg">Cancel</button>
</div>
<p class="cap" style="margin-top: var(--space-4)">
Tab through to see the focus ring (2px accent outline).
@@ -972,7 +976,7 @@
emphasis.)
</p>
<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">
<rect x="8" y="18" width="4" height="2" />
<rect x="14" y="18" width="4" height="2" />
@@ -985,19 +989,19 @@
/></svg
>Datasets
</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">
<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
>Import
</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">
<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
>Export
</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">
<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" />
@@ -1007,7 +1011,7 @@
>About
</button>
<span class="hdr-divider"></span>
<button class="btn btn-soft sm">Donate</button>
<button class="btn btn-soft">Donate</button>
</div>
</section>
@@ -1495,8 +1499,8 @@
><span><span class="dot draft"></span> Draft changes</span>
</div>
<div class="row" style="margin-top: var(--space-5)">
<button class="btn btn-primary sm">Publish</button>
<button class="btn btn-ghost sm">Revert</button>
<button class="btn btn-primary">Publish</button>
<button class="btn btn-ghost">Revert</button>
</div>
</div>
</div>
@@ -1611,7 +1615,7 @@
<div class="motion-box"></div>
</div>
<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"
>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);
background: var(--layer-01);
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 {
@@ -61,42 +64,9 @@
flex: 1;
}
/*
* Header utility buttons (Datasets / Import / Export / About) — low-emphasis
* ghost buttons: borderless, icon + label, so they recede behind the work area
* 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. */
/* Vertical rule separating the utilities from Donate + the theme toggle.
(The header buttons themselves are the shared Button primitive — ghost
utilities, soft-accent Donate; arch 09 §4.) */
.headerDivider {
flex: 0 0 auto;
width: var(--border-width);
@@ -105,22 +75,6 @@
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. */
.hiddenInput {
display: none;
+18 -23
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react';
import { Button } from './components/Button';
import { ConfirmDialog } from './components/ConfirmDialog';
import { LivePreview } from './components/LivePreview';
import { ModalShell } from './components/ModalShell';
@@ -80,52 +81,46 @@ export function App() {
better at low emphasis). The utilities are ghost (borderless) buttons
with a leading icon for scanning; a divider then sets off Donate — the
one solicitation, given a soft-accent wash — and the theme toggle. */}
<button
type="button"
className={styles.headerButton}
<Button
variant="ghost"
onClick={() => openModal('datasets')}
aria-keyshortcuts="Meta+K Control+K"
title="Datasets (⌘/Ctrl+K)"
>
<Icon name="dataset" />
Datasets
</button>
<button
type="button"
className={styles.headerButton}
</Button>
<Button
variant="ghost"
onClick={() => fileInputRef.current?.click()}
title="Import a workspace JSON file"
>
<Icon name="import" />
Import
</button>
<button
type="button"
className={styles.headerButton}
</Button>
{/* TODO: two controls labelled "Export" are visible at once (this
workspace export + the preview pane's per-chart export) — rename or
differentiate (docs/ux-second-pass.md). */}
<Button
variant="ghost"
onClick={() => exportWorkspace()}
title="Export your workspace to a JSON file"
>
<Icon name="export" />
Export
</button>
<button
type="button"
className={styles.headerButton}
</Button>
<Button
variant="ghost"
onClick={() => openModal('about')}
title="About, keyboard shortcuts, and privacy information"
>
<Icon name="info" />
About
</button>
</Button>
<span className={styles.headerDivider} aria-hidden="true" />
<button
type="button"
className={`${styles.headerButton} ${styles.donate}`}
onClick={() => openModal('donate')}
title="Support Astrolabe"
>
<Button variant="soft-accent" onClick={() => openModal('donate')} title="Support Astrolabe">
Donate
</button>
</Button>
<ThemeToggle />
{/* Hidden picker for Import; restricted to JSON (spec §08). */}
<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;
}
/* Trigger — a compact ghost button (icon + label), matching the header utilities. */
.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 trigger is the shared Button primitive (ghost; arch 09 §4). */
/* The disclosed panel — portaled to <body>, positioned `fixed` (top/right set
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 { notify } from '../stores/NotificationStore';
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { Button } from './Button';
import { Icon } from './Icon';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { SettingRow } from './SettingsPopover';
@@ -183,19 +184,18 @@ export function ChartExport({ chartReady, getImageUrl }: ChartExportProps) {
return (
<div className={styles.wrap}>
<button
<Button
ref={triggerRef}
type="button"
className={styles.trigger}
variant="ghost"
aria-expanded={open}
aria-controls={POP_ID}
disabled={!hasSpec}
title={hasSpec ? 'Export this chart' : 'Select a snippet to export'}
onClick={toggle}
>
<Icon name="export" className={styles.triggerIcon} />
<Icon name="export" />
<span>Export</span>
</button>
</Button>
{open &&
createPortal(
<div
+5 -41
View File
@@ -21,6 +21,9 @@
border-radius: var(--radius);
/* Minimal shadow, reserved for true overlays (doc §09). */
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 {
@@ -44,49 +47,10 @@
margin-top: var(--space-2);
}
.cancel,
.confirm {
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);
}
/* The actions are shared Buttons (secondary / primary|danger, lg). These two
classes are pure markers — the focus trap's initial-focus selectors. */
.cancel {
background: transparent;
border-color: var(--border-strong);
color: var(--text);
}
.cancel:hover {
background: var(--layer-02);
}
.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 { useFocusTrap } from '../hooks/useFocusTrap';
import { Button } from './Button';
import styles from './ConfirmDialog.module.css';
/**
@@ -42,16 +43,17 @@ export function ConfirmDialog() {
{message}
</p>
<div className={styles.actions}>
<button type="button" className={styles.cancel} onClick={() => resolve(false)}>
<Button size="lg" className={styles.cancel} onClick={() => resolve(false)}>
{cancelLabel ?? 'Cancel'}
</button>
<button
type="button"
className={`${styles.confirm} ${danger ? styles.danger : ''}`}
</Button>
<Button
size="lg"
variant={danger ? 'danger' : 'primary'}
className={styles.confirm}
onClick={() => resolve(true)}
>
{confirmLabel ?? 'Confirm'}
</button>
</Button>
</div>
</div>
</div>
+2 -68
View File
@@ -15,27 +15,10 @@
border-right: var(--border-width) solid var(--border);
}
/* A shared primary Button (lg); locally just pinned with a margin. */
.newButton {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
margin: var(--space-4);
height: 40px;
padding: 0 var(--space-5);
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 {
@@ -177,56 +160,7 @@
justify-content: flex-end;
}
.action {
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;
}
/* Actions are shared Buttons (secondary / primary / danger-outline). */
.comment {
margin: 0;
+16 -43
View File
@@ -37,6 +37,7 @@ import {
} from '../stores/DatasetStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { Button } from './Button';
import { Icon } from './Icon';
import styles from './DatasetsModal.module.css';
@@ -73,9 +74,9 @@ export function DatasetsModal() {
return (
<div className={styles.manager}>
<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
</button>
</Button>
<ul className={styles.list}>
{ordered.length === 0 && (
<li className={styles.empty}>
@@ -239,9 +240,7 @@ function DatasetDetail({
<div className={styles.detailHead}>
<h3 className={styles.detailName}>{dataset.name}</h3>
<div className={styles.detailActions}>
<button type="button" className={styles.action} onClick={() => void handleCopy()}>
{copied ? 'Copied' : 'Copy Reference'}
</button>
<Button onClick={() => void handleCopy()}>{copied ? 'Copied' : 'Copy Reference'}</Button>
{/* The clipboard write is invisible, so the success is confirmed inline
("Copied") rather than by a toast (docs/architecture/10 → Toast copy).
This polite live region announces it to assistive tech, which the
@@ -250,35 +249,18 @@ function DatasetDetail({
{copied ? 'Reference copied to clipboard' : ''}
</span>
{dataset.source === 'url' && (
<button
type="button"
className={styles.action}
onClick={() => void handleRefresh()}
disabled={refreshing}
>
<Button onClick={() => void handleRefresh()} disabled={refreshing}>
{refreshing ? 'Refreshing…' : 'Refresh'}
</button>
</Button>
)}
<button type="button" className={styles.action} onClick={handleEdit}>
Edit
</button>
<Button onClick={handleEdit}>Edit</Button>
{/* 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
no transient form state, so no discard prompt. */}
<button
type="button"
className={styles.action}
onClick={() => openModal('chartBuilder', String(dataset.id))}
>
Build Chart
</button>
<button
type="button"
className={`${styles.action} ${styles.danger}`}
onClick={() => void handleDelete()}
>
<Button onClick={() => openModal('chartBuilder', String(dataset.id))}>Build Chart</Button>
<Button variant="danger-outline" onClick={() => void handleDelete()}>
Delete
</button>
</Button>
</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
when CORS or offline blocks the URL) — offered as a direct one-click path. */}
{fetchError && (
<button
type="button"
className={`${styles.action} ${styles.fallback}`}
onClick={handlePasteInline}
>
<Button className={styles.fallback} onClick={handlePasteInline}>
Paste data inline instead
</button>
</Button>
)}
<div className={styles.formActions}>
<button type="button" className={styles.action} onClick={handleCancel} disabled={fetching}>
<Button onClick={handleCancel} disabled={fetching}>
Cancel
</button>
<button
type="button"
className={`${styles.action} ${styles.primary}`}
disabled={!canSave || fetching}
onClick={() => void handleSave()}
>
</Button>
<Button variant="primary" disabled={!canSave || fetching} onClick={() => void handleSave()}>
{fetching ? 'Fetching…' : editing ? 'Save changes' : 'Create dataset'}
</button>
</Button>
</div>
</div>
);
+1 -1
View File
@@ -26,7 +26,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
height: var(--control-height-lg);
padding: 0 var(--space-6);
background: var(--accent);
border: var(--border-width) solid transparent;
+1 -33
View File
@@ -74,41 +74,9 @@
color: var(--support-error);
}
/* Cancel / Create are shared Buttons (secondary / primary, lg — modal footer). */
.actions {
display: flex;
justify-content: flex-end;
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 { closeModal } from '../modals/ModalCoordinator';
import { useExtractStore } from '../stores/ExtractStore';
import { Button } from './Button';
import styles from './ExtractModal.module.css';
const MAX_PREVIEW = 1500;
@@ -74,16 +75,12 @@ export function ExtractModal() {
)}
<div className={styles.actions}>
<button type="button" className={styles.action} onClick={() => void closeModal()}>
<Button size="lg" onClick={() => void closeModal()}>
Cancel
</button>
<button
type="button"
className={`${styles.action} ${styles.primary}`}
onClick={handleCreate}
>
</Button>
<Button variant="primary" size="lg" onClick={handleCreate}>
Create dataset
</button>
</Button>
</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-radius: var(--radius);
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. */
@@ -66,32 +69,7 @@
outline: none;
}
.close {
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 close affordance is the shared IconButton. */
/* 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. */
@@ -100,4 +78,5 @@
min-height: 0;
overflow: auto;
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 { ThemeBuilderModal } from './ThemeBuilderModal';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import styles from './ModalShell.module.css';
/** 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}>
{getModalTitle(name)}
</h2>
<button
type="button"
className={styles.close}
aria-label="Close"
onClick={() => void closeModal()}
>
<IconButton label="Close" onClick={() => void closeModal()}>
<Icon name="close" />
</button>
</IconButton>
</header>
<div className={styles.body}>
<Body />
+2 -88
View File
@@ -35,53 +35,7 @@
gap: var(--space-3);
}
/* Primary call to action — the accent button, matching the library's Create. */
.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 two CTAs and the per-example Adds are shared Buttons (arch 09 §4). */
/* The "or start from an example" header row, with Add all pushed to the end. */
.galleryHead {
@@ -101,27 +55,8 @@
color: var(--text-secondary);
}
/* Secondary button — bordered, transparent (like the header actions). */
.addAll {
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. */
@@ -174,28 +109,7 @@
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 {
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 { usePanesStore } from '../stores/PanesStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { Button } from './Button';
import { Icon } from './Icon';
import styles from './Onboarding.module.css';
@@ -141,19 +142,19 @@ export function Onboarding() {
</p>
<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
</button>
<button type="button" className={styles.buildCta} onClick={handleBuild}>
</Button>
<Button size="lg" onClick={handleBuild}>
<Icon name="chart" /> Build a chart from your data
</button>
</Button>
</div>
<div className={styles.galleryHead}>
<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
</button>
</Button>
</div>
<ul className={styles.gallery}>
@@ -164,14 +165,13 @@ export function Onboarding() {
<span className={styles.cardName}>{example.name}</span>
<span className={styles.cardDesc}>{example.description}</span>
</div>
<button
type="button"
<Button
className={styles.add}
aria-label={`Add ${example.name}`}
onClick={() => handleAdd(example)}
>
<Icon name="add" /> Add
</button>
</Button>
</li>
))}
</ul>
+9 -31
View File
@@ -12,41 +12,19 @@
padding: var(--space-3) var(--space-2);
border-right: var(--border-width) solid var(--border);
background: var(--layer-01);
/* The strip sits on --layer-01, so the IconButtons' hover fill steps up. */
--control-hover-fill: var(--layer-02);
}
.button {
display: 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;
/* 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 {
/* The controls are shared IconButtons; only the toggle-on state is local.
Shown pane: the toggle is "on" — full-strength glyph on a filled chip.
(.strip prefix outranks the primitive's base background/color.) */
/* TODO: the pressed chip (--layer-02 fill) is identical to the IconButton hover
fill on this surface, so hovering an off toggle looks the same as an on toggle
at rest — the pressed state needs its own cue (docs/ux-second-pass.md). */
.strip .pressed {
color: var(--text);
background: var(--layer-02);
border-color: var(--border-strong);
}
.divider {
+8 -12
View File
@@ -23,6 +23,7 @@ import { useRef, useState } from 'react';
import { openModal } from '../modals/ModalCoordinator';
import { usePanesStore, type PaneName } from '../stores/PanesStore';
import { Icon, type IconName } from './Icon';
import { IconButton } from './IconButton';
import styles from './PaneToggleStrip.module.css';
interface PaneItem {
@@ -105,14 +106,13 @@ export function PaneToggleStrip() {
className={styles.strip}
>
{PANES.map((item, i) => (
<button
<IconButton
key={item.pane}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
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
// hidden pane's <section>, so emitting aria-controls then would leave a
// 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.
title={`${visible[item.pane] ? 'Hide' : 'Show'} ${item.label.toLowerCase()}`}
tabIndex={focusIndex === i ? 0 : -1}
className={[styles.button, visible[item.pane] && styles.pressed]
.filter(Boolean)
.join(' ')}
className={visible[item.pane] ? styles.pressed : undefined}
onClick={() => {
togglePane(item.pane, panesInner());
setFocusIndex(i);
@@ -134,21 +132,19 @@ export function PaneToggleStrip() {
onKeyDown={(e) => onKeyDown(e, i)}
>
<Icon name={item.icon} />
</button>
</IconButton>
))}
<div className={styles.divider} aria-hidden="true" />
<button
<IconButton
ref={(el) => {
refs.current[datasetsIndex] = el;
}}
type="button"
aria-label="Datasets"
label="Datasets"
aria-keyshortcuts="Meta+K Control+K"
title="Datasets (⌘/Ctrl+K)"
tabIndex={focusIndex === datasetsIndex ? 0 : -1}
className={styles.button}
onClick={() => {
openModal('datasets');
setFocusIndex(datasetsIndex);
@@ -156,7 +152,7 @@ export function PaneToggleStrip() {
onKeyDown={(e) => onKeyDown(e, datasetsIndex)}
>
<Icon name="dataset" />
</button>
</IconButton>
</div>
);
}
@@ -8,18 +8,22 @@
*/
.group {
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);
}
.option {
appearance: none;
display: inline-flex;
align-items: center;
border: none;
background: var(--bg);
color: var(--text-secondary);
font: inherit;
font-size: 12px;
font-size: 13px;
line-height: 1;
padding: var(--space-2) var(--space-3);
padding: 0 var(--space-3);
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
+1 -1
View File
@@ -6,7 +6,7 @@
display: inline-flex;
align-items: center;
gap: var(--space-2);
height: 32px;
height: var(--control-height);
padding: 0 var(--space-3);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
+5 -34
View File
@@ -5,38 +5,7 @@
display: inline-flex;
}
/* Gear trigger — a subtle icon button matching the modal-shell close affordance. */
.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 gear trigger is the shared IconButton primitive (arch 09 §4). */
/* 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. */
@@ -66,7 +35,8 @@
align-items: center;
justify-content: space-between;
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 {
@@ -109,7 +79,8 @@
.number,
.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-radius: var(--radius);
background: var(--bg);
+4 -6
View File
@@ -23,6 +23,7 @@ import { type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { usePopover } from '../hooks/usePopover';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import styles from './SettingsPopover.module.css';
/** Focus the first interactive control on open (any kind — these are forms). */
@@ -55,18 +56,15 @@ export function SettingsPopover({
return (
<div className={styles.wrap}>
<button
<IconButton
ref={triggerRef}
type="button"
className={styles.gear}
label={label}
aria-expanded={open}
aria-controls={id}
aria-label={label}
title={label}
onClick={toggle}
>
<Icon name="settings" />
</button>
</IconButton>
{open &&
createPortal(
<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);
}
/* Both creators are the shared Button primitive (primary / secondary, lg);
locally they only divide the row's width. */
.createNew {
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 {
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
@@ -135,7 +94,7 @@
intrinsic content width (~20ch), which otherwise overflowed the slot and slid
over the Sort trigger at the pane minimum. */
min-width: 0;
height: 32px;
height: var(--control-height);
/* Room for the leading magnifier and the trailing clear button. */
padding: 0 32px 0 32px;
border: var(--border-width) solid var(--border-strong);
@@ -161,33 +120,11 @@
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 {
position: absolute;
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). */
@@ -341,33 +278,27 @@
color: var(--text-placeholder);
}
/* Row trash — a shared IconButton (sm); locally it only handles the row-hover
reveal and the destructive hover colour. */
.delete {
flex: 0 0 auto;
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;
transition:
opacity var(--dur-fast) var(--ease),
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.item:hover .delete,
.delete:focus-visible {
.item .delete:focus-visible {
opacity: 1;
}
/* Destructive intent reddens on hover AND keyboard focus, not colour-by-mouse-only
(arch 10 — destructive controls signal danger on hover/focus). */
.delete:hover,
.delete:focus-visible {
(arch 10 — destructive controls signal danger on hover/focus). The .item prefix
outranks the IconButton primitive's own hover colour. */
.item .delete:hover,
.item .delete:focus-visible {
color: var(--support-error);
}
@@ -385,6 +316,8 @@
padding: var(--space-4);
border-top: var(--border-width) solid var(--border);
background: var(--layer-01);
/* The panel sits on --layer-01, so its controls' hover fill steps up. */
--control-hover-fill: var(--layer-02);
}
.metaField {
@@ -418,6 +351,8 @@
}
.metaName {
height: var(--control-height);
padding: 0 var(--space-3);
font-weight: 500;
}
@@ -495,30 +430,7 @@
margin-top: var(--space-1);
}
/* Shared Buttons (secondary / danger-outline); locally they only split the row. */
.metaAction {
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 { selectActiveSnippet, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { Button } from './Button';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { SettingRow, SettingsPopover, TextControl } from './SettingsPopover';
import { SortControl } from './SortControl';
@@ -162,16 +164,12 @@ function SnippetMeta({
)}
<div className={styles.metaActions}>
<button type="button" className={styles.metaAction} onClick={onDuplicate}>
<Button className={styles.metaAction} onClick={onDuplicate}>
Duplicate
</button>
<button
type="button"
className={`${styles.metaAction} ${styles.metaDanger}`}
onClick={onDelete}
>
</Button>
<Button variant="danger-outline" className={styles.metaAction} onClick={onDelete}>
Delete
</button>
</Button>
</div>
</section>
);
@@ -260,7 +258,9 @@ export function SnippetLibrary() {
pane is dragged narrow the labels collapse to icons (a @container
query); `aria-label` keeps each accessible name. */}
<div className={styles.createRow}>
<button
<Button
variant="primary"
size="lg"
className={styles.createNew}
onClick={() => openModal('chartBuilder')}
aria-label="Build Chart"
@@ -268,8 +268,9 @@ export function SnippetLibrary() {
>
<Icon name="chart" />
<span className={styles.createNewLabel}>Build Chart</span>
</button>
<button
</Button>
<Button
size="lg"
className={styles.createGhost}
onClick={() => createSnippet()}
aria-label="New JSON snippet"
@@ -277,7 +278,7 @@ export function SnippetLibrary() {
>
<Icon name="add" />
<span className={styles.createNewLabel}>New JSON snippet</span>
</button>
</Button>
</div>
{/* Search + Sort controls, pinned above the list (spec §02; council
@@ -300,15 +301,14 @@ export function SnippetLibrary() {
spellCheck={false}
/>
{searching && (
<button
type="button"
<IconButton
size="sm"
className={styles.searchClear}
aria-label="Clear search"
title="Clear search"
label="Clear search"
onClick={clearSearch}
>
<Icon name="close" />
</button>
</IconButton>
)}
</div>
<SortControl />
@@ -381,14 +381,15 @@ export function SnippetLibrary() {
)}
</span>
</button>
<button
<IconButton
size="sm"
className={styles.delete}
aria-label={`Delete ${s.name}`}
label={`Delete ${s.name}`}
title="Delete snippet"
onClick={() => void handleDelete(s.id, s.name)}
>
<Icon name="delete" />
</button>
</IconButton>
</li>
);
})}
+2 -3
View File
@@ -10,13 +10,12 @@
}
/* 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
mismatched 28/32px heights before). */
The standard control height, matching the search input it sits beside. */
.trigger {
display: inline-flex;
align-items: center;
gap: var(--space-1);
height: 32px;
height: var(--control-height);
padding: 0 var(--space-3);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
+6 -47
View File
@@ -23,44 +23,12 @@
flex: 1 1 auto;
}
/* Draft/Published toggle styling now lives in SegmentedControl.module.css. */
/* 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;
}
/* Toolbar controls are the shared primitives: Draft/Published is a
SegmentedControl, Publish/Revert/Extract are Buttons (arch 09 §4). */
/* Collapsible secondary actions (Extract, Revert): label by default, icon-only
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. */
.collapsible {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
}
.actionIcon {
display: none;
}
@@ -73,10 +41,11 @@
/* 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 +
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) {
.collapsible {
width: 28px;
.toolbar .collapsible {
width: var(--control-height);
padding: 0;
}
.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 {
position: relative;
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 { useUserSettingsStore } from '../stores/UserSettingsStore';
import { Icon } from './Icon';
import { Button } from './Button';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { SelectControl } from './SelectControl';
import {
@@ -239,16 +240,15 @@ function EditorToolbar({
`title` (and the accessible name) when only the icon shows. Publish — the
one primary action — keeps its label at every width. */}
{canExtract && (
<button
type="button"
className={`${styles.action} ${styles.collapsible}`}
<Button
className={styles.collapsible}
onClick={() => openModal('extract')}
title="Extract inline data into a reusable dataset"
aria-label="Extract to Dataset"
>
<Icon name="dataset" className={styles.actionIcon} />
<span className={styles.actionLabel}>Extract to Dataset</span>
</button>
</Button>
)}
<SelectControl
id="editor-config-actions"
@@ -256,14 +256,12 @@ function EditorToolbar({
heading="Spec config"
options={CONFIG_ACTIONS}
onSelect={handleConfigAction}
triggerClassName={styles.action}
triggerContent="Config"
triggerTitle="Spec config actions — merge the chart theme in, or extract the config out"
disabled={activeId === null || editorView === 'published'}
/>
<button
type="button"
className={`${styles.action} ${styles.collapsible}`}
<Button
className={styles.collapsible}
onClick={() => void handleRevert()}
disabled={activeId === null || !dirty}
title="Revert draft to the last published version"
@@ -271,17 +269,16 @@ function EditorToolbar({
>
<Icon name="revert" className={styles.actionIcon} />
<span className={styles.actionLabel}>Revert</span>
</button>
<button
type="button"
className={`${styles.action} ${styles.publish}`}
</Button>
<Button
variant="primary"
onClick={handlePublish}
disabled={activeId === null}
title="Publish (⌘/Ctrl+S)"
aria-keyshortcuts="Meta+S Control+S"
>
Publish
</button>
</Button>
<EditorSettings />
</div>
);
@@ -18,27 +18,10 @@
border-right: var(--border-width) solid var(--border);
}
/* A shared primary Button (lg); locally just pinned with a margin. */
.newButton {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
margin: var(--space-4);
height: 40px;
padding: 0 var(--space-5);
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 {
@@ -136,7 +119,7 @@
.input {
width: 100%;
height: 32px;
height: var(--control-height);
padding: 0 var(--space-3);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
@@ -157,60 +140,7 @@
gap: var(--space-3);
}
.action {
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;
}
/* Actions are shared Buttons (danger-outline / primary). */
.errorMessage {
flex: 0 0 auto;
+8 -12
View File
@@ -26,6 +26,7 @@ import {
} from '../stores/CustomThemeStore';
import { notify } from '../stores/NotificationStore';
import { resnapshot } from '../modals/ModalCoordinator';
import { Button } from './Button';
import { SelectControl } from './SelectControl';
import styles from './ThemeBuilderModal.module.css';
@@ -152,9 +153,9 @@ export function ThemeBuilderModal() {
return (
<div className={styles.builder}>
<div className={styles.listPane}>
<button type="button" className={styles.newButton} onClick={handleNew}>
<Button variant="primary" size="lg" className={styles.newButton} onClick={handleNew}>
New theme
</button>
</Button>
<ul className={styles.list}>
{themes.length === 0 && (
<li className={styles.empty}>
@@ -208,21 +209,16 @@ export function ThemeBuilderModal() {
triggerTitle="Write one font family into every font slot of the config"
/>
<div className={styles.toolbarEnd}>
<button
type="button"
className={`${styles.action} ${styles.danger}`}
onClick={() => void handleDelete()}
>
<Button variant="danger-outline" onClick={() => void handleDelete()}>
Delete
</button>
<button
type="button"
className={`${styles.action} ${styles.primary}`}
</Button>
<Button
variant="primary"
disabled={!dirty || parseError !== null}
onClick={handleSave}
>
Save theme
</button>
</Button>
</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.
*
* 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
* the shared rule in base.css; chart + editor follow the theme via their own
* store subscriptions, so flipping the store repaints everything.
* sun when dark) and labels itself for screen readers. Chart + editor follow the
* theme via their own store subscriptions, so flipping the store repaints
* everything.
*/
import { useAppStore } from '../stores/AppStore';
import { Icon } from './Icon';
import styles from './ThemeToggle.module.css';
import { IconButton } from './IconButton';
export function ThemeToggle() {
const uiTheme = useAppStore((s) => s.uiTheme);
@@ -22,18 +22,16 @@ export function ThemeToggle() {
const target = uiTheme === 'dark' ? 'light' : 'dark';
return (
<button
type="button"
className={styles.toggle}
<IconButton
onClick={toggleTheme}
// Toggle button with a stable name + pressed state (APG; doc §10.6): AT
// 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.
aria-pressed={uiTheme === 'dark'}
aria-label="Dark theme"
label="Dark theme"
title={`Switch to ${target} theme`}
>
{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);
}
/* 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 {
padding: var(--space-1) var(--space-3);
font: inherit;
@@ -167,29 +171,7 @@
outline-offset: 2px;
}
/* Dismiss is the shared IconButton (sm); the class is a layout marker only. */
.close {
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,
} from '../stores/NotificationStore';
import { Icon, type IconName } from './Icon';
import { IconButton } from './IconButton';
import styles from './Toaster.module.css';
/** Auto-dismiss delay for the non-critical kinds (ms). Errors/warnings persist. */
@@ -115,14 +116,14 @@ function Toast({
</div>
)}
</div>
<button
type="button"
<IconButton
size="sm"
className={styles.close}
aria-label="Dismiss notification"
label="Dismiss notification"
onClick={handleDismiss}
>
<Icon name="close" />
</button>
</IconButton>
</div>
);
}
+8
View File
@@ -39,6 +39,14 @@
/* Layout */
--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 */
--dur-fast: 70ms;
--dur-fast-2: 110ms;