mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add distributed settings and workspace import/export (M5)
This commit is contained in:
@@ -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
@@ -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. */}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 10–18 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 §5–6).
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 } } })),
|
||||
}));
|
||||
Reference in New Issue
Block a user