Add distributed settings and workspace import/export (M5)

This commit is contained in:
2026-06-07 15:51:00 +03:00
parent 80bedd2a8d
commit 548aa199d9
38 changed files with 3150 additions and 101 deletions
+5
View File
@@ -85,6 +85,11 @@
outline-offset: 2px;
}
/* The Import file picker is driven programmatically by its header button. */
.hiddenInput {
display: none;
}
.panes {
display: flex;
flex: 1 1 auto;
+47 -4
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { ConfirmDialog } from './components/ConfirmDialog';
import { LivePreview } from './components/LivePreview';
import { ModalShell } from './components/ModalShell';
@@ -8,8 +8,10 @@ import { SpecEditor } from './components/SpecEditor';
import { ThemeToggle } from './components/ThemeToggle';
import { Toaster } from './components/Toaster';
import { openModal, setConfirm, toggleDatasets } from './modals/ModalCoordinator';
import { openSettingsPopover } from './stores/SettingsPopoverStore';
import { confirm } from './stores/ConfirmStore';
import { usePanesStore } from './stores/PanesStore';
import { exportWorkspace, importWorkspace } from './services/transfer';
import styles from './App.module.css';
/**
@@ -24,6 +26,9 @@ import styles from './App.module.css';
export function App() {
const libraryWidth = usePanesStore((s) => s.libraryWidth);
const previewWidth = usePanesStore((s) => s.previewWidth);
// Hidden file input driving Import — the header button proxies its click so the
// browser file picker is the only chrome (spec §08 → no intermediate dialog).
const fileInputRef = useRef<HTMLInputElement>(null);
// Route the modal coordinator's discard prompt through the in-app confirm
// dialog (docs/architecture/03 → "The coordinator seam").
@@ -31,19 +36,31 @@ export function App() {
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).
// Cmd/Ctrl+K toggles the Datasets manager (spec §05); Cmd/Ctrl+, opens the
// editor settings popover the primary preference cluster, now that settings
// are distributed per pane (spec §07). Full key router lands in M6 (arch 04).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
if (!(e.metaKey || e.ctrlKey)) return;
if (e.key === 'k' || e.key === 'K') {
e.preventDefault();
toggleDatasets();
} else if (e.key === ',') {
e.preventDefault();
openSettingsPopover('editor-settings');
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
// Reset the input so picking the same file again still fires onChange.
e.target.value = '';
if (file) void importWorkspace(file);
};
return (
<div className={styles.app}>
{/* Skip link (WCAG 2.4.1 / GOV.UK): the first focusable element, hidden
@@ -64,7 +81,33 @@ export function App() {
>
Datasets
</button>
<button
type="button"
className={styles.headerButton}
onClick={() => fileInputRef.current?.click()}
title="Import a workspace JSON file"
>
Import
</button>
<button
type="button"
className={styles.headerButton}
onClick={() => exportWorkspace()}
title="Export your workspace to a JSON file"
>
Export
</button>
<ThemeToggle />
{/* Hidden picker for Import; restricted to JSON (spec §08). */}
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
className={styles.hiddenInput}
onChange={handleImportFile}
aria-hidden="true"
tabIndex={-1}
/>
</header>
{/* tabIndex -1 makes the landmark a focus target for the skip link. */}
+7
View File
@@ -27,6 +27,7 @@ export type IconName =
| 'dataset' // "references a dataset" — Carbon DataTable
| 'delete' // delete — Carbon TrashCan
| 'add' // add / create-new — Carbon Add
| 'settings' // per-pane settings disclosure (gear) — Carbon Settings
// Status sub-family (arch 09 §5.2) — Carbon's FILLED notification glyphs, coloured
// by status (not text). A redundant non-colour severity channel (WCAG 1.4.1): the
// triangle shape-codes warning apart from the round error/success/info.
@@ -44,6 +45,12 @@ const GLYPHS: Record<IconName, ReactNode> = {
<polygon points="17.4141 16 24 9.4141 22.5859 8 16 14.5859 9.4143 8 8 9.4141 14.5859 16 8 22.5859 9.4143 24 16 17.4141 22.5859 24 24 22.5859 17.4141 16" />
),
add: <polygon points="17,15 17,8 15,8 15,15 8,15 8,17 15,17 15,24 17,24 17,17 24,17 24,15" />,
settings: (
<>
<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" />
</>
),
delete: (
<>
<rect x="12" y="12" width="2" height="12" />
+28 -5
View File
@@ -26,12 +26,11 @@ import { useAppStore } from '../stores/AppStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { RangeControl, SettingRow, SettingsPopover } from './SettingsPopover';
import styles from './LivePreview.module.css';
/** Render debounce (ms). Becomes the configurable `renderDebounce` setting in M5. */
const RENDER_DEBOUNCE_MS = 300;
/** The four fit modes in display order (spec §04 → Fit / Sizing Modes). */
const FIT_OPTIONS: ReadonlyArray<SegmentedOption<FitMode>> = [
{ value: 'default', label: 'Original' },
@@ -68,6 +67,27 @@ function FitControl() {
);
}
/** Preview settings cluster (spec §07 → Performance), disclosed beside Fit. */
function PreviewSettings() {
const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce);
const setPerformance = useUserSettingsStore((s) => s.setPerformance);
return (
<SettingsPopover id="preview-settings" label="Preview settings" title="Preview">
<SettingRow label="Render debounce" htmlFor="set-debounce">
<RangeControl
id="set-debounce"
min={500}
max={5000}
step={100}
value={renderDebounce}
suffix="ms"
onChange={(renderDebounce) => setPerformance({ renderDebounce })}
/>
</SettingRow>
</SettingsPopover>
);
}
export function LivePreview() {
const hostRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<RenderHandle | null>(null);
@@ -84,6 +104,8 @@ export function LivePreview() {
// two are unchanged the change is typing and the debounce applies.
const bufferEpoch = useSnippetStore((s) => s.bufferEpoch);
const editorView = useSnippetStore((s) => s.editorView);
// User-tunable render debounce (spec §07 → Performance).
const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce);
// 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);
@@ -102,7 +124,7 @@ export function LivePreview() {
const prevLoad = lastLoadRef.current;
const immediate = bufferEpoch !== prevLoad.bufferEpoch || editorView !== prevLoad.editorView;
lastLoadRef.current = { bufferEpoch, editorView };
const delay = immediate ? 0 : RENDER_DEBOUNCE_MS;
const delay = immediate ? 0 : renderDebounce;
// 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).
@@ -165,7 +187,7 @@ export function LivePreview() {
}, delay);
return () => clearTimeout(timer);
}, [shownText, fitMode, uiTheme, datasets, setError, bufferEpoch, editorView]);
}, [shownText, fitMode, uiTheme, datasets, setError, bufferEpoch, editorView, renderDebounce]);
// 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
@@ -198,6 +220,7 @@ export function LivePreview() {
<div className={styles.preview}>
<div className={styles.header}>
<FitControl />
<PreviewSettings />
</div>
<div className={styles.body}>
{/* Frame is React-owned and carries the fit-sizing class; the inner host
@@ -0,0 +1,159 @@
/* SettingsPopover — per-pane settings disclosure (spec §07; arch 10). */
.wrap {
position: relative;
display: inline-flex;
}
/* Gear trigger — a subtle icon button matching the modal-shell close affordance. */
.gear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: var(--border-width) solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.gear:hover {
background: var(--layer-01);
color: var(--text);
}
.gear[aria-expanded='true'] {
background: var(--layer-02);
color: var(--text);
}
.gear:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
/* The disclosed panel — portaled to <body>, positioned `fixed` from the gear's
rect (top/left|right set inline) so it escapes the panes' overflow clipping. */
.pop {
position: fixed;
z-index: 1000;
width: 288px;
max-width: min(320px, 90vw);
padding: var(--space-4);
background: var(--layer-01);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.title {
margin: 0 0 var(--space-3);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-secondary);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
min-height: 32px;
}
.row + .row {
margin-top: var(--space-1);
}
.label {
font-size: 13px;
color: var(--text);
}
.control {
display: flex;
align-items: center;
gap: var(--space-3);
}
.footer {
display: flex;
justify-content: flex-end;
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: var(--border-width) solid var(--border);
}
/* Shared controls used inside popovers ------------------------------------- */
.range {
width: 128px;
accent-color: var(--accent);
}
.value {
min-width: 48px;
text-align: right;
font-variant-numeric: tabular-nums;
font-size: 12px;
color: var(--text-secondary);
}
.number,
.text {
padding: var(--space-2) 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: 12px;
}
.number {
width: 60px;
text-align: right;
}
.text {
width: 160px;
font-family: var(--font-mono);
}
.number:focus-visible,
.text:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -1px;
}
.text::placeholder {
color: var(--text-placeholder);
}
.reset {
border: none;
background: none;
padding: 0;
color: var(--accent);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.reset:hover {
color: var(--accent-hover);
text-decoration: underline;
}
.reset:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
+277
View File
@@ -0,0 +1,277 @@
/**
* SettingsPopover — the per-pane settings disclosure (spec §07; arch 10).
*
* Astrolabe distributes preferences to where they apply: a small gear button in a
* pane's toolbar discloses a popover of **live** controls for that cluster
* (editor / preview / library). This is the shared primitive behind all of them.
*
* It is a **disclosure + non-modal popover** (WAI-ARIA APG disclosure; Carbon
* popover), deliberately NOT an ARIA menu: a menu lists actions/commands
* (menuitem/checkbox/radio), whereas these panels hold sliders, number/text
* inputs, and radio groups — so the container is a labelled `group`, not a
* `menu`. The gear carries `aria-expanded` + `aria-controls`; Enter/Space toggle;
* Esc closes and returns focus to the gear; an outside click closes. Non-modal,
* so there is no focus trap (unlike the modal shell). At most one popover is open
* at a time, and any can be opened imperatively (the Cmd/Ctrl+, shortcut).
*
* The panel is **portaled to `document.body`** and positioned `fixed` from the
* gear's rect, because the panes clip their content (`overflow: auto/hidden`); an
* in-flow absolute popover would be cut off. Position re-measures on scroll/resize.
*/
import { useCallback, useEffect, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useSettingsPopoverStore } from '../stores/SettingsPopoverStore';
import { Icon } from './Icon';
import styles from './SettingsPopover.module.css';
/** Gap (px) between the gear and the disclosed panel. */
const GAP = 6;
export interface SettingsPopoverProps {
/** Stable id, also the popover's element id for `aria-controls` (e.g. 'editor-settings'). */
id: string;
/** Accessible name for the gear button. */
label: string;
/** Heading shown at the top of the popover, and its group label. */
title: string;
/** Which edge of the gear the popover aligns to (default right). */
align?: 'left' | 'right';
children: ReactNode;
}
export function SettingsPopover({
id,
label,
title,
align = 'right',
children,
}: SettingsPopoverProps) {
const open = useSettingsPopoverStore((s) => s.openId === id);
const toggle = useSettingsPopoverStore((s) => s.toggle);
const close = useSettingsPopoverStore((s) => s.close);
const triggerRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement | null>(null);
// Position the (fixed) panel imperatively from the gear's rect — no React state,
// so there's no setState-in-effect and no re-render on scroll. The panes clip
// their content, hence the body portal + fixed positioning.
const place = useCallback(() => {
const trigger = triggerRef.current;
const pop = popRef.current;
if (!trigger || !pop) return;
const r = trigger.getBoundingClientRect();
pop.style.top = `${r.bottom + GAP}px`;
if (align === 'left') {
pop.style.left = `${r.left}px`;
pop.style.right = 'auto';
} else {
pop.style.right = `${window.innerWidth - r.right}px`;
pop.style.left = 'auto';
}
}, [align]);
// Re-place while open so the panel tracks the gear if the workspace scrolls/resizes.
useEffect(() => {
if (!open) return;
window.addEventListener('resize', place);
window.addEventListener('scroll', place, true); // capture: catch pane scrolls too
return () => {
window.removeEventListener('resize', place);
window.removeEventListener('scroll', place, true);
};
}, [open, place]);
// Esc closes + restores focus to the gear; an outside pointer click closes
// (APG disclosure; non-modal). Capture Esc so it settles here, not a parent.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
close();
triggerRef.current?.focus();
}
};
const onPointer = (e: PointerEvent) => {
const t = e.target as Node;
if (!popRef.current?.contains(t) && !triggerRef.current?.contains(t)) close();
};
document.addEventListener('keydown', onKey, true);
document.addEventListener('pointerdown', onPointer, true);
return () => {
document.removeEventListener('keydown', onKey, true);
document.removeEventListener('pointerdown', onPointer, true);
};
}, [open, close]);
// Ref callback: on mount, position the panel before paint and move focus to the
// first control so keyboard users — including those who opened via Cmd/Ctrl+, —
// land inside it. Fires once per open (align is constant per instance).
const setPopNode = useCallback(
(node: HTMLDivElement | null) => {
popRef.current = node;
if (node) {
place();
node.querySelector<HTMLElement>('input, button, select, textarea, [tabindex]')?.focus();
}
},
[place],
);
return (
<div className={styles.wrap}>
<button
ref={triggerRef}
type="button"
className={styles.gear}
aria-expanded={open}
aria-controls={id}
aria-label={label}
title={label}
onClick={() => toggle(id)}
>
<Icon name="settings" />
</button>
{open &&
createPortal(
<div ref={setPopNode} id={id} className={styles.pop} role="group" aria-label={title}>
<h4 className={styles.title}>{title}</h4>
{children}
</div>,
document.body,
)}
</div>
);
}
/** A label + control row inside a settings popover (keeps clusters tidy + uniform). */
export function SettingRow({
label,
htmlFor,
children,
}: {
label: string;
htmlFor?: string;
children: ReactNode;
}) {
return (
<div className={styles.row}>
{htmlFor ? (
<label className={styles.label} htmlFor={htmlFor}>
{label}
</label>
) : (
<span className={styles.label}>{label}</span>
)}
<div className={styles.control}>{children}</div>
</div>
);
}
/** A right-aligned footer row for popover-level actions (e.g. Reset). */
export function SettingFooter({ children }: { children: ReactNode }) {
return <div className={styles.footer}>{children}</div>;
}
/** A range slider with a live value read-out (font size, render debounce, …). */
export function RangeControl({
id,
min,
max,
step = 1,
value,
suffix,
onChange,
}: {
id: string;
min: number;
max: number;
step?: number;
value: number;
suffix?: string;
onChange: (value: number) => void;
}) {
return (
<>
<input
id={id}
type="range"
className={styles.range}
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
<span className={styles.value}>
{value}
{suffix ? ` ${suffix}` : ''}
</span>
</>
);
}
/** A small integer number input (e.g. tab size). Coerces to a clamped integer. */
export function NumberControl({
id,
min,
max,
value,
onChange,
}: {
id: string;
min: number;
max: number;
value: number;
onChange: (value: number) => void;
}) {
return (
<input
id={id}
type="number"
className={styles.number}
min={min}
max={max}
step={1}
value={value}
onChange={(e) =>
onChange(Math.min(max, Math.max(min, Math.round(Number(e.target.value) || min))))
}
/>
);
}
/** A monospaced free-text input (e.g. the custom date format). */
export function TextControl({
id,
value,
placeholder,
onChange,
}: {
id: string;
value: string;
placeholder?: string;
onChange: (value: string) => void;
}) {
return (
<input
id={id}
type="text"
className={styles.text}
value={value}
placeholder={placeholder}
spellCheck={false}
onChange={(e) => onChange(e.target.value)}
/>
);
}
/** A text-style reset action for a popover footer. */
export function ResetButton({ onClick, children }: { onClick: () => void; children: ReactNode }) {
return (
<button type="button" className={styles.reset} onClick={onClick}>
{children}
</button>
);
}
@@ -4,6 +4,26 @@
height: 100%;
}
/* Library toolbar — heading + settings gear (sort/search will join, spec §02). */
.toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
height: 40px;
padding: 0 var(--space-2) 0 var(--space-4);
border-bottom: var(--border-width) solid var(--border);
}
.heading {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-secondary);
}
.createNew {
flex: 0 0 auto;
display: inline-flex;
+57 -26
View File
@@ -12,6 +12,7 @@
import { useEffect, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { formatDate } from '@core/date-format';
import {
formatSnippetSize,
hasUnpublishedChanges,
@@ -21,36 +22,55 @@ import {
import { confirm } from '../stores/ConfirmStore';
import { notify } from '../stores/NotificationStore';
import { selectActiveSnippet, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { Icon } from './Icon';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { SettingRow, SettingsPopover, TextControl } from './SettingsPopover';
import styles from './SnippetLibrary.module.css';
/** Date display modes (spec §07 → Formatting). */
const DATE_FORMAT_OPTIONS: ReadonlyArray<SegmentedOption<'smart' | 'iso' | 'custom'>> = [
{ value: 'smart', label: 'Smart' },
{ value: 'iso', label: 'ISO' },
{ value: 'custom', label: 'Custom' },
];
/**
* Library settings cluster (spec §07 → Formatting), disclosed from the library
* toolbar — it controls the timestamps the list and metadata panel render. Opens
* rightward (align left) so the wide panel clears the narrow left pane.
*/
function LibrarySettings() {
const formatting = useUserSettingsStore((s) => s.saved.formatting);
const setFormatting = useUserSettingsStore((s) => s.setFormatting);
return (
<SettingsPopover id="library-settings" label="Date format settings" title="Dates" align="left">
<SettingRow label="Date format">
<SegmentedControl
label="Date format"
options={DATE_FORMAT_OPTIONS}
value={formatting.dateFormat}
onChange={(dateFormat) => setFormatting({ dateFormat })}
/>
</SettingRow>
{formatting.dateFormat === 'custom' && (
<SettingRow label="Custom" htmlFor="set-customdate">
<TextControl
id="set-customdate"
value={formatting.customDateFormat}
placeholder="yyyy-MM-dd HH:mm"
onChange={(customDateFormat) => setFormatting({ customDateFormat })}
/>
</SettingRow>
)}
</SettingsPopover>
);
}
/** 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 {
const then = new Date(iso);
const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const dayMs = 24 * 60 * 60 * 1000;
const days = Math.floor(
(startOfToday.getTime() -
new Date(then.getFullYear(), then.getMonth(), then.getDate()).getTime()) /
dayMs,
);
if (days <= 0) return 'Today';
if (days === 1) return 'Yesterday';
if (days < 7) return `${days}d ago`;
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
@@ -69,6 +89,7 @@ function SnippetMeta({
}) {
const renameSnippet = useSnippetStore((s) => s.renameSnippet);
const setComment = useSnippetStore((s) => s.setComment);
const formatting = useUserSettingsStore((s) => s.saved.formatting);
const [name, setName] = useState(snippet.name);
const [comment, setCommentLocal] = useState(snippet.comment);
@@ -112,11 +133,13 @@ function SnippetMeta({
<dl className={styles.metaTimes}>
<div>
<dt>Created</dt>
<dd>{formatTimestamp(snippet.created)}</dd>
<dd>{formatDate(snippet.created, formatting.dateFormat, formatting.customDateFormat)}</dd>
</div>
<div>
<dt>Modified</dt>
<dd>{formatTimestamp(snippet.modified)}</dd>
<dd>
{formatDate(snippet.modified, formatting.dateFormat, formatting.customDateFormat)}
</dd>
</div>
</dl>
@@ -158,6 +181,7 @@ export function SnippetLibrary() {
const selectSnippet = useSnippetStore((s) => s.selectSnippet);
const removeSnippet = useSnippetStore((s) => s.removeSnippet);
const duplicateActiveSnippet = useSnippetStore((s) => s.duplicateActiveSnippet);
const formatting = useUserSettingsStore((s) => s.saved.formatting);
// Default ordering: newest-modified first (spec §02 → Sort).
const ordered = [...snippets].sort((a, b) => b.modified.localeCompare(a.modified));
@@ -196,6 +220,13 @@ export function SnippetLibrary() {
return (
<div className={styles.library}>
{/* Library toolbar: heading + the date-format settings gear (sort/search
controls will join it here per spec §02). */}
<div className={styles.toolbar}>
<span className={styles.heading}>Snippets</span>
<LibrarySettings />
</div>
{/* 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. */}
@@ -212,7 +243,7 @@ export function SnippetLibrary() {
{ordered.map((s) => {
// Size is omitted under ~1 KB per spec §02; null collapses the suffix.
const size = formatSnippetSize(snippetSizeBytes(s));
const date = relativeDate(s.modified);
const date = formatDate(s.modified, formatting.dateFormat, formatting.customDateFormat);
return (
<li key={s.id} className={`${styles.item} ${s.id === activeId ? styles.active : ''}`}>
{/* The row's selectable area is a real <button> so it's keyboard
+120 -7
View File
@@ -30,7 +30,16 @@ import { hasInlineData } from '../stores/ExtractStore';
import { notify } from '../stores/NotificationStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import {
NumberControl,
RangeControl,
ResetButton,
SettingFooter,
SettingRow,
SettingsPopover,
} from './SettingsPopover';
import type { EditorView } from '../stores/SnippetStore';
import styles from './SpecEditor.module.css';
@@ -40,6 +49,87 @@ const VIEW_OPTIONS: ReadonlyArray<SegmentedOption<EditorView>> = [
{ value: 'published', label: 'Published' },
];
/** Editor syntax theme: Auto follows the app theme; overrides force one (spec §07). */
const EDITOR_THEME_OPTIONS: ReadonlyArray<SegmentedOption<string>> = [
{ value: 'auto', label: 'Auto' },
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
];
const ONOFF_OPTIONS: ReadonlyArray<SegmentedOption<'on' | 'off'>> = [
{ value: 'on', label: 'On' },
{ value: 'off', label: 'Off' },
];
/**
* Editor settings cluster (spec §07 → Editor), disclosed from the editor toolbar
* and applied live. The id matches the Cmd/Ctrl+, shortcut target (App.tsx).
*/
function EditorSettings() {
const editor = useUserSettingsStore((s) => s.saved.editor);
const setEditor = useUserSettingsStore((s) => s.setEditor);
const resetEditor = useUserSettingsStore((s) => s.resetEditor);
return (
<SettingsPopover id="editor-settings" label="Editor settings" title="Editor">
<SettingRow label="Font size" htmlFor="set-fontsize">
<RangeControl
id="set-fontsize"
min={10}
max={18}
value={editor.fontSize}
suffix="px"
onChange={(fontSize) => setEditor({ fontSize })}
/>
</SettingRow>
<SettingRow label="Theme">
<SegmentedControl
label="Editor theme"
options={EDITOR_THEME_OPTIONS}
value={editor.theme}
onChange={(theme) => setEditor({ theme })}
/>
</SettingRow>
<SettingRow label="Minimap">
<SegmentedControl
label="Minimap"
options={ONOFF_OPTIONS}
value={editor.minimap ? 'on' : 'off'}
onChange={(v) => setEditor({ minimap: v === 'on' })}
/>
</SettingRow>
<SettingRow label="Word wrap">
<SegmentedControl
label="Word wrap"
options={ONOFF_OPTIONS}
value={editor.wordWrap}
onChange={(wordWrap) => setEditor({ wordWrap })}
/>
</SettingRow>
<SettingRow label="Line numbers">
<SegmentedControl
label="Line numbers"
options={ONOFF_OPTIONS}
value={editor.lineNumbers}
onChange={(lineNumbers) => setEditor({ lineNumbers })}
/>
</SettingRow>
<SettingRow label="Tab size" htmlFor="set-tabsize">
<NumberControl
id="set-tabsize"
min={1}
max={8}
value={editor.tabSize}
onChange={(tabSize) => setEditor({ tabSize })}
/>
</SettingRow>
<SettingFooter>
<ResetButton onClick={resetEditor}>Reset editor defaults</ResetButton>
</SettingFooter>
</SettingsPopover>
);
}
// Register the bundled Vega-Lite schema once: resolves `$schema` locally (no
// network warning) and powers validation, autocomplete, and hover docs.
configureVegaLiteJson();
@@ -132,6 +222,7 @@ function EditorToolbar() {
>
Publish
</button>
<EditorSettings />
</div>
);
}
@@ -144,21 +235,26 @@ export function SpecEditor() {
const bufferEpoch = useSnippetStore((s) => s.bufferEpoch);
const uiTheme = useAppStore((s) => s.uiTheme);
const error = usePreviewStore((s) => s.error);
// Editor preferences (spec §07 → Editor); applied live below as they change.
const editorPrefs = useUserSettingsStore((s) => s.saved.editor);
// Create the editor once, on mount.
useEffect(() => {
if (!hostRef.current) return;
// Seed from the user's current editor settings so the first paint matches.
const ed = useUserSettingsStore.getState().saved.editor;
const editor = monaco.editor.create(hostRef.current, {
value: selectShownText(useSnippetStore.getState()),
language: 'json',
automaticLayout: true,
minimap: { enabled: false },
minimap: { enabled: ed.minimap },
// The editor is a Plex Mono surface per the design language (doc §3.1).
// Monaco needs an explicit family string — it can't read the CSS token.
fontFamily: "'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace",
fontSize: 13,
tabSize: 2,
wordWrap: 'on',
fontSize: ed.fontSize,
tabSize: ed.tabSize,
wordWrap: ed.wordWrap,
lineNumbers: ed.lineNumbers,
folding: true,
showFoldingControls: 'always', // keep fold arrows visible, not only on hover
scrollBeyondLastLine: false,
@@ -204,10 +300,27 @@ export function SpecEditor() {
});
}, [bufferEpoch, editorView, activeId]);
// Editor theme follows the UI theme.
// Apply editor preferences live as they change (spec §07 Apply → takes effect
// immediately). tabSize is a model option, so it's set on the model.
useEffect(() => {
monaco.editor.setTheme(uiTheme === 'dark' ? 'vs-dark' : 'vs');
}, [uiTheme]);
const editor = editorRef.current;
if (!editor) return;
editor.updateOptions({
fontSize: editorPrefs.fontSize,
wordWrap: editorPrefs.wordWrap,
lineNumbers: editorPrefs.lineNumbers,
minimap: { enabled: editorPrefs.minimap },
});
editor.getModel()?.updateOptions({ tabSize: editorPrefs.tabSize });
}, [editorPrefs]);
// Editor theme: Auto follows the app UI theme; an explicit override forces one
// (spec §07 → Editor theme, provisional).
useEffect(() => {
const pref = editorPrefs.theme;
const effective = pref === 'auto' ? uiTheme : pref;
monaco.editor.setTheme(effective === 'dark' ? 'vs-dark' : 'vs');
}, [uiTheme, editorPrefs.theme]);
return (
<div className={styles.editorPane}>
+4 -4
View File
@@ -1,10 +1,10 @@
/**
* Theme toggle — a header control that flips light ⇄ dark (spec §07 Appearance).
*
* Interim home: the spec houses the UI-theme control inside the Settings modal,
* which arrives in M5. Until then this header button is the control; it persists
* through the same `ui.theme` settings key, so M5 can move it into Settings (or
* keep it as a shortcut) without changing what's stored.
* This header button is the UI-theme control's permanent home: the M5 settings
* review distributed preferences to their panes and dropped the central Settings
* modal, so Appearance stays a one-click header toggle (spec §07). It writes the
* persisted `ui.theme` key directly — no separate Appearance control to sync.
*
* The button shows the icon of the theme you'll switch *to* (moon when light,
* sun when dark) and labels itself for screen readers. The focus ring comes from
+27
View File
@@ -0,0 +1,27 @@
/**
* File transfer adapter — the browser side of Import/Export (spec §08).
*
* Per the architecture rule, DOM/file APIs are confined to infrastructure: this
* is the only module that builds a download or reads a picked file, so the
* transfer *service* stays about orchestration (normalize, merge, notify) rather
* than Blobs and anchors.
*/
/** Trigger a client-side download of `json` text as `filename`. */
export function downloadJson(filename: string, json: string): void {
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
// Firefox needs the anchor in the document for the click to register.
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
/** Read a picked file's text content (rejects on an unreadable file). */
export function readTextFile(file: File): Promise<string> {
return file.text();
}
+47 -4
View File
@@ -1,5 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loadPreviewFitMode, loadUiTheme, savePreviewFitMode, saveUiTheme } from './settings-store';
import { defaultSettings } from '@core/settings';
import {
loadPreviewFitMode,
loadUiTheme,
loadUserSettings,
saveManagedSettings,
savePreviewFitMode,
saveUiTheme,
} from './settings-store';
const KEY = 'astrolabe:settings';
@@ -85,7 +93,7 @@ describe('settings-store · ui.theme', () => {
});
});
describe('settings-store · preview.fitMode', () => {
describe('settings-store · ui.previewFitMode', () => {
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
afterEach(() => vi.unstubAllGlobals());
@@ -94,12 +102,12 @@ describe('settings-store · preview.fitMode', () => {
});
it('returns a stored valid fit mode', () => {
localStorage.setItem(KEY, JSON.stringify({ preview: { fitMode: 'full' } }));
localStorage.setItem(KEY, JSON.stringify({ ui: { previewFitMode: 'full' } }));
expect(loadPreviewFitMode()).toBe('full');
});
it('falls back to default for an unrecognized value', () => {
localStorage.setItem(KEY, JSON.stringify({ preview: { fitMode: 'cover' } }));
localStorage.setItem(KEY, JSON.stringify({ ui: { previewFitMode: 'cover' } }));
expect(loadPreviewFitMode()).toBe('default');
});
@@ -110,3 +118,38 @@ describe('settings-store · preview.fitMode', () => {
expect(loadUiTheme()).toBe('dark'); // the other slice survives the merge
});
});
describe('settings-store · full UserSettings record', () => {
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
afterEach(() => vi.unstubAllGlobals());
it('loadUserSettings returns defaults when nothing is stored', () => {
expect(loadUserSettings()).toEqual(defaultSettings());
});
it('loadUserSettings normalizes a partial stored record', () => {
localStorage.setItem(KEY, JSON.stringify({ editor: { fontSize: 99 } }));
const loaded = loadUserSettings();
expect(loaded.editor.fontSize).toBe(18); // clamped to the 1018 range
expect(loaded.performance.renderDebounce).toBe(1500); // gap filled from defaults
});
it('saveManagedSettings round-trips and leaves the ui slice untouched', () => {
saveUiTheme('dark');
savePreviewFitMode('width');
const managed = defaultSettings();
managed.editor.fontSize = 16;
managed.performance.renderDebounce = 800;
managed.formatting.dateFormat = 'iso';
saveManagedSettings(managed);
const loaded = loadUserSettings();
expect(loaded.editor.fontSize).toBe(16);
expect(loaded.performance.renderDebounce).toBe(800);
expect(loaded.formatting.dateFormat).toBe('iso');
// The ui slice (written by the narrow slice writers) survives the managed merge.
expect(loaded.ui.theme).toBe('dark');
expect(loaded.ui.previewFitMode).toBe('width');
});
});
+41 -13
View File
@@ -1,19 +1,23 @@
/**
* Settings persistence (localStorage) — docs/architecture/02 §5.
* Settings persistence (localStorage) — docs/architecture/02 §5, spec §07/§09C.
*
* The authoritative home for `ui.theme` is the *UserSettings* record under the
* `astrolabe:settings` key (spec §09C). M1.5 pulls the **theme** slice forward
* (the toggle ships before the Settings modal), so this adapter currently wires
* only `ui.theme`. It reads/writes with **load-with-fallback + write-through
* merge**: a partial record written now is preserved key-for-key, so when M5
* builds the full UserSettings adapter on this same key it upgrades cleanly
* rather than clobbering anything.
* The home for the *UserSettings* record (spec §09C) under `astrolabe:settings`.
* M1.5/M2 pulled the **theme** and **preview fit mode** slices forward (their
* controls shipped first); M5 adds the full record — editor, performance, and
* formatting groups — on the same key.
*
* Everything reads with **load-with-fallback** (the pure `loadSettings` in
* `@core/settings` normalizes whatever is stored back onto a valid record) and
* writes with a **field-level write-through merge**, so the narrow per-slice
* writers (theme, fit mode) and the managed-settings write never clobber each
* other: each merges into the stored record and leaves the other groups intact.
*
* Per the architecture rule, this is one of the only modules that may touch
* `localStorage`; everything else goes through these typed functions.
*/
import type { FitMode } from '@core/rendering';
import { loadSettings, type UserSettings } from '@core/settings';
import type { UiTheme } from '@core/theme';
const KEY = 'astrolabe:settings';
@@ -24,10 +28,12 @@ const DEFAULT_THEME: UiTheme = 'light';
/** Spec §04 — the Fit control defaults to Original. */
const DEFAULT_FIT_MODE: FitMode = 'default';
/** Loose view of the stored record — M5 will give this its full typed shape. */
/** The managed slice the per-pane settings clusters own (theme + fit live in their own slices). */
export type ManagedSettings = Pick<UserSettings, 'editor' | 'performance' | 'formatting'>;
/** Loose view of the stored record for the per-slice write-through merges. */
interface StoredSettings {
ui?: { theme?: unknown; [k: string]: unknown };
preview?: { fitMode?: unknown; [k: string]: unknown };
ui?: { theme?: unknown; previewFitMode?: unknown; [k: string]: unknown };
[k: string]: unknown;
}
@@ -70,6 +76,28 @@ function isFitMode(v: unknown): v is FitMode {
return v === 'default' || v === 'width' || v === 'height' || v === 'full';
}
/**
* The full, normalized UserSettings record — the load-with-fallback gate
* (spec §07 "Startup load"). Used to hydrate the Settings store at startup.
*/
export function loadUserSettings(): UserSettings {
return loadSettings(readRaw());
}
/**
* Persist the managed groups (editor / performance / formatting),
* merging into the stored record so the `ui` slice (theme, fit mode) is untouched.
*/
export function saveManagedSettings(managed: ManagedSettings): void {
const current = readRaw();
writeRaw({
...current,
editor: managed.editor,
performance: managed.performance,
formatting: managed.formatting,
});
}
/** The persisted UI theme, or the default — unknown/legacy values fall back. */
export function loadUiTheme(): UiTheme {
const stored = readRaw().ui?.theme;
@@ -86,12 +114,12 @@ export function saveUiTheme(theme: UiTheme): void {
/** The persisted preview fit mode, or the default — unknown values fall back. */
export function loadPreviewFitMode(): FitMode {
const stored = readRaw().preview?.fitMode;
const stored = readRaw().ui?.previewFitMode;
return isFitMode(stored) ? stored : DEFAULT_FIT_MODE;
}
/** Persist the preview fit mode, preserving every other key already in the record. */
export function savePreviewFitMode(fitMode: FitMode): void {
const current = readRaw();
writeRaw({ ...current, preview: { ...current.preview, fitMode } });
writeRaw({ ...current, ui: { ...current.ui, previewFitMode: fitMode } });
}
+4 -2
View File
@@ -10,8 +10,10 @@
* single shell-level Save/Cancel doesn't fit). The registry therefore omits
* `hasError`/`getError` (validity is the modal's own concern) and keeps only
* `getState` for unsaved-change detection on close. The registry is a partial
* map: modals land milestone by milestone (settings/about/donate → M5/M6,
* chartBuilder → M4), so only the implemented ones are registered here.
* map: modals land milestone by milestone (about/donate → M6, chartBuilder → M4),
* so only the implemented ones are registered here. Settings is deliberately NOT
* a modal — preferences are distributed to per-pane disclosure popovers (spec §07;
* see components/SettingsPopover).
*/
import type { ComponentType } from 'react';
+2 -1
View File
@@ -9,11 +9,12 @@
export type ModalName =
| 'datasets' // Datasets manager (list / detail / new-dataset form)
| 'settings' // Appearance, editor, performance, formatting prefs (M5)
| 'about' // About & Help (M6)
| 'donate' // Donate (M6)
| 'chartBuilder' // Visual no-JSON chart composition for a dataset (M4)
| 'extract'; // Extract inline spec data into a new dataset (M3)
// Settings is NOT a modal — preferences are distributed to per-pane disclosure
// popovers (spec §07; see components/SettingsPopover).
/** The active modal, or `null` when none is open (at most one at a time, §01C). */
export type ActiveModal = ModalName | null;
+34
View File
@@ -0,0 +1,34 @@
/**
* Settings orchestration — bridges the (browser-free) UserSettingsStore to the
* localStorage adapter, the same store↔adapter pattern as theme/preferences.
*
* `initSettings` hydrates the applied managed groups (editor / performance /
* formatting) from the persisted record before render, so the editor, preview,
* and library read the user's settings from the first paint. `wireSettings`
* persists the managed slice whenever it changes (live, the moment a control
* changes — there is no Apply step).
*
* Theme and preview fit mode are persisted by their own slice subscribers
* (theme.ts, preferences.ts) onto the same `astrolabe:settings` record; the
* field-level write-through merge keeps the slices from clobbering each other.
*
* Settings are distributed and live-applied (spec §07): a control mutates the
* store the instant it changes, so `wireSettings` persists on every change —
* there is no Apply/Cancel commit step.
*/
import { loadUserSettings, saveManagedSettings } from '../infrastructure/settings-store';
import { useUserSettingsStore } from '../stores/UserSettingsStore';
/** Hydrate the applied managed settings into the store. Call before render. */
export function initSettings(): void {
useUserSettingsStore.getState().hydrate(loadUserSettings());
}
/** Persist the managed settings on change. Returns a teardown that detaches it. */
export function wireSettings(): () => void {
return useUserSettingsStore.subscribe((state, prev) => {
if (state.saved === prev.saved) return;
saveManagedSettings(state.saved);
});
}
+156
View File
@@ -0,0 +1,156 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// The browser file adapter is mocked: export drives downloadJson, import reads
// via readTextFile. The service logic (merge, notify) is what we exercise.
vi.mock('../infrastructure/file-transfer', () => ({
downloadJson: vi.fn(),
readTextFile: vi.fn(),
}));
import { createDataset } from '@core/dataset';
import { createSnippet } from '@core/snippet';
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
import { useDatasetStore } from '../stores/DatasetStore';
import { useNotificationStore } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { exportWorkspace, importWorkspace } from './transfer';
const mockedDownload = vi.mocked(downloadJson);
const mockedRead = vi.mocked(readTextFile);
/** The most recent notification raised. */
function lastNote() {
const notes = useNotificationStore.getState().notifications;
return notes[notes.length - 1];
}
/** Drive an import from a JSON string (readTextFile is mocked to return it). */
async function importJson(text: string) {
mockedRead.mockResolvedValueOnce(text);
await importWorkspace({} as File);
}
beforeEach(() => {
vi.clearAllMocks();
useSnippetStore.getState().reset();
useDatasetStore.getState().reset();
useNotificationStore.getState().clear();
});
describe('exportWorkspace', () => {
it('reports and downloads nothing for an empty library', () => {
exportWorkspace(new Date('2026-06-07T00:00:00.000Z'));
expect(mockedDownload).not.toHaveBeenCalled();
expect(lastNote()).toMatchObject({ kind: 'info' });
expect(lastNote().message).toContain('No snippets to export');
});
it('downloads an envelope and reports counts', () => {
useSnippetStore.getState().hydrate([createSnippet({ id: 's1', name: 'A' })]);
useDatasetStore
.getState()
.addDatasets([
createDataset({ name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline' }),
]);
exportWorkspace(new Date('2026-06-07T12:00:00.000Z'));
expect(mockedDownload).toHaveBeenCalledTimes(1);
const [filename, json] = mockedDownload.mock.calls[0];
expect(filename).toBe('astrolabe-project-2026-06-07.json');
const env = JSON.parse(json) as { version: string; snippets: unknown[]; datasets: unknown[] };
expect(env.version).toBe('1.0');
expect(env.snippets).toHaveLength(1);
expect(env.datasets).toHaveLength(1);
expect(lastNote()).toMatchObject({ kind: 'success' });
expect(lastNote().message).toBe('Exported 1 snippet and 1 dataset');
});
});
describe('importWorkspace', () => {
it('reports an invalid JSON file and leaves the workspace untouched', async () => {
useSnippetStore.getState().hydrate([createSnippet({ id: 's1', name: 'A' })]);
await importJson('{ not json');
expect(useSnippetStore.getState().snippets).toHaveLength(1);
expect(lastNote()).toMatchObject({ kind: 'error' });
expect(lastNote().message).toContain('valid JSON');
});
it('reports an empty file (no snippets found)', async () => {
await importJson(JSON.stringify({ version: '1.0', snippets: [] }));
expect(useSnippetStore.getState().snippets).toHaveLength(0);
expect(lastNote()).toMatchObject({ kind: 'info' });
expect(lastNote().message).toBe('No snippets found in file.');
});
it('merges an envelope: datasets and snippets are appended', async () => {
await importJson(
JSON.stringify({
version: '1.0',
snippets: [
{ id: 's1', created: '2026-01-01T00:00:00.000Z', name: 'A', spec: '{"mark":"bar"}' },
],
datasets: [{ id: 1, name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline' }],
}),
);
expect(useSnippetStore.getState().snippets).toHaveLength(1);
expect(useDatasetStore.getState().datasets).toHaveLength(1);
expect(useDatasetStore.getState().datasets[0].name).toBe('Sales');
expect(lastNote()).toMatchObject({ kind: 'success' });
expect(lastNote().message).toBe('Imported 1 snippet and 1 dataset');
});
it('auto-suffixes a clashing dataset name and rewrites the importing snippet ref', async () => {
useDatasetStore
.getState()
.addDatasets([
createDataset({ name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline' }),
]);
await importJson(
JSON.stringify({
version: '1.0',
snippets: [
{
id: 's9',
created: '2026-01-01T00:00:00.000Z',
name: 'Ref',
spec: '{"data":{"name":"Sales"},"mark":"bar"}',
},
],
datasets: [{ id: 1, name: 'Sales', data: [{ b: 2 }], format: 'json', source: 'inline' }],
}),
);
const names = useDatasetStore
.getState()
.datasets.map((d) => d.name)
.sort();
expect(names).toEqual(['Sales', 'Sales 2']);
const imported = useSnippetStore.getState().snippets.find((s) => s.name === 'Ref')!;
expect(imported.datasetRefs).toContain('Sales 2');
expect(imported.spec).toContain('Sales 2');
expect(lastNote()).toMatchObject({ kind: 'warning' });
expect(lastNote().message).toContain('Sales → Sales 2');
});
it('reassigns a colliding snippet id, keeping the existing one', async () => {
useSnippetStore.getState().hydrate([createSnippet({ id: 's1', name: 'Existing' })]);
await importJson(
JSON.stringify({
version: '1.0',
snippets: [{ id: 's1', created: '2026-01-01T00:00:00.000Z', name: 'Imported', spec: '{}' }],
}),
);
const snippets = useSnippetStore.getState().snippets;
expect(snippets).toHaveLength(2);
const imported = snippets.find((s) => s.name === 'Imported')!;
expect(imported.id).not.toBe('s1');
expect(snippets.find((s) => s.name === 'Existing')!.id).toBe('s1');
});
});
+168
View File
@@ -0,0 +1,168 @@
/**
* Import / Export service (spec §08) — orchestration over the pure core helpers
* and the stores. The deterministic work (shape detection, normalization, name
* de-dupe, id reassignment, rename propagation, envelope/message building) lives
* in `@core/import-normalize` and `@core/export-envelope`; this layer reads the
* stores, drives the browser file adapter, commits the merge, and reports.
*
* Order matters on import (spec §08 → Merge): datasets are committed **before**
* snippets so a snippet's by-name reference resolves against the just-added
* dataset (whose name may have been auto-suffixed to avoid a clash).
*/
import { buildExportEnvelope, exportFilename, exportSummaryMessage } from '@core/export-envelope';
import {
applyDatasetRenamesToSnippets,
dedupeIncomingDatasetNames,
importSummaryMessage,
normalizeImport,
reassignCollidingSnippetIds,
} from '@core/import-normalize';
import { snippetSizeBytes } from '@core/snippet';
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
import { notify } from '../stores/NotificationStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useSnippetStore } from '../stores/SnippetStore';
/** Practical snippet-storage budget (spec §02 storage monitor / §08). */
const SNIPPET_BUDGET_BYTES = 5 * 1024 * 1024;
/** Round bytes to a short KB/MB string for the overage warning. */
function formatBytes(bytes: number): string {
const kb = bytes / 1024;
if (kb < 1024) return `${Math.max(1, Math.round(kb))} KB`;
return `${Math.round(kb / 1024)} MB`;
}
/**
* Export the whole workspace to a downloaded JSON file (spec §08 → Export).
* Flushes the live editor buffer first so in-progress draft edits are included.
* An empty library is reported and nothing is downloaded, even if datasets exist.
*/
export function exportWorkspace(now: Date = new Date()): void {
// Commit any valid, uncommitted buffer so the export reflects current work.
useSnippetStore.getState().commitDraft(now);
const snippets = useSnippetStore.getState().snippets;
const datasets = useDatasetStore.getState().datasets;
if (snippets.length === 0) {
notify({
kind: 'info',
title: 'Nothing to export',
message: 'No snippets to export. Create a snippet first, then export your workspace.',
});
return;
}
const envelope = buildExportEnvelope(snippets, datasets, { now });
downloadJson(exportFilename(now), JSON.stringify(envelope, null, 2));
notify({
kind: 'success',
title: 'Workspace exported',
message: exportSummaryMessage(snippets.length, datasets.length),
});
}
/**
* Import a picked JSON file: normalize, merge (never overwrite), and save
* (spec §08 → Import). The existing workspace is never lost — an unreadable or
* unparseable file leaves it untouched, and merges only ever append.
*/
export async function importWorkspace(file: File): Promise<void> {
let text: string;
try {
text = await readTextFile(file);
} catch (err) {
notify({
kind: 'error',
title: "Couldn't read the file",
message: 'The selected file could not be read. Choose the file again and retry.',
detail: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
});
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
notify({
kind: 'error',
title: 'Import failed',
message: 'Failed to import. Please check that the file is valid JSON.',
});
return;
}
const { snippets: normSnippets, datasets: normDatasets } = normalizeImport(parsed);
if (normSnippets.length === 0) {
notify({
kind: 'info',
title: 'Nothing imported',
message: 'No snippets found in file.',
});
return;
}
// Datasets first: de-dupe their names against the library (and within the batch),
// then propagate any rename into the imported snippets so their references still
// resolve (spec §08 → Dataset conflicts; docs/architecture/07 §56).
const existingDatasetNames = useDatasetStore.getState().datasets.map((d) => d.name);
const { datasets: dedupedDatasets, renames } = dedupeIncomingDatasetNames(
existingDatasetNames,
normDatasets,
);
const renamedSnippets = applyDatasetRenamesToSnippets(normSnippets, renames);
// Reassign incoming snippet ids that clash with the library (spec §08 → ID collisions).
const existingSnippetIds = useSnippetStore.getState().snippets.map((s) => s.id);
const finalSnippets = reassignCollidingSnippetIds(existingSnippetIds, renamedSnippets);
// Commit datasets BEFORE snippets so by-name references resolve (spec §08).
useDatasetStore.getState().addDatasets(dedupedDatasets);
// Storage budget pre-check (spec §08 → Storage limit handling): warn on overage
// but still attempt the save.
const existingBytes = useSnippetStore
.getState()
.snippets.reduce((n, s) => n + snippetSizeBytes(s), 0);
const incomingBytes = finalSnippets.reduce((n, s) => n + snippetSizeBytes(s), 0);
const overage = existingBytes + incomingBytes - SNIPPET_BUDGET_BYTES;
// TODO (spec §08 → Storage limit handling): a *hard* quota failure on save
// should commit no partial snippet import. The write-through subscriber
// currently surfaces such a failure as a per-record error toast, but the
// in-memory store keeps the records (they vanish on reload). True atomic
// rollback needs a write-before-store import path — a persistence-architecture
// change deferred until the M6 storage monitor (web.dev quota estimate) lands.
useSnippetStore.getState().addSnippets(finalSnippets);
// Feedback (spec §08 → Feedback): one summary toast; a warning when datasets were
// renamed or storage is over budget, otherwise a success.
const summary = importSummaryMessage(finalSnippets.length, dedupedDatasets.length);
const clauses: string[] = [];
if (renames.length > 0) {
clauses.push(
`Renamed to avoid clashes: ${renames.map((r) => `${r.from}${r.to}`).join(', ')}.`,
);
}
if (overage > 0) {
clauses.push(
`This puts snippet storage about ${formatBytes(overage)} over the ~5 MB budget; ` +
`consider deleting some snippets.`,
);
}
if (clauses.length > 0) {
notify({
kind: 'warning',
title: 'Import complete',
message: `${summary}. ${clauses.join(' ')}`,
});
} else {
notify({ kind: 'success', title: 'Import complete', message: summary });
}
}
+17
View File
@@ -69,6 +69,14 @@ export interface DatasetState {
/** Low-level: add a fully-formed dataset and select it. */
add: (dataset: Dataset) => void;
/**
* Append imported datasets (spec §08 → datasets imported before snippets). Each
* is given a fresh monotonic numeric id so a batch never collides on the
* `Date.now()` default (the `add` TODO) nor with existing ids — safe because
* datasets are referenced by **name**, not id (docs/architecture/07 §1). Names
* are assumed already de-duped by the import service. Selection is unchanged.
*/
addDatasets: (incoming: Dataset[]) => void;
/** Low-level: merge a patch into a dataset, advancing `modified`. */
update: (id: number, patch: Partial<Dataset>, now?: Date) => void;
/** Low-level: remove a dataset; clears the selection if it was selected. */
@@ -237,6 +245,15 @@ export const useDatasetStore = create<DatasetState>((set, get) => ({
// before wiring import.
add: (dataset) => set((s) => ({ datasets: [dataset, ...s.datasets], selectedId: dataset.id })),
addDatasets: (incoming) => {
if (incoming.length === 0) return;
set((s) => {
let nextId = s.datasets.reduce((max, d) => Math.max(max, d.id), 0) + 1;
const withIds = incoming.map((d) => ({ ...d, id: nextId++ }));
return { datasets: [...withIds, ...s.datasets] };
});
},
update: (id, patch, now) => {
const modified = patch.modified ?? (now ?? new Date()).toISOString();
set((s) => ({
+29
View File
@@ -0,0 +1,29 @@
/**
* Open-state for the per-pane settings disclosures (spec §07; arch 10).
*
* A single id names whichever settings popover is open — so at most one shows at a
* time, and any can be opened imperatively (the Cmd/Ctrl+, shortcut targets the
* editor cluster). Kept in its own module so the SettingsPopover component file
* exports only components (fast-refresh friendly).
*/
import { create } from 'zustand';
export interface SettingsPopoverState {
/** Id of the single open popover, or null. */
openId: string | null;
toggle: (id: string) => void;
show: (id: string) => void;
close: () => void;
}
export const useSettingsPopoverStore = create<SettingsPopoverState>((set) => ({
openId: null,
toggle: (id) => set((s) => ({ openId: s.openId === id ? null : id })),
show: (id) => set({ openId: id }),
close: () => set({ openId: null }),
}));
/** Open a settings popover by id from outside React (e.g. the Cmd/Ctrl+, shortcut). */
export const openSettingsPopover = (id: string): void =>
useSettingsPopoverStore.getState().show(id);
+23
View File
@@ -46,6 +46,13 @@ export interface SnippetState {
/** Replace the library from storage and choose an active snippet. */
hydrate: (snippets: Snippet[], activeId?: string | null) => void;
/**
* Append imported snippets (spec §08 → merge: appended, never overwritten). Ids
* are assumed already unique against the library (the import service reassigns
* collisions). Keeps the current selection; selects the newest import only when
* nothing is active, so an import into an empty workspace lands the user on it.
*/
addSnippets: (incoming: Snippet[]) => void;
/** Create a new snippet (sample template by default), prepend, and select it. */
createSnippet: (options?: CreateSnippetOptions) => string;
/** Make a snippet active and load its draft into the editor buffer. */
@@ -148,6 +155,22 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
}));
},
addSnippets: (incoming) => {
if (incoming.length === 0) return;
set((s) => {
const snippets = [...incoming, ...s.snippets];
if (s.activeSnippetId !== null) return { snippets };
const id = newestId(snippets);
return {
snippets,
activeSnippetId: id,
draftText: draftFor(snippets, id),
editorView: 'draft',
bufferEpoch: s.bufferEpoch + 1,
};
});
},
createSnippet: (options) => {
get().commitDraft(); // flush the outgoing snippet's valid edits before switching away
const created = createSnippet(options);
+59
View File
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { defaultSettings } from '@core/settings';
import { useUserSettingsStore } from './UserSettingsStore';
/** The managed slice (editor/performance/formatting) of a full settings record. */
function managed(s = defaultSettings()) {
return { editor: s.editor, performance: s.performance, formatting: s.formatting };
}
beforeEach(() => {
useUserSettingsStore.getState().hydrate(defaultSettings());
});
describe('UserSettingsStore', () => {
it('hydrate strips a full record to the managed slice', () => {
useUserSettingsStore.getState().hydrate(defaultSettings());
expect(useUserSettingsStore.getState().saved).toEqual(managed());
});
it('setters apply live and patch only their cluster', () => {
useUserSettingsStore.getState().setEditor({ fontSize: 16 });
expect(useUserSettingsStore.getState().saved.editor.fontSize).toBe(16);
// other editor fields and other clusters untouched
expect(useUserSettingsStore.getState().saved.editor.tabSize).toBe(2);
expect(useUserSettingsStore.getState().saved.performance.renderDebounce).toBe(1500);
useUserSettingsStore.getState().setPerformance({ renderDebounce: 800 });
expect(useUserSettingsStore.getState().saved.performance.renderDebounce).toBe(800);
useUserSettingsStore.getState().setFormatting({ dateFormat: 'iso' });
expect(useUserSettingsStore.getState().saved.formatting.dateFormat).toBe('iso');
});
it('each setter produces a fresh `saved` reference (drives the persistence subscriber)', () => {
const before = useUserSettingsStore.getState().saved;
useUserSettingsStore.getState().setEditor({ minimap: true });
const after = useUserSettingsStore.getState().saved;
expect(after).not.toBe(before);
expect(after.editor.minimap).toBe(true);
});
it('resetEditor restores only the editor cluster to defaults', () => {
useUserSettingsStore.getState().setEditor({ fontSize: 18, minimap: true, tabSize: 8 });
useUserSettingsStore.getState().setFormatting({ dateFormat: 'iso' });
useUserSettingsStore.getState().resetEditor();
expect(useUserSettingsStore.getState().saved.editor).toEqual(defaultSettings().editor);
// formatting (a different cluster) is left as the user set it
expect(useUserSettingsStore.getState().saved.formatting.dateFormat).toBe('iso');
});
it('does not alias the stored record on hydrate (mutating input later is inert)', () => {
const input = defaultSettings();
useUserSettingsStore.getState().hydrate(input);
input.editor.fontSize = 99;
expect(useUserSettingsStore.getState().saved.editor.fontSize).toBe(12);
});
});
+65
View File
@@ -0,0 +1,65 @@
/**
* User settings state (spec §07, docs/architecture/01).
*
* Holds the **applied** preferences the app reads reactively — editor,
* performance, and formatting. Following Astrolabe's distributed-settings model
* (spec §07), each cluster is edited **in place**, next to what it affects (the
* editor pane, the preview, the library), and every change applies **live**: a
* setter mutates `saved` immediately and a startup subscriber writes it through
* to localStorage (orchestration/settings). There is no draft/Apply/Cancel
* stage — that's the explicit, commit-style model we replaced.
*
* UI theme and preview fit mode are not here: they live in the AppStore (the
* header theme toggle and the preview Fit control), persisted onto the same
* `UserSettings` record by their own slice subscribers. Settings controls that
* change theme call `useAppStore.setTheme` directly, exactly like the toggle.
*/
import { create } from 'zustand';
import { defaultSettings, type UserSettings } from '@core/settings';
/** The clusters this store owns (theme + fit mode live in the AppStore). */
export type ManagedSettings = Pick<UserSettings, 'editor' | 'performance' | 'formatting'>;
/** Deep-copy the managed slice so callers never alias the stored record. */
function cloneManaged(s: ManagedSettings): ManagedSettings {
return {
editor: { ...s.editor },
performance: { ...s.performance },
formatting: { ...s.formatting },
};
}
export interface UserSettingsState {
/** The applied settings — the reactive source the editor/preview/library read. */
saved: ManagedSettings;
/** Replace `saved` from the persisted record at startup. */
hydrate: (settings: UserSettings) => void;
/** Live-patch a cluster (each produces a fresh `saved` → persistence fires). */
setEditor: (patch: Partial<UserSettings['editor']>) => void;
setPerformance: (patch: Partial<UserSettings['performance']>) => void;
setFormatting: (patch: Partial<UserSettings['formatting']>) => void;
/** Restore the editor cluster to its factory defaults (the editor popover's Reset). */
resetEditor: () => void;
}
export const useUserSettingsStore = create<UserSettingsState>((set) => ({
saved: cloneManaged(defaultSettings()),
hydrate: (settings) => set({ saved: cloneManaged(settings) }),
setEditor: (patch) =>
set((s) => ({ saved: { ...s.saved, editor: { ...s.saved.editor, ...patch } } })),
setPerformance: (patch) =>
set((s) => ({ saved: { ...s.saved, performance: { ...s.saved.performance, ...patch } } })),
setFormatting: (patch) =>
set((s) => ({ saved: { ...s.saved, formatting: { ...s.saved.formatting, ...patch } } })),
resetEditor: () =>
set((s) => ({ saved: { ...s.saved, editor: { ...defaultSettings().editor } } })),
}));
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest';
import {
formatCustom,
formatDate,
formatIso,
formatSmart,
type DateFormatMode,
} from './date-format';
/** A fixed local reference "now" for relative-date tests. */
const NOW = new Date(2026, 5, 7, 12, 0, 0); // 2026-06-07 12:00 local
const at = (y: number, mo: number, d: number, h = 9, mi = 0, s = 0) => new Date(y, mo, d, h, mi, s);
describe('formatSmart', () => {
it('renders the same calendar day as Today', () => {
expect(formatSmart(at(2026, 5, 7, 0, 1), NOW)).toBe('Today');
expect(formatSmart(at(2026, 5, 7, 23, 59), NOW)).toBe('Today');
});
it('renders the previous calendar day as Yesterday', () => {
expect(formatSmart(at(2026, 5, 6), NOW)).toBe('Yesterday');
});
it('renders under a week as Nd ago', () => {
expect(formatSmart(at(2026, 5, 4), NOW)).toBe('3d ago');
expect(formatSmart(at(2026, 5, 1), NOW)).toBe('6d ago');
});
it('falls back to a full locale date at a week or older', () => {
const old = at(2026, 4, 1);
expect(formatSmart(old, NOW)).toBe(old.toLocaleDateString());
});
it('does not flip to Yesterday on the hour within the same day', () => {
// 1 minute ago but same calendar day → still Today.
expect(formatSmart(at(2026, 5, 7, 11, 59), NOW)).toBe('Today');
});
});
describe('formatIso', () => {
it('returns a full ISO 8601 timestamp', () => {
const d = new Date('2026-06-07T10:30:00.000Z');
expect(formatIso(d)).toBe('2026-06-07T10:30:00.000Z');
});
});
describe('formatCustom', () => {
it('formats the placeholder pattern yyyy-MM-dd HH:mm', () => {
expect(formatCustom(at(2026, 0, 5, 8, 4), 'yyyy-MM-dd HH:mm')).toBe('2026-01-05 08:04');
});
it('supports month names and short year', () => {
expect(formatCustom(at(2026, 11, 25), 'd MMMM yy')).toBe('25 December 26');
expect(formatCustom(at(2026, 2, 9), 'MMM d, yyyy')).toBe('Mar 9, 2026');
});
it('supports 12-hour clock with meridiem', () => {
expect(formatCustom(at(2026, 5, 7, 0, 5), 'h:mm a')).toBe('12:05 AM');
expect(formatCustom(at(2026, 5, 7, 13, 5), 'h:mm a')).toBe('1:05 PM');
expect(formatCustom(at(2026, 5, 7, 12, 0), 'hh:mm a')).toBe('12:00 PM');
});
it('preserves non-token literals verbatim', () => {
expect(formatCustom(at(2026, 0, 1), 'yyyy/MM/dd')).toBe('2026/01/01');
});
});
describe('formatDate', () => {
it('dispatches by mode', () => {
const iso = at(2026, 5, 6, 9, 0).toISOString();
expect(formatDate(iso, 'smart', '', NOW)).toBe('Yesterday');
expect(formatDate(iso, 'iso')).toBe(new Date(iso).toISOString());
expect(formatDate(iso, 'custom', 'yyyy-MM-dd', NOW)).toBe('2026-06-06');
});
it('falls back to ISO when a custom pattern is blank', () => {
const iso = '2026-06-07T10:30:00.000Z';
expect(formatDate(iso, 'custom', ' ')).toBe(new Date(iso).toISOString());
expect(formatDate(iso, 'custom', '')).toBe(new Date(iso).toISOString());
});
it('returns an unparseable timestamp verbatim rather than "Invalid Date"', () => {
expect(formatDate('not-a-date', 'iso')).toBe('not-a-date');
expect(formatDate('not-a-date', 'smart', '', NOW)).toBe('not-a-date');
});
it('defaults unknown modes to smart', () => {
const iso = at(2026, 5, 7).toISOString();
expect(formatDate(iso, 'bogus' as DateFormatMode, '', NOW)).toBe('Today');
});
});
+137
View File
@@ -0,0 +1,137 @@
/**
* Date formatting — how timestamps render throughout the app (spec §07 →
* Formatting). Governs the Snippet Library list dates and the metadata panel's
* Created/Modified, driven by the user's `formatting.dateFormat` setting.
*
* Portable core: no browser APIs, no React. `Intl` is deliberately avoided for
* the custom tokens so output is locale-stable and unit-testable; month/day
* names are the fixed English set. Pure: an ISO string in, a display string out.
*
* - smart → relative, human-friendly ("Today", "Yesterday", "3d ago", then a
* full locale date for older items).
* - iso → a full ISO 8601 timestamp.
* - custom → the user's format string (token grammar below); falls back to ISO
* when the pattern is empty.
*/
/** The three date-display modes (mirrors UserSettings.formatting.dateFormat). */
export type DateFormatMode = 'smart' | 'iso' | 'custom';
const DAY_MS = 24 * 60 * 60 * 1000;
const MONTHS_SHORT = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
const MONTHS_LONG = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
/** Two-digit zero-pad (mirrors snippet.ts → generateSnippetName). */
function pad(n: number): string {
return String(n).padStart(2, '0');
}
/**
* Relative, human-friendly rendering (spec §07 → Smart). Same day → "Today",
* one calendar day back → "Yesterday", under a week → "Nd ago", else the full
* locale date. Comparison is by calendar day (local), so "Yesterday" doesn't
* flip on the exact hour. `now` is injectable for deterministic tests.
*/
export function formatSmart(date: Date, now: Date): string {
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const days = Math.floor((startOfDay(now) - startOfDay(date)) / DAY_MS);
if (days <= 0) return 'Today';
if (days === 1) return 'Yesterday';
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString();
}
/** Full ISO 8601 timestamp (spec §07 → ISO 8601). */
export function formatIso(date: Date): string {
return date.toISOString();
}
// Tokens longest-first so MMMM matches before MMM before MM before M, etc.
const TOKEN = /yyyy|yy|MMMM|MMM|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s|a/g;
/**
* Format a date with a date-fns-style token pattern, in **local** time
* (spec §07 → Custom). Supported tokens: `yyyy yy MMMM MMM MM M dd d HH H hh h
* mm m ss s a`. Text between tokens is preserved verbatim (no quoting), which is
* enough for patterns like `yyyy-MM-dd HH:mm`; a stray literal letter that
* happens to be a token (e.g. a `d` in prose) would be substituted — the field
* is a power-user affordance, so this stays simple and predictable.
*/
export function formatCustom(date: Date, pattern: string): string {
const h24 = date.getHours();
const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
const map: Record<string, string> = {
yyyy: String(date.getFullYear()),
yy: pad(date.getFullYear() % 100),
MMMM: MONTHS_LONG[date.getMonth()],
MMM: MONTHS_SHORT[date.getMonth()],
MM: pad(date.getMonth() + 1),
M: String(date.getMonth() + 1),
dd: pad(date.getDate()),
d: String(date.getDate()),
HH: pad(h24),
H: String(h24),
hh: pad(h12),
h: String(h12),
mm: pad(date.getMinutes()),
m: String(date.getMinutes()),
ss: pad(date.getSeconds()),
s: String(date.getSeconds()),
a: h24 < 12 ? 'AM' : 'PM',
};
return pattern.replace(TOKEN, (t) => map[t] ?? t);
}
/**
* Render an ISO timestamp per the user's date-format setting (spec §07). An
* unparseable timestamp is returned verbatim rather than rendered as "Invalid
* Date", so a malformed stored value degrades gracefully. `customFormat` is used
* only in `custom` mode and falls back to ISO when blank. `now` (for `smart`)
* defaults to the current time; inject it in tests.
*/
export function formatDate(
iso: string,
mode: DateFormatMode,
customFormat = '',
now: Date = new Date(),
): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
switch (mode) {
case 'iso':
return formatIso(date);
case 'custom':
return customFormat.trim() === '' ? formatIso(date) : formatCustom(date, customFormat);
case 'smart':
default:
return formatSmart(date, now);
}
}
+129
View File
@@ -0,0 +1,129 @@
import { describe, it, expect } from 'vitest';
import { createDataset, type Dataset } from './dataset';
import {
EXPORT_ENVELOPE_VERSION,
buildExportEnvelope,
exportFilename,
exportSummaryMessage,
type ExportEnvelope,
} from './export-envelope';
import { createSnippet, type Snippet } from './snippet';
const FIXED_NOW = new Date('2026-06-03T12:00:00.000Z');
function makeSnippet(overrides: Partial<Snippet> = {}): Snippet {
return { ...createSnippet({ now: FIXED_NOW, id: 's1' }), ...overrides };
}
function makeDataset(overrides: Partial<Dataset> = {}): Dataset {
return {
...createDataset({
name: 'D1',
data: 'a,b\n1,2',
format: 'csv',
source: 'inline',
now: FIXED_NOW,
id: 1,
}),
...overrides,
};
}
describe('EXPORT_ENVELOPE_VERSION', () => {
it('is the spec-mandated "1.0"', () => {
expect(EXPORT_ENVELOPE_VERSION).toBe('1.0');
});
});
describe('buildExportEnvelope', () => {
it('stamps version, ISO timestamp, and the fixed exporter tag', () => {
const env = buildExportEnvelope([], [], { now: FIXED_NOW });
expect(env.version).toBe('1.0');
expect(env.exportedAt).toBe('2026-06-03T12:00:00.000Z');
expect(env.exportedBy).toBe('Astrolabe');
});
it('produces the full envelope shape (matches spec §08 example fields)', () => {
const snippet = makeSnippet();
const dataset = makeDataset();
const env = buildExportEnvelope([snippet], [dataset], { now: FIXED_NOW });
const expected: ExportEnvelope = {
version: '1.0',
exportedAt: '2026-06-03T12:00:00.000Z',
exportedBy: 'Astrolabe',
snippets: [snippet],
datasets: [dataset],
};
expect(env).toEqual(expected);
});
it('carries the records through unchanged', () => {
const snippet = makeSnippet();
const dataset = makeDataset();
const env = buildExportEnvelope([snippet], [dataset], { now: FIXED_NOW });
expect(env.snippets[0]).toBe(snippet);
expect(env.datasets[0]).toBe(dataset);
});
it("preserves each record's own version field", () => {
const snippet = makeSnippet({ version: 1 });
const dataset = makeDataset({ version: 1 });
const env = buildExportEnvelope([snippet], [dataset], { now: FIXED_NOW });
expect(env.snippets[0].version).toBe(1);
expect(env.datasets[0].version).toBe(1);
// The per-record version is distinct from the envelope's file-format version.
expect(env.version).toBe('1.0');
});
it("does not alias the caller's arrays (mutating inputs afterward is inert)", () => {
const snippets = [makeSnippet()];
const datasets = [makeDataset()];
const env = buildExportEnvelope(snippets, datasets, { now: FIXED_NOW });
snippets.push(makeSnippet({ id: 's2' }));
datasets.push(makeDataset({ id: 2, name: 'D2' }));
expect(env.snippets).toHaveLength(1);
expect(env.datasets).toHaveLength(1);
});
it('handles empty arrays', () => {
const env = buildExportEnvelope([], [], { now: FIXED_NOW });
expect(env.snippets).toEqual([]);
expect(env.datasets).toEqual([]);
});
});
describe('exportFilename', () => {
it('formats as astrolabe-project-YYYY-MM-DD.json with zero-padding', () => {
// Local date parts; construct with local Y/M/D to avoid TZ ambiguity.
expect(exportFilename(new Date(2026, 0, 5))).toBe('astrolabe-project-2026-01-05.json');
});
it('zero-pads two-digit month and day', () => {
expect(exportFilename(new Date(2026, 11, 25))).toBe('astrolabe-project-2026-12-25.json');
});
});
describe('exportSummaryMessage', () => {
it('reports both counts (plural)', () => {
expect(exportSummaryMessage(4, 2)).toBe('Exported 4 snippets and 2 datasets');
});
it('omits the dataset clause when there are no datasets', () => {
expect(exportSummaryMessage(4, 0)).toBe('Exported 4 snippets');
});
it('uses singular wording for counts of 1', () => {
expect(exportSummaryMessage(1, 1)).toBe('Exported 1 snippet and 1 dataset');
});
it('singular snippet with omitted dataset clause', () => {
expect(exportSummaryMessage(1, 0)).toBe('Exported 1 snippet');
});
it('pluralizes zero counts (and omits the dataset clause)', () => {
expect(exportSummaryMessage(0, 0)).toBe('Exported 0 snippets');
});
});
+93
View File
@@ -0,0 +1,93 @@
/**
* Export envelope — the single JSON object Astrolabe downloads to back up or
* transfer a whole workspace (spec §08 → Export, Export envelope shape).
*
* Portable core: no browser APIs, no React. Builds the in-memory envelope, the
* download filename, and the success-toast message as plain data. The actual file
* download (Blob/anchor/DOM) is browser code that lives in the app layer; this
* module only shapes what that code serializes and reports.
*
* The envelope `version` is the **file-format** version, distinct from the
* per-record `version` fields each snippet/dataset carries (their read-time
* migration target — see snippet.ts / dataset.ts).
*/
import type { Dataset } from './dataset';
import type { Snippet } from './snippet';
/**
* Current export file-format version (spec §08 → "currently `\"1.0\"`"). Bump when
* the envelope shape changes in a way importers must branch on; this is NOT the
* per-record schema version.
*/
export const EXPORT_ENVELOPE_VERSION = '1.0';
/**
* The downloaded file's top-level shape (spec §08 → Export envelope shape): format
* metadata plus the two complete-record arrays. Each record keeps its own `version`
* field unchanged.
*/
export interface ExportEnvelope {
/** Export format version (currently `"1.0"`). */
version: string;
/** ISO 8601 timestamp of the export. */
exportedAt: string;
/** Fixed exporter tag. */
exportedBy: 'Astrolabe';
/** All snippets, as complete records (each including its record `version`). */
snippets: Snippet[];
/** All datasets, as complete records (each including its record `version`). */
datasets: Dataset[];
}
/**
* Build the export envelope (spec §08 → Export). Stamps the format version, the
* export timestamp (`now.toISOString()`), and the fixed exporter tag, and copies
* the records into fresh arrays so the envelope does not alias the caller's arrays
* (the records themselves are referenced as-is — they are the complete records to
* serialize, each keeping its own `version`).
*/
export function buildExportEnvelope(
snippets: ReadonlyArray<Snippet>,
datasets: ReadonlyArray<Dataset>,
opts: { now: Date },
): ExportEnvelope {
return {
version: EXPORT_ENVELOPE_VERSION,
exportedAt: opts.now.toISOString(),
exportedBy: 'Astrolabe',
snippets: [...snippets],
datasets: [...datasets],
};
}
/** Two-digit zero-pad for the filename date (mirrors snippet.ts → generateSnippetName). */
function pad(n: number): string {
return String(n).padStart(2, '0');
}
/**
* Download filename for an export (spec §08 → Filename):
* `astrolabe-project-YYYY-MM-DD.json`, where the date is today's date (export day).
* Uses local date parts, like `generateSnippetName`.
*/
export function exportFilename(now: Date): string {
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
return `astrolabe-project-${date}.json`;
}
/** Pluralize a count's noun: "1 snippet" / "4 snippets". */
function countClause(count: number, noun: string): string {
return `${count} ${noun}${count === 1 ? '' : 's'}`;
}
/**
* Success-toast message reporting the export counts (spec §08 → Feedback), e.g.
* "Exported 4 snippets and 2 datasets". The dataset clause is omitted entirely when
* there are no datasets; singular/plural wording adapts to the counts.
*/
export function exportSummaryMessage(snippetCount: number, datasetCount: number): string {
const snippets = countClause(snippetCount, 'snippet');
if (datasetCount === 0) return `Exported ${snippets}`;
return `Exported ${snippets} and ${countClause(datasetCount, 'dataset')}`;
}
+437
View File
@@ -0,0 +1,437 @@
import { describe, expect, it } from 'vitest';
import { CURRENT_DATASET_VERSION, type Dataset } from './dataset';
import {
applyDatasetRenamesToSnippets,
dedupeIncomingDatasetNames,
importSummaryMessage,
normalizeImport,
reassignCollidingSnippetIds,
} from './import-normalize';
import { CURRENT_SNIPPET_VERSION, type Snippet } from './snippet';
/** Deterministic id generator returning id-1, id-2, … */
function counterIds(prefix = 'id'): () => string {
let n = 0;
return () => `${prefix}-${++n}`;
}
const FIXED_NOW = new Date('2026-06-07T10:00:00.000Z');
const FIXED_NOW_ISO = FIXED_NOW.toISOString();
/** Parse a spec string and read its top-level `data.name` (typed for lint). */
function dataName(spec: string): string {
return (JSON.parse(spec) as { data: { name: string } }).data.name;
}
/** A fully-current snippet record (carries an ISO `created`). */
function currentSnippetRecord(over: Partial<Snippet> = {}): Record<string, unknown> {
return {
id: 's-existing',
version: CURRENT_SNIPPET_VERSION,
name: 'Bar chart',
created: '2025-01-02T03:04:05.000Z',
modified: '2025-02-02T03:04:05.000Z',
spec: '{"mark":"bar"}',
draftSpec: '{"mark":"bar"}',
comment: 'hi',
tags: ['fav'],
datasetRefs: ['Sales'],
meta: { k: 1 },
...over,
};
}
/** A minimal current dataset record. */
function datasetRecord(over: Partial<Dataset> = {}): Dataset {
return {
id: 1,
version: CURRENT_DATASET_VERSION,
name: 'Sales',
data: 'a,b\n1,2',
format: 'csv',
source: 'inline',
comment: '',
rowCount: 1,
columnCount: 2,
columns: ['a', 'b'],
columnTypes: [
{ name: 'a', type: 'number' },
{ name: 'b', type: 'number' },
],
size: 7,
created: '2025-01-01T00:00:00.000Z',
modified: '2025-01-01T00:00:00.000Z',
...over,
};
}
describe('normalizeImport — shape detection', () => {
it('recognizes the Astrolabe export envelope (snippets + datasets)', () => {
const parsed = {
version: '1.0',
exportedBy: 'Astrolabe',
snippets: [currentSnippetRecord()],
datasets: [datasetRecord()],
};
const result = normalizeImport(parsed, { now: FIXED_NOW });
expect(result.snippets).toHaveLength(1);
expect(result.datasets).toHaveLength(1);
expect(result.datasets[0].name).toBe('Sales');
});
it('treats a top-level array as a bare list of snippets (no datasets)', () => {
const parsed = [currentSnippetRecord({ id: 'a' }), currentSnippetRecord({ id: 'b' })];
const result = normalizeImport(parsed, { now: FIXED_NOW });
expect(result.snippets).toHaveLength(2);
expect(result.datasets).toEqual([]);
});
it('treats a non-envelope object as a single snippet', () => {
const parsed = currentSnippetRecord();
const result = normalizeImport(parsed, { now: FIXED_NOW });
expect(result.snippets).toHaveLength(1);
expect(result.datasets).toEqual([]);
});
it('treats an object with version but no snippets array as a single snippet', () => {
// `version` is a record field here, not an envelope marker — no snippets array.
const parsed = currentSnippetRecord();
const result = normalizeImport(parsed, { now: FIXED_NOW });
expect(result.snippets).toHaveLength(1);
expect(result.snippets[0].name).toBe('Bar chart');
});
it('returns empty for null / non-object / non-array junk', () => {
expect(normalizeImport(null)).toEqual({ snippets: [], datasets: [] });
expect(normalizeImport(42)).toEqual({ snippets: [], datasets: [] });
expect(normalizeImport('hello')).toEqual({ snippets: [], datasets: [] });
expect(normalizeImport(undefined)).toEqual({ snippets: [], datasets: [] });
});
it('ignores a non-array datasets field on the envelope', () => {
const parsed = { version: '1.0', snippets: [currentSnippetRecord()], datasets: 'nope' };
const result = normalizeImport(parsed, { now: FIXED_NOW });
expect(result.datasets).toEqual([]);
});
});
describe('normalizeImport — snippet normalization', () => {
it('preserves an already-current snippet and does NOT add the imported tag', () => {
const result = normalizeImport([currentSnippetRecord()], { now: FIXED_NOW });
const s = result.snippets[0];
expect(s.id).toBe('s-existing');
expect(s.name).toBe('Bar chart');
expect(s.created).toBe('2025-01-02T03:04:05.000Z');
expect(s.modified).toBe('2025-02-02T03:04:05.000Z');
expect(s.comment).toBe('hi');
expect(s.tags).toEqual(['fav']);
expect(s.datasetRefs).toEqual(['Sales']);
expect(s.meta).toEqual({ k: 1 });
expect(s.version).toBe(CURRENT_SNIPPET_VERSION);
expect(s.tags).not.toContain('imported');
});
it('fills missing fields on a current snippet with sensible fallbacks', () => {
const parsed = [{ created: '2025-03-03T03:03:03.000Z', spec: '{"mark":"line"}' }];
const result = normalizeImport(parsed, { now: FIXED_NOW, makeId: counterIds() });
const s = result.snippets[0];
expect(s.id).toBe('id-1');
expect(s.name).toBe('Untitled');
expect(s.modified).toBe('2025-03-03T03:03:03.000Z'); // falls back to created
expect(s.draftSpec).toBe('{"mark":"line"}'); // falls back to spec
expect(s.comment).toBe('');
expect(s.tags).toEqual([]); // current shape → not tagged
expect(s.datasetRefs).toEqual([]);
expect(s.meta).toEqual({});
});
it('maps foreign field names content→spec, draft→draftSpec, createdAt→created', () => {
const parsed = [
{
id: 'foreign-1',
content: '{"mark":"point"}',
draft: '{"mark":"point","x":1}',
createdAt: '2024-12-12T08:00:00.000Z',
},
];
const result = normalizeImport(parsed, { now: FIXED_NOW });
const s = result.snippets[0];
expect(s.spec).toBe('{"mark":"point"}');
expect(s.draftSpec).toBe('{"mark":"point","x":1}');
expect(s.created).toBe('2024-12-12T08:00:00.000Z'); // derived from source createdAt
expect(s.modified).toBe('2024-12-12T08:00:00.000Z');
});
it('tags foreign/older snippets "imported" and generates missing timestamps to now', () => {
const parsed = [{ content: '{"mark":"area"}' }];
const result = normalizeImport(parsed, { now: FIXED_NOW, makeId: counterIds() });
const s = result.snippets[0];
expect(s.tags).toContain('imported');
expect(s.created).toBe(FIXED_NOW_ISO);
expect(s.modified).toBe(FIXED_NOW_ISO);
expect(s.id).toBe('id-1');
});
it('does not duplicate the imported tag when already present', () => {
const parsed = [{ content: '{}', tags: ['imported', 'x'] }];
const result = normalizeImport(parsed, { now: FIXED_NOW });
expect(result.snippets[0].tags).toEqual(['imported', 'x']);
});
it('treats a bare YYYY-MM-DD created as foreign (tags imported)', () => {
// A date-only stamp is not the ISO timestamp shape → foreign → tagged + regenerated.
const parsed = [{ created: '2024-05-05', content: '{}' }];
const result = normalizeImport(parsed, { now: FIXED_NOW });
const s = result.snippets[0];
expect(s.tags).toContain('imported');
expect(s.created).toBe('2024-05-05'); // derived from the present source timestamp
});
it('coerces an object spec/draftSpec into pretty JSON string form', () => {
const parsed = [
{
created: '2025-01-01T00:00:00.000Z',
spec: { mark: 'bar', encoding: { x: { field: 'a' } } },
},
];
const result = normalizeImport(parsed, { now: FIXED_NOW });
const s = result.snippets[0];
expect(s.spec).toBe(JSON.stringify({ mark: 'bar', encoding: { x: { field: 'a' } } }, null, 2));
expect(s.draftSpec).toBe(s.spec); // draft falls back to spec
});
it('falls back spec to {} when entirely absent', () => {
const parsed = [{ created: '2025-01-01T00:00:00.000Z' }];
const result = normalizeImport(parsed, { now: FIXED_NOW });
expect(result.snippets[0].spec).toBe('{}');
expect(result.snippets[0].draftSpec).toBe('{}');
});
it('is deterministic with injected now and makeId', () => {
const parsed = [{ content: '{}' }, { content: '{}' }];
const a = normalizeImport(parsed, { now: FIXED_NOW, makeId: counterIds() });
const b = normalizeImport(parsed, { now: FIXED_NOW, makeId: counterIds() });
expect(a).toEqual(b);
expect(a.snippets.map((s) => s.id)).toEqual(['id-1', 'id-2']);
});
});
describe('normalizeImport — dataset normalization', () => {
it('preserves envelope dataset summary fields without re-profiling', () => {
const parsed = { version: '1.0', snippets: [], datasets: [datasetRecord()] };
const result = normalizeImport(parsed, { now: FIXED_NOW });
const d = result.datasets[0];
expect(d.rowCount).toBe(1);
expect(d.columnCount).toBe(2);
expect(d.columns).toEqual(['a', 'b']);
expect(d.columnTypes).toHaveLength(2);
expect(d.version).toBe(CURRENT_DATASET_VERSION);
});
it('fills gaps with defaults and coerces id to a number', () => {
const parsed = { version: '1.0', snippets: [], datasets: [{ id: '42', name: 'D' }] };
const result = normalizeImport(parsed, { now: FIXED_NOW });
const d = result.datasets[0];
expect(d.id).toBe(42);
expect(d.columns).toEqual([]);
expect(d.columnTypes).toEqual([]);
expect(d.rowCount).toBeNull();
expect(d.columnCount).toBeNull();
expect(d.format).toBe('json');
expect(d.source).toBe('inline');
expect(d.comment).toBe('');
expect(d.size).toBe(0);
expect(d.created).toBe(FIXED_NOW_ISO);
expect(d.modified).toBe(FIXED_NOW_ISO);
});
});
describe('dedupeIncomingDatasetNames', () => {
it('returns names unchanged when there are no collisions', () => {
const { datasets, renames } = dedupeIncomingDatasetNames(
['Other'],
[datasetRecord({ name: 'Sales' })],
);
expect(datasets[0].name).toBe('Sales');
expect(renames).toEqual([]);
});
it('suffixes a collision with an existing library name', () => {
const { datasets, renames } = dedupeIncomingDatasetNames(
['Sales'],
[datasetRecord({ name: 'Sales' })],
);
expect(datasets[0].name).toBe('Sales 2');
expect(renames).toEqual([{ from: 'Sales', to: 'Sales 2' }]);
});
it('reserves names as-you-go so intra-batch dupes become Sales 2, Sales 3', () => {
const { datasets, renames } = dedupeIncomingDatasetNames(
['Sales'],
[datasetRecord({ name: 'Sales' }), datasetRecord({ name: 'Sales' })],
);
expect(datasets.map((d) => d.name)).toEqual(['Sales 2', 'Sales 3']);
expect(renames).toEqual([
{ from: 'Sales', to: 'Sales 2' },
{ from: 'Sales', to: 'Sales 3' },
]);
});
it('matches collisions case-insensitively (Sales vs sales)', () => {
const { datasets, renames } = dedupeIncomingDatasetNames(
['Sales'],
[datasetRecord({ name: 'sales' })],
);
expect(datasets[0].name).toBe('sales 2'); // preserves incoming casing
expect(renames).toEqual([{ from: 'sales', to: 'sales 2' }]);
});
it('only clones datasets whose name changed', () => {
const keep = datasetRecord({ name: 'Untouched' });
const { datasets } = dedupeIncomingDatasetNames(['Sales'], [keep]);
expect(datasets[0]).toBe(keep);
});
});
describe('reassignCollidingSnippetIds', () => {
const mk = (id: string): Snippet => ({
id,
version: CURRENT_SNIPPET_VERSION,
name: id,
created: FIXED_NOW_ISO,
modified: FIXED_NOW_ISO,
spec: '{}',
draftSpec: '{}',
comment: '',
tags: [],
datasetRefs: [],
meta: {},
});
it('keeps a non-colliding id untouched (same object reference)', () => {
const s = mk('fresh');
const [out] = reassignCollidingSnippetIds(['existing'], [s], counterIds());
expect(out).toBe(s);
});
it('reassigns an incoming id that collides with an existing snippet', () => {
const out = reassignCollidingSnippetIds(['dup'], [mk('dup')], counterIds());
expect(out[0].id).toBe('id-1');
expect(out[0].name).toBe('dup'); // other fields preserved
});
it('gives two incoming snippets sharing one id distinct fresh ids', () => {
const out = reassignCollidingSnippetIds(['dup'], [mk('dup'), mk('dup')], counterIds());
expect(out.map((s) => s.id)).toEqual(['id-1', 'id-2']);
});
it('skips a generated id that would itself collide with a reserved id', () => {
// First generated id "id-1" is already reserved → must skip to "id-2".
const out = reassignCollidingSnippetIds(['dup', 'id-1'], [mk('dup')], counterIds());
expect(out[0].id).toBe('id-2');
});
it('keeps all reassigned ids distinct even when a fresh id matches a later input id', () => {
// Reservation is left-to-right: the first colliding snippet takes "id-1" and
// reserves it; the second snippet's existing id is also "id-1", now reserved,
// so it too is reassigned (→ "id-2"). No two snippets end up sharing an id.
const out = reassignCollidingSnippetIds(['dup'], [mk('dup'), mk('id-1')], counterIds());
expect(out[0].id).toBe('id-1');
expect(out[1].id).toBe('id-2');
expect(new Set(out.map((s) => s.id)).size).toBe(2);
});
});
describe('applyDatasetRenamesToSnippets', () => {
const referencing: Snippet = {
id: 'r1',
version: CURRENT_SNIPPET_VERSION,
name: 'Uses Sales',
created: FIXED_NOW_ISO,
modified: FIXED_NOW_ISO,
spec: JSON.stringify({ data: { name: 'Sales' }, mark: 'bar' }, null, 2),
draftSpec: JSON.stringify({ data: { name: 'Sales' }, mark: 'line' }, null, 2),
comment: '',
tags: [],
datasetRefs: ['Sales'],
meta: {},
};
it('rewrites spec, draftSpec, and datasetRefs for a referencing snippet', () => {
const [out] = applyDatasetRenamesToSnippets([referencing], [{ from: 'Sales', to: 'Sales 2' }]);
expect(dataName(out.spec)).toBe('Sales 2');
expect(dataName(out.draftSpec)).toBe('Sales 2');
expect(out.draftSpec).toContain('"line"'); // draft kept its own mark
expect(out.datasetRefs).toEqual(['Sales 2']);
});
it('matches the rename target case-insensitively', () => {
const lower: Snippet = { ...referencing, datasetRefs: ['Sales'] };
const [out] = applyDatasetRenamesToSnippets([lower], [{ from: 'sales', to: 'sales 2' }]);
expect(dataName(out.spec)).toBe('sales 2');
expect(out.datasetRefs).toEqual(['sales 2']);
});
it('leaves a non-referencing snippet untouched (same reference)', () => {
const unrelated: Snippet = {
...referencing,
id: 'u1',
spec: JSON.stringify({ data: { name: 'Other' } }, null, 2),
draftSpec: JSON.stringify({ data: { name: 'Other' } }, null, 2),
datasetRefs: ['Other'],
};
const out = applyDatasetRenamesToSnippets([unrelated], [{ from: 'Sales', to: 'Sales 2' }]);
expect(out[0]).toBe(unrelated);
});
it('returns a fresh array (no aliasing) when there are no renames', () => {
const input = [referencing];
const out = applyDatasetRenamesToSnippets(input, []);
expect(out).not.toBe(input);
expect(out[0]).toBe(referencing);
});
it('applies multiple renames to one snippet', () => {
const twoRefs: Snippet = {
...referencing,
spec: JSON.stringify(
{ layer: [{ data: { name: 'Sales' } }, { data: { name: 'Regions' } }] },
null,
2,
),
draftSpec: JSON.stringify(
{ layer: [{ data: { name: 'Sales' } }, { data: { name: 'Regions' } }] },
null,
2,
),
datasetRefs: ['Regions', 'Sales'],
};
const [out] = applyDatasetRenamesToSnippets(
[twoRefs],
[
{ from: 'Sales', to: 'Sales 2' },
{ from: 'Regions', to: 'Regions 2' },
],
);
expect(out.datasetRefs).toEqual(['Regions 2', 'Sales 2']);
});
});
describe('importSummaryMessage', () => {
it('pluralizes snippets and includes the dataset clause', () => {
expect(importSummaryMessage(4, 2)).toBe('Imported 4 snippets and 2 datasets');
});
it('uses singular wording for counts of 1', () => {
expect(importSummaryMessage(1, 1)).toBe('Imported 1 snippet and 1 dataset');
});
it('omits the dataset clause when datasetCount is 0', () => {
expect(importSummaryMessage(3, 0)).toBe('Imported 3 snippets');
});
it('pluralizes zero counts correctly', () => {
expect(importSummaryMessage(0, 0)).toBe('Imported 0 snippets');
});
});
+345
View File
@@ -0,0 +1,345 @@
/**
* Import normalization — shape detection, per-record normalization to the current
* model, and pure merge helpers (spec §08 → Import; docs/architecture/07 §5, §6).
*
* Portable core: no browser APIs, no React, no store/file/IndexedDB access. Plain
* data in, plain data out. The app's ImportService orchestrates the file read,
* store reads (for existing names/ids), and the commit; everything *deterministic*
* about an import — recognizing the file shape, coercing each record onto the
* current `Snippet`/`Dataset` shape, de-duping names, reassigning colliding ids,
* and propagating renames into specs — lives here so it can be unit-tested hardest.
*
* `crypto.randomUUID` is a platform global (like in snippet.ts), allowed in core.
* Both id generators are injectable so tests can assert deterministically.
*/
import { CURRENT_DATASET_VERSION, type DataSource, type Dataset } from './dataset';
import type { DataFormat } from './format-detection';
import { makeUniqueName } from './naming';
import { CURRENT_SNIPPET_VERSION, type Snippet } from './snippet';
import { extractDatasetRefs, recomputeDatasetRefs, renameDatasetInSpec } from './spec-refs';
import type { ColumnType } from './type-inference';
/** The tag stamped onto foreign/older snippets so the user can find imports. */
const IMPORTED_TAG = 'imported';
/** Result of normalizing a parsed import payload onto the current model. */
export interface NormalizedImport {
snippets: Snippet[];
datasets: Dataset[];
}
/** A single dataset rename applied during dedupe (`from` original → `to` unique). */
export interface DatasetRename {
from: string;
to: string;
}
export interface NormalizeImportOptions {
/** Clock injection for generated timestamps; defaults to the current time. */
now?: Date;
/** Id injection for generated snippet ids; defaults to `crypto.randomUUID`. */
makeId?: () => string;
}
// ----------------------------------------------------------------------------
// Small shape helpers (kept private; mirror migrateSnippet's fallback approach
// without importing from app/infrastructure — core cannot depend on app).
// ----------------------------------------------------------------------------
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** Coerce a stored spec field (object or string) into the canonical string form. */
function asSpecText(value: unknown, fallback: string): string {
if (typeof value === 'string') return value;
if (value == null) return fallback;
try {
return JSON.stringify(value, null, 2);
} catch {
return fallback;
}
}
/**
* An ISO-ish creation timestamp marks an already-current Astrolabe snippet. We
* accept the common ISO 8601 lead-in `YYYY-MM-DDTHH:MM` (with optional seconds,
* fractional seconds, and zone) — the shape `Date#toISOString` produces. We do
* not require the trailing `Z`/offset so a hand-edited but clearly-current
* timestamp still counts; we deliberately reject a bare `YYYY-MM-DD` date, which
* older/foreign shapes use, so those still get normalized + tagged.
*/
function isIsoTimestamp(value: unknown): value is string {
return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value);
}
/** A trimmed, non-empty string, or undefined — used to derive a source timestamp. */
function asNonEmptyString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() !== '' ? value : undefined;
}
// ----------------------------------------------------------------------------
// Per-record normalization
// ----------------------------------------------------------------------------
/**
* Normalize one raw snippet record onto the current `Snippet` shape.
*
* - Already-current (carries an ISO `created`): preserve existing fields with
* fallbacks; do NOT add the "imported" tag.
* - Foreign/older: map alternative field names (`content` → spec, `draft` →
* draftSpec, `createdAt` → created), generate missing timestamps, fill all
* gaps with defaults, and ensure the "imported" tag is present.
*
* Specs are coerced to canonical JSON-string form; draftSpec falls back to spec.
* `version` is always stamped to the current value (a missing/old version is the
* earliest shape, migrated up here).
*/
function normalizeSnippet(raw: unknown, nowIso: string, makeId: () => string): Snippet {
const r = isPlainObject(raw) ? raw : {};
// Field mapping for foreign shapes: prefer the current field, fall back to the
// alternative name (`content` → spec, `draft` → draftSpec, `createdAt` → created).
const specSource = r.spec ?? r.content;
const draftSource = r.draftSpec ?? r.draft;
const createdSource = asNonEmptyString(r.created) ?? asNonEmptyString(r.createdAt);
const isCurrent = isIsoTimestamp(r.created);
const spec = asSpecText(specSource, '{}');
const draftSpec = asSpecText(draftSource, spec);
// Timestamps: a current record keeps its ISO `created`; otherwise derive from a
// present source timestamp (created/createdAt), else fall back to `now`.
const created = isCurrent ? (r.created as string) : (createdSource ?? nowIso);
const modified = asNonEmptyString(r.modified) ?? created;
// Tags: preserve any existing tags; for foreign/older shapes ensure "imported"
// is present (without duplicating it).
const existingTags = Array.isArray(r.tags)
? (r.tags as unknown[]).filter((t): t is string => typeof t === 'string')
: [];
const tags = isCurrent
? existingTags
: existingTags.includes(IMPORTED_TAG)
? existingTags
: [...existingTags, IMPORTED_TAG];
const datasetRefs = Array.isArray(r.datasetRefs)
? (r.datasetRefs as unknown[]).filter((d): d is string => typeof d === 'string')
: [];
return {
id: typeof r.id === 'string' && r.id !== '' ? r.id : makeId(),
version: CURRENT_SNIPPET_VERSION,
name: typeof r.name === 'string' ? r.name : 'Untitled',
created,
modified,
spec,
draftSpec,
comment: typeof r.comment === 'string' ? r.comment : '',
tags,
datasetRefs,
meta: isPlainObject(r.meta) ? r.meta : {},
};
}
/**
* Normalize one raw dataset record onto the current `Dataset` shape. Envelope
* datasets already carry their derived summary fields (rowCount, columns, …); we
* preserve those and only fill gaps. We do NOT re-profile here — that needs the
* profiling pipeline and would be wasteful for already-summarized records.
*/
function normalizeDataset(raw: unknown, nowIso: string): Dataset {
const r = isPlainObject(raw) ? raw : {};
const created = asNonEmptyString(r.created) ?? nowIso;
const modified = asNonEmptyString(r.modified) ?? created;
return {
id: typeof r.id === 'number' ? r.id : Number(r.id) || 0,
version: CURRENT_DATASET_VERSION,
name: typeof r.name === 'string' ? r.name : 'Untitled',
data: r.data,
format: (typeof r.format === 'string' ? r.format : 'json') as DataFormat,
source: (typeof r.source === 'string' ? r.source : 'inline') as DataSource,
comment: typeof r.comment === 'string' ? r.comment : '',
rowCount: typeof r.rowCount === 'number' ? r.rowCount : null,
columnCount: typeof r.columnCount === 'number' ? r.columnCount : null,
columns: Array.isArray(r.columns)
? (r.columns as unknown[]).filter((c): c is string => typeof c === 'string')
: [],
columnTypes: Array.isArray(r.columnTypes)
? (r.columnTypes as Array<{ name: string; type: ColumnType }>)
: [],
size: typeof r.size === 'number' ? r.size : 0,
created,
modified,
};
}
// ----------------------------------------------------------------------------
// Shape detection (spec §08 "Accepted inputs")
// ----------------------------------------------------------------------------
/**
* Detect the import shape and normalize every record onto the current model.
*
* - Envelope: object with a `version` AND a `snippets` array → its snippets
* (+ optional `datasets` array).
* - Bare array: a top-level array → a list of snippets, no datasets.
* - Single object: any other object → one snippet, no datasets.
* - junk (null / non-object / non-array) → empty.
*/
export function normalizeImport(
parsed: unknown,
opts: NormalizeImportOptions = {},
): NormalizedImport {
const nowIso = (opts.now ?? new Date()).toISOString();
const makeId = opts.makeId ?? (() => crypto.randomUUID());
let rawSnippets: unknown[] = [];
let rawDatasets: unknown[] = [];
if (Array.isArray(parsed)) {
// Bare array of snippets.
rawSnippets = parsed;
} else if (isPlainObject(parsed)) {
const hasEnvelope = 'version' in parsed && Array.isArray(parsed.snippets);
if (hasEnvelope) {
rawSnippets = parsed.snippets as unknown[];
if (Array.isArray(parsed.datasets)) rawDatasets = parsed.datasets;
} else {
// Single snippet object.
rawSnippets = [parsed];
}
}
// else: null / non-object / non-array junk → both stay empty.
return {
snippets: rawSnippets.map((s) => normalizeSnippet(s, nowIso, makeId)),
datasets: rawDatasets.map((d) => normalizeDataset(d, nowIso)),
};
}
// ----------------------------------------------------------------------------
// Merge helpers
// ----------------------------------------------------------------------------
/**
* De-dupe incoming dataset names against the existing library and within the
* batch itself (architecture 07 §5). Names are reserved *as we go* so two incoming
* `Sales` become `Sales 2`, `Sales 3` — never two `Sales 2`. Existing datasets are
* never overwritten. Every rename is collected for reporting. Only datasets whose
* name actually changed are cloned.
*/
export function dedupeIncomingDatasetNames(
existing: ReadonlyArray<string>,
incoming: ReadonlyArray<Dataset>,
): { datasets: Dataset[]; renames: DatasetRename[] } {
const reserved = new Set(existing.map((n) => n.toLowerCase()));
const renames: DatasetRename[] = [];
const datasets = incoming.map((d) => {
const unique = makeUniqueName(d.name, reserved);
reserved.add(unique.toLowerCase()); // reserve so later imports don't collide
if (unique !== d.name) renames.push({ from: d.name, to: unique });
return unique === d.name ? d : { ...d, name: unique };
});
return { datasets, renames };
}
/**
* Reassign ids for incoming snippets whose id already exists (spec §08 "ID
* collisions"). The existing snippet keeps its id; the incoming one gets a fresh
* unique id. Ids are reserved *as we go* so two incoming snippets sharing one id
* both get distinct fresh ids — and a freshly-minted id can't collide with another
* incoming snippet either. Only snippets that get a new id are cloned.
*/
export function reassignCollidingSnippetIds(
existingIds: ReadonlyArray<string>,
incoming: ReadonlyArray<Snippet>,
makeId: () => string = () => crypto.randomUUID(),
): Snippet[] {
const reserved = new Set(existingIds);
return incoming.map((s) => {
if (!reserved.has(s.id)) {
reserved.add(s.id);
return s;
}
let fresh = makeId();
while (reserved.has(fresh)) fresh = makeId();
reserved.add(fresh);
return { ...s, id: fresh };
});
}
/**
* Propagate dataset renames (from `dedupeIncomingDatasetNames`) into the imported
* snippets that reference them (architecture 07 §6): for each rename, rewrite both
* `spec` and `draftSpec` via `renameDatasetInSpec`, then recompute `datasetRefs`
* from the rewritten spec — the spec is the source of truth, `datasetRefs` mirrors
* it. Matching is case-insensitive (consistent with the naming helpers). Only
* snippets that actually change are cloned.
*
* Applied to the imported set ONLY — existing snippets keep their references,
* because existing datasets were never renamed (collisions suffix the incoming).
*/
export function applyDatasetRenamesToSnippets(
snippets: ReadonlyArray<Snippet>,
renames: ReadonlyArray<DatasetRename>,
): Snippet[] {
if (renames.length === 0) return snippets.slice();
return snippets.map((snippet) => {
// The names this snippet actually references: what the specs literally
// reference (spec casing first, so `renameDatasetInSpec`'s case-sensitive
// match gets the right oldName) unioned with the stored `datasetRefs` mirror.
// Consulting the spec — not only datasetRefs — makes propagation robust when a
// hand-crafted/foreign import references a dataset in its spec without a
// matching datasetRefs entry; the renderer resolves by spec, so a missed
// rename would otherwise break rendering (arch 07 §3/§6).
const referenced = [
...extractDatasetRefs(snippet.spec),
...extractDatasetRefs(snippet.draftSpec),
...snippet.datasetRefs,
];
const targets: Array<{ oldName: string; to: string }> = [];
for (const { from, to } of renames) {
const ref = referenced.find((r) => r.toLowerCase() === from.toLowerCase());
if (ref !== undefined) targets.push({ oldName: ref, to });
}
if (targets.length === 0) return snippet;
let spec = snippet.spec;
let draftSpec = snippet.draftSpec;
for (const { oldName, to } of targets) {
spec = renameDatasetInSpec(spec, oldName, to);
draftSpec = renameDatasetInSpec(draftSpec, oldName, to);
}
return { ...snippet, spec, draftSpec, datasetRefs: recomputeDatasetRefs(spec) };
});
}
// ----------------------------------------------------------------------------
// Feedback
// ----------------------------------------------------------------------------
/** Pluralize a count: `1 thing`, `0 things`, `4 things`. */
function plural(count: number, noun: string): string {
return `${count} ${noun}${count === 1 ? '' : 's'}`;
}
/**
* The import success message (spec §08 "Feedback"), e.g.
* "Imported 4 snippets and 2 datasets". The dataset clause is omitted when
* `datasetCount` is 0; singular/plural adapts for any counts ≥ 0.
*/
export function importSummaryMessage(snippetCount: number, datasetCount: number): string {
const head = `Imported ${plural(snippetCount, 'snippet')}`;
return datasetCount > 0 ? `${head} and ${plural(datasetCount, 'dataset')}` : head;
}
+214
View File
@@ -0,0 +1,214 @@
import { describe, it, expect } from 'vitest';
import {
CURRENT_SETTINGS_VERSION,
DEFAULT_SETTINGS,
defaultSettings,
loadSettings,
type UserSettings,
} from './settings';
describe('defaultSettings', () => {
it('returns the factory-default shape (spec §07)', () => {
expect(defaultSettings()).toEqual({
version: CURRENT_SETTINGS_VERSION,
editor: {
fontSize: 12,
theme: 'auto',
minimap: false,
wordWrap: 'on',
lineNumbers: 'on',
tabSize: 2,
},
performance: { renderDebounce: 1500 },
ui: { theme: 'light', previewFitMode: 'default' },
formatting: { dateFormat: 'smart', customDateFormat: '' },
});
});
it('returns independent copies (mutating one does not affect another)', () => {
const a = defaultSettings();
const b = defaultSettings();
a.editor.fontSize = 18;
a.editor.wordWrap = 'off';
a.formatting.customDateFormat = 'yyyy';
expect(b.editor.fontSize).toBe(12);
expect(b.editor.wordWrap).toBe('on');
expect(b.formatting.customDateFormat).toBe('');
});
it('matches DEFAULT_SETTINGS by value', () => {
expect(defaultSettings()).toEqual(DEFAULT_SETTINGS);
});
});
describe('DEFAULT_SETTINGS', () => {
it('is deeply frozen (read-only reference)', () => {
expect(Object.isFrozen(DEFAULT_SETTINGS)).toBe(true);
expect(Object.isFrozen(DEFAULT_SETTINGS.editor)).toBe(true);
expect(Object.isFrozen(DEFAULT_SETTINGS.ui)).toBe(true);
expect(Object.isFrozen(DEFAULT_SETTINGS.formatting)).toBe(true);
expect(Object.isFrozen(DEFAULT_SETTINGS.performance)).toBe(true);
});
});
describe('loadSettings — valid records', () => {
it('round-trips a full valid record (stamping current version)', () => {
const record: UserSettings = {
version: CURRENT_SETTINGS_VERSION,
editor: {
fontSize: 14,
theme: 'vs-dark',
minimap: true,
wordWrap: 'off',
lineNumbers: 'off',
tabSize: 4,
},
performance: { renderDebounce: 2500 },
ui: { theme: 'dark', previewFitMode: 'full' },
formatting: { dateFormat: 'custom', customDateFormat: 'yyyy-MM-dd' },
};
expect(loadSettings(record)).toEqual(record);
});
it('accepts any string for editor.theme and customDateFormat', () => {
const out = loadSettings({
editor: { theme: 'my-custom-theme' },
formatting: { dateFormat: 'custom', customDateFormat: 'HH:mm' },
});
expect(out.editor.theme).toBe('my-custom-theme');
expect(out.formatting.customDateFormat).toBe('HH:mm');
});
});
describe('loadSettings — partial records fill gaps', () => {
it('keeps a single nested field and fills the rest with defaults', () => {
const out = loadSettings({ editor: { fontSize: 14 } });
expect(out.editor.fontSize).toBe(14);
expect(out).toEqual({
...defaultSettings(),
editor: { ...defaultSettings().editor, fontSize: 14 },
});
});
it('fills entirely-missing groups from defaults', () => {
const out = loadSettings({ ui: { theme: 'dark' } });
expect(out.ui.theme).toBe('dark');
expect(out.editor).toEqual(defaultSettings().editor);
expect(out.performance).toEqual(defaultSettings().performance);
expect(out.formatting).toEqual(defaultSettings().formatting);
});
it('treats a non-object group as empty (uses defaults)', () => {
const out = loadSettings({ editor: 'nope', performance: 42, ui: null });
expect(out.editor).toEqual(defaultSettings().editor);
expect(out.performance).toEqual(defaultSettings().performance);
expect(out.ui).toEqual(defaultSettings().ui);
});
});
describe('loadSettings — numeric coercion and clamping', () => {
it('clamps and rounds fontSize to [10, 18]', () => {
expect(loadSettings({ editor: { fontSize: 5 } }).editor.fontSize).toBe(10);
expect(loadSettings({ editor: { fontSize: 99 } }).editor.fontSize).toBe(18);
expect(loadSettings({ editor: { fontSize: 12.7 } }).editor.fontSize).toBe(13);
expect(loadSettings({ editor: { fontSize: 14 } }).editor.fontSize).toBe(14);
});
it('coerces tabSize to a positive integer', () => {
expect(loadSettings({ editor: { tabSize: 0 } }).editor.tabSize).toBe(1);
expect(loadSettings({ editor: { tabSize: -3 } }).editor.tabSize).toBe(1);
expect(loadSettings({ editor: { tabSize: 2.9 } }).editor.tabSize).toBe(3);
expect(loadSettings({ editor: { tabSize: 8 } }).editor.tabSize).toBe(8);
});
it('clamps renderDebounce to [500, 5000]', () => {
expect(loadSettings({ performance: { renderDebounce: 100 } }).performance.renderDebounce).toBe(
500,
);
expect(loadSettings({ performance: { renderDebounce: 9999 } }).performance.renderDebounce).toBe(
5000,
);
expect(loadSettings({ performance: { renderDebounce: 1500 } }).performance.renderDebounce).toBe(
1500,
);
});
it('falls back to default for non-number / non-finite numerics', () => {
const d = defaultSettings();
expect(loadSettings({ editor: { fontSize: '14' } }).editor.fontSize).toBe(d.editor.fontSize);
expect(loadSettings({ editor: { fontSize: NaN } }).editor.fontSize).toBe(d.editor.fontSize);
expect(loadSettings({ editor: { fontSize: Infinity } }).editor.fontSize).toBe(
d.editor.fontSize,
);
expect(loadSettings({ editor: { tabSize: null } }).editor.tabSize).toBe(d.editor.tabSize);
expect(
loadSettings({ performance: { renderDebounce: 'fast' } }).performance.renderDebounce,
).toBe(d.performance.renderDebounce);
});
});
describe('loadSettings — enum validation', () => {
it('falls back when an enum value is not allowed', () => {
const d = defaultSettings();
expect(loadSettings({ editor: { wordWrap: 'sometimes' } }).editor.wordWrap).toBe(
d.editor.wordWrap,
);
expect(loadSettings({ editor: { lineNumbers: 1 } }).editor.lineNumbers).toBe(
d.editor.lineNumbers,
);
expect(loadSettings({ ui: { theme: 'sepia' } }).ui.theme).toBe(d.ui.theme);
expect(loadSettings({ ui: { previewFitMode: 'tall' } }).ui.previewFitMode).toBe(
d.ui.previewFitMode,
);
expect(loadSettings({ formatting: { dateFormat: 'relative' } }).formatting.dateFormat).toBe(
d.formatting.dateFormat,
);
});
it('accepts each allowed enum value', () => {
expect(loadSettings({ editor: { wordWrap: 'off' } }).editor.wordWrap).toBe('off');
expect(loadSettings({ editor: { lineNumbers: 'off' } }).editor.lineNumbers).toBe('off');
expect(loadSettings({ ui: { theme: 'dark' } }).ui.theme).toBe('dark');
expect(loadSettings({ ui: { previewFitMode: 'width' } }).ui.previewFitMode).toBe('width');
expect(loadSettings({ ui: { previewFitMode: 'height' } }).ui.previewFitMode).toBe('height');
expect(loadSettings({ formatting: { dateFormat: 'iso' } }).formatting.dateFormat).toBe('iso');
});
it('falls back when minimap is not a boolean', () => {
expect(loadSettings({ editor: { minimap: 'yes' } }).editor.minimap).toBe(false);
expect(loadSettings({ editor: { minimap: true } }).editor.minimap).toBe(true);
});
});
describe('loadSettings — junk and edge inputs', () => {
it('returns defaults for null/undefined/array/string/number', () => {
const d = defaultSettings();
expect(loadSettings(null)).toEqual(d);
expect(loadSettings(undefined)).toEqual(d);
expect(loadSettings([])).toEqual(d);
expect(loadSettings([{ editor: { fontSize: 14 } }])).toEqual(d);
expect(loadSettings('settings')).toEqual(d);
expect(loadSettings(42)).toEqual(d);
expect(loadSettings(true)).toEqual(d);
});
it('tolerates unknown extra keys without throwing', () => {
const out = loadSettings({
version: CURRENT_SETTINGS_VERSION,
futureFeature: { enabled: true },
editor: { fontSize: 16, somethingNew: 'x' },
});
expect(out.editor.fontSize).toBe(16);
expect(out).not.toHaveProperty('futureFeature');
expect(out.editor).not.toHaveProperty('somethingNew');
});
});
describe('loadSettings — version stamping', () => {
it('always stamps the current version regardless of input version', () => {
expect(loadSettings({ version: 0 }).version).toBe(CURRENT_SETTINGS_VERSION);
expect(loadSettings({ version: 99 }).version).toBe(CURRENT_SETTINGS_VERSION);
expect(loadSettings({ version: 'old' }).version).toBe(CURRENT_SETTINGS_VERSION);
expect(loadSettings({}).version).toBe(CURRENT_SETTINGS_VERSION);
});
});
+210
View File
@@ -0,0 +1,210 @@
/**
* UserSettings — persisted user preferences (spec §07, storage shape §09C).
*
* Portable core: no browser APIs, no React, no Monaco. Defines the settings
* record shape, the current schema version, the factory defaults, and the
* read-time load/normalize function. The localStorage wiring that actually
* reads and writes the JSON lives in infrastructure — this module only knows
* the shape and how to coerce arbitrary parsed input back into a valid record.
*
* Settings load at startup; per spec §07 "Startup load", any missing or
* unrecognized value silently falls back to its factory default, so older or
* partial saved records never break the app. `loadSettings` is the gate that
* guarantees that (the read-time migration, mirroring `migrateSnippet`).
*/
/** Current schema version for a UserSettings record (read-time migration target). */
export const CURRENT_SETTINGS_VERSION = 1;
export interface UserSettings {
/** Record schema version, for read-time migration (spec §09C). */
version: number;
/** Spec-editor configuration (spec §07 → Editor). Applies to the editing surface. */
editor: {
/** Editor font size, 1018 px integer (spec §07; default 12). */
fontSize: number;
/**
* Editor color theme id. The `'auto'` sentinel follows the app UI theme
* (light app → light editor, dark → dark); any other value is an explicit
* override (spec §07; default `'auto'`).
*/
theme: string;
/** Whether the editor minimap (overview strip) is shown (spec §07; default false). */
minimap: boolean;
/** Soft-wrap long lines (spec §07; default `'on'`). */
wordWrap: 'on' | 'off';
/** Show the line-number gutter (spec §07; default `'on'`). */
lineNumbers: 'on' | 'off';
/** Indentation width in spaces, positive integer (spec §07; default 2). */
tabSize: number;
};
/** Preview-performance tuning (spec §07 → Performance). */
performance: {
/**
* Delay (ms) after the user stops typing before the preview re-renders,
* 5005000 (spec §07; default 1500). Lower feels snappier but re-renders
* more often; higher is calmer but laggier.
*/
renderDebounce: number;
};
/** App-chrome appearance (spec §07 → Appearance; §09C). */
ui: {
/** Overall UI theme; flips the whole app chrome (spec §07; default `'light'`). */
theme: 'light' | 'dark';
/**
* Preview sizing/fit mode (set by the preview's own Fit control, not a
* settings cluster, but persisted in this record per §09C; see _Live
* Preview_). Default `'default'`.
*/
previewFitMode: 'default' | 'width' | 'height' | 'full';
};
/** Date-rendering preferences (spec §07 → Formatting). */
formatting: {
/**
* How dates render throughout the app (spec §07; default `'smart'`).
* `'smart'` = relative/human-friendly; `'iso'` = full ISO 8601 timestamp;
* `'custom'` = use `customDateFormat`.
*/
dateFormat: 'smart' | 'iso' | 'custom';
/**
* Free-text format string, used only when `dateFormat = 'custom'`
* (spec §07; default empty string).
*/
customDateFormat: string;
};
}
/**
* Build a fresh UserSettings with every field at its factory default
* (spec §07). Returns an independent deep copy each call so callers may freely
* mutate the result without affecting the shared `DEFAULT_SETTINGS` constant or
* each other.
*/
export function defaultSettings(): UserSettings {
return {
version: CURRENT_SETTINGS_VERSION,
editor: {
fontSize: 12,
theme: 'auto',
minimap: false,
wordWrap: 'on',
lineNumbers: 'on',
tabSize: 2,
},
performance: {
renderDebounce: 1500,
},
ui: {
theme: 'light',
previewFitMode: 'default',
},
formatting: {
dateFormat: 'smart',
customDateFormat: '',
},
};
}
/**
* Deeply-frozen factory defaults, for read-only reference (e.g. comparing a
* form against defaults). To get a mutable copy, call `defaultSettings()`.
*/
export const DEFAULT_SETTINGS: UserSettings = deepFreeze(defaultSettings());
/** Recursively freeze an object and its nested plain-object members. */
function deepFreeze<T>(value: T): T {
if (value !== null && typeof value === 'object') {
for (const member of Object.values(value)) deepFreeze(member);
Object.freeze(value);
}
return value;
}
/** Round to an integer and clamp to `[min, max]`. */
function clampInt(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, Math.round(value)));
}
/** A finite number, or `null` for `NaN`/`Infinity`/non-numbers. */
function asNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
/** Return `value` if it is one of `allowed`, else `fallback`. */
function asEnum<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
return typeof value === 'string' && (allowed as readonly string[]).includes(value)
? (value as T)
: fallback;
}
/** Safely read a nested settings group as a record (tolerates non-objects). */
function group(raw: Record<string, unknown>, key: string): Record<string, unknown> {
const value = raw[key];
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
/**
* Normalize an arbitrary parsed value into a fully-valid UserSettings — the
* read-time migration (spec §07 "Startup load"; §09C). Accepts `null`, junk, a
* partial object, or a record from an older version. Every missing or
* wrong-typed field silently falls back to its default; numeric fields are
* coerced and clamped to their ranges; enum fields are validated against their
* allowed values; unknown extra keys are ignored. The output `version` is
* always stamped to `CURRENT_SETTINGS_VERSION` (like `migrateSnippet`).
*
* Tolerates partial nesting: `{ editor: { fontSize: 14 } }` keeps 14 and fills
* the rest of `editor` (and every other group) from defaults.
*/
export function loadSettings(raw: unknown): UserSettings {
const d = defaultSettings();
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return d;
const r = raw as Record<string, unknown>;
const editor = group(r, 'editor');
const performance = group(r, 'performance');
const ui = group(r, 'ui');
const formatting = group(r, 'formatting');
const fontSize = asNumber(editor.fontSize);
const tabSize = asNumber(editor.tabSize);
const renderDebounce = asNumber(performance.renderDebounce);
return {
version: CURRENT_SETTINGS_VERSION,
editor: {
fontSize: fontSize === null ? d.editor.fontSize : clampInt(fontSize, 10, 18),
theme: typeof editor.theme === 'string' ? editor.theme : d.editor.theme,
minimap: typeof editor.minimap === 'boolean' ? editor.minimap : d.editor.minimap,
wordWrap: asEnum(editor.wordWrap, ['on', 'off'] as const, d.editor.wordWrap),
lineNumbers: asEnum(editor.lineNumbers, ['on', 'off'] as const, d.editor.lineNumbers),
tabSize: tabSize === null ? d.editor.tabSize : clampInt(tabSize, 1, Number.MAX_SAFE_INTEGER),
},
performance: {
renderDebounce:
renderDebounce === null
? d.performance.renderDebounce
: clampInt(renderDebounce, 500, 5000),
},
ui: {
theme: asEnum(ui.theme, ['light', 'dark'] as const, d.ui.theme),
previewFitMode: asEnum(
ui.previewFitMode,
['default', 'width', 'height', 'full'] as const,
d.ui.previewFitMode,
),
},
formatting: {
dateFormat: asEnum(
formatting.dateFormat,
['smart', 'iso', 'custom'] as const,
d.formatting.dateFormat,
),
customDateFormat:
typeof formatting.customDateFormat === 'string'
? formatting.customDateFormat
: d.formatting.customDateFormat,
},
};
}
+7
View File
@@ -2,6 +2,7 @@ import { createRoot } from 'react-dom/client';
import { App } from './app/App';
import { initPanes, wirePanes } from './app/orchestration/panes';
import { initPreviewFitMode, wirePreviewFitMode } from './app/orchestration/preferences';
import { initSettings, wireSettings } from './app/orchestration/settings';
import { initApp } from './app/orchestration/startup';
import { initTheme, wireTheme } from './app/orchestration/theme';
import '../styles/base.css';
@@ -19,6 +20,12 @@ wirePreviewFitMode();
initPanes();
wirePanes();
// Hydrate + persist the full UserSettings record (editor / performance /
// formatting) before render, so the editor, preview, and library read the user's
// settings from the first paint (spec §07 → Startup load).
initSettings();
wireSettings();
// Load the library from IndexedDB (seeding a sample on first run) and wire
// persistence. Fire-and-forget: the UI renders immediately and fills in when
// hydration resolves.