Fix dataset-ref walk false positive and wire spec-mandated success toasts

- Prune the data payload and top-level datasets keys in all three ref walks
  (extractDatasetRefs, renameDatasetInSpec, resolveDatasetRefs) so a data row
  carrying a field named "data" is no longer misread as a library reference,
  spuriously rewritten, or made to throw DatasetNotFoundError. Adds tests,
  including a guard that lookup-transform refs (from.data) still resolve.
- Wire the deferred success toasts now the Toaster has landed: publish, revert,
  extract-to-dataset, and snippet/dataset delete. Copy follows the council
  title-vs-message rule (title states the action, message adds the consequence).
- Reconcile the spec's blanket toast mandate to "toast only what the user can't
  already see": no toast on visible-result creates (snippet, dataset form);
  Copy Reference stays inline and gains an aria-live announcement (new shared
  .visually-hidden utility) instead of a toast-per-copy.
- Move the toast region to bottom-right so it stops covering the header action
  cluster (Publish/Revert, theme/datasets).
- Update docs/spec 01F/02/05 and docs/architecture/07 + 10 to match.
This commit is contained in:
2026-06-05 16:34:58 +03:00
parent a4e4d96d3b
commit 693f5d7073
17 changed files with 263 additions and 67 deletions
@@ -132,9 +132,14 @@ always mirrors the dataset names actually referenced in the published spec.
### 3.1 Extracting referenced names from a spec (pure — `src/core/spec-refs.ts`)
A Vega-Lite spec can reference named data in several places: the top-level
`data`, per-layer `data`, `data` inside `spec`/`facet`/`hconcat`/`vconcat`, and
named entries in top-level `datasets`. Rather than enumerate Vega-Lite's grammar,
we walk the spec recursively and collect every `{ data: { name } }` we find.
`data`, per-layer `data`, `data` inside `spec`/`facet`/`hconcat`/`vconcat`, and a
lookup transform's `from.data`. A spec may also define its OWN inline datasets via
a top-level `datasets` map — those are self-defined, not library references.
Rather than enumerate Vega-Lite's grammar, we walk the spec recursively and
collect every `{ data: { name } }` — but **prune two keys**: never recurse into a
`data` object's payload (its `values`/rows) or the top-level `datasets` map,
because those hold user data, not nested specs. Without the prune, a data _row_
carrying a field literally named `data: { name: "x" }` is misread as a reference.
This is pure, deterministic, and the most heavily unit-tested function here.
```ts
@@ -142,8 +147,10 @@ This is pure, deterministic, and the most heavily unit-tested function here.
type Json = unknown;
/** Collects every dataset name referenced by `{ data: { name } }` anywhere in the spec. */
/** Collects every library dataset name referenced by `{ data: { name } }`, excluding self-defined ones. */
export function extractDatasetRefs(spec: Json): string[] {
const root = typeof spec === 'string' ? safeParse(spec) : spec;
const selfDefined = selfDefinedNames(root); // names from the spec's own top-level `datasets`
const names = new Set<string>();
const walk = (node: Json): void => {
@@ -155,13 +162,17 @@ export function extractDatasetRefs(spec: Json): string[] {
const obj = node as Record<string, Json>;
const data = obj.data as Record<string, Json> | undefined;
if (data && typeof data === 'object' && typeof data.name === 'string') {
names.add(data.name);
if (!selfDefined.has(data.name)) names.add(data.name);
}
// Prune: a `data` payload and the `datasets` map hold user data, not refs.
for (const key of Object.keys(obj)) {
if (key === 'data' || key === 'datasets') continue;
walk(obj[key]);
}
for (const key of Object.keys(obj)) walk(obj[key]);
}
};
walk(typeof spec === 'string' ? safeParse(spec) : spec);
walk(root);
return [...names];
}
@@ -193,11 +204,20 @@ export function recomputeDatasetRefs(spec: Json): string[] {
agreeing with what the renderer actually resolves.
- Recompute and store `datasetRefs` on **publish**, not on every keystroke —
the draft can be transiently invalid, and only the published spec is shared.
- Prune the **same two keys** (`data`, `datasets`) in all three ref walks —
extraction here, `renameDatasetInSpec`, and the renderer's `resolveDatasetRefs`
(`src/core/rendering.ts`). They must agree on what counts as a reference; if one
descends into data payloads and another doesn't, extraction and rendering
disagree and a row field named `data` either gets counted, rewritten, or throws
`DatasetNotFoundError`.
**Don't**
- Don't let two code paths each have their own idea of "referenced names".
Renamer and ref-recomputer must use the same extractor.
Renamer, ref-recomputer, and renderer must use the same walk shape.
- Don't "enumerate the grammar" (scope the walk to a fixed list of container
keys) to fix the payload-descent problem — pruning the two data-bearing keys
stays correct as Vega-Lite's composition grammar grows; an allow-list rots.
---
@@ -323,19 +343,21 @@ The spec rewrite is pure; the orchestration reads and writes stores.
/** Returns a copy of `spec` with every data.name === oldName replaced by newName. */
export function renameDatasetInSpec(spec: Json, oldName: string, newName: string): Json {
const obj = typeof spec === 'string' ? safeParse(spec) : spec;
const selfDefined = selfDefinedNames(obj); // never rename a spec's own inline dataset name
const rewrite = (node: Json): Json => {
if (Array.isArray(node)) return node.map(rewrite);
if (node && typeof node === 'object') {
const out: Record<string, Json> = {};
for (const [k, v] of Object.entries(node as Record<string, Json>)) {
if (
k === 'data' &&
v &&
typeof v === 'object' &&
(v as Record<string, Json>).name === oldName
) {
out[k] = { ...(v as object), name: newName };
// A `data` object is a reference site: rename a matching name, but never
// recurse into its payload. `datasets` (self-defined inline data) is left
// whole. Same prune as extractDatasetRefs — see §3.1.
if (k === 'data' && v && typeof v === 'object' && !Array.isArray(v)) {
const dv = v as Record<string, Json>;
out[k] = dv.name === oldName && !selfDefined.has(oldName) ? { ...dv, name: newName } : dv;
} else if (k === 'data' || k === 'datasets') {
out[k] = v;
} else {
out[k] = rewrite(v);
}
@@ -43,6 +43,28 @@ nature of the message, not by convenience.
consent-for-destruction in a toast.
- **Errors persist; success fades.** An error/warning toast waits for the user (a critical
message must not vanish on a timer); success/info auto-dismiss (~6s).
- **Toasts sit bottom-right**, not top-right. Carbon's default is the top, but our header's
action cluster (Publish/Revert, theme/datasets) lives top-right — a toast there covers the
control the user just used. Bottom-anchored, the stack grows upward with the newest toast
nearest the corner, and still clears the centered confirm dialog. (`Toaster.module.css`.)
- **Toast copy: title states it, message adds to it.** Every toast renders a `title` and a
`message`. The title is the short headline — the action or what stopped, **no terminal
period** ("Snippet published", "Storage full"). The message is **one short sentence that
must not paraphrase the title** (Carbon, `components/notification/usage.mdx` §Body
content: _"Don't repeat or paraphrase the title"_); it carries the **consequence** for a
success ("Your draft is now the published version") or the **next step** for a fixable
error. Name the specific item in the message when toasts can stack — a delete confirms
_which_ one went ("…removed `\"Sales\"`…").
- **Toast only what the user can't already see.** A success toast is for an outcome with no
strong on-screen cue: a **side effect** (Extract creates a dataset off-screen while the
user is in the editor), a **disappearance** (delete), or a **state flip** (publish/revert).
An action whose result is immediately visible — a created snippet opening in the editor, a
new dataset shown selected — is confirmed by that visible change; adding a toast is noise
(NN/g aesthetic-and-minimalist; Carbon `notification/usage.mdx` "Deciding what to use").
An **invisible** outcome that still shouldn't toast is **copy-to-clipboard**: confirm it
_inline on the control_ ("Copied") with a polite `aria-live` announcement for assistive
tech, never a toast-per-copy. This refines the spec's earlier blanket "every action
toasts" (spec §01F/§02/§05, reconciled).
- **The same failure can light up two channels.** An unrenderable spec shows the _same_
message inline in both the editor (§03E) and the preview (§04) — one producer
(`PreviewStore`), two subscribers. That's intentional, not duplication.