Refine header emphasis, field-on-layer treatment, and narrow-pane control collapse

This commit is contained in:
2026-06-10 14:14:02 +03:00
parent 2410c6e965
commit 68a044752f
18 changed files with 478 additions and 88 deletions
+1 -1
View File
@@ -450,7 +450,7 @@ through `services/transfer.ts` (→ `normalizeImport` / envelope build), no moda
IDB-transaction atomicity would require exposing a raw transaction from `db.ts` — see IDB-transaction atomicity would require exposing a raw transaction from `db.ts` — see
[arch 02](architecture/02-persistence.md)). [arch 02](architecture/02-persistence.md)).
- **A11y:** modal focus trap + return, labelled icon buttons, contrast in both themes (§10) — ✅ in place. - **A11y:** modal focus trap + return, labelled icon buttons, contrast in both themes (§10) — ✅ in place.
- **About & Privacy** and **Donate** modals ~~(§01)~~ ✅ (Donate URL is a placeholder - **About** and **Donate** modals ~~(§01)~~ ✅ (Donate URL is a placeholder
`DONATE_URL` pending the real link). `DONATE_URL` pending the real link).
- **Offline/installable:** the manifest now ships a full SVG icon set (favicon / maskable / - **Offline/installable:** the manifest now ships a full SVG icon set (favicon / maskable /
monochrome) + `theme_color`, and the SW precaches the shell — the app is **installable**. monochrome) + `theme_color`, and the SW precaches the shell — the app is **installable**.
+73 -18
View File
@@ -95,12 +95,13 @@ Color is expressed as **roles**, never raw hexes, so themes can repaint the whol
UI by swapping one set of values. Borrowed from Carbon's layering model: UI by swapping one set of values. Borrowed from Carbon's layering model:
| Role token | Meaning | | Role token | Meaning |
| ---------------------------------------------------- | ----------------------------------------------------------------------------------- | | ---------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `--bg` | App canvas (lowest layer) | | `--bg` | App canvas (lowest layer) |
| `--layer-01` / `--layer-02` | Raised surfaces (panels, cards, popovers) — elevation by lightness step, not shadow | | `--layer-01` / `--layer-02` | Raised surfaces (panels, cards, popovers) — elevation by lightness step, not shadow |
| `--border` / `--border-strong` | Subtle and prominent separators | | `--border` / `--border-strong` | Subtle and prominent separators |
| `--text` / `--text-secondary` / `--text-placeholder` | Text hierarchy | | `--text` / `--text-secondary` / `--text-placeholder` | Text hierarchy |
| `--accent` / `--accent-hover` / `--accent-contrast` | The expressive accent — **swappable**; UI must never hardcode a hue | | `--accent` / `--accent-hover` / `--accent-contrast` | The expressive accent — **swappable**; UI must never hardcode a hue |
| `--accent-soft` / `--accent-soft-hover` | Low-emphasis accent **wash** (accent mixed into `--bg`) for a tinted-but-quiet surface |
| `--focus` | Focus-ring color (defaults to `--accent`) | | `--focus` | Focus-ring color (defaults to `--accent`) |
| `--support-error / -success / -warning / -info` | Status only — color = meaning | | `--support-error / -success / -warning / -info` | Status only — color = meaning |
@@ -113,6 +114,17 @@ UI by swapping one set of values. Borrowed from Carbon's layering model:
warning `#f1c21b`, info `#0043ce` — tuned per theme for contrast. warning `#f1c21b`, info `#0043ce` — tuned per theme for contrast.
- **Contrast:** target WCAG AA (4.5:1 text, 3:1 large/UI). Accent-on-`--bg` and - **Contrast:** target WCAG AA (4.5:1 text, 3:1 large/UI). Accent-on-`--bg` and
text-on-`--accent` must both pass for any shipped theme. text-on-`--accent` must both pass for any shipped theme.
- **Soft accent is derived, not hardcoded.** `--accent-soft` /
`--accent-soft-hover` are `color-mix(in srgb, var(--accent) 1220%, var(--bg))`,
so the wash follows whatever accent + theme is active rather than carrying a
per-accent value. Use it for a surface that should be _noticed_ without competing
with a primary action (the header's **Donate** button).
- **Field-on-layer (Carbon layering).** A field is a layer _on top of_ the surface
it sits on — so it steps **toward `--bg`**, never to a darker gray. On the white
canvas and on a `--layer-01` panel alike, an input reads as **`--bg` fill +
`--border-strong` outline**, not a `--layer-02` fill. Stacking a darker-gray field
on a gray panel (canvas → `--layer-01` panel → `--layer-02` field) muddies into
three indistinct grays — the bug this rule prevents (the snippet metadata panel).
### 3.4 Shape & elevation ### 3.4 Shape & elevation
@@ -138,9 +150,19 @@ 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** - **Buttons:** square, 32px (compact) / 40px (default) tall. Variants: **primary**
(filled `--accent`), **secondary** (bordered), **ghost** (text-only), (filled `--accent`), **secondary** (bordered), **ghost** (text-only, borderless),
**danger** (filled `--support-error`). 600-weight label. Clear hover/active and **soft-accent** (ghost on an `--accent-soft` wash — a low-emphasis solicitation,
a visible focus ring. e.g. Donate), **danger** (filled `--support-error`). 600-weight label. Clear
hover/active and a visible focus ring.
- **Emphasis hierarchy (Carbon button/usage).** A region carries **one**
high-emphasis (primary) button at most; everything else is lower emphasis. In
toolbars/headers full of utilities, the utilities go **ghost** so they recede
behind the work area and read as a row of equals — only the genuine call to
action is filled. Worked examples: the **editor toolbar** (Publish is the lone
primary; Extract/Revert are secondary, and collapse to icons when narrow — see
[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
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
@@ -150,8 +172,10 @@ UI by swapping one set of values. Borrowed from Carbon's layering model:
must step its hover fill up to match. must step its hover fill up to match.
- **Focus ring:** a 2px `--focus` outline (offset 12px). Always visible on - **Focus ring:** a 2px `--focus` outline (offset 12px). 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`, `--layer-01` - **Fields** (text, textarea, select, search): square, 1px `--border-strong`,
fill, accent border + focus ring on focus. Mono font for spec/JSON inputs. **`--bg` fill** (field-on-layer, §3.3 — white on the canvas _and_ on a
`--layer-01` panel; never a darker-gray fill), accent border + focus ring on
focus. Mono font for spec/JSON inputs.
- **List rows** (snippet library): compact, full-row hover (`--layer-01`), - **List rows** (snippet library): compact, full-row hover (`--layer-01`),
active row marked by an accent left-border + `--layer-01` fill, secondary active row marked by an accent left-border + `--layer-01` fill, secondary
metadata in `--text-secondary`. Row-level actions reveal on hover. metadata in `--text-secondary`. Row-level actions reveal on hover.
@@ -233,15 +257,32 @@ Carbon and drawn `fill: currentColor`.
**Core set** — recurring, cross-surface: **Core set** — recurring, cross-surface:
| Meaning | Carbon glyph | Form | Surfaces | | Meaning | Carbon glyph | Form | Surfaces |
| -------------------- | --------------- | --------------------- | ------------------------------------------------- | | -------------------- | --------------- | ------------------------ | -------------------------------------------------------------------------------------------- |
| Close / dismiss | `Close` (✕) | icon-only ⭐ | `ModalShell`, `Toaster` | | Close / dismiss | `Close` (✕) | icon-only ⭐ | `ModalShell`, `Toaster` |
| Theme → dark | `Asleep` (moon) | icon-only ⭐ | `ThemeToggle` (shown when light) | | Theme → dark | `Asleep` (moon) | icon-only ⭐ | `ThemeToggle` (shown when light) |
| Theme → light | `Light` (sun) | icon-only ⭐ | `ThemeToggle` (shown when dark) | | Theme → light | `Light` (sun) | icon-only ⭐ | `ThemeToggle` (shown when dark) |
| References a dataset | `DataTable` | icon + text | Library row marker, Linked-datasets list | | References a dataset | `DataTable` | icon + text | Library row marker, Linked-datasets list, header **Datasets**, editor **Extract**⁴ |
| Add / create-new | `Add` | icon + text | Library "Create New Snippet", Datasets "New …" | | Add / create-new | `Add` | icon + text → icon-only⁴ | Library "Create New Snippet" (collapses to "+" when the pane is narrow), Datasets "New …" |
| Delete | `TrashCan` | icon-only ⭐ (danger) | Library row delete¹ — text "Delete" in the panel² | | Delete | `TrashCan` | icon-only ⭐ (danger) | Library row delete¹ — text "Delete" in the panel² |
| Import workspace | `Upload` | icon + text | Header **Import** (a file is brought into the app) |
| Export workspace | `Download` | icon + text | Header **Export** (the workspace is written out) |
| About / information | `Information` | icon + text | Header **About** |
| Revert draft | `Reset` | icon + text → icon-only⁴ | Editor toolbar **Revert** (restore last published) |
| Live search | `Search` | icon-in-field⁵ | Library search box (leading magnifier; the input's `aria-label`/placeholder names the field) |
| Settings (gear) | `Settings` | icon-only ⭐ | Per-pane settings disclosures (Editor, Preview, Library dates) |
| Unpublished draft | (CSS dot) | status-glyph | Library row (paired with a hidden label) | | Unpublished draft | (CSS dot) | status-glyph | Library row (paired with a hidden label) |
**Pane-toggle set** — the one **custom** sub-family (not single Carbon glyphs): a
panel frame with one of three regions filled, where the filled bar's _position_
encodes which pane it toggles. Icon-only by design — position is the meaning — each
carrying an `aria-label`:
| Meaning | Glyph | Form | Surface |
| ------------------- | ------------------------------ | ------------ | ----------------- |
| Toggle library pane | panel frame, **left** filled | icon-only ⭐ | `PaneToggleStrip` |
| Toggle editor pane | panel frame, **centre** filled | icon-only ⭐ | `PaneToggleStrip` |
| Toggle preview pane | panel frame, **right** filled | icon-only ⭐ | `PaneToggleStrip` |
**Status set** — Carbon's **filled** notification glyphs, one per severity. Unlike **Status set** — Carbon's **filled** notification glyphs, one per severity. Unlike
the outline UI set, these are coloured **by status** (not by surrounding text) and the outline UI set, these are coloured **by status** (not by surrounding text) and
are a deliberate _filled_ sub-family. They add a **redundant, non-colour severity are a deliberate _filled_ sub-family. They add a **redundant, non-colour severity
@@ -257,23 +298,37 @@ notifications/validation as they arrive):
| Success | `CheckmarkFilled` | `--support-success` | `Toaster` (success) | | Success | `CheckmarkFilled` | `--support-success` | `Toaster` (success) |
| Info | `InformationFilled` | `--support-info` | `Toaster` (info) | | Info | `InformationFilled` | `--support-info` | `Toaster` (info) |
**Scoped set** — registered (glyph reserved) but single-surface and **not yet **Scoped set** — single-surface, glyph **reserved** in the ledger but **not yet in
implemented**: the `Icon` registry**:
| Meaning | Carbon glyph | Form | Surface / note | | Meaning | Carbon glyph | Form | Surface / note |
| ---------------- | ------------------ | ----------- | ----------------------------------------------------- | | ---------------- | ------------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Swap / transpose | `ArrowsHorizontal` | icon + text | Chart Builder "Swap X/Y" — one button; icon is polish | | Swap / transpose | `ArrowsHorizontal` | icon + text | Chart Builder "Swap X/Y" — the button **ships** today with an interim Unicode ``, not a registry `Icon`. `ArrowsHorizontal` stays reserved here so the meaning is claimed; promote the button to it (add the `IconName` + glyph) when polishing the swap. |
⭐ = **icon-only set**: close + theme (truly universal), plus **delete** as a ⭐ = **icon-only set** (the glyph alone names the control, via `aria-label`): the
deliberate destructive-row exception — a dense, repeated list action where a label **universal** glyphs `close` + `theme`; the conventional disclosure/affordance
would cost more than it gives. ✕ means **close only**; delete is `TrashCan`, never glyphs `settings` (gear) and the **pane-toggle** trio (position is the meaning);
✕ (that collision is exactly what one-glyph-one-meaning forbids). and **delete** as a deliberate destructive-row exception — a dense, repeated list
action where a label would cost more than it gives. `search` is _not_ ⭐: its
magnifier is a decorative lead-in to a labelled input (footnote ⁵), not a control
named by the glyph. ✕ means **close only**; delete is `TrashCan`, never ✕ (that
collision is exactly what one-glyph-one-meaning forbids). Admitting a glyph to ⭐ is
a contract change (§5.1 rule 4), not a per-component call.
¹ Row delete is hover/focus-revealed and reddens on hover/focus (arch 10 — reveal & ¹ Row delete is hover/focus-revealed and reddens on hover/focus (arch 10 — reveal &
destructive-intent rules). ² "Duplicate" and "Delete" in the detail panel stay destructive-intent rules). ² "Duplicate" and "Delete" in the detail panel stay
**text** (label-first; lower frequency, not a dense row). ³ The raw warning yellow **text** (label-first; lower frequency, not a dense row). ³ The raw warning yellow
fails contrast on light surfaces, so the warning glyph uses `--support-warning-fg` fails contrast on light surfaces, so the warning glyph uses `--support-warning-fg`
(darkened amber; the yellow `--toast-accent` stays on the decorative border). (darkened amber; the yellow `--toast-accent` stays on the decorative border).
**Responsive collapse**, not membership in the icon-only set: these are icon+text
that _shed the label_ under width pressure (the editor toolbar's secondary actions,
and the library's standalone Create CTA, when the pane is narrow — [arch 10
§8](10-interaction-and-feedback.md)), keeping the accessible name in
`aria-label`/`title`. A degradation that preserves the name is distinct from a
permanent icon-only control (§5.1 rule 4), so it isn't a closed-set change. ⁵
**Icon-in-field**: a decorative leading glyph _inside_ a labelled control — the
search input's magnifier is `aria-hidden`, and the input itself carries the
accessible name. Not icon-only (the control is named by its label, not the glyph).
### 5.3 Size ### 5.3 Size
@@ -303,8 +303,10 @@ Carbon search/active-search, NN/g #3. This bullet is the contract; cite it, not
**Resolved — library sort (APG menu-button + NN/g recognition).** The Sort control (spec **Resolved — library sort (APG menu-button + NN/g recognition).** The Sort control (spec
§02) reuses the **disclosure popover** primitive (the settings-popover model above), **not** §02) reuses the **disclosure popover** primitive (the settings-popover model above), **not**
an ARIA menu — but its trigger shows the **current state for recognition** ("Sort: Modified an ARIA menu — but its trigger shows the **current state for recognition** ("Modified
↓"), per NN/g #6 (recognition over recall), instead of a bare gear. Fields are **Modified / ↓"), per NN/g #6 (recognition over recall), instead of a bare gear. (The visible text drops
the "Sort:" verb prefix to stay compact in the narrow library rail — §8; the full name
"Sort by Modified, descending" rides in `aria-label`.) Fields are **Modified /
Created / Name / Size**; the active field shows a **direction arrow** (↓ desc / ↑ asc) in both Created / Name / Size**; the active field shows a **direction arrow** (↓ desc / ↑ asc) in both
the trigger and the field row, and the arrow's meaning is mirrored into the field's the trigger and the field row, and the arrow's meaning is mirrored into the field's
`aria-label` ("Modified, descending") so it isn't carried by the glyph alone. **Selection `aria-label` ("Modified, descending") so it isn't carried by the glyph alone. **Selection
@@ -454,6 +456,42 @@ iconography contract, [arch 09 §5](09-visual-design.md).)
--- ---
## 8. Space-constrained controls (responsive collapse)
The three work panes resize independently, so a toolbar's room is a function of its
**pane's** width, not the viewport's. Controls must stay usable at the pane minimum
without clipping, wrapping, or crowding.
- **Query the pane, not the window.** Use a CSS **container query**
(`container-type: inline-size` on the row, `@container` on the controls), not a
media query — the pane width is what changed. Scope the container to the toolbar
row itself, away from heavy children (e.g. the Monaco editor) whose own layout
shouldn't inherit size containment.
- **Shed labels under pressure; keep a contested primary labelled.** When a toolbar
would wrap or crowd, **secondary** actions collapse to icon-only and the label
moves to `aria-label`/`title`. A **primary that shares the row with secondaries**
keeps its text — the label is what marks it as _the_ action to take (the editor
toolbar below ~480px: Publish stays "Publish"; Extract/Revert become glyphs). A
**standalone primary CTA**, whose prominence is carried by fill + size + position
rather than its words, _may_ collapse to a universal-set icon at the pane floor
(the library's "Create New Snippet" → "+" below ~250px). Either way it's a
degradation that preserves the accessible name — distinct from the closed
icon-only set (arch 09 §5.1 rule 4).
- **Trim a compact trigger's prose, not its state.** A disclosure trigger that names
its current state for recognition (NN/g #6) may drop the **verb prefix** to fit a
narrow rail — the library Sort trigger shows "Modified ↓", not "Sort: Modified ↓"
— but the full name (`aria-label="Sort by Modified, descending"`) is preserved for
assistive tech, so only redundant visible words are cut.
- **A flex control must be able to shrink.** A side control (a Sort button) carries
`flex: 0 0 auto` so the flexible field (search) absorbs the slack; the field's
`<input>` needs `min-width: 0`, or its intrinsic ~20ch width overflows the slot
and overlaps its neighbour. Right-aligned toolbars (`justify-content: flex-end`)
clip their **leftmost** item on overflow — left-align so the trimmable end is a
settings affordance, not a primary control, and size the pane minimum so it
doesn't overflow at all.
---
## Do / Don't ## Do / Don't
**Do** **Do**
+94 -3
View File
@@ -87,6 +87,13 @@
--on-status: #161616; --on-status: #161616;
} }
/* Soft accent — derived wash (mirrors styles/tokens.css). References --accent
+ --bg, so it follows whatever accent/theme is active. */
[data-theme] {
--accent-soft: color-mix(in srgb, var(--accent) 12%, var(--bg));
--accent-soft-hover: color-mix(in srgb, var(--accent) 20%, var(--bg));
}
/* --- Accent: the FREE, swappable layer (theme-aware). Deep teal is the /* --- Accent: the FREE, swappable layer (theme-aware). Deep teal is the
default; see styles/tokens.css for the shipped source of truth. --- */ default; see styles/tokens.css for the shipped source of truth. --- */
[data-accent='teal'] { [data-accent='teal'] {
@@ -436,6 +443,29 @@
.btn-ghost:hover { .btn-ghost:hover {
background: var(--layer-01); background: var(--layer-01);
} }
/* Header utility ghost: text-coloured, recedes; hover steps up one layer. */
.btn-ghost-util {
background: transparent;
color: var(--text);
gap: var(--space-2);
}
.btn-ghost-util:hover {
background: var(--layer-02);
}
/* Soft-accent: a low-emphasis accent wash (Donate) — noticed, not shouting. */
.btn-soft {
background: var(--accent-soft);
color: var(--accent-hover);
}
.btn-soft:hover {
background: var(--accent-soft-hover);
}
.hdr-divider {
width: var(--border-width);
height: 20px;
background: var(--border-strong);
margin: 0 var(--space-2);
}
.btn-danger { .btn-danger {
background: var(--support-error); background: var(--support-error);
color: var(--on-status); color: var(--on-status);
@@ -459,13 +489,16 @@
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
} }
/* Field-on-layer (arch 09 §3.3): a field is a layer above its surface, so it
steps toward --bg (white) with a --border-strong outline — on the canvas
and on a gray panel alike — never a darker-gray fill. */
.input, .input,
.select, .select,
.textarea { .textarea {
font: inherit; font: inherit;
font-size: 14px; font-size: 14px;
color: var(--text); color: var(--text);
background: var(--layer-01); background: var(--bg);
border: var(--border-width) solid var(--border-strong); border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius); border-radius: var(--radius);
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
@@ -930,6 +963,26 @@
<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).
</p> </p>
<h3 style="margin-top: var(--space-6)">Emphasis hierarchy — a header cluster</h3>
<p class="cap" style="margin-bottom: var(--space-4)">
Utilities go <strong>ghost</strong> (icon + text, borderless) so they recede;
a divider sets off <strong>Donate</strong> on a <strong>soft-accent</strong> wash
— one low-key solicitation, no border. (Carbon: one high-emphasis action per
region; utilities at low emphasis.)
</p>
<div class="row" style="align-items: center">
<button class="btn btn-ghost-util sm">
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor"><rect x="8" y="18" width="4" height="2"/><rect x="14" y="18" width="4" height="2"/><rect x="8" y="14" width="4" height="2"/><rect x="14" y="22" width="4" height="2"/><rect x="20" y="14" width="4" height="2"/><rect x="20" y="22" width="4" height="2"/><path d="M27,3H5A2.0025,2.0025,0,0,0,3,5V27a2.0025,2.0025,0,0,0,2,2H27a2.0025,2.0025,0,0,0,2-2V5A2.0025,2.0025,0,0,0,27,3Zm0,2,0,4H5V5ZM5,27V11H27l0,16Z"/></svg>Datasets</button>
<button class="btn btn-ghost-util sm">
<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="M6,12l1.41,1.41L15,5.83V24h2V5.83l7.59,7.59L26,12,16,2Z"/></svg>Import</button>
<button class="btn btn-ghost-util sm">
<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,14l-1.41-1.41L17,20.17V2H15V20.17l-7.59-7.58L6,14l10,10Z"/></svg>Export</button>
<button class="btn btn-ghost-util sm">
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor"><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,30A14,14,0,1,1,30,16,14,14,0,0,1,16,30ZM16,4A12,12,0,1,0,28,16,12,12,0,0,0,16,4Z"/></svg>About</button>
<span class="hdr-divider"></span>
<button class="btn btn-soft sm">Donate</button>
</div>
</section> </section>
<!-- FIELDS --> <!-- FIELDS -->
@@ -1027,10 +1080,48 @@
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor"><rect x="12" y="12" width="2" height="12"/><rect x="18" y="12" width="2" height="12"/><path d="M4,6V8H6V28a2,2,0,0,0,2,2H24a2,2,0,0,0,2-2V8h2V6ZM8,28V8H24V28Z"/><rect x="12" y="2" width="8" height="2"/></svg> <svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor"><rect x="12" y="12" width="2" height="12"/><rect x="18" y="12" width="2" height="12"/><path d="M4,6V8H6V28a2,2,0,0,0,2,2H24a2,2,0,0,0,2-2V8h2V6ZM8,28V8H24V28Z"/><rect x="12" y="2" width="8" height="2"/></svg>
<span class="cap">delete</span> <span class="cap">delete</span>
</span> </span>
<span style="display: inline-flex; flex-direction: column; align-items: center; gap: var(--space-2); color: var(--text-secondary)">
<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="M6,12l1.41,1.41L15,5.83V24h2V5.83l7.59,7.59L26,12,16,2Z"/></svg>
<span class="cap">import</span>
</span>
<span style="display: inline-flex; flex-direction: column; align-items: center; gap: var(--space-2); color: var(--text-secondary)">
<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,14l-1.41-1.41L17,20.17V2H15V20.17l-7.59-7.58L6,14l10,10Z"/></svg>
<span class="cap">export</span>
</span>
<span style="display: inline-flex; flex-direction: column; align-items: center; gap: var(--space-2); color: var(--text-secondary)">
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor"><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,30A14,14,0,1,1,30,16,14,14,0,0,1,16,30ZM16,4A12,12,0,1,0,28,16,12,12,0,0,0,16,4Z"/></svg>
<span class="cap">info</span>
</span>
<span style="display: inline-flex; flex-direction: column; align-items: center; gap: var(--space-2); color: var(--text-secondary)">
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor"><path d="M18,28A12,12,0,1,0,6,16v6.2L2.41,18.59,1,20l6,6,6-6L11.59,18.59,8,22.2V16A10,10,0,1,1,18,26Z"/></svg>
<span class="cap">revert</span>
</span>
<span style="display: inline-flex; flex-direction: column; align-items: center; gap: var(--space-2); color: var(--text-secondary)">
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor"><path d="M29,27.5859l-7.5521-7.5521a11.0177,11.0177,0,1,0-1.4141,1.4141L27.5859,29ZM4,13a9,9,0,1,1,9,9A9.01,9.01,0,0,1,4,13Z"/></svg>
<span class="cap">search</span>
</span>
<span style="display: inline-flex; flex-direction: column; align-items: center; gap: var(--space-2); color: var(--text-secondary)">
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor"><path d="M27,16.76c0-.25,0-.5,0-.76s0-.51,0-.77l1.92-1.68A2,2,0,0,0,29.3,11L26.94,7a2,2,0,0,0-1.73-1,2,2,0,0,0-.64.1l-2.43.82a11.35,11.35,0,0,0-1.31-.75l-.51-2.52a2,2,0,0,0-2-1.61H13.64a2,2,0,0,0-2,1.61l-.51,2.52a11.48,11.48,0,0,0-1.32.75L7.43,6.06A2,2,0,0,0,6.79,6,2,2,0,0,0,5.06,7L2.7,11a2,2,0,0,0,.41,2.51L5,15.24c0,.25,0,.5,0,.76s0,.51,0,.77L3.11,18.45A2,2,0,0,0,2.7,21L5.06,25a2,2,0,0,0,1.73,1,2,2,0,0,0,.64-.1l2.43-.82a11.35,11.35,0,0,0,1.31.75l.51,2.52a2,2,0,0,0,2,1.61h4.72a2,2,0,0,0,2-1.61l.51-2.52a11.48,11.48,0,0,0,1.32-.75l2.42.82a2,2,0,0,0,.64.1,2,2,0,0,0,1.73-1L29.3,21a2,2,0,0,0-.41-2.51ZM25.21,24l-3.43-1.16a8.86,8.86,0,0,1-2.71,1.57L18.36,28H13.64l-.71-3.55a9.36,9.36,0,0,1-2.7-1.57L6.79,24,4.43,20l2.72-2.4a8.9,8.9,0,0,1,0-3.13L4.43,12,6.79,8l3.43,1.16a8.86,8.86,0,0,1,2.71-1.57L13.64,4h4.72l.71,3.55a9.36,9.36,0,0,1,2.7,1.57L25.21,8,27.57,12l-2.72,2.4a8.9,8.9,0,0,1,0,3.13L27.57,20Z"/><path d="M16,22a6,6,0,1,1,6-6A5.94,5.94,0,0,1,16,22Zm0-10a3.91,3.91,0,0,0-4,4,3.91,3.91,0,0,0,4,4,3.91,3.91,0,0,0,4-4A3.91,3.91,0,0,0,16,12Z"/></svg>
<span class="cap">settings</span>
</span>
<span style="display: inline-flex; flex-direction: column; align-items: center; gap: var(--space-2); color: var(--text-secondary)">
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor"><rect x="4" y="6" width="24" height="2"/><rect x="4" y="24" width="24" height="2"/><rect x="4" y="6" width="2" height="20"/><rect x="26" y="6" width="2" height="20"/><rect x="7" y="10" width="4" height="12"/></svg>
<span class="cap">pane-library</span>
</span>
<span style="display: inline-flex; flex-direction: column; align-items: center; gap: var(--space-2); color: var(--text-secondary)">
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor"><rect x="4" y="6" width="24" height="2"/><rect x="4" y="24" width="24" height="2"/><rect x="4" y="6" width="2" height="20"/><rect x="26" y="6" width="2" height="20"/><rect x="14" y="10" width="4" height="12"/></svg>
<span class="cap">pane-editor</span>
</span>
<span style="display: inline-flex; flex-direction: column; align-items: center; gap: var(--space-2); color: var(--text-secondary)">
<svg width="16" height="16" viewBox="0 0 32 32" style="fill: currentColor"><rect x="4" y="6" width="24" height="2"/><rect x="4" y="24" width="24" height="2"/><rect x="4" y="6" width="2" height="20"/><rect x="26" y="6" width="2" height="20"/><rect x="21" y="10" width="4" height="12"/></svg>
<span class="cap">pane-preview</span>
</span>
</div> </div>
<div class="cap" style="margin-top: var(--space-6)"> <div class="cap" style="margin-top: var(--space-6)">
In context: icon + text is the default; icon-only is the universal/destructive The full Core + pane-toggle set (arch 09 §5.2); the four status glyphs appear,
exception. ✕ means close only — delete is the trash glyph. coloured, in the Toasts section below. In context: icon + text is the default;
icon-only is the universal/affordance/destructive exception. ✕ means close only —
delete is the trash glyph.
</div> </div>
</section> </section>
+3 -3
View File
@@ -28,17 +28,17 @@ A fixed header spans the top of the app.
- **Right side**: a row of text entry points. Each opens a destination: - **Right side**: a row of text entry points. 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_). | | Import | A file-picker dialog to choose a previously exported file; the chosen file is imported (see _Import & Export_). |
| Export | Immediately produces a downloaded file containing all snippets and datasets (see _Import & Export_). | | Export | Immediately produces a downloaded file containing all snippets and datasets (see _Import & Export_). |
| Datasets | The Datasets manager modal (see _Datasets_). | | Datasets | The Datasets manager modal (see _Datasets_). |
| About & Privacy | The About & Help modal (keyboard shortcuts, about, and privacy information). | | About | The About & Help modal (keyboard shortcuts, about, and privacy information). Labelled "About"; privacy lives inside. |
| Donate | The Donate modal. | | Donate | The Donate modal. |
Notes: Notes:
- 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 & Privacy, 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
preferences to the panes they affect — the appearance theme is a header toggle, and the preferences to the panes they affect — the appearance theme is a header toggle, and the
Editor / Performance / Formatting clusters open from a gear control in their own pane. There Editor / Performance / Formatting clusters open from a gear control in their own pane. There
+41 -5
View File
@@ -61,11 +61,20 @@
flex: 1; flex: 1;
} }
/* Header text-button (Datasets, and future header actions). */ /*
* Header utility buttons (Datasets / Import / Export / About) low-emphasis
* ghost buttons: borderless, icon + label, so they recede behind the work area
* and read as a row of equals (Carbon: utilities in toolbars/dashboards belong at
* low emphasis). The transparent border holds the box size so hover doesn't shift
* neighbours. Matches the ThemeToggle's ghost icon button beside them.
*/
.headerButton { .headerButton {
display: inline-flex;
align-items: center;
gap: var(--space-2);
height: 32px; height: 32px;
padding: 0 var(--space-4); padding: 0 var(--space-3);
border: var(--border-width) solid var(--border-strong); border: var(--border-width) solid transparent;
border-radius: var(--radius); border-radius: var(--radius);
background: transparent; background: transparent;
color: var(--text); color: var(--text);
@@ -73,11 +82,13 @@
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: background var(--dur-fast) var(--ease); transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
} }
.headerButton:hover { .headerButton:hover {
background: var(--layer-01); background: var(--layer-02);
} }
.headerButton:focus-visible { .headerButton:focus-visible {
@@ -85,6 +96,31 @@
outline-offset: 2px; outline-offset: 2px;
} }
/* Vertical rule separating the utilities from Donate + the theme toggle. */
.headerDivider {
flex: 0 0 auto;
width: var(--border-width);
height: 20px;
margin: 0 var(--space-2);
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;
+13 -2
View File
@@ -4,6 +4,7 @@ import { LivePreview } from './components/LivePreview';
import { ModalShell } from './components/ModalShell'; 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 { 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';
@@ -74,6 +75,11 @@ export function App() {
<h1 className={styles.title}>Astrolabe</h1> <h1 className={styles.title}>Astrolabe</h1>
<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
high-emphasis action per region; utilities in dashboards/toolbars read
better at low emphasis). The utilities are ghost (borderless) buttons
with a leading icon for scanning; a divider then sets off Donate the
one solicitation, given a soft-accent wash and the theme toggle. */}
<button <button
type="button" type="button"
className={styles.headerButton} className={styles.headerButton}
@@ -81,6 +87,7 @@ export function App() {
aria-keyshortcuts="Meta+K Control+K" aria-keyshortcuts="Meta+K Control+K"
title="Datasets (⌘/Ctrl+K)" title="Datasets (⌘/Ctrl+K)"
> >
<Icon name="dataset" />
Datasets Datasets
</button> </button>
<button <button
@@ -89,6 +96,7 @@ export function App() {
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
title="Import a workspace JSON file" title="Import a workspace JSON file"
> >
<Icon name="import" />
Import Import
</button> </button>
<button <button
@@ -97,6 +105,7 @@ export function App() {
onClick={() => exportWorkspace()} onClick={() => exportWorkspace()}
title="Export your workspace to a JSON file" title="Export your workspace to a JSON file"
> >
<Icon name="export" />
Export Export
</button> </button>
<button <button
@@ -105,11 +114,13 @@ export function App() {
onClick={() => openModal('about')} onClick={() => openModal('about')}
title="About, keyboard shortcuts, and privacy information" title="About, keyboard shortcuts, and privacy information"
> >
About &amp; Privacy <Icon name="info" />
About
</button> </button>
<span className={styles.headerDivider} aria-hidden="true" />
<button <button
type="button" type="button"
className={styles.headerButton} className={`${styles.headerButton} ${styles.donate}`}
onClick={() => openModal('donate')} onClick={() => openModal('donate')}
title="Support Astrolabe" title="Support Astrolabe"
> >
+30
View File
@@ -29,6 +29,10 @@ export type IconName =
| 'add' // add / create-new — Carbon Add | 'add' // add / create-new — Carbon Add
| 'search' // live library search — Carbon Search | 'search' // live library search — Carbon Search
| 'settings' // per-pane settings disclosure (gear) — Carbon Settings | 'settings' // per-pane settings disclosure (gear) — Carbon Settings
| 'import' // import a workspace file — Carbon Upload (a file goes in)
| 'export' // export the workspace to a file — Carbon Download (a file comes out)
| 'info' // about / information — Carbon Information (outline)
| 'revert' // revert draft to last published — Carbon Reset
// Pane-toggle sub-family (spec §01A): a panel frame with one region filled, so the // Pane-toggle sub-family (spec §01A): a panel frame with one region filled, so the
// glyph shows *which* pane it controls by position (left / centre / right). // glyph shows *which* pane it controls by position (left / centre / right).
| 'pane-library' // toggle the library pane (left) | 'pane-library' // toggle the library pane (left)
@@ -69,6 +73,32 @@ const GLYPHS: Record<IconName, ReactNode> = {
<rect x="12" y="2" width="8" height="2" /> <rect x="12" y="2" width="8" height="2" />
</> </>
), ),
// Carbon Upload — open tray with an up arrow (a file is brought into the app).
import: (
<>
<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" />
</>
),
// Carbon Download — open tray with a down arrow (a file is written out).
export: (
<>
<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" />
</>
),
// Carbon Information (outline) — ring + "i".
info: (
<>
<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,30A14,14,0,1,1,30,16,14,14,0,0,1,16,30ZM16,4A12,12,0,1,0,28,16,12,12,0,0,0,16,4Z" />
</>
),
// Carbon Reset — a circular arrow, "return to the prior state".
revert: (
<path d="M18,28A12,12,0,1,0,6,16v6.2L2.41,18.59,1,20l6,6,6-6L11.59,18.59,8,22.2V16A10,10,0,1,1,18,26Z" />
),
dataset: ( dataset: (
<> <>
<rect x="8" y="18" width="4" height="2" /> <rect x="8" y="18" width="4" height="2" />
+10 -1
View File
@@ -10,13 +10,22 @@
flex: 0 0 auto; flex: 0 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; /* Fit control hugs the left, settings gear trails right (mirrors the editor
toolbar). It used to be flex-end, which made any overflow trim the *left*
item clipping "Original". Left-aligned, the gear is the trimmable end, and
the preview's min-width is sized so it never actually overflows. */
justify-content: flex-start;
gap: var(--space-3); gap: var(--space-3);
height: 40px; height: 40px;
padding: 0 var(--space-4); padding: 0 var(--space-4);
border-bottom: var(--border-width) solid var(--border); border-bottom: var(--border-width) solid var(--border);
} }
/* Push the settings gear (the last child) to the far right. */
.header > :last-child {
margin-left: auto;
}
.body { .body {
position: relative; /* establishes stacking context for the busy overlay */ position: relative; /* establishes stacking context for the busy overlay */
flex: 1 1 auto; flex: 1 1 auto;
+34 -9
View File
@@ -2,6 +2,10 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
/* Size-query container so the Create CTA can respond to the pane's own width.
`inline-size` (not `size`) leaves block-size uncontained, so the metadata
panel's percentage max-height and the list's flex height still resolve. */
container-type: inline-size;
} }
/* Library toolbar — heading + date-format settings gear (spec §02 / §07). */ /* Library toolbar — heading + date-format settings gear (spec §02 / §07). */
@@ -39,6 +43,9 @@
color: var(--accent-contrast); color: var(--accent-contrast);
font: inherit; font: inherit;
font-weight: 600; 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; cursor: pointer;
transition: background var(--dur-fast) var(--ease); transition: background var(--dur-fast) var(--ease);
} }
@@ -47,6 +54,16 @@
background: var(--accent-hover); background: var(--accent-hover);
} }
/* Narrow pane the CTA sheds its label and centres the "+" icon (the button stays
full-width, so it keeps a large target). The threshold sits comfortably above the
216px pane minimum, so the full label is shown across the normal width range and
only collapses when the pane is dragged tight. */
@container (max-width: 250px) {
.createNewLabel {
display: none;
}
}
/* Search + Sort row, pinned above the list (spec §02). */ /* Search + Sort row, pinned above the list (spec §02). */
.controls { .controls {
flex: 0 0 auto; flex: 0 0 auto;
@@ -74,6 +91,10 @@
.searchInput { .searchInput {
width: 100%; width: 100%;
/* Let the input shrink with its flex slot: an <input>'s default min-width is its
intrinsic content width (~20ch), which otherwise overflowed the slot and slid
over the Sort trigger at the pane minimum. */
min-width: 0;
height: 32px; height: 32px;
/* 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;
@@ -344,10 +365,14 @@
.metaComment { .metaComment {
width: 100%; width: 100%;
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
border: var(--border-width) solid var(--border); /* A field is a layer *on top of* the panel it sits on (Carbon layering,
arch 09 §3.2): on the gray `--layer-01` panel the field goes white with a
strong border matching the search input above rather than a darker gray,
which stacked three muddy grays. */
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--layer-02, var(--background)); background: var(--bg);
color: var(--text-primary); color: var(--text);
font: inherit; font: inherit;
font-size: 13px; font-size: 13px;
} }
@@ -390,7 +415,7 @@
.metaTimes dd { .metaTimes dd {
margin: 0; margin: 0;
color: var(--text-primary); color: var(--text);
white-space: nowrap; white-space: nowrap;
} }
@@ -434,10 +459,11 @@
flex: 1 1 auto; flex: 1 1 auto;
height: 32px; height: 32px;
padding: 0 var(--space-3); padding: 0 var(--space-3);
border: var(--border-width) solid var(--border); /* 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); border-radius: var(--radius);
background: var(--background); background: var(--bg);
color: var(--text-primary); color: var(--text);
font: inherit; font: inherit;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
@@ -449,8 +475,7 @@
} }
.metaAction:hover { .metaAction:hover {
background: var(--layer-02, var(--layer-01)); background: var(--layer-02);
border-color: var(--border-strong, var(--border));
} }
.metaDanger:hover { .metaDanger:hover {
+11 -2
View File
@@ -254,8 +254,17 @@ export function SnippetLibrary() {
{/* Create raises no toast: the new snippet opens in the editor, so the {/* Create raises no toast: the new snippet opens in the editor, so the
result is already on-screen (spec §02; docs/architecture/10 Toast result is already on-screen (spec §02; docs/architecture/10 Toast
copy). Delete/duplicate toast because the outcome isn't visible. */} copy). Delete/duplicate toast because the outcome isn't visible. */}
<button className={styles.createNew} onClick={() => createSnippet()}> {/* Primary CTA. When the library pane is dragged narrow the label collapses
<Icon name="add" /> Create New Snippet to just the "+" icon (a @container query) so it never wraps to two lines;
`aria-label` keeps the accessible name when the text is hidden. */}
<button
className={styles.createNew}
onClick={() => createSnippet()}
aria-label="Create New Snippet"
title="Create New Snippet"
>
<Icon name="add" />
<span className={styles.createNewLabel}>Create New Snippet</span>
</button> </button>
{/* Search + Sort controls, pinned above the list (spec §02; council {/* Search + Sort controls, pinned above the list (spec §02; council
+8 -2
View File
@@ -2,15 +2,21 @@
.wrap { .wrap {
position: relative; position: relative;
/* Never shrink: the search box (flex: 1) absorbs the slack, so the sort trigger
keeps its size instead of being squeezed into the search input (which used to
overlap it at the pane minimum). */
flex: 0 0 auto;
display: inline-flex; display: inline-flex;
} }
/* 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
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: 28px; height: 32px;
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);
+13 -5
View File
@@ -4,9 +4,11 @@
* A disclosure + non-modal popover, the same APG primitive as SettingsPopover * A disclosure + non-modal popover, the same APG primitive as SettingsPopover
* (docs/architecture/10 "settings are … a disclosure popover") deliberately * (docs/architecture/10 "settings are … a disclosure popover") deliberately
* NOT an ARIA menu. It differs from SettingsPopover only in its **trigger**: per * NOT an ARIA menu. It differs from SettingsPopover only in its **trigger**: per
* NN/g recognition-over-recall the trigger shows the current state ("Sort: * NN/g recognition-over-recall the trigger shows the current state ("Modified ↓")
* Modified ") instead of a bare gear, so the user reads the order without * instead of a bare gear, so the user reads the order without opening the popover.
* opening the popover. The body is a labelled `group` of field buttons (radio * The visible text drops the "Sort:" verb prefix to stay compact in the narrow
* library rail; the full name rides in `aria-label` (arch 10 §8). The body is a
* labelled `group` of field buttons (radio
* group), each showing a direction arrow on the active field. * group), each showing a direction arrow on the active field.
* *
* Selection model (spec §02): re-selecting the ACTIVE field flips direction; * Selection model (spec §02): re-selecting the ACTIVE field flips direction;
@@ -121,11 +123,17 @@ export function SortControl() {
className={styles.trigger} className={styles.trigger}
aria-expanded={open} aria-expanded={open}
aria-controls={ID} aria-controls={ID}
// Trigger shows current state for recognition (council SORT, NN/g #6). // Trigger shows current field + direction for recognition (council SORT,
// NN/g #6) — but without a "Sort:" prefix, so it stays compact beside the
// search box in the narrow library pane (the prefix wasted ~36px and
// crowded search at the pane minimum). The accessible name keeps the verb
// ("Sort by Modified, descending") via aria-label, since the trimmed
// visible text ("Modified ↓") would otherwise read as a bare field name.
aria-label={`Sort by ${labelFor(sortBy)}, ${sortOrder === 'desc' ? 'descending' : 'ascending'}`}
title={`Sort by ${labelFor(sortBy)}, ${sortOrder === 'desc' ? 'descending' : 'ascending'}`} title={`Sort by ${labelFor(sortBy)}, ${sortOrder === 'desc' ? 'descending' : 'ascending'}`}
onClick={() => toggle(ID)} onClick={() => toggle(ID)}
> >
Sort: {labelFor(sortBy)} <span aria-hidden="true">{arrow}</span> {labelFor(sortBy)} <span aria-hidden="true">{arrow}</span>
</button> </button>
{open && {open &&
createPortal( createPortal(
+40
View File
@@ -13,6 +13,10 @@
height: 40px; height: 40px;
padding: 0 var(--space-4); padding: 0 var(--space-4);
border-bottom: var(--border-width) solid var(--border); border-bottom: var(--border-width) solid var(--border);
/* Size-query container so the actions below respond to the *pane's* width (the
toolbar spans it), not the viewport's. Scoped to the toolbar not the pane
to keep size containment well clear of the Monaco editor's own layout. */
container-type: inline-size;
} }
.spacer { .spacer {
@@ -47,6 +51,42 @@
cursor: default; cursor: default;
} }
/* Collapsible secondary actions (Extract, Revert): label by default, icon-only
when the pane is narrow. Both children live in the DOM always; the container
query swaps which one shows, so there's no reflow cost beyond display. */
.collapsible {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
}
.actionIcon {
display: none;
}
.actionLabel {
display: inline;
white-space: nowrap;
}
/* Below this pane width the labels would crowd Publish / wrap, so the secondary
actions shed their text and become square icon buttons (the accessible name +
title keep them identifiable). 480px is comfortably above the 384px pane
minimum, so the collapse is in effect across the whole narrow range. */
@container (max-width: 480px) {
.collapsible {
width: 28px;
padding: 0;
}
.collapsible .actionLabel {
display: none;
}
.collapsible .actionIcon {
display: inline-flex;
}
}
.publish { .publish {
border-color: transparent; border-color: transparent;
background: var(--accent); background: var(--accent);
+15 -4
View File
@@ -33,6 +33,7 @@ import { notify } from '../stores/NotificationStore';
import { usePreviewStore } from '../stores/PreviewStore'; 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 { SegmentedControl, type SegmentedOption } from './SegmentedControl'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { import {
NumberControl, NumberControl,
@@ -189,23 +190,33 @@ function EditorToolbar() {
<span className={styles.spacer} /> <span className={styles.spacer} />
{/* Secondary actions collapse to icon-only when the editor pane is narrow
(a @container query in the CSS), so "Extract to Dataset" no longer wraps
to two lines and Revert/Publish stay on one row. The label rides in
`title` (and the accessible name) when only the icon shows. Publish the
one primary action keeps its label at every width. */}
{canExtract && ( {canExtract && (
<button <button
type="button" type="button"
className={styles.action} 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"
> >
Extract to Dataset <Icon name="dataset" className={styles.actionIcon} />
<span className={styles.actionLabel}>Extract to Dataset</span>
</button> </button>
)} )}
<button <button
type="button" type="button"
className={styles.action} 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"
aria-label="Revert"
> >
Revert <Icon name="revert" className={styles.actionIcon} />
<span className={styles.actionLabel}>Revert</span>
</button> </button>
<button <button
type="button" type="button"
+10 -9
View File
@@ -40,7 +40,7 @@ describe('clampSideWidth', () => {
}); });
test('a container too narrow for all minimums still never drops below the min', () => { test('a container too narrow for all minimums still never drops below the min', () => {
// 500px total can't fit library(180)+preview(240)+editor(320)+handles. // 500px total can't fit library(216)+preview(300)+editor(384)+handles.
expect(clampSideWidth('library', 300, 500, PANE_MIN.preview)).toBe(PANE_MIN.library); expect(clampSideWidth('library', 300, 500, PANE_MIN.preview)).toBe(PANE_MIN.library);
}); });
}); });
@@ -161,8 +161,8 @@ describe('splitLibraryWidth (editor-hidden library↔preview split, §01A)', ()
}); });
test('a span too small for both minimums still keeps the library minimum', () => { test('a span too small for both minimums still keeps the library minimum', () => {
// 300px can't fit library(180)+preview(240); the library holds its own min. // 400px can't fit library(216)+preview(300); the library holds its own min.
expect(splitLibraryWidth(250, 300)).toBe(PANE_MIN.library); expect(splitLibraryWidth(250, 400)).toBe(PANE_MIN.library);
}); });
}); });
@@ -218,12 +218,13 @@ describe('shownSideWidths (editor re-shown, §01A)', () => {
}); });
test('pins the editor width and keeps the ratio set while hidden', () => { test('pins the editor width and keeps the ratio set while hidden', () => {
// The user re-split to 400:240 while the editor was hidden. // The user re-split to 340:300 while the editor was hidden (both ≥ their
const { libraryWidth, previewWidth } = shownSideWidths(PANES_INNER, E, 400, 240, true, true); // minimums, so the rescale preserves the ratio rather than clamping).
const { libraryWidth, previewWidth } = shownSideWidths(PANES_INNER, E, 340, 300, true, true);
// Editor reclaims its slot, so the side panes' combined width is unchanged… // Editor reclaims its slot, so the side panes' combined width is unchanged…
expect(libraryWidth + previewWidth).toBe(PANES_INNER - E - HANDLES_TOTAL); expect(libraryWidth + previewWidth).toBe(PANES_INNER - E - HANDLES_TOTAL);
// …and the 400:240 ratio is preserved (5:3). // …and the 340:300 ratio is preserved.
expect(libraryWidth / previewWidth).toBeCloseTo(400 / 240, 5); expect(libraryWidth / previewWidth).toBeCloseTo(340 / 300, 5);
}); });
test('no remembered editor width leaves the side widths untouched (legacy/never hidden)', () => { test('no remembered editor width leaves the side widths untouched (legacy/never hidden)', () => {
@@ -263,14 +264,14 @@ describe('editor toggle remembers and restores its width (§01A)', () => {
store().togglePane('editor', PANES_INNER); store().togglePane('editor', PANES_INNER);
const captured = store().editorWidth; const captured = store().editorWidth;
// Drag the library↔preview handle while hidden (setSplit writes both widths). // Drag the library↔preview handle while hidden (setSplit writes both widths).
store().setSplit(400, 240); store().setSplit(340, 300);
store().togglePane('editor', PANES_INNER); store().togglePane('editor', PANES_INNER);
expect(store().editorWidth).toBe(captured); // editor reclaims its slot expect(store().editorWidth).toBe(captured); // editor reclaims its slot
expect(store().libraryWidth + store().previewWidth).toBe( expect(store().libraryWidth + store().previewWidth).toBe(
PANES_INNER - captured - HANDLES_TOTAL, PANES_INNER - captured - HANDLES_TOTAL,
); );
expect(store().libraryWidth / store().previewWidth).toBeCloseTo(400 / 240, 5); expect(store().libraryWidth / store().previewWidth).toBeCloseTo(340 / 300, 5);
}); });
}); });
+11 -2
View File
@@ -25,8 +25,17 @@ export type PaneSide = 'library' | 'preview';
/** Every pane that can be shown/hidden via the toggle strip (spec §01A). */ /** Every pane that can be shown/hidden via the toggle strip (spec §01A). */
export type PaneName = 'library' | 'editor' | 'preview'; export type PaneName = 'library' | 'editor' | 'preview';
/** Minimum widths (px) enforced while resizing, so no pane collapses (§01A). */ /**
export const PANE_MIN = { library: 180, preview: 240, editor: 320 } as const; * Minimum widths (px) enforced while resizing, so no pane collapses (§01A).
* Tuned so each pane's toolbar stays uncrowded at its floor:
* - library 216 the search box + compact Sort trigger have room to breathe;
* below ~250 the "Create New Snippet" CTA collapses to its icon (container query).
* - preview 300 below ~280 the fit control + settings gear clip, and a chart in
* <300px is too cramped to read.
* - editor 384 gives the JSON real horizontal room (it wrapped hard at 320); the
* toolbar's secondary actions still collapse to icons below ~480.
*/
export const PANE_MIN = { library: 216, preview: 300, editor: 384 } as const;
/** Initial side-pane widths (px) on first run, before any persisted layout. */ /** Initial side-pane widths (px) on first run, before any persisted layout. */
export const PANE_DEFAULT = { library: 280, preview: 360 } as const; export const PANE_DEFAULT = { library: 280, preview: 360 } as const;
+11
View File
@@ -102,6 +102,17 @@
--accent-hover: #0c5f77; --accent-hover: #0c5f77;
--accent-contrast: #ffffff; --accent-contrast: #ffffff;
} }
/*
* Soft accent a low-emphasis accent wash for a tinted-but-quiet surface (e.g.
* the header's Donate button: noticeable without competing with a primary action).
* Derived from the swappable accent + the theme background, so it follows any
* accent/theme automatically rather than being a hardcoded per-accent value.
*/
:root {
--accent-soft: color-mix(in srgb, var(--accent) 12%, var(--bg));
--accent-soft-hover: color-mix(in srgb, var(--accent) 20%, var(--bg));
}
[data-theme='dark'] { [data-theme='dark'] {
--accent: #2dd4bf; --accent: #2dd4bf;
--accent-hover: #5eead4; --accent-hover: #5eead4;