Local UI preferences for the editor — auto-save behaviour, hover-preview gating, admin theme, UI text size, density, layers panel options, etc. Stored in localStorage, scoped to the device, never written to the site file.
The feature is catalog-driven: one declarative array drives the schema, the runtime defaults, and the Settings → Preferences UI. Adding a preference is two lines.
- Source of truth:
PREFERENCE_CATALOGinsrc/admin/pages/site/preferences/catalog.ts. - Read from React:
useEditorPreference('autoSave')/useEditorSelectPreference('density')/useEditorAppearancePreferences(). - Read from non-React:
readEditorPreference('autoSave')+subscribeToEditorPrefsChanged(listener). - Settings UI renders automatically from the catalog — no per-preference wiring.
- Storage:
localStorage["instatic-editor-prefs"](EDITOR_PREFS_KEY).additionalProperties: trueon the schema keeps forward / backward compatibility silent.
- Append one entry to
PREFERENCE_CATALOGinsrc/admin/pages/site/preferences/catalog.ts. - Read it with
useEditorPreference('your-id')wherever you need it.
// catalog.ts — append:
{
id: 'layersShowIcon',
type: 'boolean',
category: 'layers',
label: 'Show module icon',
description: 'Display the module type icon next to each layer row.',
default: true,
}
// Use it:
import { useEditorPreference } from '@site/preferences/editorPreferences'
const showIcon = useEditorPreference('layersShowIcon')The Settings panel renders the new toggle in the matching category. The id becomes a literal-typed string union via as const, so useEditorPreference('typo') is a compile error.
Three files, one source of truth.
src/admin/pages/site/preferences/
├── catalog.ts — declarative source of truth
├── editorPreferences.ts — runtime: schema, IO, hook, event bus
└── (consumed by)
src/admin/modals/Settings/sections/PreferencesSection.tsx
A single PREFERENCE_CATALOG array lists every preference. Each entry declares everything required:
export const PREFERENCE_CATALOG = [
{
id: 'autoSave',
type: 'boolean',
category: 'editor',
label: 'Auto-save',
description: 'Automatically save the site every 30 seconds.',
default: true,
},
// …
] as const satisfies ReadonlyArray<PreferenceDef>Why a catalog (not hand-rolled per-preference code):
- Settings UI auto-renders.
PreferencesSectioniterates the catalog grouped by category. New preferences surface automatically. - One place to look. Defaults, labels, descriptions, and valid ids all live together.
- Type-safe ids.
as constpreserves the literal union type.useEditorPreference('layersShowTag')compiles,useEditorPreference('layersShowtag')does not. - Defaults can't drift. The schema and
DEFAULT_EDITOR_PREFSare derived from the catalog at module load. There is no separate "registered" list to keep in sync.
The discriminated union has three branches:
interface BooleanPreferenceDef { id, type: 'boolean', category, label, description, default: boolean }
interface SelectPreferenceDef { id, type: 'select', category, label, description, options, default: string }
interface DynamicSelectPreferenceDef { id, type: 'select-dynamic', category, label, description, optionsSource, default: string }
export type PreferenceDef =
| BooleanPreferenceDef
| SelectPreferenceDef
| DynamicSelectPreferenceDefA new preference type (e.g. 'number', 'colour') adds a branch to this union, one runtime read/set/hook in editorPreferences.ts, and one matching row component in PreferencesSection.tsx.
Three layers:
1. Schema and defaults — derived
const schemaFields: Record<string, ReturnType<typeof Type.Optional>> = {}
for (const def of PREFERENCE_CATALOG) {
if (def.type === 'boolean') schemaFields[def.id] = Type.Optional(Type.Boolean())
}
export const EditorPrefsSchema = Type.Object(schemaFields, { additionalProperties: true })
export const DEFAULT_EDITOR_PREFS = /* same loop, building the defaults object */additionalProperties: true means an older client reading prefs written by a newer build does not crash on unknown fields — it ignores them.
2. Generic IO
export function readEditorPreference(id: BooleanPreferenceId): boolean
export function setEditorPreference(id: BooleanPreferenceId, value: boolean): voidBoth go through parseJsonWithFallback(EditorPrefsSchema, …) so corrupt or partial localStorage falls back to defaults instead of throwing.
3. Event bus + React hooks
// Event bus — for non-React consumers (e.g. usePersistence's auto-save scheduler)
export function subscribeToEditorPrefsChanged(listener: () => void): () => void
export function notifyEditorPrefsChanged(): void
// React — preferred path for components.
// One hook per preference type so each callsite stays type-safe.
export function useEditorPreference(id: BooleanPreferenceId): boolean
export function useEditorSelectPreference(id: SelectPreferenceId): stringThe event bus listens to two things:
- Same-tab updates —
setEditorPreferencedispatches a custom event that all hook instances pick up. - Cross-tab updates — the browser's native
storageevent re-fires the listeners when another tab writes the same key, so two editor windows stay in sync.
The hook is intentionally one-preference-per-call. Multiple prefs in one component is multiple useEditorPreference() calls — React's dependency tracking stays trivial and re-renders are scoped to the prefs the component actually reads.
const groups = preferencesByCategory()
return groups.map((group) => (
<section key={group.id}>
<h4>{group.label}</h4>
{group.preferences.map((pref) => <PreferenceRow key={pref.id} pref={pref} />)}
</section>
))PreferenceRow switches on pref.type and dispatches to the right concrete row component. A new preference type adds a branch here and a matching row component.
Categories are declared in the catalog itself:
export const PREFERENCE_CATEGORIES = [
{ id: 'editor', label: 'Editor' },
{ id: 'canvas', label: 'Canvas', description: '…' },
{ id: 'layers', label: 'Layers panel', description: '…' },
{ id: 'properties', label: 'Properties panel' },
{ id: 'spotlight', label: 'Command palette', description: '…' },
]Order here is the rendering order in the Preferences screen. Categories without any preferences are skipped automatically.
'select-dynamic' preferences resolve their options at render time from runtime state. The declared source 'site.breakpoints' is used by defaultBreakpoint. Each source maps to a small hook in PreferencesSection.tsx:
function useDynamicSelectOptions(source: DynamicOptionsSource) {
const breakpoints = useEditorStore((state) => state.site?.breakpoints)
if (source === 'site.breakpoints') {
if (!breakpoints) return EMPTY_OPTIONS
return breakpoints.map((bp) => ({ value: bp.id, label: bp.label }))
}
return EMPTY_OPTIONS
}A new source is one branch here plus a new value in the DynamicOptionsSource union in catalog.ts.
When the persisted value is no longer in the dynamic option list (e.g. user previously picked a wide breakpoint, then opened a site without it), the dropdown still shows the stored value with a (not in current site) suffix so the mismatch is visible. The runtime reader (readEditorSelectPreference) returns the stored string regardless — consumers (e.g. applyDefaultBreakpointPreference in usePersistence.ts) decide whether to apply it or fall back.
defaultBreakpoint has two effects on load. applyDefaultBreakpointPreference sets activeBreakpointId (the editing context) when the loaded site has a matching breakpoint. Setting the active breakpoint alone does not move the canvas — it always mounts at pan (0, 0), which shows the left-most (narrowest) frame. So CanvasRoot runs an effect (keyed on canvasPage.id) that pans the canvas to horizontally center the active frame (top aligned just below the viewport top), via useCanvas().centerOnBreakpointFrame and the pure panToCenterBreakpointFrame geometry in canvasDomGeometry.ts. This is the spatial half of "Which viewport context the canvas focuses on when a site is opened."
Because the effect keys on the document id, it also re-centers when the active document changes — switching pages or entering/leaving a Visual Component — so jumping from a long page you'd scrolled down to a shorter one brings the active frame back into view instead of leaving it panned off-screen. The current zoom is preserved; only the pan moves. Edits to the same document do not re-center (Mutative returns a new page object per edit, but the id is stable), and breakpoint switches within a document (toolbar, node clicks) also keep the designer's place. The retry that waits for the per-frame iframes to lay out uses setTimeout, not requestAnimationFrame: rAF only fires while the tab is painting, so a centering scheduled while the editor is backgrounded would otherwise silently never run.
The loading skeleton has two phases. While the editor-body chunk itself lazy-loads, AdminCanvasLayout paints a lightweight shell fallback using the shared canvas-frame skeletons. That fallback keeps cold Site loads from showing only the toolbar over an empty workspace, but it does not import the real canvas, sidebars, DnD context, modules, or panel graph. Once CanvasRoot mounts, CanvasTransformLayer owns loading state: while page data loads, it renders skeleton frames (canvas-loading-frame-<id>) instead of real ones. The same centering effect runs during this phase (when canvasPage is still null) and centers the skeleton for the defaultBreakpoint preference — i.e. what activeBreakpointId will resolve to once the site loads — so centerOnBreakpointFrame matches both the real wrapper and its loading skeleton. The result is an immediate visual placeholder on cold load, followed by the pannable skeleton/real content sharing the same centered position.
Some call sites are not React components — usePersistence.ts's auto-save scheduler is one example. They use the imperative API:
import {
readAutoSavePreference,
readAutoSaveDelayMs,
readEditorSelectPreference,
subscribeToEditorPrefsChanged,
} from '@site/preferences/editorPreferences'
// Read once at setup time
const enabled = readAutoSavePreference()
const delayMs = readAutoSaveDelayMs()
// React to changes
const unsub = subscribeToEditorPrefsChanged(() => {
scheduleAutoSave()
})Named convenience wrappers (readAutoSavePreference, readHoverPreviewPreference, readAutoSaveDelayMs) sit on top of the generic getters for one reason:
They self-document at the call site — readAutoSavePreference() reads better than readEditorPreference('autoSave').
When a new preference needs an imperative reader, add a similarly-named wrapper in editorPreferences.ts. They're one-liners.
Both setters dispatch the change event so all hook consumers re-render and the bus listeners (usePersistence.ts) re-evaluate. They're available outside React for migration scripts, plugin defaults, or one-shot programmatic toggles, but the typical setter path is the Settings UI.
Missing fields fall back to the catalog default. Unknown fields are preserved on round-trip (forward-compat). The storage key is exported as EDITOR_PREFS_KEY so tests and tooling can clear it without hardcoding the literal.
Two tests cover the catalog-driven UI:
src/__tests__/settings/settingsSections.test.tsx— rendersPreferencesSectionin isolation and asserts one switch per boolean catalog entry, each by name, plus one combobox per select/select-dynamic catalog entry. Adding a boolean preference: (1) increment the switchtoHaveLength(N)count and (2) add agetByRole('switch', { name: /your label/i })assertion. Adding a select preference: increment the combobox count and assert the new combobox label.src/__tests__/settings/settingsModal.test.tsx— renders the fullSettingsModaland asserts the overall count of switches in the Preferences section (expect(switches.length).toBe(N)), pluspref-${id}ids andhtmlForlinkage for auto-rendered rows. Adding a boolean preference: increment the count assertion only.src/__tests__/settings/editorAppearancePreferences.test.tscovers the global appearance defaults, persistence, and document-root data attributes used by theme/text-size/density token scopes.
Adding a non-boolean (select / select-dynamic) preference leaves the switch counts alone, but the combobox count and label assertions should move with the catalog.
The Settings → Preferences screen renders this list automatically from the catalog.
| Category | Id | Type | Default | Wired in |
|---|---|---|---|---|
| Editor | autoSave |
boolean | true |
usePersistence.ts |
| Editor | autoSaveDelay |
select (5s/15s/30s/60s/5min) | '30' |
usePersistence.ts (readAutoSaveDelayMs) |
| Editor | hoverPreview |
boolean | true |
ClassPicker.tsx, SpacingBoxControl.tsx |
| Editor | confirmBeforeDelete |
boolean | false |
ConfirmDeleteProvider |
| Editor | theme |
select (dark / light) | 'dark' |
data-editor-theme on the document + layout roots |
| Editor | density |
select (compact / comfortable) | 'compact' |
data-editor-density on the document + layout roots |
| Editor | textScale |
select (small / default / large / extra-large) | 'default' |
data-editor-text-scale on the document + layout roots |
| Canvas | defaultBreakpoint |
select-dynamic (site.breakpoints) |
'desktop' |
applyDefaultBreakpointPreference |
| Canvas | dimInactiveBreakpoints |
boolean | true |
CanvasRoot.tsx |
| Layers panel | layersShowIcon |
boolean | true |
TreeNode.tsx |
| Layers panel | layersShowTag |
boolean | true |
TreeNode.tsx |
| Layers panel | layersShowClasses |
boolean | true |
TreeNode.tsx |
| Layers panel | layersAutoExpandSelected |
boolean | true |
DomPanel.tsx selection effect |
| Layers panel | layersSmoothScroll |
boolean | true |
DomPanel.tsx scroll handler |
| Properties panel | propertiesSmoothScroll |
boolean | true |
StyleSurface.tsx + PropertiesPanel.tsx |
| Properties panel | propertiesSectionsExpanded |
boolean | true |
StyleSectionsEditor.tsx, StyleSurface.tsx, CustomPropertiesSection.tsx |
| Command palette | spotlightTelemetryEnabled |
boolean | false |
command palette usage tracking |
confirmBeforeDelete runs through a single shared <ConfirmDeleteProvider/> mounted in AdminCanvasLayout. Components call useConfirmDelete() and pass a commit callback:
const confirmDelete = useConfirmDelete()
confirmDelete({
title: 'Delete layer?',
description: `${displayName} and any of its children will be removed.`,
commit: () => deleteNode(nodeId),
})When the preference is on, the provider mounts a small <ConfirmDeleteDialog/> and only runs commit after user confirmation. When off, commit runs immediately. The hook falls back to direct execution when used outside a provider (e.g. unit tests rendering a single panel in isolation), so test fixtures don't need to wrap in the provider.
The hoverPreview preference covers every transient hover-driven canvas preview the Properties panel exposes:
ClassPicker— hovering a class suggestion temporarily applies it to the selected node.SpacingBoxControl— hovering a token in the spacing autocomplete dropdown previews the resolved value on the active class.- Any future variable / token autocomplete in other property controls must opt into the same gate so users have one place to control hover behaviour.
The convention for new controls:
const hoverPreviewEnabled = useEditorPreference('hoverPreview')
const previewOnHover = useCallback((value: string) => {
if (!hoverPreviewEnabled) return
applyTransientPreview(value)
}, [hoverPreviewEnabled, …])
// Defensive: clear any active preview if the pref toggles off mid-flight
// (cross-tab edit, Settings dropdown, etc.).
useEffect(() => {
if (!hoverPreviewEnabled) clearPreview()
}, [hoverPreviewEnabled, clearPreview])Notes:
- Only hover-triggered previews are gated. Live as-you-type previews (e.g.
previewDraftinSpacingBoxControl) are NOT gated — they reflect an explicit edit the user is making. - Clearing the preview on any code path (commit, mouse-leave, menu close, pref-off effect) always runs unconditionally so a stale preview never sticks around.
AdminEntry and the authenticated admin layouts read useEditorAppearancePreferences(). The hook applies the current appearance values to document.documentElement, while each layout also mirrors them onto its root <div>:
data-editor-theme="dark"(default) or"light"drives global admin token scopes insrc/styles/globals.css.data-editor-density="compact"(default) or"comfortable"drives spacing/height overrides for dense surfaces.data-editor-text-scale="small" | "default" | "large" | "extra-large"remaps the global--text-*scale without changing spacing density.
The document-level attributes are required because many admin surfaces are portaled to document.body (select menus, context menus, tooltips, modals, toasts). The layout-level attributes avoid a first-paint mismatch for authenticated pages before the effect writes the document attributes.
Surfaces that respond to density use scoped :global([data-editor-density='comfortable']) selectors in their CSS module to override their default values:
/* TreeRow.module.css */
.row {
--tree-row-h: 28px; /* compact default */
height: var(--tree-row-h);
}
:global([data-editor-density='comfortable']) .row {
--tree-row-h: 36px;
font-size: 12px;
}Adding density support to a new surface is two CSS lines. Theme and text-size support should usually require no component CSS; use the global --bg-*, --text-*, --border*, --overlay-*, and --space-* tokens.
- Renaming a preference id requires updating its storage key handling at the same time — the schema's
additionalProperties: truekeeps the old data harmless in storage, but reads usingreadEditorPreference('oldId')return the catalog default for a missing id. - Dropping a preference is safe:
additionalProperties: truelets stale fields linger silently in users'localStoragewithout any cleanup step.
- docs/editor.md — admin / editor architecture
- docs/reference/typebox-patterns.md —
parseJsonWithFallbackand the soft-boundary pattern - Source-of-truth files:
src/admin/pages/site/preferences/catalog.ts—PREFERENCE_CATALOG,PREFERENCE_CATEGORIESsrc/admin/pages/site/preferences/editorPreferences.ts— schema, IO, hooks, event bussrc/admin/modals/Settings/sections/PreferencesSection.tsx— auto-rendered UIsrc/admin/pages/site/store/slices/uiSlice.ts—usePersistence.tsand its prefs subscription
- Gate tests:
src/__tests__/settings/settingsSections.test.tsxsrc/__tests__/settings/settingsModal.test.tsx