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
+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