From a4e4d96d3ba99bc21770861bd5ef79f73cd7698e Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Fri, 5 Jun 2026 15:49:40 +0300 Subject: [PATCH] Add dataset library, extract-to-dataset, and render-time reference resolution --- .../10-interaction-and-feedback.md | 25 + src/app/App.module.css | 24 + src/app/App.tsx | 40 +- src/app/components/DatasetsModal.module.css | 437 ++++++++++++++++++ src/app/components/DatasetsModal.tsx | 419 +++++++++++++++++ src/app/components/ExtractModal.module.css | 114 +++++ src/app/components/ExtractModal.tsx | 90 ++++ src/app/components/LivePreview.tsx | 29 +- src/app/components/ModalShell.module.css | 95 ++++ src/app/components/ModalShell.tsx | 81 ++++ src/app/components/SnippetLibrary.module.css | 12 + src/app/components/SnippetLibrary.tsx | 42 ++ src/app/components/SpecEditor.tsx | 17 + src/app/infrastructure/dataset-migrations.ts | 51 ++ src/app/infrastructure/dataset-store.ts | 28 ++ src/app/modals/ModalCoordinator.ts | 78 ++++ src/app/modals/UrlStateSync.ts | 26 ++ src/app/modals/modal-registry.ts | 73 +++ src/app/modals/types.ts | 19 + src/app/orchestration/dataset-persistence.ts | 54 +++ src/app/orchestration/startup.ts | 17 +- src/app/services/RelationshipService.test.ts | 74 +++ src/app/services/RelationshipService.ts | 54 +++ src/app/stores/AppStore.ts | 7 +- src/app/stores/DatasetStore.test.ts | 144 ++++++ src/app/stores/DatasetStore.ts | 257 ++++++++++ src/app/stores/ExtractStore.ts | 133 ++++++ src/app/stores/SnippetStore.test.ts | 89 ++++ src/app/stores/SnippetStore.ts | 78 +++- src/core/dataset.test.ts | 180 ++++++++ src/core/dataset.ts | 271 +++++++++++ src/core/naming.test.ts | 53 +++ src/core/naming.ts | 46 ++ src/core/profile.test.ts | 55 +++ src/core/profile.ts | 99 ++++ src/core/rendering.test.ts | 123 ++++- src/core/rendering.ts | 128 ++++- src/core/spec-refs.test.ts | 108 +++++ src/core/spec-refs.ts | 128 +++++ src/core/type-inference.test.ts | 58 +++ src/core/type-inference.ts | 72 +++ 41 files changed, 3909 insertions(+), 19 deletions(-) create mode 100644 src/app/components/DatasetsModal.module.css create mode 100644 src/app/components/DatasetsModal.tsx create mode 100644 src/app/components/ExtractModal.module.css create mode 100644 src/app/components/ExtractModal.tsx create mode 100644 src/app/components/ModalShell.module.css create mode 100644 src/app/components/ModalShell.tsx create mode 100644 src/app/infrastructure/dataset-migrations.ts create mode 100644 src/app/infrastructure/dataset-store.ts create mode 100644 src/app/modals/ModalCoordinator.ts create mode 100644 src/app/modals/UrlStateSync.ts create mode 100644 src/app/modals/modal-registry.ts create mode 100644 src/app/modals/types.ts create mode 100644 src/app/orchestration/dataset-persistence.ts create mode 100644 src/app/services/RelationshipService.test.ts create mode 100644 src/app/services/RelationshipService.ts create mode 100644 src/app/stores/DatasetStore.test.ts create mode 100644 src/app/stores/DatasetStore.ts create mode 100644 src/app/stores/ExtractStore.ts create mode 100644 src/core/dataset.test.ts create mode 100644 src/core/dataset.ts create mode 100644 src/core/naming.test.ts create mode 100644 src/core/naming.ts create mode 100644 src/core/profile.test.ts create mode 100644 src/core/profile.ts create mode 100644 src/core/spec-refs.test.ts create mode 100644 src/core/spec-refs.ts create mode 100644 src/core/type-inference.test.ts create mode 100644 src/core/type-inference.ts diff --git a/docs/architecture/10-interaction-and-feedback.md b/docs/architecture/10-interaction-and-feedback.md index 15dc793..4d1b72a 100644 --- a/docs/architecture/10-interaction-and-feedback.md +++ b/docs/architecture/10-interaction-and-feedback.md @@ -180,6 +180,27 @@ contract; cite it, not the external source.)_ other shows the text visually with no live role. Two live regions would announce the same message twice. +**Resolved — feature-modal dismissal & initial focus.** A feature modal (Datasets, and +later Settings/Chart Builder) is a **passive** `dialog-modal`: dismissed by the close +button, Escape, or a backdrop click (a passive modal carries no in-flight transaction, so +an outside click is a safe cancel — unlike the `alertdialog` confirm, where backdrop-dismiss +is forbidden). `role="dialog"` + `aria-modal` + `aria-labelledby` the title; focus is trapped +and **returns to the trigger** on close. Backdrop-dismiss stays correct even for the +multi-view Datasets manager because an in-progress create/edit form is guarded separately by +the discard prompt. **Initial focus depends on size** (APG dialog-modal): a large manager +with semantic content (list + detail) focuses a **static title** (`tabindex="-1"`) so the +content's start is perceived rather than skipped to the first control; a small form modal +(Extract) focuses its **primary field**. _(Consulted via /council → WAI-ARIA APG +`dialog-modal`. This bullet is the contract; cite it, not the APG file.)_ + +**Resolved — an error names the right fix, not a boilerplate one.** Don't staple a generic +remedy onto every failure. A missing dataset reference is **not** a JSON/spec syntax problem, +so the preview gives it a tailored, fixable line — _"Dataset «X» not found. Create it from +Datasets (⌘/Ctrl+K), or check the dataset name in your spec."_ — instead of the catch-all +"check your JSON syntax" hint reserved for actual parse/Vega-Lite errors. The fix in the copy +must match the actual cause (NN/g #9, GOV.UK error-message). The thrown +`DatasetNotFoundError` carries `datasetName` so the surface can name it. + ## 6. Motion & accessibility as default Not features to add later — the baseline every surface is built on. @@ -212,6 +233,7 @@ Not features to add later — the baseline every surface is built on. - Treat loading/empty/error as three designed states for every data surface. - Adopt the APG keyboard pattern for new widgets; route all global keys through the one router. +- Mark **optional** fields, not required ones (GOV.UK) — e.g. "Comment (optional)". - Consult `/council` when this contract is silent — then record the answer back here. **Don't** @@ -224,3 +246,6 @@ Not features to add later — the baseline every surface is built on. disclosure, the next step in the message. - Don't invent a keyboard model, attach ad-hoc `window` listeners, or gate Escape behind the typing check. +- Don't ship a **dead disabled control** as a placeholder for an unbuilt feature — a + disabled button explains nothing and is skipped by assistive tech (GOV.UK, NN/g). Omit the + action until it works, then show it enabled (e.g. "Build Chart" appears with M4). diff --git a/src/app/App.module.css b/src/app/App.module.css index 21f80f6..1382370 100644 --- a/src/app/App.module.css +++ b/src/app/App.module.css @@ -61,6 +61,30 @@ flex: 1; } +/* Header text-button (Datasets, and future header actions). */ +.headerButton { + height: 32px; + padding: 0 var(--space-4); + border: var(--border-width) solid var(--border-strong); + border-radius: var(--radius); + background: transparent; + color: var(--text); + font: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background var(--dur-fast) var(--ease); +} + +.headerButton:hover { + background: var(--layer-01); +} + +.headerButton:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + .panes { display: flex; flex: 1 1 auto; diff --git a/src/app/App.tsx b/src/app/App.tsx index 21f6e8a..b27e509 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,10 +1,14 @@ +import { useEffect } from 'react'; import { ConfirmDialog } from './components/ConfirmDialog'; import { LivePreview } from './components/LivePreview'; +import { ModalShell } from './components/ModalShell'; import { ResizeHandle } from './components/ResizeHandle'; import { SnippetLibrary } from './components/SnippetLibrary'; import { SpecEditor } from './components/SpecEditor'; import { ThemeToggle } from './components/ThemeToggle'; import { Toaster } from './components/Toaster'; +import { openModal, setConfirm, toggleDatasets } from './modals/ModalCoordinator'; +import { confirm } from './stores/ConfirmStore'; import { usePanesStore } from './stores/PanesStore'; import styles from './App.module.css'; @@ -21,6 +25,25 @@ export function App() { const libraryWidth = usePanesStore((s) => s.libraryWidth); const previewWidth = usePanesStore((s) => s.previewWidth); + // Route the modal coordinator's discard prompt through the in-app confirm + // dialog (docs/architecture/03 → "The coordinator seam"). + useEffect(() => { + setConfirm((message) => confirm({ title: 'Discard changes?', message, danger: true })); + }, []); + + // Cmd/Ctrl+K toggles the Datasets manager (spec §05 → Opening). The full + // keyboard router (other shortcuts) lands in M6 (docs/architecture/04). + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { + e.preventDefault(); + toggleDatasets(); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + return (
{/* Skip link (WCAG 2.4.1 / GOV.UK): the first focusable element, hidden @@ -32,6 +55,15 @@ export function App() {

Astrolabe

v{__APP_VERSION__} + @@ -60,8 +92,12 @@ export function App() { - {/* Global confirmation layer — sits above the (future) feature-modal - shell so a discard-changes prompt can appear over an open modal. */} + {/* The one feature modal (Datasets / Extract / …), rendered from the + registry by the shared shell. At most one open at a time (spec §01C). */} + + + {/* Global confirmation layer — sits above the feature-modal shell so a + discard-changes prompt can appear over an open modal. */} {/* Non-blocking notifications (failed saves, etc.) — top-right toasts, diff --git a/src/app/components/DatasetsModal.module.css b/src/app/components/DatasetsModal.module.css new file mode 100644 index 0000000..4f4bde3 --- /dev/null +++ b/src/app/components/DatasetsModal.module.css @@ -0,0 +1,437 @@ +/* Datasets manager — two-pane body inside the modal shell (spec §05 → Layout). */ + +.manager { + display: grid; + grid-template-columns: 260px 1fr; + min-height: 0; + height: 100%; +} + +/* --- List pane --- */ +.listPane { + display: flex; + flex-direction: column; + min-height: 0; + border-right: var(--border-width) solid var(--border); +} + +.newButton { + flex: 0 0 auto; + margin: var(--space-4); + height: 40px; + padding: 0 var(--space-5); + border: var(--border-width) solid transparent; + border-radius: var(--radius); + background: var(--accent); + color: var(--accent-contrast); + font: inherit; + font-weight: 600; + cursor: pointer; + transition: background var(--dur-fast) var(--ease); +} + +.newButton:hover { + background: var(--accent-hover); +} + +.list { + list-style: none; + margin: 0; + padding: 0; + flex: 1 1 auto; + min-height: 0; + overflow: auto; + border-top: var(--border-width) solid var(--border); +} + +.empty { + color: var(--text-secondary); + font-size: 13px; + line-height: 1.4; + padding: var(--space-5) var(--space-4); +} + +.item { + display: flex; + align-items: center; + gap: var(--space-3); + padding: 0 var(--space-4); + border-left: 2px solid transparent; + transition: background var(--dur-fast) var(--ease); +} + +.item + .item { + border-top: var(--border-width) solid var(--border); +} + +.item:hover { + background: var(--layer-01); +} + +.itemActive { + background: var(--layer-01); + border-left-color: var(--accent); +} + +.itemMain { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--space-1); + appearance: none; + border: none; + background: none; + padding: var(--space-3) 0; + font: inherit; + color: inherit; + text-align: left; + cursor: pointer; +} + +.itemName { + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.itemMeta { + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Usage badge — count of referencing snippets (spec §05 → List item). */ +.badge { + flex: 0 0 auto; + min-width: 18px; + height: 18px; + padding: 0 var(--space-2); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + background: var(--layer-02); + border-radius: var(--radius); +} + +/* --- Detail pane --- */ +.detailPane { + min-height: 0; + overflow: auto; +} + +.detailEmpty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-secondary); + font-size: 14px; + padding: var(--space-6); + text-align: center; +} + +.detail, +.form { + display: flex; + flex-direction: column; + gap: var(--space-5); + padding: var(--space-5); +} + +.detailHead { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-4); + flex-wrap: wrap; +} + +.detailName { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--text); + word-break: break-word; +} + +.detailActions, +.formActions { + display: flex; + gap: var(--space-3); + flex-wrap: wrap; +} + +.formActions { + justify-content: flex-end; +} + +.action { + height: 32px; + padding: 0 var(--space-4); + border: var(--border-width) solid var(--border-strong); + border-radius: var(--radius); + background: transparent; + color: var(--text); + font: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background var(--dur-fast) var(--ease); +} + +.action:hover:not(:disabled) { + background: var(--layer-01); +} + +.action:disabled { + color: var(--text-placeholder); + border-color: var(--border); + cursor: not-allowed; +} + +.action:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +.primary { + background: var(--accent); + border-color: transparent; + color: var(--accent-contrast); + font-weight: 600; +} + +.primary:hover:not(:disabled) { + background: var(--accent-hover); +} + +.danger { + border-color: var(--border-strong); + color: var(--support-error); +} + +.danger:hover:not(:disabled) { + background: var(--support-error); + color: var(--on-status); + border-color: transparent; +} + +.comment { + margin: 0; + font-size: 13px; + line-height: 1.4; + color: var(--text-secondary); +} + +.section { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.sectionTitle { + margin: 0; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-secondary); +} + +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(80px, 1fr)); + gap: var(--space-4); + margin: 0; +} + +.stats div { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.stats dt { + font-size: 11px; + color: var(--text-secondary); +} + +.stats dd { + margin: 0; + font-size: 14px; + font-weight: 500; + color: var(--text); +} + +.columns { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + border: var(--border-width) solid var(--border); +} + +.column { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); +} + +.column + .column { + border-top: var(--border-width) solid var(--border); +} + +.columnName { + font-size: 13px; + font-family: var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.columnType { + flex: 0 0 auto; + font-size: 11px; + color: var(--text-secondary); + background: var(--layer-01); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius); +} + +.timestamps { + display: flex; + flex-wrap: wrap; + gap: var(--space-4); + font-size: 11px; + color: var(--text-secondary); +} + +.preview { + margin: 0; + max-height: 220px; + overflow: auto; + padding: var(--space-3); + background: var(--layer-01); + border: var(--border-width) solid var(--border); + border-radius: var(--radius); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.5; + white-space: pre; +} + +.muted { + margin: 0; + font-size: 13px; + color: var(--text-secondary); +} + +.linked { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.linkButton { + appearance: none; + border: none; + background: none; + padding: 0; + font: inherit; + font-size: 13px; + color: var(--accent); + cursor: pointer; + text-align: left; +} + +.linkButton:hover { + text-decoration: underline; +} + +.linkButton:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +/* --- Create / edit form --- */ +.field { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.label { + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); +} + +.input, +.textarea { + width: 100%; + padding: var(--space-3); + border: var(--border-width) solid var(--border-strong); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font: inherit; + font-size: 13px; +} + +.textarea { + font-family: var(--font-mono); + resize: vertical; + min-height: 160px; +} + +.input:focus-visible, +.textarea:focus-visible { + outline: 2px solid var(--focus); + outline-offset: -1px; +} + +.input::placeholder, +.textarea::placeholder { + color: var(--text-placeholder); +} + +.detected { + display: flex; + align-items: center; + gap: var(--space-3); + margin-top: calc(-1 * var(--space-2)); +} + +.detectedBadge { + font-size: 11px; + font-weight: 600; + color: var(--text); + background: var(--layer-02); + padding: var(--space-1) var(--space-3); + border-radius: var(--radius); +} + +.detectedHint { + font-size: 11px; + color: var(--text-secondary); +} + +.formError { + margin: 0; + font-size: 13px; + color: var(--support-error); +} diff --git a/src/app/components/DatasetsModal.tsx b/src/app/components/DatasetsModal.tsx new file mode 100644 index 0000000..5b87459 --- /dev/null +++ b/src/app/components/DatasetsModal.tsx @@ -0,0 +1,419 @@ +/** + * Datasets manager — the modal body (spec §05). + * + * A two-pane manager rendered inside the shared modal shell (App provides the + * backdrop, header, close, and focus trap). Left: a "New Dataset" action plus the + * dataset list, newest-modified first, each row carrying a source/rows/format/size + * meta line and a usage badge. Right: the selected dataset's detail, the + * create/edit form, or an empty prompt. + * + * State lives in DatasetStore; the bidirectional snippet↔dataset link is derived + * by scanning SnippetStore (docs/architecture/07 §4), so usage counts and Linked + * Snippets stay reactive without a stored back-pointer. + */ + +import { useState } from 'react'; +import { useShallow } from 'zustand/react/shallow'; +import { datasetReference, type DataSource, type Dataset } from '@core/dataset'; +import { detectFormat, detectFormatFromUrl, type DataFormat } from '@core/format-detection'; +import { closeModal, resnapshot } from '../modals/ModalCoordinator'; +import { confirm } from '../stores/ConfirmStore'; +import { notify } from '../stores/NotificationStore'; +import { selectSelectedDataset, useDatasetStore, byModifiedDesc } from '../stores/DatasetStore'; +import { useSnippetStore } from '../stores/SnippetStore'; +import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; +import styles from './DatasetsModal.module.css'; + +/** Display label for a format (spec §05 → List item: JSON / CSV / TSV / TopoJSON). */ +function formatLabel(format: DataFormat): string { + return format === 'topojson' ? 'TopoJSON' : format.toUpperCase(); +} + +/** Human-readable byte size (B / KB / MB). */ +function humanBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const kb = bytes / 1024; + if (kb < 1024) return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)} KB`; + const mb = kb / 1024; + return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)} MB`; +} + +/** Map of dataset name (lower-cased) → how many snippets reference it. */ +function usageByName(snippets: ReadonlyArray<{ datasetRefs: string[] }>): Map { + const counts = new Map(); + for (const s of snippets) { + for (const ref of s.datasetRefs) { + const key = ref.toLowerCase(); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + } + return counts; +} + +const SOURCE_OPTIONS: ReadonlyArray> = [ + { value: 'inline', label: 'Inline' }, + { value: 'url', label: 'URL' }, +]; + +export function DatasetsModal() { + const datasets = useDatasetStore(useShallow((s) => s.datasets)); + const view = useDatasetStore((s) => s.view); + const selected = useDatasetStore(selectSelectedDataset); + const snippets = useSnippetStore(useShallow((s) => s.snippets)); + + const select = useDatasetStore((s) => s.select); + const startCreate = useDatasetStore((s) => s.startCreate); + + const usage = usageByName(snippets); + const ordered = [...datasets].sort(byModifiedDesc); + + const handleNew = () => { + startCreate(); + resnapshot(); // baseline the discard check to the freshly-opened empty form + }; + + return ( +
+
+ +
    + {ordered.length === 0 && ( +
  • + No datasets yet — create one to reuse data across snippets. +
  • + )} + {ordered.map((d) => ( + select(d.id)} + /> + ))} +
+
+ +
+ {view === 'new' || view === 'edit' ? ( + + ) : selected ? ( + + ) : ( +
Select a dataset or create a new one.
+ )} +
+
+ ); +} + +function DatasetListItem({ + dataset, + active, + usage, + onSelect, +}: { + dataset: Dataset; + active: boolean; + usage: number; + onSelect: () => void; +}) { + // Meta line: source ("URL" prefix), row count when known, format label, size. + const parts: string[] = []; + if (dataset.source === 'url') parts.push('URL'); + if (dataset.rowCount !== null) parts.push(`${dataset.rowCount} rows`); + parts.push(formatLabel(dataset.format)); + if (dataset.source !== 'url') parts.push(humanBytes(dataset.size)); + + return ( +
  • + + {usage > 0 && ( + + {usage} + + )} +
  • + ); +} + +function DatasetDetail({ + dataset, + snippets, +}: { + dataset: Dataset; + snippets: ReadonlyArray<{ id: string; name: string; datasetRefs: string[] }>; +}) { + const startEdit = useDatasetStore((s) => s.startEdit); + const remove = useDatasetStore((s) => s.remove); + const selectSnippet = useSnippetStore((s) => s.selectSnippet); + const [copied, setCopied] = useState(false); + + const lower = dataset.name.toLowerCase(); + const linked = snippets.filter((s) => s.datasetRefs.some((r) => r.toLowerCase() === lower)); + + const handleEdit = () => { + startEdit(); + resnapshot(); + }; + + const handleCopy = async () => { + const text = JSON.stringify(datasetReference(dataset.name), null, 2); + try { + await navigator.clipboard.writeText(text); + // Lightweight local feedback; the success-toast wiring is deferred to M6 + // with the other success toasts (spec §05 → Actions). + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + notify({ + kind: 'error', + title: "Couldn't copy", + message: 'Your browser blocked clipboard access. Select and copy the reference manually.', + }); + } + }; + + const handleDelete = async () => { + const ok = await confirm({ + title: 'Delete dataset', + message: `Delete "${dataset.name}"? This cannot be undone.`, + confirmLabel: 'Delete', + danger: true, + }); + // TODO (M6, spec §05): success toast on delete. + if (ok) remove(dataset.id); + }; + + return ( +
    +
    +

    {dataset.name}

    +
    + + + {/* "Build Chart from dataset" (spec §05) lands enabled with the Chart + Builder in M4. Per council (GOV.UK / NN/g), we don't ship a dead + disabled control in the meantime — the action appears when it works. */} + +
    +
    + + {dataset.comment &&

    {dataset.comment}

    } + +
    +

    Overview

    +
    +
    +
    Rows
    +
    {dataset.rowCount ?? 'N/A'}
    +
    +
    +
    Columns
    +
    {dataset.columnCount ?? 'N/A'}
    +
    +
    +
    Format
    +
    {formatLabel(dataset.format)}
    +
    +
    +
    Size
    +
    {dataset.source === 'url' ? 'N/A' : humanBytes(dataset.size)}
    +
    +
    + + {dataset.columnTypes.length > 0 && ( +
      + {dataset.columnTypes.map((col) => ( +
    • + {col.name} + {col.type} +
    • + ))} +
    + )} + +
    + Created {new Date(dataset.created).toLocaleString()} + Modified {new Date(dataset.modified).toLocaleString()} +
    +
    + +
    +

    Preview

    +
    {previewText(dataset)}
    +
    + +
    +

    Linked Snippets

    + {linked.length === 0 ? ( +

    No snippets reference this dataset yet.

    + ) : ( +
      + {linked.map((s) => ( +
    • + +
    • + ))} +
    + )} +
    +
    + ); +} + +/** A truncated rendering of the data: raw for csv/tsv/url, pretty JSON otherwise. */ +function previewText(dataset: Dataset): string { + const MAX = 2000; + let text: string; + if (dataset.source === 'url') { + text = String(dataset.data); + } else if (dataset.format === 'csv' || dataset.format === 'tsv') { + text = String(dataset.data); + } else { + try { + text = JSON.stringify(dataset.data, null, 2); + } catch { + text = String(dataset.data); + } + } + return text.length > MAX ? `${text.slice(0, MAX)}\n…` : text; +} + +function DatasetFormView({ editing }: { editing: boolean }) { + const form = useDatasetStore((s) => s.form); + const formError = useDatasetStore((s) => s.formError); + const updateForm = useDatasetStore((s) => s.updateForm); + const cancelForm = useDatasetStore((s) => s.cancelForm); + const save = useDatasetStore((s) => s.save); + + // Live format/source hint from the current input (spec §05 → Auto-detection). + const detected = + form.source === 'url' + ? { format: detectFormatFromUrl(form.input.trim()), confidence: 'url' as const } + : detectFormat(form.input); + + const handleSave = () => { + if (save()) resnapshot(); // committed — re-baseline so a later close won't prompt + }; + + const handleCancel = () => { + cancelForm(); + resnapshot(); + }; + + return ( +
    +

    {editing ? 'Edit dataset' : 'New dataset'}

    + + + +
    + Source + updateForm({ source })} + /> +
    + +