Add snippet metadata panel, duplicate, and immediate-load preview (M4.5)

This commit is contained in:
2026-06-06 23:54:59 +03:00
parent 3e89d9a531
commit 80bedd2a8d
13 changed files with 785 additions and 51 deletions
+40
View File
@@ -324,6 +324,46 @@ The convergent rules and citations live in that doc; the spec (§06) was amended
---
## M4.5 · Snippet-library consolidation ✅ (done)
**Why out of band:** a spec-vs-implementation audit after M4 found §02 features that no
later milestone owned — the **Selected-Snippet Metadata Panel** (inline name + comment
editing, timestamps, linked datasets) and the **Duplicate** operation. Without them a
snippet could only ever carry its auto-generated date-time name (no rename, no annotation,
no copy), a sharp edge for a _snippet manager_. Closed before M5 since the spec text
already existed and the work was core-first and cheap.
**Core**
- `snippet.ts``duplicateSnippet(source, {now,id})`: independent copy carrying both spec
versions, comment, tags, and dataset refs; "(copy)" name; fresh identity/timestamps;
cloned mutable members.
**App**
- `SnippetStore``renameSnippet`, `setComment` (both advance `modified` per §02 → Sort,
no editor-buffer touch), `duplicateActiveSnippet` (flushes the live buffer first, prepends
the copy, makes it active).
- `SnippetLibrary` — the metadata panel below the list: Name + Comment auto-save (debounced
while typing, flushed on blur), read-only Created/Modified, Linked Datasets list, and
Duplicate / Delete. Duplicate raises a success toast (the copy isn't self-evident, unlike
Create); §02-compliant.
**Also fixed (§03C divergence):** the preview debounced _every_ change, so a snippet
load / Draft↔Published switch incurred a 300 ms blank instead of the spec's **immediate**
render. `LivePreview` now renders immediately on `bufferEpoch`/`editorView` change and
debounces only keystroke (`shownText`-only) changes.
**Still deferred to M5/M6 (per §02):** Search, Sort controls + persistence, two distinct
empty-state messages, Storage Monitor.
**Verified:** `typecheck` + `test` (287 passing — `snippet` duplicate factory,
`SnippetStore` rename/comment/duplicate, a `SnippetLibrary` render test guarding the
auto-save effect against a render loop) + `eslint` clean + `build` (PWA, 41 precache
entries).
---
## M5 · Settings + Import/Export
**Goal:** preferences and whole-workspace backup/transfer.
+8
View File
@@ -472,6 +472,14 @@ reset: () => set({ snippets: [], activeSnippetId: null, draftSpec: '' });
- Do persistence and external sync (IndexedDB, `localStorage`, URL hash, theme) in
startup `subscribe` listeners via `infrastructure/` adapters.
- Debounce expensive reactions (auto-save, re-render) inside the subscriber.
- Advance a snippet's `modified` on **every** save the library sorts by — draft
auto-save, inline name/comment edits, publish, revert, rename-propagation — so
Modified-descending keeps the just-touched snippet on top (spec §02 → Sort).
- Bump `SnippetStore.bufferEpoch` only on a _programmatic_ buffer load (select /
create / duplicate / revert / hydrate) — it is the "reload the editor, this isn't
a keystroke" signal consumed by both the Monaco buffer and the preview's
immediate-render path (arch 05 §5). Metadata edits (name/comment) advance
`modified` but must **not** bump it — they aren't in the spec buffer.
- Import singleton store hooks directly in the leaves that need shared state.
**Don't**
@@ -333,6 +333,28 @@ useSettingsStore.subscribe((s, prev) => {
});
```
### Implemented policy: what renders immediately vs. debounced
> The service above is a **sketch**; the shipped renderer lives inline in
> `LivePreview.tsx` (one `setTimeout` whose delay is computed per change) and
> subscribes to the stores via hooks rather than startup subscribers. When it is
> extracted into a service, preserve this policy.
The debounce exists to stay out of the way **while typing** — nothing else. So the
delay is `0` (immediate) for everything except keystrokes (spec §03C):
- **Immediate** — a _programmatic buffer load_ (`SnippetStore.bufferEpoch` changed:
select / create / duplicate / revert / hydrate) or a _Draft↔Published switch_
(`editorView` changed). These are the cases §03C names; the editor and preview
both key off `bufferEpoch` to tell a load from a keystroke.
- **Debounced** — a keystroke (only `shownText` changed). This is the churn the
debounce protects against.
Detect "this was a keystroke" by elimination: `shownText` changed but `bufferEpoch`
and `editorView` did **not**. Fit-mode and theme changes currently fall through the
debounce too (harmless; not typing) — flush them if instant feedback is wanted, but
never debounce a load or a view switch.
### Busy indicator
`setBusy(true/false)` toggles store state that the preview reads to overlay a
@@ -532,7 +554,7 @@ bookkeeping. Gate the observer to responsive modes (Original needs no re-fit).
| View teardown | `view.finalize()` before each re-render and on unmount | the renderer's `RenderHandle` |
| Theming | Vega `Config` per UI theme, applied at embed time | `chartConfigFor()` in `src/core/vega-themes.ts` |
| Field names | `escapeVegaField` on every data-derived `field:` | `src/core/rendering.ts` |
| Debounce | `createDebouncedRenderer`, delay from `renderDebounce` setting | `src/app/services/debounced-renderer.ts` |
| Debounce | Inline timer; `0` on buffer-load/view-switch, `renderDebounce` on keystroke (§5) | `LivePreview.tsx` (service not yet extracted) |
| Spec prep | `prepareSpecForRender` (pure, on a copy) | `src/core/rendering.ts` (see _Live Preview_) |
| Errors | One error field, cleared on success, empty = nothing | `PreviewStore.error` |
| Container fit | Inner host + frame (out-specify `.vega-embed`); resize via synthetic `window:resize` | §8 (`LivePreview` + `chart-renderer`) |
@@ -252,6 +252,29 @@ Not features to add later — the baseline every surface is built on.
---
## 7. Revealed actions & destructive affordances
How row/list actions appear, and how dangerous ones signal themselves. (Pairs with the
iconography contract, [arch 09 §5](09-visual-design.md).)
- **Reveal-on-hover is a per-surface choice, not a default.** Hiding a control until hover
cuts clutter in a **dense, repeated** list the user inevitably traverses (the snippet-row
delete) — there, arrival is guaranteed, so discoverability isn't lost. But a **rare or
load-bearing** action must stay **always-visible**, or it becomes effectively unreachable
(NN/g #6 — recognition over recall; a feature you can't see you can't use). Decide per
surface; when in doubt, show it.
- **A hover-revealed control must also reveal on keyboard focus.** Gate visibility on
`:hover` **and** `:focus-within`/`:focus-visible`, never hover alone — otherwise the
action is mouse-only and invisible to keyboard users (WCAG 2.1.1). The snippet row reveals
its delete on `.item:hover` _and_ `.delete:focus-visible`.
- **Destructive controls signal danger on hover _and_ focus.** A delete/remove affordance
reddens to `--support-error` on both `:hover` and `:focus-visible` — not colour-by-mouse
only — so the warning reaches keyboard users at parity. Colour is a _reinforcement_ here,
never the sole signal: the control still carries its label/`aria-label` and the
consequential ones still route through a confirm dialog (§4).
---
## Do / Don't
**Do**
+1 -1
View File
@@ -61,7 +61,7 @@ The library provides the lifecycle operations for snippets. An operation whose o
- **Create New**: starts a new snippet from a small sample Vega-Lite bar-chart template (a few inline category/value rows), assigns it an auto-generated default name (see _Naming & Tags_), saves it, and makes it the active snippet. Opening in the editor _is_ the confirmation, so no toast is raised.
- **Duplicate**: creates an independent copy of the active snippet with a name suffixed "(copy)". The copy carries over the specification, comment, tags, and dataset references, gets fresh created/modified timestamps and a new identity, and becomes the active snippet. A success toast confirms the duplication.
- **Delete**: permanently removes the active snippet after the user confirms a warning that the action cannot be undone. After deletion no snippet is active. A toast confirms the deletion.
- **Delete**: permanently removes the active snippet after the user confirms a warning that the action cannot be undone. After deletion the newest remaining snippet becomes active (so the editor and detail panel stay populated); if none remain, no snippet is active. A toast confirms the deletion.
- These operations never affect other snippets.
## Naming & Tags