From 7d4906ec383f906b7c8d95841ec0d9e71eff7904 Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Fri, 12 Jun 2026 23:13:13 +0300 Subject: [PATCH] Control scale: Button/IconButton primitives, two-height tokens, app-wide migration --- .claude/skills/alignment/SKILL.md | 11 ++ docs/architecture/09-visual-design.md | 64 +++++++-- docs/architecture/visual-specimen.html | 46 ++++--- src/app/App.module.css | 58 +------- src/app/App.tsx | 41 +++--- src/app/components/Button.module.css | 107 +++++++++++++++ src/app/components/Button.test.tsx | 79 +++++++++++ src/app/components/Button.tsx | 74 +++++++++++ src/app/components/ChartExport.module.css | 43 +----- src/app/components/ChartExport.tsx | 10 +- src/app/components/ConfirmDialog.module.css | 46 +------ src/app/components/ConfirmDialog.tsx | 14 +- src/app/components/DatasetsModal.module.css | 70 +--------- src/app/components/DatasetsModal.tsx | 59 +++------ src/app/components/DonateModal.module.css | 2 +- src/app/components/ExtractModal.module.css | 34 +---- src/app/components/ExtractModal.tsx | 13 +- src/app/components/IconButton.module.css | 48 +++++++ src/app/components/IconButton.tsx | 53 ++++++++ src/app/components/ModalShell.module.css | 31 +---- src/app/components/ModalShell.tsx | 10 +- src/app/components/Onboarding.module.css | 90 +------------ src/app/components/Onboarding.tsx | 18 +-- src/app/components/PaneToggleStrip.module.css | 40 ++---- src/app/components/PaneToggleStrip.tsx | 20 ++- .../components/SegmentedControl.module.css | 8 +- src/app/components/SelectControl.module.css | 2 +- src/app/components/SettingsPopover.module.css | 39 +----- src/app/components/SettingsPopover.tsx | 10 +- src/app/components/SnippetLibrary.module.css | 124 +++--------------- src/app/components/SnippetLibrary.tsx | 41 +++--- src/app/components/SortControl.module.css | 5 +- src/app/components/SpecEditor.module.css | 53 +------- src/app/components/SpecEditor.tsx | 23 ++-- .../components/ThemeBuilderModal.module.css | 76 +---------- src/app/components/ThemeBuilderModal.tsx | 20 ++- src/app/components/ThemeToggle.module.css | 21 --- src/app/components/ThemeToggle.tsx | 16 +-- src/app/components/Toaster.module.css | 28 +--- src/app/components/Toaster.tsx | 9 +- styles/tokens.css | 8 ++ 41 files changed, 660 insertions(+), 904 deletions(-) create mode 100644 src/app/components/Button.module.css create mode 100644 src/app/components/Button.test.tsx create mode 100644 src/app/components/Button.tsx create mode 100644 src/app/components/IconButton.module.css create mode 100644 src/app/components/IconButton.tsx delete mode 100644 src/app/components/ThemeToggle.module.css 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 + ` @@ -956,9 +961,8 @@
- - - + +

Tab through to see the focus ring (2px accent outline). @@ -972,7 +976,7 @@ emphasis.)

- - - - - +
@@ -1495,8 +1499,8 @@ > Draft changes
- - + +
@@ -1611,7 +1615,7 @@
- + Neutralized automatically under prefers-reduced-motion. diff --git a/src/app/App.module.css b/src/app/App.module.css index 2dfe36b..02d0625 100644 --- a/src/app/App.module.css +++ b/src/app/App.module.css @@ -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; diff --git a/src/app/App.tsx b/src/app/App.tsx index 9f0a658..888618e 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -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. */} - - - - +
diff --git a/src/app/components/DatasetsModal.module.css b/src/app/components/DatasetsModal.module.css index d4c3892..86e4b4f 100644 --- a/src/app/components/DatasetsModal.module.css +++ b/src/app/components/DatasetsModal.module.css @@ -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; diff --git a/src/app/components/DatasetsModal.tsx b/src/app/components/DatasetsModal.tsx index ee4dc08..eb3c507 100644 --- a/src/app/components/DatasetsModal.tsx +++ b/src/app/components/DatasetsModal.tsx @@ -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 (
- +
    {ordered.length === 0 && (
  • @@ -239,9 +240,7 @@ function DatasetDetail({

    {dataset.name}

    - + {/* 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' : ''} {dataset.source === 'url' && ( - + )} - + {/* 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. */} - - + +
    @@ -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 && ( - + )}
    - - + +
); diff --git a/src/app/components/DonateModal.module.css b/src/app/components/DonateModal.module.css index cfb5975..d92cb78 100644 --- a/src/app/components/DonateModal.module.css +++ b/src/app/components/DonateModal.module.css @@ -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; diff --git a/src/app/components/ExtractModal.module.css b/src/app/components/ExtractModal.module.css index 94592eb..1164fae 100644 --- a/src/app/components/ExtractModal.module.css +++ b/src/app/components/ExtractModal.module.css @@ -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); -} diff --git a/src/app/components/ExtractModal.tsx b/src/app/components/ExtractModal.tsx index d876194..965ced7 100644 --- a/src/app/components/ExtractModal.tsx +++ b/src/app/components/ExtractModal.tsx @@ -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() { )}
- - + +
); diff --git a/src/app/components/IconButton.module.css b/src/app/components/IconButton.module.css new file mode 100644 index 0000000..7a0b588 --- /dev/null +++ b/src/app/components/IconButton.module.css @@ -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; +} diff --git a/src/app/components/IconButton.tsx b/src/app/components/IconButton.tsx new file mode 100644 index 0000000..6db063b --- /dev/null +++ b/src/app/components/IconButton.tsx @@ -0,0 +1,53 @@ +/** + * IconButton — the shared icon-only button primitive (arch 09 §4 + §5). + * + * A square button holding a single `` (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 ( + +
diff --git a/src/app/components/Onboarding.module.css b/src/app/components/Onboarding.module.css index 9d1eb38..ab414ca 100644 --- a/src/app/components/Onboarding.module.css +++ b/src/app/components/Onboarding.module.css @@ -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; } diff --git a/src/app/components/Onboarding.tsx b/src/app/components/Onboarding.tsx index ba6b8e6..fae3033 100644 --- a/src/app/components/Onboarding.tsx +++ b/src/app/components/Onboarding.tsx @@ -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() {

- - + +

Or start from an example

- +
    @@ -164,14 +165,13 @@ export function Onboarding() { {example.name} {example.description}
- + ))} 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) => ( - + ))} ); } 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({ )}
- - + +
); @@ -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. */}
- - +
{/* Search + Sort controls, pinned above the list (spec §02; council @@ -300,15 +301,14 @@ export function SnippetLibrary() { spellCheck={false} /> {searching && ( - + )}
@@ -381,14 +381,15 @@ export function SnippetLibrary() { )} - + ); })} diff --git a/src/app/components/SortControl.module.css b/src/app/components/SortControl.module.css index 1a49e25..2639163 100644 --- a/src/app/components/SortControl.module.css +++ b/src/app/components/SortControl.module.css @@ -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); diff --git a/src/app/components/SpecEditor.module.css b/src/app/components/SpecEditor.module.css index 08cb8cd..42f46ce 100644 --- a/src/app/components/SpecEditor.module.css +++ b/src/app/components/SpecEditor.module.css @@ -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; diff --git a/src/app/components/SpecEditor.tsx b/src/app/components/SpecEditor.tsx index b858567..6e68579 100644 --- a/src/app/components/SpecEditor.tsx +++ b/src/app/components/SpecEditor.tsx @@ -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 && ( - + )} - - +
); diff --git a/src/app/components/ThemeBuilderModal.module.css b/src/app/components/ThemeBuilderModal.module.css index ceb1937..343d887 100644 --- a/src/app/components/ThemeBuilderModal.module.css +++ b/src/app/components/ThemeBuilderModal.module.css @@ -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; diff --git a/src/app/components/ThemeBuilderModal.tsx b/src/app/components/ThemeBuilderModal.tsx index 33300bf..95d72b8 100644 --- a/src/app/components/ThemeBuilderModal.tsx +++ b/src/app/components/ThemeBuilderModal.tsx @@ -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 (
- +
    {themes.length === 0 && (
  • @@ -208,21 +209,16 @@ export function ThemeBuilderModal() { triggerTitle="Write one font family into every font slot of the config" />
    - - +
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 ( - + ); } 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;