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
+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>
);
}