mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Control scale: Button/IconButton primitives, two-height tokens, app-wide migration
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user