UX second pass: icon-only header, pane-toggle cue, schema columns, facet cleanup

This commit is contained in:
2026-06-13 10:13:40 +03:00
parent e0806bf3db
commit 92bfe888b5
14 changed files with 124 additions and 154 deletions
+5 -2
View File
@@ -210,8 +210,11 @@ UI by swapping one set of values. Borrowed from Carbon's layering model:
action is filled. Worked examples: the **editor toolbar** (Publish is the lone action is filled. Worked examples: the **editor toolbar** (Publish is the lone
primary; Extract/Revert are secondary, and collapse to icons when narrow — see primary; Extract/Revert are secondary, and collapse to icons when narrow — see
[arch 10 §8](10-interaction-and-feedback.md)), and the **header** (Datasets / [arch 10 §8](10-interaction-and-feedback.md)), and the **header** (Datasets /
Import / Export / About are ghost; a divider then sets off the soft-accent Donate Import / Export / About are **icon-only** IconButtons per Carbon's UI-shell
and the ghost theme toggle). header — global actions as a right-aligned icon row — with accessible names
that carry scope, e.g. "Export workspace" vs the preview's per-chart "Export";
a divider then sets off the soft-accent Donate, which keeps its text label —
a solicitation needs the word — and the ghost theme toggle).
- **Hover is variant-specific:** filled buttons (primary/danger) **darken** - **Hover is variant-specific:** filled buttons (primary/danger) **darken**
(`--accent-hover` / a slight brightness drop); outlined/ghost buttons **gain a (`--accent-hover` / a slight brightness drop); outlined/ghost buttons **gain a
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
@@ -231,6 +231,13 @@ last-focused control on re-entry.
- The **Datasets** control is a plain **command button** (no `aria-pressed`) in the _same_ - The **Datasets** control is a plain **command button** (no `aria-pressed`) in the _same_
toolbar — APG permits mixed control types — set off from the toggles by a visual divider (and toolbar — APG permits mixed control types — set off from the toggles by a visual divider (and
optionally a nested `role="group"`), but kept in the roving sequence as its last element. optionally a nested `role="group"`), but kept in the roving sequence as its last element.
- The **pressed (pane-visible) state has its own visual cue**: an inset accent edge bar plus the
full-strength glyph on the filled chip (the activity-bar convention). A fill alone cannot be
the state cue — hover uses the same one-step fill, so a fill-only pressed state is
indistinguishable from hovering an off toggle (the collision class arch 09 names for hover);
and a bar, not a border, keeps the strip boxless per the arch 09 box-discipline rule. The
unlabeled glyph rail itself is the established activity-bar convention (NN/g #4) — toggles
carry tooltips per Carbon's icon-button rule; no visible labels.
- **Focus**: show/hide is only ever initiated **from the strip**, so the activating toggle - **Focus**: show/hide is only ever initiated **from the strip**, so the activating toggle
already holds focus when its pane disappears and **retains it** (the button stays, flips to already holds focus when its pane disappears and **retains it** (the button stays, flips to
not-pressed) — no orphaned focus, no restoration logic. The strip is **never itself hidden**, not-pressed) — no orphaned focus, no restoration logic. The strip is **never itself hidden**,
@@ -252,7 +259,10 @@ the Draft/Published view) is a **radio group**, never a row of `aria-pressed` to
**toggle switch** was also weighed and rejected: APG defines `role="switch"` as on/off of a **toggle switch** was also weighed and rejected: APG defines `role="switch"` as on/off of a
**single** setting, but Draft/Published selects between two **named peer views** with no **single** setting, but Draft/Published selects between two **named peer views** with no
natural "on" side — a radio group is the right semantics. Reserve the switch for genuine natural "on" side — a radio group is the right semantics. Reserve the switch for genuine
on/off settings. Consulted via /council → APG switch / radio-group / tabs.)_ on/off settings. Consulted via /council → APG switch / radio-group / tabs.)_ A per-option
`title` (tooltip for a terse label) doubles as the option's accessible name, so it must
**lead with the visible label** ("Original — the natural size from the spec"), or
speech-input users can't address the control they see (WCAG 2.5.3 label-in-name).
**Resolved — selectable lists.** A row the user selects must be a real `<button>` (or a **Resolved — selectable lists.** A row the user selects must be a real `<button>` (or a
proper option), not a click handler on `<li>` (mouse-only, no keyboard, no role). It is proper option), not a click handler on `<li>` (mouse-only, no keyboard, no role). It is
@@ -453,6 +463,14 @@ in the keyboard order, never a heading. **The boundary is set where the list is
module that decides the option order marks the divider-carrying option (e.g. module that decides the option order marks the divider-carrying option (e.g.
`chartThemeOptions` stamps the first preset); a consumer must never recompute a group `chartThemeOptions` stamps the first preset); a consumer must never recompute a group
boundary by index arithmetic, which silently misplaces when the producer's ordering changes. boundary by index arithmetic, which silently misplaces when the producer's ordering changes.
A value list may carry an **action row** (the VS Code theme-picker pattern — e.g. "Edit
themes…" inside the chart-theme picker): permissible because options are real buttons, not
listbox options (APG's no-interactive-children listbox constraint doesn't apply); the row's
label ends in "…" (the opens-further-UI convention) and sets the option's `hasPopup` so AT
hears `aria-haspopup` — no special visual styling beyond an adjacent group divider. The
default trigger caps its value label at **16ch with ellipsis**, so a long value (a preset or
user-named theme) can't blow out a crowded pane header; the full label remains in the open
list and the trigger's accessible name.
The single-open registry means **disclosures cannot nest**: a SelectControl inside a The single-open registry means **disclosures cannot nest**: a SelectControl inside a
settings popover would close — and unmount — its own parent on open. A control that needs settings popover would close — and unmount — its own parent on open. A control that needs
its own popover sits beside the gear in the pane header, never inside the panel. its own popover sits beside the gear in the pane header, never inside the panel.
@@ -494,6 +512,15 @@ Datasets (⌘/Ctrl+K), or check the dataset name in your spec."_ — instead of
must match the actual cause (NN/g #9, GOV.UK error-message). The thrown must match the actual cause (NN/g #9, GOV.UK error-message). The thrown
`DatasetNotFoundError` carries `datasetName` so the surface can name it. `DatasetNotFoundError` carries `datasetName` so the surface can name it.
**Resolved — no affordance for unbuilt features.** A visible placeholder promising future
functionality (the Chart Builder's dashed "+ row facet · LATER" shelf slots, removed
2026-06-13) is roadmap language shipped to users: it speaks our planning vocabulary, not
theirs (NN/g #2), and competes with the working controls around it (NN/g #8). A gated or
later-phase feature gets **no placeholder, tag, or disabled stub** until it ships — disabled
states are for _temporarily unavailable_ actions, not unbuilt ones. Design the layout so the
future control can land without rework (e.g. shelf slots are row-shaped), and keep the
roadmap in `docs/`, not in the UI.
**Resolved — service-worker update prompt & persistent storage (web.dev seat).** The build **Resolved — service-worker update prompt & persistent storage (web.dev seat).** The build
uses `registerType: 'prompt'`, so a new service worker waits and never takes over a running uses `registerType: 'prompt'`, so a new service worker waits and never takes over a running
session on its own — the app **must** tell the user, or "ask before updating" silently means session on its own — the app **must** tell the user, or "ask before updating" silently means
+9 -8
View File
@@ -25,18 +25,19 @@ Behavior:
A fixed header spans the top of the app. A fixed header spans the top of the app.
- **Left side**: the app icon, the app title ("Astrolabe"), and a version badge showing the current app version. - **Left side**: the app icon, the app title ("Astrolabe"), and a version badge showing the current app version.
- **Right side**: a row of text entry points. Each opens a destination: - **Right side**: a row of **icon-only** utility entry points (each with a tooltip and an accessible name), then — set off by a divider — the text-labelled Donate button and the theme toggle. Each opens a destination:
| Entry point | Opens | | Entry point | Opens |
| ----------- | -------------------------------------------------------------------------------------------------------------------- | | ---------------- | --------------------------------------------------------------------------------------------------------------- |
| Import | A file-picker dialog to choose a previously exported file; the chosen file is imported (see _Import & Export_). | | Datasets | The Datasets manager modal (see _Datasets_). |
| Export | Immediately produces a downloaded file containing all snippets and datasets (see _Import & Export_). | | Import workspace | A file-picker dialog to choose a previously exported file; the chosen file is imported (see _Import & Export_). |
| Datasets | The Datasets manager modal (see _Datasets_). | | Export workspace | Immediately produces a downloaded file containing all snippets and datasets (see _Import & Export_). |
| About | The About & Help modal (keyboard shortcuts, about, and privacy information). Labelled "About"; privacy lives inside. | | About | The About & Help modal (keyboard shortcuts, about, and privacy information); privacy lives inside. |
| Donate | The Donate modal. | | Donate | The Donate modal. The one text-labelled, soft-accent button — the solicitation keeps its word. |
Notes: Notes:
- The utilities are icon-only so the header reads as quiet chrome (Carbon UI-shell header: global actions are a right-aligned icon row). The accessible names scope the workspace-level Import/Export ("Export workspace") apart from the preview pane's per-chart "Export" — the two were previously both labelled "Export" at once.
- Import and Export act directly (file dialog / file download); they do not open in-app modals. - Import and Export act directly (file dialog / file download); they do not open in-app modals.
- The Datasets, About, and Donate entry points each open a modal (see _Modal System_). - The Datasets, About, and Donate entry points each open a modal (see _Modal System_).
- **Settings are not a header entry point.** A design review (see _Settings_) distributed - **Settings are not a header entry point.** A design review (see _Settings_) distributed
+8 -59
View File
@@ -8,65 +8,9 @@ record the resolution into the contract (`docs/architecture/09`+`10` and the rel
## Open ## Open
- **Two visible "Export" buttons** (`App.tsx` header — workspace export; _(empty — the 2026-06-13 batch resolved all eleven open items; resolutions live in
`ChartExport.tsx` in the preview header — per-chart export). Same label, spec §01B/§02/§03D, arch 09 §5 (header worked example), and arch 10 §5 (toggle-strip
different scope, both visible at once. Candidate fix: rename one ("Export pressed cue, SelectControl action row + trigger cap, no-affordance-for-unbuilt-features).)_
chart" / "Export workspace") or differentiate via grouping.
- **"LATER" facet placeholders in Chart Builder shelves**
(`ChartBuilderModal.module.css``.facetSlot`/`.facetTag`). Internal roadmap
language shipped to users. Either hide the affordance until faceting works or
write user-facing copy.
- **Default snippet names are timestamps** ("Snippet 2026-06-10 18:04:37") — the
library reads like a downloads folder next to builder-generated names ("Bar
chart of count by Ship Mode"). Candidate: derive a content-based name from the
spec (mark + encoded fields) when the user never renames.
- **Fit-mode tab labels** ("Original / Width / Height / Full", `LivePreview.tsx`)
are terse for a first-time user; tooltips likely suffice.
- **Donate is the most saturated element on screen** (header soft-accent wash) —
visually outranks Publish/Build Chart. Decide whether intentional.
- **Left rail affordance** (`PaneToggleStrip.tsx`): three unlabeled glyphs + a
tall empty strip; pane toggles aren't discoverable without hovering.
- **Pane toggle pressed state = hover state** (`PaneToggleStrip.module.css`).
The pressed chip is a `--layer-02` fill — the same fill the strip's IconButtons
use on hover — so hovering an off toggle looks identical to an on toggle at
rest. (The pressed chip's `--border-strong` border used to disambiguate; it was
dropped in the box-discipline pass.) Candidate cues: a filled/accent glyph,
an inset edge marker, or restoring a border for the on state only.
- **Dataset detail schema density** (`DatasetsModal.tsx`): one field per row
makes a 25-column dataset a long scroll before the preview table; a denser
two-column field list would fit overview + schema + preview on one screen.
- **Chart theme picker placement & header crowding** (`LivePreview.tsx` — ChartThemeControl).
The picker sits in the preview header because nesting a SelectControl inside the
PreviewSettings popover is impossible today (one-open-popover registry: the select would
close/unmount its own parent). Header now holds Fit + theme + export + gear; at narrow
pane widths the long trigger labels ("FiveThirtyEight", "Urban Institute") may crowd it.
Council questions: does the picker deserve header prominence (the "transform your chart"
showcase) or settings-cluster placement (a persistent global pref); should the popover
registry learn nesting; should the preset block also carry a group _heading_ (a
divider now bounds it — 2026-06-12, user-decided; `SelectControl` `dividerBefore`)?
- **"Edit themes…" action row inside the value picker** (`LivePreview.tsx`
ChartThemeControl). A non-value action lives inside a single-select disclosure (the
VS Code theme-picker pattern), placed after the custom-themes block and before the
preset roster (first-use feedback: at the very bottom it was invisible without
scrolling). The ours-vs-presets divider (2026-06-12) now bounds it below; remaining
council questions: should the action row itself be styled distinctly from the value
rows; is a mid-list row that opens a modal instead of selecting surprising to AT
users?
- **Theme Builder config editor is a plain textarea** (`ThemeBuilderModal.tsx`). Monaco
(with the Vega-Lite config schema for completions) would match the main editor but is
heavy inside a modal and untested in that mounting. Revisit whether the builder deserves
a Monaco instance, and whether the gallery's canvas charts need text alternatives
beyond the per-card captions.
## Deferred (not design debts, revisit on demand) ## Deferred (not design debts, revisit on demand)
@@ -74,3 +18,8 @@ record the resolution into the contract (`docs/architecture/09`+`10` and the rel
be a progressive enhancement on top of the chooser model, not a replacement. Revisit if be a progressive enhancement on top of the chooser model, not a replacement. Revisit if
real usage asks for it (likely alongside Phase 4 faceting, where drag-to-shelf reads most real usage asks for it (likely alongside Phase 4 faceting, where drag-to-shelf reads most
naturally). naturally).
- **Theme Builder config editor stays a plain textarea** (decided 2026-06-13) — a second
Monaco mount is heavy inside a modal for an occasional surface; the parse error is the
feedback channel that matters. Revisit only if real usage asks for config completions.
The gallery's per-card captions stand as the canvas charts' text alternative.
+19 -23
View File
@@ -6,6 +6,7 @@ import { ModalShell } from './components/ModalShell';
import { Onboarding } from './components/Onboarding'; import { Onboarding } from './components/Onboarding';
import { PaneSplitHandle } from './components/PaneSplitHandle'; import { PaneSplitHandle } from './components/PaneSplitHandle';
import { Icon } from './components/Icon'; import { Icon } from './components/Icon';
import { IconButton } from './components/IconButton';
import { PaneToggleStrip } from './components/PaneToggleStrip'; import { PaneToggleStrip } from './components/PaneToggleStrip';
import { ResizeHandle } from './components/ResizeHandle'; import { ResizeHandle } from './components/ResizeHandle';
import { SnippetLibrary } from './components/SnippetLibrary'; import { SnippetLibrary } from './components/SnippetLibrary';
@@ -77,46 +78,41 @@ export function App() {
<span className={styles.version}>v{__APP_VERSION__}</span> <span className={styles.version}>v{__APP_VERSION__}</span>
<span className={styles.spacer} /> <span className={styles.spacer} />
{/* Header actions establish a hierarchy (Carbon button/usage one {/* Header actions establish a hierarchy (Carbon button/usage one
high-emphasis action per region; utilities in dashboards/toolbars read high-emphasis action per region; Carbon UI-shell header global
better at low emphasis). The utilities are ghost (borderless) buttons actions are a right-aligned row of icon-only buttons). The utilities
with a leading icon for scanning; a divider then sets off Donate the are IconButtons tooltips carry the full action, accessible names
one solicitation, given a soft-accent wash and the theme toggle. */} scope it ("Export workspace" vs the preview pane's per-chart
<Button "Export") then a divider sets off Donate the one solicitation,
variant="ghost" given a soft-accent wash and the theme toggle. */}
<IconButton
label="Datasets"
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 </IconButton>
</Button> <IconButton
<Button label="Import workspace"
variant="ghost"
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 </IconButton>
</Button> <IconButton
{/* TODO: two controls labelled "Export" are visible at once (this label="Export workspace"
workspace export + the preview pane's per-chart export) rename or
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 </IconButton>
</Button> <IconButton
<Button label="About"
variant="ghost"
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 </IconButton>
</Button>
<span className={styles.headerDivider} aria-hidden="true" /> <span className={styles.headerDivider} aria-hidden="true" />
<Button variant="soft-accent" onClick={() => openModal('donate')} title="Support Astrolabe"> <Button variant="soft-accent" onClick={() => openModal('donate')} title="Support Astrolabe">
Donate Donate
+3 -1
View File
@@ -70,7 +70,9 @@ export function AboutModal() {
yourself. yourself.
</li> </li>
<li>After the first load, the app works fully offline.</li> <li>After the first load, the app works fully offline.</li>
<li>Use Import / Export to move your library between devices.</li> <li>
Use the header&rsquo;s import / export buttons to move your library between devices.
</li>
</ul> </ul>
</section> </section>
</div> </div>
@@ -1007,25 +1007,3 @@
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
/* Reserved faceting placeholder (Phase 4) — non-interactive, signals where it'll live. */
.facetSlot {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 150px;
min-height: var(--control-height);
padding: var(--space-1) var(--space-3);
border: var(--border-width) dashed var(--border);
color: var(--text-placeholder);
font-size: 12px;
user-select: none;
}
.facetTag {
padding: 0 var(--space-2);
border: var(--border-width) solid var(--border);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
+4 -17
View File
@@ -620,24 +620,13 @@ function MarksCard() {
); );
} }
/** A reserved, non-interactive shelf slot for faceting (small multiples) Phase 4.
* Shown so the layout telegraphs where row/column faceting will live. */
// TODO: the "later" tag is internal roadmap language shown to users — hide the
// affordance until faceting ships, or reword for users (docs/ux-second-pass.md).
function FacetSlot({ kind }: { kind: 'column' | 'row' }) {
return (
<div className={styles.facetSlot} title="Faceting → small multiples (coming later)">
<span>+ {kind} facet</span>
<span className={styles.facetTag}>later</span>
</div>
);
}
/** /**
* The on-chart **Columns** (X) and **Rows** (Y) shelves stacked above the preview, * The on-chart **Columns** (X) and **Rows** (Y) shelves stacked above the preview,
* Tableau-style position is a property of the chart, so its controls sit on the * Tableau-style position is a property of the chart, so its controls sit on the
* chart. Each shelf holds the axis slot plus a reserved faceting placeholder. A Swap * chart. A Swap X/Y action flips the two axes. The slot rows are shelf-shaped so
* X/Y action flips the two axes. * Phase 4 faceting can add a second slot per shelf without a layout change, but
* no placeholder ships before the feature does (an affordance promising unbuilt
* functionality is a promise, not a feature NN/g #2/#8).
*/ */
function OnChartShelves() { function OnChartShelves() {
const swapXY = useChartBuilderStore((s) => s.swapXY); const swapXY = useChartBuilderStore((s) => s.swapXY);
@@ -653,14 +642,12 @@ function OnChartShelves() {
<span className={styles.shelfName}>Columns</span> <span className={styles.shelfName}>Columns</span>
<div className={styles.shelfSlots}> <div className={styles.shelfSlots}>
<ChannelSlot channel="x" hint="Add a field for the X axis" /> <ChannelSlot channel="x" hint="Add a field for the X axis" />
<FacetSlot kind="column" />
</div> </div>
</div> </div>
<div className={styles.shelfStrip}> <div className={styles.shelfStrip}>
<span className={styles.shelfName}>Rows</span> <span className={styles.shelfName}>Rows</span>
<div className={styles.shelfSlots}> <div className={styles.shelfSlots}>
<ChannelSlot channel="y" hint="Add a field for the Y axis" /> <ChannelSlot channel="y" hint="Add a field for the Y axis" />
<FacetSlot kind="row" />
</div> </div>
</div> </div>
</div> </div>
+11 -5
View File
@@ -235,12 +235,17 @@
color: var(--text); color: var(--text);
} }
/* Two-up multi-column flow (min 220px per column, so a narrow pane falls back
to one): a wide schema reads down-then-over and a 25-field dataset no longer
buries the preview table below a full-screen scroll (NN/g #8;
docs/ux-second-pass.md resolution 2026-06-13). */
.columns { .columns {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
display: flex; columns: 220px 2;
flex-direction: column; column-gap: 0;
column-rule: var(--border-width) solid var(--border);
border: var(--border-width) solid var(--border); border: var(--border-width) solid var(--border);
} }
@@ -250,10 +255,11 @@
justify-content: space-between; justify-content: space-between;
gap: var(--space-3); gap: var(--space-3);
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
} break-inside: avoid;
/* Hairline between rows; the negative margin tucks each column's first border
.column + .column { under the container border (a `+`-sibling border would double there). */
border-top: var(--border-width) solid var(--border); border-top: var(--border-width) solid var(--border);
margin-top: calc(-1 * var(--border-width));
} }
.columnName { .columnName {
+15 -9
View File
@@ -41,10 +41,12 @@ import styles from './LivePreview.module.css';
/** The four fit modes in display order (spec §04 → Fit / Sizing Modes). */ /** The four fit modes in display order (spec §04 → Fit / Sizing Modes). */
const FIT_OPTIONS: ReadonlyArray<SegmentedOption<FitMode>> = [ const FIT_OPTIONS: ReadonlyArray<SegmentedOption<FitMode>> = [
{ value: 'default', label: 'Original' }, // `title` doubles as the accessible name, so it leads with the visible label
{ value: 'width', label: 'Width' }, // (WCAG 2.5.3 label-in-name; see SegmentedOption.title).
{ value: 'height', label: 'Height' }, { value: 'default', label: 'Original', title: 'Original — the natural size from the spec' },
{ value: 'full', label: 'Full' }, { value: 'width', label: 'Width', title: 'Width — fit to the pane width' },
{ value: 'height', label: 'Height', title: 'Height — fit to the pane height' },
{ value: 'full', label: 'Full', title: 'Full — fill the pane' },
]; ];
/** /**
@@ -81,9 +83,11 @@ function FitControl() {
* snippet's own `config` still overrides it property by property. Lives in the * snippet's own `config` still overrides it property by property. Lives in the
* header, not inside PreviewSettings: SelectControl and SettingsPopover share * header, not inside PreviewSettings: SelectControl and SettingsPopover share
* the one-open-popover registry, so a select nested in the popover would close * the one-open-popover registry, so a select nested in the popover would close
* (and unmount) its own parent on open. * (and unmount) its own parent on open (the contract: arch 10 §5 a control
* needing its own popover sits beside the gear, never inside the panel). The
* trigger's 16ch label cap keeps long preset/custom names from crowding a
* narrow header.
*/ */
// TODO: header placement/crowding parked for the batched council pass (docs/ux-second-pass.md).
/** Sentinel option that opens the Theme Builder instead of selecting a theme. */ /** Sentinel option that opens the Theme Builder instead of selecting a theme. */
const EDIT_THEMES = 'edit-themes'; const EDIT_THEMES = 'edit-themes';
type ThemePickerValue = ChartThemeSelection | typeof EDIT_THEMES; type ThemePickerValue = ChartThemeSelection | typeof EDIT_THEMES;
@@ -103,14 +107,16 @@ function ChartThemeControl() {
// the entries it manages. The roster boundary itself (the divider) is set by // the entries it manages. The roster boundary itself (the divider) is set by
// chartThemeOptions where the order is decided, so the splice is the only // chartThemeOptions where the order is decided, so the splice is the only
// index this component owns. The first preset is the divider-carrying option, // index this component owns. The first preset is the divider-carrying option,
// so splicing right before it needs no count arithmetic. // so splicing right before it needs no count arithmetic. The row needs no
// TODO: action row inside a value picker (visual separation? AT surprise?) // special styling: SelectControl options are real buttons (not listbox
// parked for the batched council pass (docs/ux-second-pass.md). // options), the "…" is the opens-further-UI convention, and `hasPopup`
// announces the dialog to AT.
const firstPreset = list.findIndex((o) => o.dividerBefore); const firstPreset = list.findIndex((o) => o.dividerBefore);
list.splice(firstPreset === -1 ? list.length : firstPreset, 0, { list.splice(firstPreset === -1 ? list.length : firstPreset, 0, {
value: EDIT_THEMES, value: EDIT_THEMES,
label: 'Edit themes…', label: 'Edit themes…',
detail: 'Create and manage custom themes', detail: 'Create and manage custom themes',
hasPopup: 'dialog',
}); });
return list; return list;
}, [customThemes]); }, [customThemes]);
@@ -17,14 +17,17 @@
} }
/* The controls are shared IconButtons; only the toggle-on state is local. /* The controls are shared IconButtons; only the toggle-on state is local.
Shown pane: the toggle is "on" full-strength glyph on a filled chip. Shown pane: the toggle is "on" full-strength glyph on a filled chip plus an
accent edge bar (the activity-bar cue). The bar is the state cue the fill
alone can't be: hover uses the same --layer-02 fill, so a fill-only pressed
state is indistinguishable from hovering an off toggle (arch 09: state
indication needs its own 3:1 cue, never a hover collision). An inset bar,
not a border boxless per the arch 09 box-discipline rule.
(.strip prefix outranks the primitive's base background/color.) */ (.strip prefix outranks the primitive's base background/color.) */
/* TODO: the pressed chip (--layer-02 fill) is identical to the IconButton hover
fill on this surface, so hovering an off toggle looks the same as an on toggle
at rest the pressed state needs its own cue (docs/ux-second-pass.md). */
.strip .pressed { .strip .pressed {
color: var(--text); color: var(--text);
background: var(--layer-02); background: var(--layer-02);
box-shadow: inset 3px 0 0 var(--accent);
} }
.divider { .divider {
@@ -42,6 +42,10 @@
.triggerLabel { .triggerLabel {
min-width: 0; min-width: 0;
/* Cap the trigger so a long value (a preset or user theme name) can't blow
out a crowded pane header at narrow widths; the full label stays available
in the open list and the trigger's aria-label (arch 10 §5). */
max-width: 16ch;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
+7
View File
@@ -41,6 +41,12 @@ export interface SelectControlOption<V extends string> {
* the user's themes and the preset roster. * the user's themes and the preset roster.
*/ */
dividerBefore?: boolean; dividerBefore?: boolean;
/**
* For an action row that opens a dialog instead of picking a value (e.g. the
* theme picker's "Edit themes…"): sets `aria-haspopup` so AT announces that
* activating discloses further UI, matching the "…" label convention.
*/
hasPopup?: 'dialog';
} }
export interface SelectControlProps<V extends string> { export interface SelectControlProps<V extends string> {
@@ -162,6 +168,7 @@ export function SelectControl<V extends string>({
type="button" type="button"
className={`${styles.option} ${selected ? styles.selected : ''}`} className={`${styles.option} ${selected ? styles.selected : ''}`}
aria-current={selected || undefined} aria-current={selected || undefined}
aria-haspopup={o.hasPopup}
onClick={() => choose(o.value)} onClick={() => choose(o.value)}
> >
<span className={styles.optionLabel}> <span className={styles.optionLabel}>
+4 -3
View File
@@ -233,9 +233,10 @@ export function ThemeBuilderModal() {
<label className={styles.label} htmlFor="theme-config"> <label className={styles.label} htmlFor="theme-config">
Config (Vega-Lite JSON) Config (Vega-Lite JSON)
</label> </label>
{/* TODO: plain textarea vs a Monaco instance (config-schema completions), and {/* A plain textarea, deliberately not Monaco: a second Monaco mount
text alternatives for the canvas gallery beyond the captions parked for is heavy inside a modal for an occasional surface, and the parse
the batched council pass (docs/ux-second-pass.md). */} error below is the feedback channel that matters here. Revisit
only if real usage asks for config completions. */}
<textarea <textarea
id="theme-config" id="theme-config"
className={styles.configText} className={styles.configText}