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
+16 -2
View File
@@ -78,6 +78,14 @@ export function LivePreview() {
// Datasets feed reference resolution (spec §04 step 1). Re-rendering on a
// dataset change keeps a referencing chart live as its data is edited.
const datasets = useDatasetStore(useShallow((s) => s.datasets));
// A programmatic buffer load (select/create/revert/hydrate — `bufferEpoch`) or a
// Draft/Published switch (`editorView`) must render *immediately*, not after the
// typing debounce (spec §03C). Keystrokes change only `shownText`, so when these
// two are unchanged the change is typing and the debounce applies.
const bufferEpoch = useSnippetStore((s) => s.bufferEpoch);
const editorView = useSnippetStore((s) => s.editorView);
// Seed with a sentinel epoch so the very first paint counts as a load (immediate).
const lastLoadRef = useRef({ bufferEpoch: -1, editorView });
const error = usePreviewStore((s) => s.error);
const setError = usePreviewStore((s) => s.setError);
@@ -90,6 +98,12 @@ export function LivePreview() {
if (!node) return;
const text = shownText.trim();
// Immediate on snippet load / view switch (spec §03C), debounced while typing.
const prevLoad = lastLoadRef.current;
const immediate = bufferEpoch !== prevLoad.bufferEpoch || editorView !== prevLoad.editorView;
lastLoadRef.current = { bufferEpoch, editorView };
const delay = immediate ? 0 : RENDER_DEBOUNCE_MS;
// The debounced body is async; wrap in a void IIFE so the timer callback
// returns void (it handles its own errors internally — nothing awaits it).
const timer = setTimeout(() => {
@@ -148,10 +162,10 @@ export function LivePreview() {
}
}
})();
}, RENDER_DEBOUNCE_MS);
}, delay);
return () => clearTimeout(timer);
}, [shownText, fitMode, uiTheme, datasets, setError]);
}, [shownText, fitMode, uiTheme, datasets, setError, bufferEpoch, editorView]);
// Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't
// observe the element, so we do: one observer on the stable host node for the
+169 -8
View File
@@ -6,6 +6,10 @@
.createNew {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
margin: var(--space-4);
height: 40px;
padding: 0 var(--space-5);
@@ -16,7 +20,6 @@
font: inherit;
font-weight: 600;
cursor: pointer;
text-align: center;
transition: background var(--dur-fast) var(--ease);
}
@@ -130,17 +133,23 @@
}
/* Linked-datasets indicator (spec §02): icon + count, meaning carried by the
icon + accessible label, not colour. Pushed to the row's trailing edge. */
icon + accessible label, not colour. Sits inline with the date as one grouped
metadata run, separated by a middot, rather than orphaned at the row's edge. */
.datasets {
flex: 0 0 auto;
margin-left: auto;
display: inline-flex;
align-items: center;
gap: var(--space-1);
font-size: 11px;
font-size: 12px;
color: var(--text-secondary);
}
.datasets::before {
content: '·';
margin-right: var(--space-2);
color: var(--text-placeholder);
}
.delete {
flex: 0 0 auto;
align-self: center;
@@ -151,11 +160,12 @@
background: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 12px;
padding: var(--space-1);
padding: var(--space-2);
border-radius: var(--radius);
opacity: 0;
transition: opacity var(--dur-fast) var(--ease);
transition:
opacity var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.item:hover .delete,
@@ -163,6 +173,157 @@
opacity: 1;
}
.delete:hover {
/* Destructive intent reddens on hover AND keyboard focus, not colour-by-mouse-only
(arch 10 — destructive controls signal danger on hover/focus). */
.delete:hover,
.delete:focus-visible {
color: var(--support-error);
}
/* Selected-Snippet Metadata Panel (spec §02) — pinned below the list, the active
snippet's editable Name/Comment, read-only timestamps, linked datasets, and
Duplicate/Delete. Capped height with its own scroll so a long comment never
pushes the list away entirely. */
.meta {
flex: 0 0 auto;
max-height: 45%;
overflow: auto;
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border-top: var(--border-width) solid var(--border);
background: var(--layer-01);
}
.metaField {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.metaLabel {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--text-secondary);
}
.metaName,
.metaComment {
width: 100%;
padding: var(--space-2) var(--space-3);
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
background: var(--layer-02, var(--background));
color: var(--text-primary);
font: inherit;
font-size: 13px;
}
.metaName {
font-weight: 500;
}
.metaComment {
resize: vertical;
min-height: 2.4em;
line-height: 1.4;
}
.metaName:focus-visible,
.metaComment:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
border-color: var(--accent);
}
/* Read-only Created / Modified, as compact label→value rows. */
.metaTimes {
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.metaTimes > div {
display: flex;
justify-content: space-between;
gap: var(--space-3);
font-size: 11px;
}
.metaTimes dt {
color: var(--text-secondary);
}
.metaTimes dd {
margin: 0;
color: var(--text-primary);
white-space: nowrap;
}
.metaLinked {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.linkedList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.linkedItem {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 12px;
color: var(--text-secondary);
}
.linkedName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.metaActions {
display: flex;
gap: var(--space-2);
margin-top: var(--space-1);
}
.metaAction {
flex: 1 1 auto;
height: 32px;
padding: 0 var(--space-3);
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
background: var(--background);
color: var(--text-primary);
font: inherit;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
border-color var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.metaAction:hover {
background: var(--layer-02, var(--layer-01));
border-color: var(--border-strong, var(--border));
}
.metaDanger:hover {
color: var(--support-error);
border-color: var(--support-error);
}
+105
View File
@@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { createSnippet } from '@core/snippet';
import { useSnippetStore } from '../stores/SnippetStore';
import { SnippetLibrary } from './SnippetLibrary';
// React 19 wants this flag set for act() to drive effects without warnings.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
useSnippetStore.getState().reset();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
/** Set a controlled input/textarea's value the way React expects, then fire input. */
function typeInto(el: HTMLInputElement | HTMLTextAreaElement, value: string) {
const proto =
el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
// The native value setter bypasses React 19's input value tracking so the
// synthetic input event registers as a real change.
// eslint-disable-next-line @typescript-eslint/unbound-method
const setter = Object.getOwnPropertyDescriptor(proto, 'value')!.set!;
setter.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
}
describe('SnippetLibrary metadata panel (spec §02)', () => {
test('renders the active snippet name, comment, and linked datasets without looping', async () => {
const s = {
...createSnippet({ id: 'a', name: 'Bar chart', now: new Date('2026-01-01T00:00:00Z') }),
comment: 'a note',
datasetRefs: ['Sales'],
};
useSnippetStore.getState().hydrate([s], 'a');
// If the auto-save effect looped, this act() would throw "Maximum update depth".
await act(async () => {
root.render(<SnippetLibrary />);
await Promise.resolve();
});
const name = container.querySelector('input') as HTMLInputElement;
const comment = container.querySelector('textarea') as HTMLTextAreaElement;
expect(name.value).toBe('Bar chart');
expect(comment.value).toBe('a note');
expect(container.textContent).toContain('Linked datasets');
expect(container.textContent).toContain('Sales');
});
test('auto-saves an inline name edit after the debounce', () => {
vi.useFakeTimers();
const s = createSnippet({ id: 'a', name: 'Old', now: new Date('2026-01-01T00:00:00Z') });
useSnippetStore.getState().hydrate([s], 'a');
act(() => {
root.render(<SnippetLibrary />);
});
const name = container.querySelector('input') as HTMLInputElement;
act(() => typeInto(name, 'Renamed'));
// Before the debounce fires, the store is unchanged.
expect(useSnippetStore.getState().snippets[0].name).toBe('Old');
act(() => {
vi.advanceTimersByTime(500);
});
expect(useSnippetStore.getState().snippets[0].name).toBe('Renamed');
});
test('Duplicate adds an independent copy and makes it active', async () => {
const s = createSnippet({ id: 'a', name: 'Chart', now: new Date('2026-01-01T00:00:00Z') });
useSnippetStore.getState().hydrate([s], 'a');
await act(async () => {
root.render(<SnippetLibrary />);
await Promise.resolve();
});
const dup = [...container.querySelectorAll('button')].find(
(b) => b.textContent === 'Duplicate',
)!;
await act(async () => {
dup.click();
await Promise.resolve();
});
const { snippets, activeSnippetId } = useSnippetStore.getState();
expect(snippets).toHaveLength(2);
const active = snippets.find((x) => x.id === activeSnippetId)!;
expect(active.name).toBe('Chart (copy)');
expect(active.id).not.toBe('a');
});
});
+152 -38
View File
@@ -1,49 +1,32 @@
/**
* Snippet Library — the left pane (spec §02).
*
* M1 scope: the always-visible list with a pinned "Create New Snippet" item,
* The always-visible list with a pinned "Create New Snippet" item,
* selection/highlight, and delete. Each row carries a secondary metadata line —
* draft status indicator, relative date, and size (spec §02). Search, sort
* controls, the metadata panel, the dataset icon, and the storage monitor arrive
* in later milestones.
* draft status indicator, relative date, and size (spec §02). Below the list, the
* Selected-Snippet Metadata Panel exposes the active snippet's editable Name and
* Comment (auto-saved), its timestamps and linked datasets, and the Duplicate /
* Delete operations. Search, sort controls, and the storage monitor arrive in
* later milestones.
*/
import { useEffect, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { formatSnippetSize, hasUnpublishedChanges, snippetSizeBytes } from '@core/snippet';
import {
formatSnippetSize,
hasUnpublishedChanges,
snippetSizeBytes,
type Snippet,
} from '@core/snippet';
import { confirm } from '../stores/ConfirmStore';
import { notify } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { selectActiveSnippet, useSnippetStore } from '../stores/SnippetStore';
import { Icon } from './Icon';
import styles from './SnippetLibrary.module.css';
/** A small database glyph for the linked-datasets indicator (icons keep their own
* rounded geometry per the design tokens). Inherits `currentColor` so it themes. */
function DatasetIcon() {
return (
<svg width="11" height="11" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<ellipse
cx="8"
cy="3.5"
rx="5.5"
ry="2.2"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
/>
<path
d="M2.5 3.5v9c0 1.2 2.46 2.2 5.5 2.2s5.5-1 5.5-2.2v-9"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
/>
<path
d="M2.5 8c0 1.2 2.46 2.2 5.5 2.2s5.5-1 5.5-2.2"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
/>
</svg>
);
}
/** Auto-save settle time for the metadata panel's Name/Comment fields, mirroring
* the editor's draft auto-save (spec §02 → "edits save automatically"). */
const META_AUTOSAVE_MS = 400;
/** Compact relative date for the list (full date formatting lands in M5). */
function relativeDate(iso: string): string {
@@ -62,12 +45,119 @@ function relativeDate(iso: string): string {
return then.toLocaleDateString();
}
/** Absolute date-time for the metadata panel's read-only timestamps (the user's
* date-format setting wires in at M5; until then, the locale default). */
function formatTimestamp(iso: string): string {
return new Date(iso).toLocaleString();
}
/**
* Selected-Snippet Metadata Panel (spec §02). Keyed by snippet id by its caller,
* so switching the active snippet remounts it and the local field state re-seeds
* cleanly. Name/Comment edits auto-save: debounced while typing and flushed on
* blur (so clicking away to another snippet commits before this unmounts). Both
* advance the snippet's modified time via the store (§02 → Sort).
*/
function SnippetMeta({
snippet,
onDuplicate,
onDelete,
}: {
snippet: Snippet;
onDuplicate: () => void;
onDelete: () => void;
}) {
const renameSnippet = useSnippetStore((s) => s.renameSnippet);
const setComment = useSnippetStore((s) => s.setComment);
const [name, setName] = useState(snippet.name);
const [comment, setCommentLocal] = useState(snippet.comment);
useEffect(() => {
if (name === snippet.name) return;
const t = setTimeout(() => renameSnippet(snippet.id, name), META_AUTOSAVE_MS);
return () => clearTimeout(t);
}, [name, snippet.id, snippet.name, renameSnippet]);
useEffect(() => {
if (comment === snippet.comment) return;
const t = setTimeout(() => setComment(snippet.id, comment), META_AUTOSAVE_MS);
return () => clearTimeout(t);
}, [comment, snippet.id, snippet.comment, setComment]);
return (
<section className={styles.meta} aria-label="Snippet details">
<label className={styles.metaField}>
<span className={styles.metaLabel}>Name</span>
<input
className={styles.metaName}
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={() => renameSnippet(snippet.id, name)}
spellCheck={false}
/>
</label>
<label className={styles.metaField}>
<span className={styles.metaLabel}>Comment</span>
<textarea
className={styles.metaComment}
value={comment}
onChange={(e) => setCommentLocal(e.target.value)}
onBlur={() => setComment(snippet.id, comment)}
rows={2}
placeholder="Add a note…"
/>
</label>
<dl className={styles.metaTimes}>
<div>
<dt>Created</dt>
<dd>{formatTimestamp(snippet.created)}</dd>
</div>
<div>
<dt>Modified</dt>
<dd>{formatTimestamp(snippet.modified)}</dd>
</div>
</dl>
{snippet.datasetRefs.length > 0 && (
<div className={styles.metaLinked}>
<span className={styles.metaLabel}>Linked datasets</span>
<ul className={styles.linkedList}>
{snippet.datasetRefs.map((ref) => (
<li key={ref} className={styles.linkedItem}>
<Icon name="dataset" />
<span className={styles.linkedName}>{ref}</span>
</li>
))}
</ul>
</div>
)}
<div className={styles.metaActions}>
<button type="button" className={styles.metaAction} onClick={onDuplicate}>
Duplicate
</button>
<button
type="button"
className={`${styles.metaAction} ${styles.metaDanger}`}
onClick={onDelete}
>
Delete
</button>
</div>
</section>
);
}
export function SnippetLibrary() {
const snippets = useSnippetStore(useShallow((s) => s.snippets));
const activeId = useSnippetStore((s) => s.activeSnippetId);
const activeSnippet = useSnippetStore(selectActiveSnippet);
const createSnippet = useSnippetStore((s) => s.createSnippet);
const selectSnippet = useSnippetStore((s) => s.selectSnippet);
const removeSnippet = useSnippetStore((s) => s.removeSnippet);
const duplicateActiveSnippet = useSnippetStore((s) => s.duplicateActiveSnippet);
// Default ordering: newest-modified first (spec §02 → Sort).
const ordered = [...snippets].sort((a, b) => b.modified.localeCompare(a.modified));
@@ -91,13 +181,26 @@ export function SnippetLibrary() {
});
};
const handleDuplicate = () => {
const id = duplicateActiveSnippet();
if (!id) return;
// The copy isn't visibly distinct from its source at a glance, so the outcome
// needs a toast (spec §02 → Duplicate; contract 10 §1 — toast what isn't
// already self-evident, unlike Create which opens visibly in the editor).
notify({
kind: 'success',
title: 'Snippet duplicated',
message: 'An independent copy was added and is now active.',
});
};
return (
<div className={styles.library}>
{/* 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()}>
+ Create New Snippet
<Icon name="add" /> Create New Snippet
</button>
<ul className={styles.list}>
@@ -146,7 +249,7 @@ export function SnippetLibrary() {
title={`Linked datasets: ${s.datasetRefs.join(', ')}`}
aria-label={`${s.datasetRefs.length} linked dataset${s.datasetRefs.length === 1 ? '' : 's'}`}
>
<DatasetIcon />
<Icon name="dataset" />
{s.datasetRefs.length}
</span>
)}
@@ -158,12 +261,23 @@ export function SnippetLibrary() {
title="Delete snippet"
onClick={() => void handleDelete(s.id, s.name)}
>
<Icon name="delete" />
</button>
</li>
);
})}
</ul>
{/* Selected-Snippet Metadata Panel (spec §02). Keyed by id so it re-seeds
its local field state when the active snippet changes. */}
{activeSnippet && (
<SnippetMeta
key={activeSnippet.id}
snippet={activeSnippet}
onDuplicate={handleDuplicate}
onDelete={() => void handleDelete(activeSnippet.id, activeSnippet.name)}
/>
)}
</div>
);
}