mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Refine header emphasis, field-on-layer treatment, and narrow-pane control collapse
This commit is contained in:
@@ -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
|
||||
[arch 02](architecture/02-persistence.md)).
|
||||
- **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).
|
||||
- **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**.
|
||||
|
||||
@@ -94,15 +94,16 @@ and size is a relationship of 8 (with 2/4 as fine sub-steps):
|
||||
Color is expressed as **roles**, never raw hexes, so themes can repaint the whole
|
||||
UI by swapping one set of values. Borrowed from Carbon's layering model:
|
||||
|
||||
| Role token | Meaning |
|
||||
| ---------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `--bg` | App canvas (lowest layer) |
|
||||
| `--layer-01` / `--layer-02` | Raised surfaces (panels, cards, popovers) — elevation by lightness step, not shadow |
|
||||
| `--border` / `--border-strong` | Subtle and prominent separators |
|
||||
| `--text` / `--text-secondary` / `--text-placeholder` | Text hierarchy |
|
||||
| `--accent` / `--accent-hover` / `--accent-contrast` | The expressive accent — **swappable**; UI must never hardcode a hue |
|
||||
| `--focus` | Focus-ring color (defaults to `--accent`) |
|
||||
| `--support-error / -success / -warning / -info` | Status only — color = meaning |
|
||||
| Role token | Meaning |
|
||||
| ---------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `--bg` | App canvas (lowest layer) |
|
||||
| `--layer-01` / `--layer-02` | Raised surfaces (panels, cards, popovers) — elevation by lightness step, not shadow |
|
||||
| `--border` / `--border-strong` | Subtle and prominent separators |
|
||||
| `--text` / `--text-secondary` / `--text-placeholder` | Text hierarchy |
|
||||
| `--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`) |
|
||||
| `--support-error / -success / -warning / -info` | Status only — color = meaning |
|
||||
|
||||
- **Neutrals** use the Carbon gray ramp (`#f4f4f4 … #161616`) — accessible and
|
||||
legible. **Accent and theming are open**: the default accent is **deep teal**
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **Soft accent is derived, not hardcoded.** `--accent-soft` /
|
||||
`--accent-soft-hover` are `color-mix(in srgb, var(--accent) 12–20%, 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
|
||||
|
||||
@@ -138,9 +150,19 @@ UI by swapping one set of values. Borrowed from Carbon's layering model:
|
||||
## 4. Component conventions
|
||||
|
||||
- **Buttons:** square, 32px (compact) / 40px (default) tall. Variants: **primary**
|
||||
(filled `--accent`), **secondary** (bordered), **ghost** (text-only),
|
||||
**danger** (filled `--support-error`). 600-weight label. Clear hover/active and
|
||||
a visible focus ring.
|
||||
(filled `--accent`), **secondary** (bordered), **ghost** (text-only, borderless),
|
||||
**soft-accent** (ghost on an `--accent-soft` wash — a low-emphasis solicitation,
|
||||
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**
|
||||
(`--accent-hover` / a slight brightness drop); outlined/ghost buttons **gain 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.
|
||||
- **Focus ring:** a 2px `--focus` outline (offset 1–2px). Always visible on
|
||||
keyboard focus — accessibility is non-negotiable (principle 4).
|
||||
- **Fields** (text, textarea, select, search): square, 1px `--border`, `--layer-01`
|
||||
fill, accent border + focus ring on focus. Mono font for spec/JSON inputs.
|
||||
- **Fields** (text, textarea, select, search): square, 1px `--border-strong`,
|
||||
**`--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`),
|
||||
active row marked by an accent left-border + `--layer-01` fill, secondary
|
||||
metadata in `--text-secondary`. Row-level actions reveal on hover.
|
||||
@@ -232,15 +256,32 @@ Carbon and drawn `fill: currentColor`.
|
||||
|
||||
**Core set** — recurring, cross-surface:
|
||||
|
||||
| Meaning | Carbon glyph | Form | Surfaces |
|
||||
| -------------------- | --------------- | --------------------- | ------------------------------------------------- |
|
||||
| Close / dismiss | `Close` (✕) | icon-only ⭐ | `ModalShell`, `Toaster` |
|
||||
| Theme → dark | `Asleep` (moon) | icon-only ⭐ | `ThemeToggle` (shown when light) |
|
||||
| Theme → light | `Light` (sun) | icon-only ⭐ | `ThemeToggle` (shown when dark) |
|
||||
| References a dataset | `DataTable` | icon + text | Library row marker, Linked-datasets list |
|
||||
| Add / create-new | `Add` | icon + text | Library "Create New Snippet", Datasets "New …" |
|
||||
| Delete | `TrashCan` | icon-only ⭐ (danger) | Library row delete¹ — text "Delete" in the panel² |
|
||||
| Unpublished draft | (CSS dot) | status-glyph | Library row (paired with a hidden label) |
|
||||
| Meaning | Carbon glyph | Form | Surfaces |
|
||||
| -------------------- | --------------- | ------------------------ | -------------------------------------------------------------------------------------------- |
|
||||
| Close / dismiss | `Close` (✕) | icon-only ⭐ | `ModalShell`, `Toaster` |
|
||||
| Theme → dark | `Asleep` (moon) | icon-only ⭐ | `ThemeToggle` (shown when light) |
|
||||
| Theme → light | `Light` (sun) | icon-only ⭐ | `ThemeToggle` (shown when dark) |
|
||||
| References a dataset | `DataTable` | icon + text | Library row marker, Linked-datasets list, header **Datasets**, editor **Extract**⁴ |
|
||||
| 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² |
|
||||
| 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) |
|
||||
|
||||
**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
|
||||
the outline UI set, these are coloured **by status** (not by surrounding text) and
|
||||
@@ -257,23 +298,37 @@ notifications/validation as they arrive):
|
||||
| Success | `CheckmarkFilled` | `--support-success` | `Toaster` (success) |
|
||||
| Info | `InformationFilled` | `--support-info` | `Toaster` (info) |
|
||||
|
||||
**Scoped set** — registered (glyph reserved) but single-surface and **not yet
|
||||
implemented**:
|
||||
**Scoped set** — single-surface, glyph **reserved** in the ledger but **not yet in
|
||||
the `Icon` registry**:
|
||||
|
||||
| Meaning | Carbon glyph | Form | Surface / note |
|
||||
| ---------------- | ------------------ | ----------- | ----------------------------------------------------- |
|
||||
| Swap / transpose | `ArrowsHorizontal` | icon + text | Chart Builder "Swap X/Y" — one button; icon is polish |
|
||||
| Meaning | Carbon glyph | Form | Surface / note |
|
||||
| ---------------- | ------------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 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
|
||||
deliberate destructive-row exception — a dense, repeated list action where a label
|
||||
would cost more than it gives. ✕ means **close only**; delete is `TrashCan`, never
|
||||
✕ (that collision is exactly what one-glyph-one-meaning forbids).
|
||||
⭐ = **icon-only set** (the glyph alone names the control, via `aria-label`): the
|
||||
**universal** glyphs `close` + `theme`; the conventional disclosure/affordance
|
||||
glyphs `settings` (gear) and the **pane-toggle** trio (position is the meaning);
|
||||
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 &
|
||||
destructive-intent rules). ² "Duplicate" and "Delete" in the detail panel stay
|
||||
**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`
|
||||
(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
|
||||
|
||||
|
||||
@@ -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
|
||||
§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
|
||||
↓"), per NN/g #6 (recognition over recall), instead of a bare gear. Fields are **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. (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
|
||||
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
|
||||
@@ -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**
|
||||
|
||||
@@ -87,6 +87,13 @@
|
||||
--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
|
||||
default; see styles/tokens.css for the shipped source of truth. --- */
|
||||
[data-accent='teal'] {
|
||||
@@ -436,6 +443,29 @@
|
||||
.btn-ghost:hover {
|
||||
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 {
|
||||
background: var(--support-error);
|
||||
color: var(--on-status);
|
||||
@@ -459,13 +489,16 @@
|
||||
font-size: 12px;
|
||||
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,
|
||||
.select,
|
||||
.textarea {
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
background: var(--layer-01);
|
||||
background: var(--bg);
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
@@ -930,6 +963,26 @@
|
||||
<p class="cap" style="margin-top: var(--space-4)">
|
||||
Tab through to see the focus ring (2px accent outline).
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
<span class="cap">delete</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 class="cap" style="margin-top: var(--space-6)">
|
||||
In context: icon + text is the default; icon-only is the universal/destructive
|
||||
exception. ✕ means close only — delete is the trash glyph.
|
||||
The full Core + pane-toggle set (arch 09 §5.2); the four status glyphs appear,
|
||||
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>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -27,18 +27,18 @@ 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.
|
||||
- **Right side**: a row of text entry points. Each opens a destination:
|
||||
|
||||
| Entry point | Opens |
|
||||
| --------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| 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_). |
|
||||
| Datasets | The Datasets manager modal (see _Datasets_). |
|
||||
| About & Privacy | The About & Help modal (keyboard shortcuts, about, and privacy information). |
|
||||
| Donate | The Donate modal. |
|
||||
| Entry point | Opens |
|
||||
| ----------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| 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_). |
|
||||
| Datasets | The Datasets manager modal (see _Datasets_). |
|
||||
| About | The About & Help modal (keyboard shortcuts, about, and privacy information). Labelled "About"; privacy lives inside. |
|
||||
| Donate | The Donate modal. |
|
||||
|
||||
Notes:
|
||||
|
||||
- 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
|
||||
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
|
||||
|
||||
+41
-5
@@ -61,11 +61,20 @@
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
height: 32px;
|
||||
padding: 0 var(--space-4);
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
padding: 0 var(--space-3);
|
||||
border: var(--border-width) solid transparent;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
@@ -73,11 +82,13 @@
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast) var(--ease);
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease),
|
||||
color var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.headerButton:hover {
|
||||
background: var(--layer-01);
|
||||
background: var(--layer-02);
|
||||
}
|
||||
|
||||
.headerButton:focus-visible {
|
||||
@@ -85,6 +96,31 @@
|
||||
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. */
|
||||
.hiddenInput {
|
||||
display: none;
|
||||
|
||||
+13
-2
@@ -4,6 +4,7 @@ import { LivePreview } from './components/LivePreview';
|
||||
import { ModalShell } from './components/ModalShell';
|
||||
import { Onboarding } from './components/Onboarding';
|
||||
import { PaneSplitHandle } from './components/PaneSplitHandle';
|
||||
import { Icon } from './components/Icon';
|
||||
import { PaneToggleStrip } from './components/PaneToggleStrip';
|
||||
import { ResizeHandle } from './components/ResizeHandle';
|
||||
import { SnippetLibrary } from './components/SnippetLibrary';
|
||||
@@ -74,6 +75,11 @@ export function App() {
|
||||
<h1 className={styles.title}>Astrolabe</h1>
|
||||
<span className={styles.version}>v{__APP_VERSION__}</span>
|
||||
<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
|
||||
type="button"
|
||||
className={styles.headerButton}
|
||||
@@ -81,6 +87,7 @@ export function App() {
|
||||
aria-keyshortcuts="Meta+K Control+K"
|
||||
title="Datasets (⌘/Ctrl+K)"
|
||||
>
|
||||
<Icon name="dataset" />
|
||||
Datasets
|
||||
</button>
|
||||
<button
|
||||
@@ -89,6 +96,7 @@ export function App() {
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="Import a workspace JSON file"
|
||||
>
|
||||
<Icon name="import" />
|
||||
Import
|
||||
</button>
|
||||
<button
|
||||
@@ -97,6 +105,7 @@ export function App() {
|
||||
onClick={() => exportWorkspace()}
|
||||
title="Export your workspace to a JSON file"
|
||||
>
|
||||
<Icon name="export" />
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
@@ -105,11 +114,13 @@ export function App() {
|
||||
onClick={() => openModal('about')}
|
||||
title="About, keyboard shortcuts, and privacy information"
|
||||
>
|
||||
About & Privacy
|
||||
<Icon name="info" />
|
||||
About
|
||||
</button>
|
||||
<span className={styles.headerDivider} aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
className={styles.headerButton}
|
||||
className={`${styles.headerButton} ${styles.donate}`}
|
||||
onClick={() => openModal('donate')}
|
||||
title="Support Astrolabe"
|
||||
>
|
||||
|
||||
@@ -29,6 +29,10 @@ export type IconName =
|
||||
| 'add' // add / create-new — Carbon Add
|
||||
| 'search' // live library search — Carbon Search
|
||||
| '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
|
||||
// glyph shows *which* pane it controls by position (left / centre / right).
|
||||
| '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" />
|
||||
</>
|
||||
),
|
||||
// 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: (
|
||||
<>
|
||||
<rect x="8" y="18" width="4" height="2" />
|
||||
|
||||
@@ -10,13 +10,22 @@
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
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);
|
||||
height: 40px;
|
||||
padding: 0 var(--space-4);
|
||||
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 {
|
||||
position: relative; /* establishes stacking context for the busy overlay */
|
||||
flex: 1 1 auto;
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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). */
|
||||
@@ -39,6 +43,9 @@
|
||||
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);
|
||||
}
|
||||
@@ -47,6 +54,16 @@
|
||||
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). */
|
||||
.controls {
|
||||
flex: 0 0 auto;
|
||||
@@ -74,6 +91,10 @@
|
||||
|
||||
.searchInput {
|
||||
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;
|
||||
/* Room for the leading magnifier and the trailing clear button. */
|
||||
padding: 0 32px 0 32px;
|
||||
@@ -344,10 +365,14 @@
|
||||
.metaComment {
|
||||
width: 100%;
|
||||
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);
|
||||
background: var(--layer-02, var(--background));
|
||||
color: var(--text-primary);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -390,7 +415,7 @@
|
||||
|
||||
.metaTimes dd {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -434,10 +459,11 @@
|
||||
flex: 1 1 auto;
|
||||
height: 32px;
|
||||
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);
|
||||
background: var(--background);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
@@ -449,8 +475,7 @@
|
||||
}
|
||||
|
||||
.metaAction:hover {
|
||||
background: var(--layer-02, var(--layer-01));
|
||||
border-color: var(--border-strong, var(--border));
|
||||
background: var(--layer-02);
|
||||
}
|
||||
|
||||
.metaDanger:hover {
|
||||
|
||||
@@ -254,8 +254,17 @@ export function SnippetLibrary() {
|
||||
{/* Create raises no toast: the new snippet opens in the editor, so the
|
||||
result is already on-screen (spec §02; docs/architecture/10 → Toast
|
||||
copy). Delete/duplicate toast because the outcome isn't visible. */}
|
||||
<button className={styles.createNew} onClick={() => createSnippet()}>
|
||||
<Icon name="add" /> Create New Snippet
|
||||
{/* Primary CTA. When the library pane is dragged narrow the label collapses
|
||||
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>
|
||||
|
||||
{/* Search + Sort controls, pinned above the list (spec §02; council
|
||||
|
||||
@@ -2,15 +2,21 @@
|
||||
|
||||
.wrap {
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
height: 28px;
|
||||
height: 32px;
|
||||
padding: 0 var(--space-3);
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
* A disclosure + non-modal popover, the same APG primitive as SettingsPopover
|
||||
* (docs/architecture/10 → "settings are … a disclosure popover") — deliberately
|
||||
* 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:
|
||||
* Modified ↓") instead of a bare gear, so the user reads the order without
|
||||
* opening the popover. The body is a labelled `group` of field buttons (radio
|
||||
* NN/g recognition-over-recall the trigger shows the current state ("Modified ↓")
|
||||
* instead of a bare gear, so the user reads the order without opening the popover.
|
||||
* 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.
|
||||
*
|
||||
* Selection model (spec §02): re-selecting the ACTIVE field flips direction;
|
||||
@@ -121,11 +123,17 @@ export function SortControl() {
|
||||
className={styles.trigger}
|
||||
aria-expanded={open}
|
||||
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'}`}
|
||||
onClick={() => toggle(ID)}
|
||||
>
|
||||
Sort: {labelFor(sortBy)} <span aria-hidden="true">{arrow}</span>
|
||||
{labelFor(sortBy)} <span aria-hidden="true">{arrow}</span>
|
||||
</button>
|
||||
{open &&
|
||||
createPortal(
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
height: 40px;
|
||||
padding: 0 var(--space-4);
|
||||
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 {
|
||||
@@ -47,6 +51,42 @@
|
||||
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 {
|
||||
border-color: transparent;
|
||||
background: var(--accent);
|
||||
|
||||
@@ -33,6 +33,7 @@ import { notify } from '../stores/NotificationStore';
|
||||
import { usePreviewStore } from '../stores/PreviewStore';
|
||||
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
||||
import { Icon } from './Icon';
|
||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||
import {
|
||||
NumberControl,
|
||||
@@ -189,23 +190,33 @@ function EditorToolbar() {
|
||||
|
||||
<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 && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.action}
|
||||
className={`${styles.action} ${styles.collapsible}`}
|
||||
onClick={() => openModal('extract')}
|
||||
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
|
||||
type="button"
|
||||
className={styles.action}
|
||||
className={`${styles.action} ${styles.collapsible}`}
|
||||
onClick={() => void handleRevert()}
|
||||
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
|
||||
type="button"
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('clampSideWidth', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
// 300px can't fit library(180)+preview(240); the library holds its own min.
|
||||
expect(splitLibraryWidth(250, 300)).toBe(PANE_MIN.library);
|
||||
// 400px can't fit library(216)+preview(300); the library holds its own min.
|
||||
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', () => {
|
||||
// The user re-split to 400:240 while the editor was hidden.
|
||||
const { libraryWidth, previewWidth } = shownSideWidths(PANES_INNER, E, 400, 240, true, true);
|
||||
// The user re-split to 340:300 while the editor was hidden (both ≥ their
|
||||
// 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…
|
||||
expect(libraryWidth + previewWidth).toBe(PANES_INNER - E - HANDLES_TOTAL);
|
||||
// …and the 400:240 ratio is preserved (5:3).
|
||||
expect(libraryWidth / previewWidth).toBeCloseTo(400 / 240, 5);
|
||||
// …and the 340:300 ratio is preserved.
|
||||
expect(libraryWidth / previewWidth).toBeCloseTo(340 / 300, 5);
|
||||
});
|
||||
|
||||
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);
|
||||
const captured = store().editorWidth;
|
||||
// Drag the library↔preview handle while hidden (setSplit writes both widths).
|
||||
store().setSplit(400, 240);
|
||||
store().setSplit(340, 300);
|
||||
|
||||
store().togglePane('editor', PANES_INNER);
|
||||
expect(store().editorWidth).toBe(captured); // editor reclaims its slot
|
||||
expect(store().libraryWidth + store().previewWidth).toBe(
|
||||
PANES_INNER - captured - HANDLES_TOTAL,
|
||||
);
|
||||
expect(store().libraryWidth / store().previewWidth).toBeCloseTo(400 / 240, 5);
|
||||
expect(store().libraryWidth / store().previewWidth).toBeCloseTo(340 / 300, 5);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -25,8 +25,17 @@ export type PaneSide = 'library' | 'preview';
|
||||
/** Every pane that can be shown/hidden via the toggle strip (spec §01A). */
|
||||
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. */
|
||||
export const PANE_DEFAULT = { library: 280, preview: 360 } as const;
|
||||
|
||||
@@ -102,6 +102,17 @@
|
||||
--accent-hover: #0c5f77;
|
||||
--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'] {
|
||||
--accent: #2dd4bf;
|
||||
--accent-hover: #5eead4;
|
||||
|
||||
Reference in New Issue
Block a user