diff --git a/.claude/skills/alignment/SKILL.md b/.claude/skills/alignment/SKILL.md
index 5762984..aa57ebd 100644
--- a/.claude/skills/alignment/SKILL.md
+++ b/.claude/skills/alignment/SKILL.md
@@ -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
+ `
))}
diff --git a/src/app/components/PaneToggleStrip.module.css b/src/app/components/PaneToggleStrip.module.css
index c285777..5f61f62 100644
--- a/src/app/components/PaneToggleStrip.module.css
+++ b/src/app/components/PaneToggleStrip.module.css
@@ -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 {
diff --git a/src/app/components/PaneToggleStrip.tsx b/src/app/components/PaneToggleStrip.tsx
index 50df0f1..a7cdd8e 100644
--- a/src/app/components/PaneToggleStrip.tsx
+++ b/src/app/components/PaneToggleStrip.tsx
@@ -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) => (
- {
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 , 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)}
>
-
+
))}
- {
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)}
>
-
+
);
}
diff --git a/src/app/components/SegmentedControl.module.css b/src/app/components/SegmentedControl.module.css
index ea0e5cd..a642c4e 100644
--- a/src/app/components/SegmentedControl.module.css
+++ b/src/app/components/SegmentedControl.module.css
@@ -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),
diff --git a/src/app/components/SelectControl.module.css b/src/app/components/SelectControl.module.css
index f3d7a79..96f1e42 100644
--- a/src/app/components/SelectControl.module.css
+++ b/src/app/components/SelectControl.module.css
@@ -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);
diff --git a/src/app/components/SettingsPopover.module.css b/src/app/components/SettingsPopover.module.css
index 205c66d..a3f838d 100644
--- a/src/app/components/SettingsPopover.module.css
+++ b/src/app/components/SettingsPopover.module.css
@@ -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 , 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);
diff --git a/src/app/components/SettingsPopover.tsx b/src/app/components/SettingsPopover.tsx
index 4bd3122..8d45ff8 100644
--- a/src/app/components/SettingsPopover.tsx
+++ b/src/app/components/SettingsPopover.tsx
@@ -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 (
-
-
+
{open &&
createPortal(
diff --git a/src/app/components/SnippetLibrary.module.css b/src/app/components/SnippetLibrary.module.css
index 7ef8908..18e3cea 100644
--- a/src/app/components/SnippetLibrary.module.css
+++ b/src/app/components/SnippetLibrary.module.css
@@ -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);
}
diff --git a/src/app/components/SnippetLibrary.tsx b/src/app/components/SnippetLibrary.tsx
index aae5bd1..ecb283a 100644
--- a/src/app/components/SnippetLibrary.tsx
+++ b/src/app/components/SnippetLibrary.tsx
@@ -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({
)}
-
+
Duplicate
-
-
+
+
Delete
-
+
);
@@ -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. */}
diff --git a/src/app/components/ThemeToggle.module.css b/src/app/components/ThemeToggle.module.css
deleted file mode 100644
index 891b69d..0000000
--- a/src/app/components/ThemeToggle.module.css
+++ /dev/null
@@ -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);
-}
diff --git a/src/app/components/ThemeToggle.tsx b/src/app/components/ThemeToggle.tsx
index 6d1b509..8314822 100644
--- a/src/app/components/ThemeToggle.tsx
+++ b/src/app/components/ThemeToggle.tsx
@@ -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 (
-
{uiTheme === 'dark' ? : }
-
+
);
}
diff --git a/src/app/components/Toaster.module.css b/src/app/components/Toaster.module.css
index 3553745..4ac4dc3 100644
--- a/src/app/components/Toaster.module.css
+++ b/src/app/components/Toaster.module.css
@@ -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;
}
diff --git a/src/app/components/Toaster.tsx b/src/app/components/Toaster.tsx
index d6df57c..ecbac16 100644
--- a/src/app/components/Toaster.tsx
+++ b/src/app/components/Toaster.tsx
@@ -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({
)}
-
-
+
);
}
diff --git a/styles/tokens.css b/styles/tokens.css
index 17e7ed2..ce4e478 100644
--- a/styles/tokens.css
+++ b/styles/tokens.css
@@ -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;