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:
@@ -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
|
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
|
(CSS Modules + design tokens in `styles/tokens.css`) rather than writing from scratch. Don't
|
||||||
fix whitespace/formatting (trailing newlines etc.) — Prettier owns that.
|
fix whitespace/formatting (trailing newlines etc.) — Prettier owns that.
|
||||||
|
- **Control primitives & the two-height scale** (arch 09 §4): action buttons are the
|
||||||
|
`Button` component, icon-only buttons are `IconButton` — never a freshly styled
|
||||||
|
`<button>`. Interactive controls are `var(--control-height)` (32px) or
|
||||||
|
`var(--control-height-lg)` (40px); a hardcoded control height (28px, 36px, …) in a
|
||||||
|
diff is a finding. A freshly written bordered-field recipe is the same finding —
|
||||||
|
fields share one look (1px `--border-strong`, `--bg` fill, `--control-height`); a
|
||||||
|
new input should copy an existing field class, not re-derive it. Call-site classes composed onto a primitive may only do layout
|
||||||
|
(flex, margins, reveal) or a documented state accent (outlined-danger, pressed) —
|
||||||
|
restyling the primitive's box from a call site is a finding. Borders mark function:
|
||||||
|
value-holding controls get `--border-strong` boxes; plain actions are ghost or
|
||||||
|
filled; list rows are flat with dividers, not stacked boxes.
|
||||||
|
|
||||||
6. **Code Comments**: Comments should not duplicate what the code already says. Remove
|
6. **Code Comments**: Comments should not duplicate what the code already says. Remove
|
||||||
parroting comments. Ensure comments capture non-obvious _why_ — design decisions,
|
parroting comments. Ensure comments capture non-obvious _why_ — design decisions,
|
||||||
|
|||||||
@@ -149,11 +149,43 @@ UI by swapping one set of values. Borrowed from Carbon's layering model:
|
|||||||
|
|
||||||
## 4. Component conventions
|
## 4. Component conventions
|
||||||
|
|
||||||
- **Buttons:** square, 32px (compact) / 40px (default) tall. Variants: **primary**
|
- **The control scale (two heights, enforced by tokens + primitives).** Every
|
||||||
(filled `--accent`), **secondary** (bordered), **ghost** (text-only, borderless),
|
interactive control is `--control-height` (32px) or `--control-height-lg`
|
||||||
**soft-accent** (ghost on an `--accent-soft` wash — a low-emphasis solicitation,
|
(40px) — tokens.css. 32px is THE control height: buttons, fields, selects,
|
||||||
e.g. Donate), **danger** (filled `--support-error`). 600-weight label. Clear
|
segmented controls, icon buttons, anything in a toolbar, form row, or dialog
|
||||||
hover/active and a visible focus ring.
|
action row. 40px is reserved for standalone primary CTAs (the library's Build
|
||||||
|
Chart, a modal list-pane's New X) and modal footers. A third height is drift —
|
||||||
|
the pre-token codebase accumulated 26/28/30/36/42px variants one component at a
|
||||||
|
time, which read as "shaky" the moment controls shared a row. The rule is
|
||||||
|
enforced mechanically: action buttons are the **`Button`** primitive, icon-only
|
||||||
|
buttons are **`IconButton`** (24px `sm` exists solely for controls nested
|
||||||
|
_inside_ a 32px control — a search field's clear, a toast's dismiss); writing
|
||||||
|
`height:` on a new ad-hoc button is the code smell.
|
||||||
|
- **Buttons:** square, via the `Button` primitive. Variants: **primary** (filled
|
||||||
|
`--accent`), **secondary** (1px `--border-strong`, `--bg` fill — the same
|
||||||
|
field-on-layer treatment as inputs, so a bordered control on a gray panel goes
|
||||||
|
white, never a darker gray), **ghost** (text-only, borderless; a transparent
|
||||||
|
border holds the box size), **soft-accent** (ghost on an `--accent-soft` wash —
|
||||||
|
a low-emphasis solicitation, e.g. Donate), **danger** (filled
|
||||||
|
`--support-error` — the confirm step), **danger-outline** (secondary geometry,
|
||||||
|
red label, filling solid red on hover/focus — a destructive action sitting
|
||||||
|
among peers, e.g. a detail view's Delete). 13px 600-weight label. Clear hover/active and a visible
|
||||||
|
focus ring. **Inline link-style actions are a separate kind, not a Button
|
||||||
|
variant**: small accent-text actions embedded in content ("+ Add filter",
|
||||||
|
"Swap X/Y", "Use a constant", a popover's Reset) deliberately sit below the
|
||||||
|
control scale — content-sized, 11–12px, no box — so they read as part of the
|
||||||
|
prose/panel they act on, not as toolbar controls. Don't "promote" them to
|
||||||
|
Buttons; their smallness is the emphasis level.
|
||||||
|
- **Borders mark function, not decoration.** A 1px `--border-strong` box belongs
|
||||||
|
to things that _hold or receive a value_: fields, selects/disclosure triggers,
|
||||||
|
segmented controls, secondary buttons, drop targets (dashed). Plain actions are
|
||||||
|
ghost or filled — never outlined boxes. List rows are flat (hairline dividers +
|
||||||
|
hover fill), not stacked boxes. With square chrome, every box makes alignment
|
||||||
|
errors visible, so each border must earn its place; when a region looks
|
||||||
|
"busy", remove boxes before shrinking anything. (Carbon goes further — fields
|
||||||
|
are bottom-border-only and its secondary button is a gray fill; our fully-boxed
|
||||||
|
fields are a recorded divergence, §2, which is exactly why box discipline
|
||||||
|
matters more here, not less.)
|
||||||
- **Emphasis hierarchy (Carbon button/usage).** A region carries **one**
|
- **Emphasis hierarchy (Carbon button/usage).** A region carries **one**
|
||||||
high-emphasis (primary) button at most; everything else is lower emphasis. In
|
high-emphasis (primary) button at most; everything else is lower emphasis. In
|
||||||
toolbars/headers full of utilities, the utilities go **ghost** so they recede
|
toolbars/headers full of utilities, the utilities go **ghost** so they recede
|
||||||
@@ -168,8 +200,11 @@ UI by swapping one set of values. Borrowed from Carbon's layering model:
|
|||||||
fill one elevation step above their surface** — on `--bg` → `--layer-01`, on a
|
fill one elevation step above their surface** — on `--bg` → `--layer-01`, on a
|
||||||
`--layer-01` surface (dialogs, panels) → `--layer-02`. Filling to the _same_
|
`--layer-01` surface (dialogs, panels) → `--layer-02`. Filling to the _same_
|
||||||
layer as the surface reads as no hover at all (the collision that left the
|
layer as the surface reads as no hover at all (the collision that left the
|
||||||
confirm dialog's Cancel looking dead). Any button placed on an elevated surface
|
confirm dialog's Cancel looking dead). The step is mechanical: Button/IconButton
|
||||||
must step its hover fill up to match.
|
hover with `var(--control-hover-fill, var(--layer-01))`, and an elevated surface
|
||||||
|
sets `--control-hover-fill: var(--layer-02)` once on its container (the header,
|
||||||
|
the modal chrome, the confirm card, the library's metadata panel) — controls
|
||||||
|
inherit the right step instead of each re-encoding it.
|
||||||
- **Focus ring:** a 2px `--focus` outline (offset 1–2px). Always visible on
|
- **Focus ring:** a 2px `--focus` outline (offset 1–2px). Always visible on
|
||||||
keyboard focus — accessibility is non-negotiable (principle 4).
|
keyboard focus — accessibility is non-negotiable (principle 4).
|
||||||
- **Fields** (text, textarea, select, search): square, 1px `--border-strong`,
|
- **Fields** (text, textarea, select, search): square, 1px `--border-strong`,
|
||||||
@@ -412,10 +447,11 @@ The chart `Config` is themed to match the app, per theme:
|
|||||||
## 7. Implementation map
|
## 7. Implementation map
|
||||||
|
|
||||||
| Artifact | Role |
|
| Artifact | Role |
|
||||||
| ------------------------------------------------ | ----------------------------------------------------- |
|
| -------------------------------------------------- | -------------------------------------------------------------------- |
|
||||||
| [`visual-specimen.html`](./visual-specimen.html) | Living preview + token sandbox. Iterate here first |
|
| [`visual-specimen.html`](./visual-specimen.html) | Living preview + token sandbox. Iterate here first |
|
||||||
| `styles/tokens.css` | The settled tokens — ported from the specimen in M1.5 |
|
| `styles/tokens.css` | The settled tokens — ported from the specimen in M1.5 |
|
||||||
| `styles/base.css` | Font wiring (`@fontsource`), reset, reduced-motion |
|
| `styles/base.css` | Font wiring (`@fontsource`), reset, reduced-motion |
|
||||||
|
| `src/app/components/Button.tsx` / `IconButton.tsx` | The shared control primitives (§4) — every action / icon-only button |
|
||||||
| component `*.module.css` | Consume tokens only; no raw hexes, no hardcoded hue |
|
| component `*.module.css` | Consume tokens only; no raw hexes, no hardcoded hue |
|
||||||
| `src/core/vega-themes.ts` | Chart `Config` per theme; categorical palettes |
|
| `src/core/vega-themes.ts` | Chart `Config` per theme; categorical palettes |
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,12 @@
|
|||||||
--radius: 0px;
|
--radius: 0px;
|
||||||
--border-width: 1px;
|
--border-width: 1px;
|
||||||
|
|
||||||
|
/* Control scale — every interactive control is one of exactly two
|
||||||
|
heights (arch 09 §4): 32px standard, 40px for standalone primary
|
||||||
|
CTAs and modal footers. */
|
||||||
|
--control-height: 32px;
|
||||||
|
--control-height-lg: 40px;
|
||||||
|
|
||||||
/* Motion — productive only */
|
/* Motion — productive only */
|
||||||
--dur-fast: 70ms;
|
--dur-fast: 70ms;
|
||||||
--dur-fast-2: 110ms;
|
--dur-fast-2: 110ms;
|
||||||
@@ -399,26 +405,25 @@
|
|||||||
padding: var(--space-5);
|
padding: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----- Buttons ----- */
|
/* ----- Buttons (mirrors src/app/components/Button.module.css) ----- */
|
||||||
.btn {
|
.btn {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-2);
|
||||||
height: 40px;
|
height: var(--control-height);
|
||||||
padding: 0 var(--space-5);
|
padding: 0 var(--space-3);
|
||||||
border: var(--border-width) solid transparent;
|
border: var(--border-width) solid transparent;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
transition:
|
transition:
|
||||||
background var(--dur-fast) var(--ease),
|
background var(--dur-fast) var(--ease),
|
||||||
border-color var(--dur-fast) var(--ease);
|
border-color var(--dur-fast) var(--ease);
|
||||||
}
|
}
|
||||||
.btn.sm {
|
.btn.lg {
|
||||||
height: 32px;
|
height: var(--control-height-lg);
|
||||||
font-size: 13px;
|
|
||||||
padding: 0 var(--space-4);
|
padding: 0 var(--space-4);
|
||||||
}
|
}
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@@ -429,7 +434,7 @@
|
|||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
}
|
}
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: transparent;
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
}
|
}
|
||||||
@@ -947,7 +952,7 @@
|
|||||||
|
|
||||||
<!-- BUTTONS -->
|
<!-- BUTTONS -->
|
||||||
<section>
|
<section>
|
||||||
<h2>Buttons — square, 600 weight</h2>
|
<h2>Buttons — square, 600 weight, 32px standard / 40px lg</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button class="btn btn-primary">Create snippet</button>
|
<button class="btn btn-primary">Create snippet</button>
|
||||||
<button class="btn btn-secondary">Duplicate</button>
|
<button class="btn btn-secondary">Duplicate</button>
|
||||||
@@ -956,9 +961,8 @@
|
|||||||
<button class="btn btn-primary" disabled>Disabled</button>
|
<button class="btn btn-primary" disabled>Disabled</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="margin-top: var(--space-5)">
|
<div class="row" style="margin-top: var(--space-5)">
|
||||||
<button class="btn btn-primary sm">Compact</button>
|
<button class="btn btn-primary lg">Build Chart</button>
|
||||||
<button class="btn btn-secondary sm">Compact</button>
|
<button class="btn btn-secondary lg">Cancel</button>
|
||||||
<button class="btn btn-ghost sm">Compact</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="cap" style="margin-top: var(--space-4)">
|
<p class="cap" style="margin-top: var(--space-4)">
|
||||||
Tab through to see the focus ring (2px accent outline).
|
Tab through to see the focus ring (2px accent outline).
|
||||||
@@ -972,7 +976,7 @@
|
|||||||
emphasis.)
|
emphasis.)
|
||||||
</p>
|
</p>
|
||||||
<div class="row" style="align-items: center">
|
<div class="row" style="align-items: center">
|
||||||
<button class="btn btn-ghost-util sm">
|
<button class="btn btn-ghost-util">
|
||||||
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor">
|
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor">
|
||||||
<rect x="8" y="18" width="4" height="2" />
|
<rect x="8" y="18" width="4" height="2" />
|
||||||
<rect x="14" y="18" width="4" height="2" />
|
<rect x="14" y="18" width="4" height="2" />
|
||||||
@@ -985,19 +989,19 @@
|
|||||||
/></svg
|
/></svg
|
||||||
>Datasets
|
>Datasets
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost-util sm">
|
<button class="btn btn-ghost-util">
|
||||||
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor">
|
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor">
|
||||||
<path d="M26,24v4H6V24H4v4a2,2,0,0,0,2,2H26a2,2,0,0,0,2-2V24Z" />
|
<path d="M26,24v4H6V24H4v4a2,2,0,0,0,2,2H26a2,2,0,0,0,2-2V24Z" />
|
||||||
<path d="M6,12l1.41,1.41L15,5.83V24h2V5.83l7.59,7.59L26,12,16,2Z" /></svg
|
<path d="M6,12l1.41,1.41L15,5.83V24h2V5.83l7.59,7.59L26,12,16,2Z" /></svg
|
||||||
>Import
|
>Import
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost-util sm">
|
<button class="btn btn-ghost-util">
|
||||||
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor">
|
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor">
|
||||||
<path d="M26,24v4H6V24H4v4a2,2,0,0,0,2,2H26a2,2,0,0,0,2-2V24Z" />
|
<path d="M26,24v4H6V24H4v4a2,2,0,0,0,2,2H26a2,2,0,0,0,2-2V24Z" />
|
||||||
<path d="M26,14l-1.41-1.41L17,20.17V2H15V20.17l-7.59-7.58L6,14l10,10Z" /></svg
|
<path d="M26,14l-1.41-1.41L17,20.17V2H15V20.17l-7.59-7.58L6,14l10,10Z" /></svg
|
||||||
>Export
|
>Export
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost-util sm">
|
<button class="btn btn-ghost-util">
|
||||||
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor">
|
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor">
|
||||||
<path d="M17,22V14H13v2h2v6H12v2h8V22Z" />
|
<path d="M17,22V14H13v2h2v6H12v2h8V22Z" />
|
||||||
<path d="M16,8a1.5,1.5,0,1,0,1.5,1.5A1.5,1.5,0,0,0,16,8Z" />
|
<path d="M16,8a1.5,1.5,0,1,0,1.5,1.5A1.5,1.5,0,0,0,16,8Z" />
|
||||||
@@ -1007,7 +1011,7 @@
|
|||||||
>About
|
>About
|
||||||
</button>
|
</button>
|
||||||
<span class="hdr-divider"></span>
|
<span class="hdr-divider"></span>
|
||||||
<button class="btn btn-soft sm">Donate</button>
|
<button class="btn btn-soft">Donate</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -1495,8 +1499,8 @@
|
|||||||
><span><span class="dot draft"></span> Draft changes</span>
|
><span><span class="dot draft"></span> Draft changes</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="margin-top: var(--space-5)">
|
<div class="row" style="margin-top: var(--space-5)">
|
||||||
<button class="btn btn-primary sm">Publish</button>
|
<button class="btn btn-primary">Publish</button>
|
||||||
<button class="btn btn-ghost sm">Revert</button>
|
<button class="btn btn-ghost">Revert</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1611,7 +1615,7 @@
|
|||||||
<div class="motion-box"></div>
|
<div class="motion-box"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="margin-top: var(--space-5)">
|
<div class="row" style="margin-top: var(--space-5)">
|
||||||
<button class="btn btn-secondary sm" id="motionBtn">Play transition</button>
|
<button class="btn btn-secondary" id="motionBtn">Play transition</button>
|
||||||
<span class="cap"
|
<span class="cap"
|
||||||
>Neutralized automatically under <code>prefers-reduced-motion</code>.</span
|
>Neutralized automatically under <code>prefers-reduced-motion</code>.</span
|
||||||
>
|
>
|
||||||
|
|||||||
+6
-52
@@ -13,6 +13,9 @@
|
|||||||
border-bottom: var(--border-width) solid var(--border);
|
border-bottom: var(--border-width) solid var(--border);
|
||||||
background: var(--layer-01);
|
background: var(--layer-01);
|
||||||
flex: 0 0 auto;
|
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 {
|
.title {
|
||||||
@@ -61,42 +64,9 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/* Vertical rule separating the utilities from Donate + the theme toggle.
|
||||||
* Header utility buttons (Datasets / Import / Export / About) — low-emphasis
|
(The header buttons themselves are the shared Button primitive — ghost
|
||||||
* ghost buttons: borderless, icon + label, so they recede behind the work area
|
utilities, soft-accent Donate; arch 09 §4.) */
|
||||||
* 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. */
|
|
||||||
.headerDivider {
|
.headerDivider {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: var(--border-width);
|
width: var(--border-width);
|
||||||
@@ -105,22 +75,6 @@
|
|||||||
background: var(--border-strong);
|
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. */
|
/* The Import file picker is driven programmatically by its header button. */
|
||||||
.hiddenInput {
|
.hiddenInput {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
+18
-23
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { Button } from './components/Button';
|
||||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||||
import { LivePreview } from './components/LivePreview';
|
import { LivePreview } from './components/LivePreview';
|
||||||
import { ModalShell } from './components/ModalShell';
|
import { ModalShell } from './components/ModalShell';
|
||||||
@@ -80,52 +81,46 @@ export function App() {
|
|||||||
better at low emphasis). The utilities are ghost (borderless) buttons
|
better at low emphasis). The utilities are ghost (borderless) buttons
|
||||||
with a leading icon for scanning; a divider then sets off Donate — the
|
with a leading icon for scanning; a divider then sets off Donate — the
|
||||||
one solicitation, given a soft-accent wash — and the theme toggle. */}
|
one solicitation, given a soft-accent wash — and the theme toggle. */}
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
className={styles.headerButton}
|
|
||||||
onClick={() => openModal('datasets')}
|
onClick={() => openModal('datasets')}
|
||||||
aria-keyshortcuts="Meta+K Control+K"
|
aria-keyshortcuts="Meta+K Control+K"
|
||||||
title="Datasets (⌘/Ctrl+K)"
|
title="Datasets (⌘/Ctrl+K)"
|
||||||
>
|
>
|
||||||
<Icon name="dataset" />
|
<Icon name="dataset" />
|
||||||
Datasets
|
Datasets
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
className={styles.headerButton}
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
title="Import a workspace JSON file"
|
title="Import a workspace JSON file"
|
||||||
>
|
>
|
||||||
<Icon name="import" />
|
<Icon name="import" />
|
||||||
Import
|
Import
|
||||||
</button>
|
</Button>
|
||||||
<button
|
{/* TODO: two controls labelled "Export" are visible at once (this
|
||||||
type="button"
|
workspace export + the preview pane's per-chart export) — rename or
|
||||||
className={styles.headerButton}
|
differentiate (docs/ux-second-pass.md). */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
onClick={() => exportWorkspace()}
|
onClick={() => exportWorkspace()}
|
||||||
title="Export your workspace to a JSON file"
|
title="Export your workspace to a JSON file"
|
||||||
>
|
>
|
||||||
<Icon name="export" />
|
<Icon name="export" />
|
||||||
Export
|
Export
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
className={styles.headerButton}
|
|
||||||
onClick={() => openModal('about')}
|
onClick={() => openModal('about')}
|
||||||
title="About, keyboard shortcuts, and privacy information"
|
title="About, keyboard shortcuts, and privacy information"
|
||||||
>
|
>
|
||||||
<Icon name="info" />
|
<Icon name="info" />
|
||||||
About
|
About
|
||||||
</button>
|
</Button>
|
||||||
<span className={styles.headerDivider} aria-hidden="true" />
|
<span className={styles.headerDivider} aria-hidden="true" />
|
||||||
<button
|
<Button variant="soft-accent" onClick={() => openModal('donate')} title="Support Astrolabe">
|
||||||
type="button"
|
|
||||||
className={`${styles.headerButton} ${styles.donate}`}
|
|
||||||
onClick={() => openModal('donate')}
|
|
||||||
title="Support Astrolabe"
|
|
||||||
>
|
|
||||||
Donate
|
Donate
|
||||||
</button>
|
</Button>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
{/* Hidden picker for Import; restricted to JSON (spec §08). */}
|
{/* Hidden picker for Import; restricted to JSON (spec §08). */}
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -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;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Trigger — a compact ghost button (icon + label), matching the header utilities. */
|
/* The trigger is the shared Button primitive (ghost; arch 09 §4). */
|
||||||
.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 disclosed panel — portaled to <body>, positioned `fixed` (top/right set
|
/* The disclosed panel — portaled to <body>, positioned `fixed` (top/right set
|
||||||
inline) so it escapes the panes' overflow clipping. Mirrors SettingsPopover. */
|
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 { useDatasetStore } from '../stores/DatasetStore';
|
||||||
import { notify } from '../stores/NotificationStore';
|
import { notify } from '../stores/NotificationStore';
|
||||||
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||||
|
import { Button } from './Button';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||||
import { SettingRow } from './SettingsPopover';
|
import { SettingRow } from './SettingsPopover';
|
||||||
@@ -183,19 +184,18 @@ export function ChartExport({ chartReady, getImageUrl }: ChartExportProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
<button
|
<Button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
variant="ghost"
|
||||||
className={styles.trigger}
|
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-controls={POP_ID}
|
aria-controls={POP_ID}
|
||||||
disabled={!hasSpec}
|
disabled={!hasSpec}
|
||||||
title={hasSpec ? 'Export this chart' : 'Select a snippet to export'}
|
title={hasSpec ? 'Export this chart' : 'Select a snippet to export'}
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
>
|
>
|
||||||
<Icon name="export" className={styles.triggerIcon} />
|
<Icon name="export" />
|
||||||
<span>Export</span>
|
<span>Export</span>
|
||||||
</button>
|
</Button>
|
||||||
{open &&
|
{open &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
/* Minimal shadow, reserved for true overlays (doc §09). */
|
/* Minimal shadow, reserved for true overlays (doc §09). */
|
||||||
box-shadow: 0 2px 12px rgb(0 0 0 / 0.3);
|
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 {
|
.title {
|
||||||
@@ -44,49 +47,10 @@
|
|||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cancel,
|
/* The actions are shared Buttons (secondary / primary|danger, lg). These two
|
||||||
.confirm {
|
classes are pure markers — the focus trap's initial-focus selectors. */
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel {
|
.cancel {
|
||||||
background: transparent;
|
|
||||||
border-color: var(--border-strong);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel:hover {
|
|
||||||
background: var(--layer-02);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm {
|
.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 { useConfirmStore } from '../stores/ConfirmStore';
|
||||||
import { useFocusTrap } from '../hooks/useFocusTrap';
|
import { useFocusTrap } from '../hooks/useFocusTrap';
|
||||||
|
import { Button } from './Button';
|
||||||
import styles from './ConfirmDialog.module.css';
|
import styles from './ConfirmDialog.module.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,16 +43,17 @@ export function ConfirmDialog() {
|
|||||||
{message}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<button type="button" className={styles.cancel} onClick={() => resolve(false)}>
|
<Button size="lg" className={styles.cancel} onClick={() => resolve(false)}>
|
||||||
{cancelLabel ?? 'Cancel'}
|
{cancelLabel ?? 'Cancel'}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
size="lg"
|
||||||
className={`${styles.confirm} ${danger ? styles.danger : ''}`}
|
variant={danger ? 'danger' : 'primary'}
|
||||||
|
className={styles.confirm}
|
||||||
onClick={() => resolve(true)}
|
onClick={() => resolve(true)}
|
||||||
>
|
>
|
||||||
{confirmLabel ?? 'Confirm'}
|
{confirmLabel ?? 'Confirm'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,27 +15,10 @@
|
|||||||
border-right: var(--border-width) solid var(--border);
|
border-right: var(--border-width) solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A shared primary Button (lg); locally just pinned with a margin. */
|
||||||
.newButton {
|
.newButton {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
margin: var(--space-4);
|
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 {
|
.list {
|
||||||
@@ -177,56 +160,7 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action {
|
/* Actions are shared Buttons (secondary / primary / danger-outline). */
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment {
|
.comment {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
} from '../stores/DatasetStore';
|
} from '../stores/DatasetStore';
|
||||||
import { useSnippetStore } from '../stores/SnippetStore';
|
import { useSnippetStore } from '../stores/SnippetStore';
|
||||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||||
|
import { Button } from './Button';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
import styles from './DatasetsModal.module.css';
|
import styles from './DatasetsModal.module.css';
|
||||||
|
|
||||||
@@ -73,9 +74,9 @@ export function DatasetsModal() {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.manager}>
|
<div className={styles.manager}>
|
||||||
<div className={styles.listPane}>
|
<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
|
<Icon name="add" /> New Dataset
|
||||||
</button>
|
</Button>
|
||||||
<ul className={styles.list}>
|
<ul className={styles.list}>
|
||||||
{ordered.length === 0 && (
|
{ordered.length === 0 && (
|
||||||
<li className={styles.empty}>
|
<li className={styles.empty}>
|
||||||
@@ -239,9 +240,7 @@ function DatasetDetail({
|
|||||||
<div className={styles.detailHead}>
|
<div className={styles.detailHead}>
|
||||||
<h3 className={styles.detailName}>{dataset.name}</h3>
|
<h3 className={styles.detailName}>{dataset.name}</h3>
|
||||||
<div className={styles.detailActions}>
|
<div className={styles.detailActions}>
|
||||||
<button type="button" className={styles.action} onClick={() => void handleCopy()}>
|
<Button onClick={() => void handleCopy()}>{copied ? 'Copied' : 'Copy Reference'}</Button>
|
||||||
{copied ? 'Copied' : 'Copy Reference'}
|
|
||||||
</button>
|
|
||||||
{/* The clipboard write is invisible, so the success is confirmed inline
|
{/* The clipboard write is invisible, so the success is confirmed inline
|
||||||
("Copied") rather than by a toast (docs/architecture/10 → Toast copy).
|
("Copied") rather than by a toast (docs/architecture/10 → Toast copy).
|
||||||
This polite live region announces it to assistive tech, which the
|
This polite live region announces it to assistive tech, which the
|
||||||
@@ -250,35 +249,18 @@ function DatasetDetail({
|
|||||||
{copied ? 'Reference copied to clipboard' : ''}
|
{copied ? 'Reference copied to clipboard' : ''}
|
||||||
</span>
|
</span>
|
||||||
{dataset.source === 'url' && (
|
{dataset.source === 'url' && (
|
||||||
<button
|
<Button onClick={() => void handleRefresh()} disabled={refreshing}>
|
||||||
type="button"
|
|
||||||
className={styles.action}
|
|
||||||
onClick={() => void handleRefresh()}
|
|
||||||
disabled={refreshing}
|
|
||||||
>
|
|
||||||
{refreshing ? 'Refreshing…' : 'Refresh'}
|
{refreshing ? 'Refreshing…' : 'Refresh'}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<button type="button" className={styles.action} onClick={handleEdit}>
|
<Button onClick={handleEdit}>Edit</Button>
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
{/* Build Chart (spec §05 → §06) — opens the Chart Builder on this dataset.
|
{/* 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
|
Replaces the Datasets modal (one modal at a time, §01C); detail view has
|
||||||
no transient form state, so no discard prompt. */}
|
no transient form state, so no discard prompt. */}
|
||||||
<button
|
<Button onClick={() => openModal('chartBuilder', String(dataset.id))}>Build Chart</Button>
|
||||||
type="button"
|
<Button variant="danger-outline" onClick={() => void handleDelete()}>
|
||||||
className={styles.action}
|
|
||||||
onClick={() => openModal('chartBuilder', String(dataset.id))}
|
|
||||||
>
|
|
||||||
Build Chart
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${styles.action} ${styles.danger}`}
|
|
||||||
onClick={() => void handleDelete()}
|
|
||||||
>
|
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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
|
{/* 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. */}
|
when CORS or offline blocks the URL) — offered as a direct one-click path. */}
|
||||||
{fetchError && (
|
{fetchError && (
|
||||||
<button
|
<Button className={styles.fallback} onClick={handlePasteInline}>
|
||||||
type="button"
|
|
||||||
className={`${styles.action} ${styles.fallback}`}
|
|
||||||
onClick={handlePasteInline}
|
|
||||||
>
|
|
||||||
Paste data inline instead
|
Paste data inline instead
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.formActions}>
|
<div className={styles.formActions}>
|
||||||
<button type="button" className={styles.action} onClick={handleCancel} disabled={fetching}>
|
<Button onClick={handleCancel} disabled={fetching}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="primary" disabled={!canSave || fetching} onClick={() => void handleSave()}>
|
||||||
type="button"
|
|
||||||
className={`${styles.action} ${styles.primary}`}
|
|
||||||
disabled={!canSave || fetching}
|
|
||||||
onClick={() => void handleSave()}
|
|
||||||
>
|
|
||||||
{fetching ? 'Fetching…' : editing ? 'Save changes' : 'Create dataset'}
|
{fetching ? 'Fetching…' : editing ? 'Save changes' : 'Create dataset'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 36px;
|
height: var(--control-height-lg);
|
||||||
padding: 0 var(--space-6);
|
padding: 0 var(--space-6);
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border: var(--border-width) solid transparent;
|
border: var(--border-width) solid transparent;
|
||||||
|
|||||||
@@ -74,41 +74,9 @@
|
|||||||
color: var(--support-error);
|
color: var(--support-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Cancel / Create are shared Buttons (secondary / primary, lg — modal footer). */
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: var(--space-3);
|
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 { useShallow } from 'zustand/react/shallow';
|
||||||
import { closeModal } from '../modals/ModalCoordinator';
|
import { closeModal } from '../modals/ModalCoordinator';
|
||||||
import { useExtractStore } from '../stores/ExtractStore';
|
import { useExtractStore } from '../stores/ExtractStore';
|
||||||
|
import { Button } from './Button';
|
||||||
import styles from './ExtractModal.module.css';
|
import styles from './ExtractModal.module.css';
|
||||||
|
|
||||||
const MAX_PREVIEW = 1500;
|
const MAX_PREVIEW = 1500;
|
||||||
@@ -74,16 +75,12 @@ export function ExtractModal() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<button type="button" className={styles.action} onClick={() => void closeModal()}>
|
<Button size="lg" onClick={() => void closeModal()}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="primary" size="lg" onClick={handleCreate}>
|
||||||
type="button"
|
|
||||||
className={`${styles.action} ${styles.primary}`}
|
|
||||||
onClick={handleCreate}
|
|
||||||
>
|
|
||||||
Create dataset
|
Create dataset
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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: var(--border-width) solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: 0 2px 12px rgb(0 0 0 / 0.3);
|
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. */
|
/* Large: the two-pane managers (Datasets). Definite height so inner panes scroll. */
|
||||||
@@ -66,32 +69,7 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close {
|
/* The close affordance is the shared IconButton. */
|
||||||
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 modal body. Background steps to --bg so the panes read as the work surface
|
/* 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. */
|
against the --layer-01 chrome. Fills the remaining height; inner content scrolls. */
|
||||||
@@ -100,4 +78,5 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
--control-hover-fill: var(--layer-01);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { DonateModal } from './DonateModal';
|
|||||||
import { ExtractModal } from './ExtractModal';
|
import { ExtractModal } from './ExtractModal';
|
||||||
import { ThemeBuilderModal } from './ThemeBuilderModal';
|
import { ThemeBuilderModal } from './ThemeBuilderModal';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
|
import { IconButton } from './IconButton';
|
||||||
import styles from './ModalShell.module.css';
|
import styles from './ModalShell.module.css';
|
||||||
|
|
||||||
/** The body rendered for each modal name (metadata stays in the registry). */
|
/** 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}>
|
<h2 id="modal-title" className={styles.title} tabIndex={-1}>
|
||||||
{getModalTitle(name)}
|
{getModalTitle(name)}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<IconButton label="Close" onClick={() => void closeModal()}>
|
||||||
type="button"
|
|
||||||
className={styles.close}
|
|
||||||
aria-label="Close"
|
|
||||||
onClick={() => void closeModal()}
|
|
||||||
>
|
|
||||||
<Icon name="close" />
|
<Icon name="close" />
|
||||||
</button>
|
</IconButton>
|
||||||
</header>
|
</header>
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
<Body />
|
<Body />
|
||||||
|
|||||||
@@ -35,53 +35,7 @@
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Primary call to action — the accent button, matching the library's Create. */
|
/* The two CTAs and the per-example Adds are shared Buttons (arch 09 §4). */
|
||||||
.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 "or start from an example" header row, with Add all pushed to the end. */
|
/* The "or start from an example" header row, with Add all pushed to the end. */
|
||||||
.galleryHead {
|
.galleryHead {
|
||||||
@@ -101,27 +55,8 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Secondary button — bordered, transparent (like the header actions). */
|
|
||||||
.addAll {
|
.addAll {
|
||||||
flex: 0 0 auto;
|
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. */
|
/* Responsive gallery: cards as wide as ~240px, filling the column. */
|
||||||
@@ -174,28 +109,7 @@
|
|||||||
line-height: 1.4;
|
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 {
|
.add {
|
||||||
align-self: flex-start;
|
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 { useAppStore } from '../stores/AppStore';
|
||||||
import { usePanesStore } from '../stores/PanesStore';
|
import { usePanesStore } from '../stores/PanesStore';
|
||||||
import { useSnippetStore } from '../stores/SnippetStore';
|
import { useSnippetStore } from '../stores/SnippetStore';
|
||||||
|
import { Button } from './Button';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
import styles from './Onboarding.module.css';
|
import styles from './Onboarding.module.css';
|
||||||
|
|
||||||
@@ -141,19 +142,19 @@ export function Onboarding() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className={styles.ctaRow}>
|
<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
|
<Icon name="add" /> Create your first snippet
|
||||||
</button>
|
</Button>
|
||||||
<button type="button" className={styles.buildCta} onClick={handleBuild}>
|
<Button size="lg" onClick={handleBuild}>
|
||||||
<Icon name="chart" /> Build a chart from your data
|
<Icon name="chart" /> Build a chart from your data
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.galleryHead}>
|
<div className={styles.galleryHead}>
|
||||||
<h3 className={styles.galleryTitle}>Or start from an example</h3>
|
<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
|
Add all
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className={styles.gallery}>
|
<ul className={styles.gallery}>
|
||||||
@@ -164,14 +165,13 @@ export function Onboarding() {
|
|||||||
<span className={styles.cardName}>{example.name}</span>
|
<span className={styles.cardName}>{example.name}</span>
|
||||||
<span className={styles.cardDesc}>{example.description}</span>
|
<span className={styles.cardDesc}>{example.description}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
|
||||||
className={styles.add}
|
className={styles.add}
|
||||||
aria-label={`Add ${example.name}`}
|
aria-label={`Add ${example.name}`}
|
||||||
onClick={() => handleAdd(example)}
|
onClick={() => handleAdd(example)}
|
||||||
>
|
>
|
||||||
<Icon name="add" /> Add
|
<Icon name="add" /> Add
|
||||||
</button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -12,41 +12,19 @@
|
|||||||
padding: var(--space-3) var(--space-2);
|
padding: var(--space-3) var(--space-2);
|
||||||
border-right: var(--border-width) solid var(--border);
|
border-right: var(--border-width) solid var(--border);
|
||||||
background: var(--layer-01);
|
background: var(--layer-01);
|
||||||
|
/* The strip sits on --layer-01, so the IconButtons' hover fill steps up. */
|
||||||
|
--control-hover-fill: var(--layer-02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
/* The controls are shared IconButtons; only the toggle-on state is local.
|
||||||
display: flex;
|
Shown pane: the toggle is "on" — full-strength glyph on a filled chip.
|
||||||
align-items: center;
|
(.strip prefix outranks the primitive's base background/color.) */
|
||||||
justify-content: center;
|
/* TODO: the pressed chip (--layer-02 fill) is identical to the IconButton hover
|
||||||
width: 32px;
|
fill on this surface, so hovering an off toggle looks the same as an on toggle
|
||||||
height: 32px;
|
at rest — the pressed state needs its own cue (docs/ux-second-pass.md). */
|
||||||
padding: 0;
|
.strip .pressed {
|
||||||
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 {
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--layer-02);
|
background: var(--layer-02);
|
||||||
border-color: var(--border-strong);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { useRef, useState } from 'react';
|
|||||||
import { openModal } from '../modals/ModalCoordinator';
|
import { openModal } from '../modals/ModalCoordinator';
|
||||||
import { usePanesStore, type PaneName } from '../stores/PanesStore';
|
import { usePanesStore, type PaneName } from '../stores/PanesStore';
|
||||||
import { Icon, type IconName } from './Icon';
|
import { Icon, type IconName } from './Icon';
|
||||||
|
import { IconButton } from './IconButton';
|
||||||
import styles from './PaneToggleStrip.module.css';
|
import styles from './PaneToggleStrip.module.css';
|
||||||
|
|
||||||
interface PaneItem {
|
interface PaneItem {
|
||||||
@@ -105,14 +106,13 @@ export function PaneToggleStrip() {
|
|||||||
className={styles.strip}
|
className={styles.strip}
|
||||||
>
|
>
|
||||||
{PANES.map((item, i) => (
|
{PANES.map((item, i) => (
|
||||||
<button
|
<IconButton
|
||||||
key={item.pane}
|
key={item.pane}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
refs.current[i] = el;
|
refs.current[i] = el;
|
||||||
}}
|
}}
|
||||||
type="button"
|
|
||||||
aria-pressed={visible[item.pane]}
|
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
|
// 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
|
// hidden pane's <section>, so emitting aria-controls then would leave a
|
||||||
// dangling IDREF (invalid, even if AT ignores it). The association is
|
// 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.
|
// stable (aria-label) per APG's toggle-button rule.
|
||||||
title={`${visible[item.pane] ? 'Hide' : 'Show'} ${item.label.toLowerCase()}`}
|
title={`${visible[item.pane] ? 'Hide' : 'Show'} ${item.label.toLowerCase()}`}
|
||||||
tabIndex={focusIndex === i ? 0 : -1}
|
tabIndex={focusIndex === i ? 0 : -1}
|
||||||
className={[styles.button, visible[item.pane] && styles.pressed]
|
className={visible[item.pane] ? styles.pressed : undefined}
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
togglePane(item.pane, panesInner());
|
togglePane(item.pane, panesInner());
|
||||||
setFocusIndex(i);
|
setFocusIndex(i);
|
||||||
@@ -134,21 +132,19 @@ export function PaneToggleStrip() {
|
|||||||
onKeyDown={(e) => onKeyDown(e, i)}
|
onKeyDown={(e) => onKeyDown(e, i)}
|
||||||
>
|
>
|
||||||
<Icon name={item.icon} />
|
<Icon name={item.icon} />
|
||||||
</button>
|
</IconButton>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className={styles.divider} aria-hidden="true" />
|
<div className={styles.divider} aria-hidden="true" />
|
||||||
|
|
||||||
<button
|
<IconButton
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
refs.current[datasetsIndex] = el;
|
refs.current[datasetsIndex] = el;
|
||||||
}}
|
}}
|
||||||
type="button"
|
label="Datasets"
|
||||||
aria-label="Datasets"
|
|
||||||
aria-keyshortcuts="Meta+K Control+K"
|
aria-keyshortcuts="Meta+K Control+K"
|
||||||
title="Datasets (⌘/Ctrl+K)"
|
title="Datasets (⌘/Ctrl+K)"
|
||||||
tabIndex={focusIndex === datasetsIndex ? 0 : -1}
|
tabIndex={focusIndex === datasetsIndex ? 0 : -1}
|
||||||
className={styles.button}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
openModal('datasets');
|
openModal('datasets');
|
||||||
setFocusIndex(datasetsIndex);
|
setFocusIndex(datasetsIndex);
|
||||||
@@ -156,7 +152,7 @@ export function PaneToggleStrip() {
|
|||||||
onKeyDown={(e) => onKeyDown(e, datasetsIndex)}
|
onKeyDown={(e) => onKeyDown(e, datasetsIndex)}
|
||||||
>
|
>
|
||||||
<Icon name="dataset" />
|
<Icon name="dataset" />
|
||||||
</button>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,18 +8,22 @@
|
|||||||
*/
|
*/
|
||||||
.group {
|
.group {
|
||||||
display: inline-flex;
|
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);
|
border: var(--border-width) solid var(--border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.option {
|
.option {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
border: none;
|
border: none;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: 0 var(--space-3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
background var(--dur-fast) var(--ease),
|
background var(--dur-fast) var(--ease),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
height: 32px;
|
height: var(--control-height);
|
||||||
padding: 0 var(--space-3);
|
padding: 0 var(--space-3);
|
||||||
border: var(--border-width) solid var(--border-strong);
|
border: var(--border-width) solid var(--border-strong);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
|
|||||||
@@ -5,38 +5,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Gear trigger — a subtle icon button matching the modal-shell close affordance. */
|
/* The gear trigger is the shared IconButton primitive (arch 09 §4). */
|
||||||
.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 disclosed panel — portaled to <body>, positioned `fixed` from the gear's
|
/* 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. */
|
rect (top/left|right set inline) so it escapes the panes' overflow clipping. */
|
||||||
@@ -66,7 +35,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-4);
|
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 {
|
.row + .row {
|
||||||
@@ -109,7 +79,8 @@
|
|||||||
|
|
||||||
.number,
|
.number,
|
||||||
.text {
|
.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: var(--border-width) solid var(--border-strong);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { type ReactNode } from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { usePopover } from '../hooks/usePopover';
|
import { usePopover } from '../hooks/usePopover';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
|
import { IconButton } from './IconButton';
|
||||||
import styles from './SettingsPopover.module.css';
|
import styles from './SettingsPopover.module.css';
|
||||||
|
|
||||||
/** Focus the first interactive control on open (any kind — these are forms). */
|
/** Focus the first interactive control on open (any kind — these are forms). */
|
||||||
@@ -55,18 +56,15 @@ export function SettingsPopover({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
<button
|
<IconButton
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
label={label}
|
||||||
className={styles.gear}
|
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-controls={id}
|
aria-controls={id}
|
||||||
aria-label={label}
|
|
||||||
title={label}
|
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
>
|
>
|
||||||
<Icon name="settings" />
|
<Icon name="settings" />
|
||||||
</button>
|
</IconButton>
|
||||||
{open &&
|
{open &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div ref={setPopNode} id={id} className={styles.pop} role="group" aria-label={title}>
|
<div ref={setPopNode} id={id} className={styles.pop} role="group" aria-label={title}>
|
||||||
|
|||||||
@@ -37,55 +37,14 @@
|
|||||||
margin: var(--space-4);
|
margin: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Both creators are the shared Button primitive (primary / secondary, lg);
|
||||||
|
locally they only divide the row's width. */
|
||||||
.createNew {
|
.createNew {
|
||||||
flex: 1 1 auto;
|
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 {
|
.createGhost {
|
||||||
flex: 0 1 auto;
|
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
|
/* 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
|
intrinsic content width (~20ch), which otherwise overflowed the slot and slid
|
||||||
over the Sort trigger at the pane minimum. */
|
over the Sort trigger at the pane minimum. */
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 32px;
|
height: var(--control-height);
|
||||||
/* Room for the leading magnifier and the trailing clear button. */
|
/* Room for the leading magnifier and the trailing clear button. */
|
||||||
padding: 0 32px 0 32px;
|
padding: 0 32px 0 32px;
|
||||||
border: var(--border-width) solid var(--border-strong);
|
border: var(--border-width) solid var(--border-strong);
|
||||||
@@ -161,33 +120,11 @@
|
|||||||
appearance: none;
|
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 {
|
.searchClear {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: var(--space-2);
|
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). */
|
/* Visually-hidden live region for the search result count (council SEARCH). */
|
||||||
@@ -341,33 +278,27 @@
|
|||||||
color: var(--text-placeholder);
|
color: var(--text-placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Row trash — a shared IconButton (sm); locally it only handles the row-hover
|
||||||
|
reveal and the destructive hover colour. */
|
||||||
.delete {
|
.delete {
|
||||||
flex: 0 0 auto;
|
|
||||||
align-self: center;
|
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;
|
opacity: 0;
|
||||||
transition:
|
transition:
|
||||||
opacity var(--dur-fast) var(--ease),
|
opacity var(--dur-fast) var(--ease),
|
||||||
|
background var(--dur-fast) var(--ease),
|
||||||
color var(--dur-fast) var(--ease);
|
color var(--dur-fast) var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item:hover .delete,
|
.item:hover .delete,
|
||||||
.delete:focus-visible {
|
.item .delete:focus-visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Destructive intent reddens on hover AND keyboard focus, not colour-by-mouse-only
|
/* Destructive intent reddens on hover AND keyboard focus, not colour-by-mouse-only
|
||||||
(arch 10 — destructive controls signal danger on hover/focus). */
|
(arch 10 — destructive controls signal danger on hover/focus). The .item prefix
|
||||||
.delete:hover,
|
outranks the IconButton primitive's own hover colour. */
|
||||||
.delete:focus-visible {
|
.item .delete:hover,
|
||||||
|
.item .delete:focus-visible {
|
||||||
color: var(--support-error);
|
color: var(--support-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,6 +316,8 @@
|
|||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
border-top: var(--border-width) solid var(--border);
|
border-top: var(--border-width) solid var(--border);
|
||||||
background: var(--layer-01);
|
background: var(--layer-01);
|
||||||
|
/* The panel sits on --layer-01, so its controls' hover fill steps up. */
|
||||||
|
--control-hover-fill: var(--layer-02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metaField {
|
.metaField {
|
||||||
@@ -418,6 +351,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.metaName {
|
.metaName {
|
||||||
|
height: var(--control-height);
|
||||||
|
padding: 0 var(--space-3);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,30 +430,7 @@
|
|||||||
margin-top: var(--space-1);
|
margin-top: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Shared Buttons (secondary / danger-outline); locally they only split the row. */
|
||||||
.metaAction {
|
.metaAction {
|
||||||
flex: 1 1 auto;
|
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 { notify } from '../stores/NotificationStore';
|
||||||
import { selectActiveSnippet, useSnippetStore } from '../stores/SnippetStore';
|
import { selectActiveSnippet, useSnippetStore } from '../stores/SnippetStore';
|
||||||
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
||||||
|
import { Button } from './Button';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
|
import { IconButton } from './IconButton';
|
||||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||||
import { SettingRow, SettingsPopover, TextControl } from './SettingsPopover';
|
import { SettingRow, SettingsPopover, TextControl } from './SettingsPopover';
|
||||||
import { SortControl } from './SortControl';
|
import { SortControl } from './SortControl';
|
||||||
@@ -162,16 +164,12 @@ function SnippetMeta({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.metaActions}>
|
<div className={styles.metaActions}>
|
||||||
<button type="button" className={styles.metaAction} onClick={onDuplicate}>
|
<Button className={styles.metaAction} onClick={onDuplicate}>
|
||||||
Duplicate
|
Duplicate
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="danger-outline" className={styles.metaAction} onClick={onDelete}>
|
||||||
type="button"
|
|
||||||
className={`${styles.metaAction} ${styles.metaDanger}`}
|
|
||||||
onClick={onDelete}
|
|
||||||
>
|
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -260,7 +258,9 @@ export function SnippetLibrary() {
|
|||||||
pane is dragged narrow the labels collapse to icons (a @container
|
pane is dragged narrow the labels collapse to icons (a @container
|
||||||
query); `aria-label` keeps each accessible name. */}
|
query); `aria-label` keeps each accessible name. */}
|
||||||
<div className={styles.createRow}>
|
<div className={styles.createRow}>
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
className={styles.createNew}
|
className={styles.createNew}
|
||||||
onClick={() => openModal('chartBuilder')}
|
onClick={() => openModal('chartBuilder')}
|
||||||
aria-label="Build Chart"
|
aria-label="Build Chart"
|
||||||
@@ -268,8 +268,9 @@ export function SnippetLibrary() {
|
|||||||
>
|
>
|
||||||
<Icon name="chart" />
|
<Icon name="chart" />
|
||||||
<span className={styles.createNewLabel}>Build Chart</span>
|
<span className={styles.createNewLabel}>Build Chart</span>
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
size="lg"
|
||||||
className={styles.createGhost}
|
className={styles.createGhost}
|
||||||
onClick={() => createSnippet()}
|
onClick={() => createSnippet()}
|
||||||
aria-label="New JSON snippet"
|
aria-label="New JSON snippet"
|
||||||
@@ -277,7 +278,7 @@ export function SnippetLibrary() {
|
|||||||
>
|
>
|
||||||
<Icon name="add" />
|
<Icon name="add" />
|
||||||
<span className={styles.createNewLabel}>New JSON snippet</span>
|
<span className={styles.createNewLabel}>New JSON snippet</span>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search + Sort controls, pinned above the list (spec §02; council
|
{/* Search + Sort controls, pinned above the list (spec §02; council
|
||||||
@@ -300,15 +301,14 @@ export function SnippetLibrary() {
|
|||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
{searching && (
|
{searching && (
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
size="sm"
|
||||||
className={styles.searchClear}
|
className={styles.searchClear}
|
||||||
aria-label="Clear search"
|
label="Clear search"
|
||||||
title="Clear search"
|
|
||||||
onClick={clearSearch}
|
onClick={clearSearch}
|
||||||
>
|
>
|
||||||
<Icon name="close" />
|
<Icon name="close" />
|
||||||
</button>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<SortControl />
|
<SortControl />
|
||||||
@@ -381,14 +381,15 @@ export function SnippetLibrary() {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<IconButton
|
||||||
|
size="sm"
|
||||||
className={styles.delete}
|
className={styles.delete}
|
||||||
aria-label={`Delete ${s.name}`}
|
label={`Delete ${s.name}`}
|
||||||
title="Delete snippet"
|
title="Delete snippet"
|
||||||
onClick={() => void handleDelete(s.id, s.name)}
|
onClick={() => void handleDelete(s.id, s.name)}
|
||||||
>
|
>
|
||||||
<Icon name="delete" />
|
<Icon name="delete" />
|
||||||
</button>
|
</IconButton>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -10,13 +10,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Trigger — shows the current field + direction for recognition (council SORT).
|
/* 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
|
The standard control height, matching the search input it sits beside. */
|
||||||
mismatched 28/32px heights before). */
|
|
||||||
.trigger {
|
.trigger {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
height: 32px;
|
height: var(--control-height);
|
||||||
padding: 0 var(--space-3);
|
padding: 0 var(--space-3);
|
||||||
border: var(--border-width) solid var(--border-strong);
|
border: var(--border-width) solid var(--border-strong);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
|
|||||||
@@ -23,44 +23,12 @@
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Draft/Published toggle styling now lives in SegmentedControl.module.css. */
|
/* Toolbar controls are the shared primitives: Draft/Published is a
|
||||||
|
SegmentedControl, Publish/Revert/Extract are Buttons (arch 09 §4). */
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Collapsible secondary actions (Extract, Revert): label by default, icon-only
|
/* Collapsible secondary actions (Extract, Revert): label by default, icon-only
|
||||||
when the pane is narrow. Both children live in the DOM always; the container
|
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. */
|
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 {
|
.actionIcon {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -73,10 +41,11 @@
|
|||||||
/* Below this pane width the labels would crowd Publish / wrap, so the secondary
|
/* 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 +
|
actions shed their text and become square icon buttons (the accessible name +
|
||||||
title keep them identifiable). 480px is comfortably above the 384px pane
|
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) {
|
@container (max-width: 480px) {
|
||||||
.collapsible {
|
.toolbar .collapsible {
|
||||||
width: 28px;
|
width: var(--control-height);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.collapsible .actionLabel {
|
.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 {
|
.editorWrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { usePreviewStore } from '../stores/PreviewStore';
|
|||||||
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||||
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
|
import { Button } from './Button';
|
||||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||||
import { SelectControl } from './SelectControl';
|
import { SelectControl } from './SelectControl';
|
||||||
import {
|
import {
|
||||||
@@ -239,16 +240,15 @@ function EditorToolbar({
|
|||||||
`title` (and the accessible name) when only the icon shows. Publish — the
|
`title` (and the accessible name) when only the icon shows. Publish — the
|
||||||
one primary action — keeps its label at every width. */}
|
one primary action — keeps its label at every width. */}
|
||||||
{canExtract && (
|
{canExtract && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
className={styles.collapsible}
|
||||||
className={`${styles.action} ${styles.collapsible}`}
|
|
||||||
onClick={() => openModal('extract')}
|
onClick={() => openModal('extract')}
|
||||||
title="Extract inline data into a reusable dataset"
|
title="Extract inline data into a reusable dataset"
|
||||||
aria-label="Extract to Dataset"
|
aria-label="Extract to Dataset"
|
||||||
>
|
>
|
||||||
<Icon name="dataset" className={styles.actionIcon} />
|
<Icon name="dataset" className={styles.actionIcon} />
|
||||||
<span className={styles.actionLabel}>Extract to Dataset</span>
|
<span className={styles.actionLabel}>Extract to Dataset</span>
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<SelectControl
|
<SelectControl
|
||||||
id="editor-config-actions"
|
id="editor-config-actions"
|
||||||
@@ -256,14 +256,12 @@ function EditorToolbar({
|
|||||||
heading="Spec config"
|
heading="Spec config"
|
||||||
options={CONFIG_ACTIONS}
|
options={CONFIG_ACTIONS}
|
||||||
onSelect={handleConfigAction}
|
onSelect={handleConfigAction}
|
||||||
triggerClassName={styles.action}
|
|
||||||
triggerContent="Config"
|
triggerContent="Config"
|
||||||
triggerTitle="Spec config actions — merge the chart theme in, or extract the config out"
|
triggerTitle="Spec config actions — merge the chart theme in, or extract the config out"
|
||||||
disabled={activeId === null || editorView === 'published'}
|
disabled={activeId === null || editorView === 'published'}
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
className={styles.collapsible}
|
||||||
className={`${styles.action} ${styles.collapsible}`}
|
|
||||||
onClick={() => void handleRevert()}
|
onClick={() => void handleRevert()}
|
||||||
disabled={activeId === null || !dirty}
|
disabled={activeId === null || !dirty}
|
||||||
title="Revert draft to the last published version"
|
title="Revert draft to the last published version"
|
||||||
@@ -271,17 +269,16 @@ function EditorToolbar({
|
|||||||
>
|
>
|
||||||
<Icon name="revert" className={styles.actionIcon} />
|
<Icon name="revert" className={styles.actionIcon} />
|
||||||
<span className={styles.actionLabel}>Revert</span>
|
<span className={styles.actionLabel}>Revert</span>
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="primary"
|
||||||
className={`${styles.action} ${styles.publish}`}
|
|
||||||
onClick={handlePublish}
|
onClick={handlePublish}
|
||||||
disabled={activeId === null}
|
disabled={activeId === null}
|
||||||
title="Publish (⌘/Ctrl+S)"
|
title="Publish (⌘/Ctrl+S)"
|
||||||
aria-keyshortcuts="Meta+S Control+S"
|
aria-keyshortcuts="Meta+S Control+S"
|
||||||
>
|
>
|
||||||
Publish
|
Publish
|
||||||
</button>
|
</Button>
|
||||||
<EditorSettings />
|
<EditorSettings />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,27 +18,10 @@
|
|||||||
border-right: var(--border-width) solid var(--border);
|
border-right: var(--border-width) solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A shared primary Button (lg); locally just pinned with a margin. */
|
||||||
.newButton {
|
.newButton {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
margin: var(--space-4);
|
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 {
|
.list {
|
||||||
@@ -136,7 +119,7 @@
|
|||||||
|
|
||||||
.input {
|
.input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 32px;
|
height: var(--control-height);
|
||||||
padding: 0 var(--space-3);
|
padding: 0 var(--space-3);
|
||||||
border: var(--border-width) solid var(--border-strong);
|
border: var(--border-width) solid var(--border-strong);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
@@ -157,60 +140,7 @@
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action {
|
/* Actions are shared Buttons (danger-outline / primary). */
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorMessage {
|
.errorMessage {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from '../stores/CustomThemeStore';
|
} from '../stores/CustomThemeStore';
|
||||||
import { notify } from '../stores/NotificationStore';
|
import { notify } from '../stores/NotificationStore';
|
||||||
import { resnapshot } from '../modals/ModalCoordinator';
|
import { resnapshot } from '../modals/ModalCoordinator';
|
||||||
|
import { Button } from './Button';
|
||||||
import { SelectControl } from './SelectControl';
|
import { SelectControl } from './SelectControl';
|
||||||
import styles from './ThemeBuilderModal.module.css';
|
import styles from './ThemeBuilderModal.module.css';
|
||||||
|
|
||||||
@@ -152,9 +153,9 @@ export function ThemeBuilderModal() {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.builder}>
|
<div className={styles.builder}>
|
||||||
<div className={styles.listPane}>
|
<div className={styles.listPane}>
|
||||||
<button type="button" className={styles.newButton} onClick={handleNew}>
|
<Button variant="primary" size="lg" className={styles.newButton} onClick={handleNew}>
|
||||||
New theme
|
New theme
|
||||||
</button>
|
</Button>
|
||||||
<ul className={styles.list}>
|
<ul className={styles.list}>
|
||||||
{themes.length === 0 && (
|
{themes.length === 0 && (
|
||||||
<li className={styles.empty}>
|
<li className={styles.empty}>
|
||||||
@@ -208,21 +209,16 @@ export function ThemeBuilderModal() {
|
|||||||
triggerTitle="Write one font family into every font slot of the config"
|
triggerTitle="Write one font family into every font slot of the config"
|
||||||
/>
|
/>
|
||||||
<div className={styles.toolbarEnd}>
|
<div className={styles.toolbarEnd}>
|
||||||
<button
|
<Button variant="danger-outline" onClick={() => void handleDelete()}>
|
||||||
type="button"
|
|
||||||
className={`${styles.action} ${styles.danger}`}
|
|
||||||
onClick={() => void handleDelete()}
|
|
||||||
>
|
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="primary"
|
||||||
className={`${styles.action} ${styles.primary}`}
|
|
||||||
disabled={!dirty || parseError !== null}
|
disabled={!dirty || parseError !== null}
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
>
|
>
|
||||||
Save theme
|
Save theme
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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.
|
* 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,
|
* 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
|
* sun when dark) and labels itself for screen readers. Chart + editor follow the
|
||||||
* the shared rule in base.css; chart + editor follow the theme via their own
|
* theme via their own store subscriptions, so flipping the store repaints
|
||||||
* store subscriptions, so flipping the store repaints everything.
|
* everything.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useAppStore } from '../stores/AppStore';
|
import { useAppStore } from '../stores/AppStore';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
import styles from './ThemeToggle.module.css';
|
import { IconButton } from './IconButton';
|
||||||
|
|
||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
const uiTheme = useAppStore((s) => s.uiTheme);
|
const uiTheme = useAppStore((s) => s.uiTheme);
|
||||||
@@ -22,18 +22,16 @@ export function ThemeToggle() {
|
|||||||
const target = uiTheme === 'dark' ? 'light' : 'dark';
|
const target = uiTheme === 'dark' ? 'light' : 'dark';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
|
||||||
className={styles.toggle}
|
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
// Toggle button with a stable name + pressed state (APG; doc §10.6): AT
|
// Toggle button with a stable name + pressed state (APG; doc §10.6): AT
|
||||||
// announces the *current* theme ("Dark theme, pressed"), at parity with the
|
// 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.
|
// icon a sighted user sees — not just the action. `title` keeps the hover hint.
|
||||||
aria-pressed={uiTheme === 'dark'}
|
aria-pressed={uiTheme === 'dark'}
|
||||||
aria-label="Dark theme"
|
label="Dark theme"
|
||||||
title={`Switch to ${target} theme`}
|
title={`Switch to ${target} theme`}
|
||||||
>
|
>
|
||||||
{uiTheme === 'dark' ? <Icon name="sun" size="md" /> : <Icon name="moon" size="md" />}
|
{uiTheme === 'dark' ? <Icon name="sun" size="md" /> : <Icon name="moon" size="md" />}
|
||||||
</button>
|
</IconButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,10 @@
|
|||||||
margin-top: var(--space-3);
|
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 {
|
.action {
|
||||||
padding: var(--space-1) var(--space-3);
|
padding: var(--space-1) var(--space-3);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -167,29 +171,7 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dismiss is the shared IconButton (sm); the class is a layout marker only. */
|
||||||
.close {
|
.close {
|
||||||
flex: none;
|
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,
|
type NotificationKind,
|
||||||
} from '../stores/NotificationStore';
|
} from '../stores/NotificationStore';
|
||||||
import { Icon, type IconName } from './Icon';
|
import { Icon, type IconName } from './Icon';
|
||||||
|
import { IconButton } from './IconButton';
|
||||||
import styles from './Toaster.module.css';
|
import styles from './Toaster.module.css';
|
||||||
|
|
||||||
/** Auto-dismiss delay for the non-critical kinds (ms). Errors/warnings persist. */
|
/** Auto-dismiss delay for the non-critical kinds (ms). Errors/warnings persist. */
|
||||||
@@ -115,14 +116,14 @@ function Toast({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
size="sm"
|
||||||
className={styles.close}
|
className={styles.close}
|
||||||
aria-label="Dismiss notification"
|
label="Dismiss notification"
|
||||||
onClick={handleDismiss}
|
onClick={handleDismiss}
|
||||||
>
|
>
|
||||||
<Icon name="close" />
|
<Icon name="close" />
|
||||||
</button>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
/* Layout */
|
/* Layout */
|
||||||
--header-height: 48px;
|
--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 */
|
/* Motion — productive only; neutralized under prefers-reduced-motion in base.css */
|
||||||
--dur-fast: 70ms;
|
--dur-fast: 70ms;
|
||||||
--dur-fast-2: 110ms;
|
--dur-fast-2: 110ms;
|
||||||
|
|||||||
Reference in New Issue
Block a user