diff --git a/docs/architecture/02-persistence.md b/docs/architecture/02-persistence.md
index 11943a4..c20b26f 100644
--- a/docs/architecture/02-persistence.md
+++ b/docs/architecture/02-persistence.md
@@ -18,7 +18,7 @@ src/
│ ├── snippet-store.ts # IndexedDB: snippets (metadata + drafts)
│ ├── dataset-store.ts # IndexedDB: datasets (heavy payloads)
│ ├── settings-store.ts # localStorage: UserSettings
-│ └── prefs-store.ts # localStorage: app/UI prefs (sort, layout)
+│ └── ux-prefs.ts # localStorage: app/UI prefs (sort, panel layout)
```
### Why this boundary exists
@@ -317,17 +317,24 @@ export function saveSettings(s: UserSettings): void {
}
```
-App/UI prefs follow the identical pattern under their own keys, e.g.:
+App/UI prefs follow the identical guard+fallback pattern, but live in **one
+record under their own key**, `astrolabe:ux-prefs` (the snippet sort and the
+panel layout together — the plan's "ux-prefs for sort + panel layout", §09D):
```ts
-// src/app/infrastructure/prefs-store.ts
-const SORT_KEY = 'astrolabe:snippet-sort';
-const LAYOUT_KEY = 'astrolabe:panel-layout';
+// src/app/infrastructure/ux-prefs.ts
+const KEY = 'astrolabe:ux-prefs'; // { panelLayout: { libraryWidth, previewWidth }, sort: { … } }
-const SORT_DEFAULTS = { sortBy: 'modified' as const, sortOrder: 'desc' as const };
-// loadSort()/saveSort() and loadLayout()/saveLayout() mirror §5's guard+fallback shape.
+// loadPanelLayout()/savePanelLayout() (and sort later) mirror §5's guard+fallback shape,
+// merging the changed section so a frequent write (a drag) never clobbers the others.
```
+> One nuance vs. the settings record: the panel-layout widths are **validated**
+> (positive finite numbers) and surfaced as `undefined` when absent/invalid; the
+> **defaults live in the `PanesStore`** (`PANE_DEFAULT`), applied at `hydrate`,
+> rather than merged in the adapter. Persistence of the live drag is **debounced**
+> in `orchestration/panes.ts` (a drag emits an update per pointer move).
+
> **Do:** keep a single complete `DEFAULTS` object as the source of truth and merge over it.
> **Don't:** read individual keys with bespoke `?? fallback` at each call site; one stale default and the shapes drift.
diff --git a/src/app/App.module.css b/src/app/App.module.css
index ccd2207..7c0987e 100644
--- a/src/app/App.module.css
+++ b/src/app/App.module.css
@@ -40,28 +40,24 @@
min-height: 0;
}
+/*
+ * Side panes (library, preview) carry an explicit width (set inline from the
+ * PanesStore) and don't grow or shrink — the drag handles change that width.
+ * The editor between them flexes to fill the remainder, so a drag leaves the
+ * opposite side pane untouched (spec §01A). Panes are separated by the
+ * ResizeHandle, so no inter-pane borders here.
+ */
.pane {
- flex: 1 1 0;
+ flex: 0 0 auto;
min-width: 0;
overflow: auto;
- border-right: var(--border-width) solid var(--border);
background: var(--bg);
}
-/* Library is a fixed-ish sidebar; editor + preview share the rest. */
-.panes > .pane:first-child {
- flex: 0 0 280px;
-}
-
/* Editor pane: Monaco manages its own scroll/layout, so no padding. */
.paneEditor {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
- border-right: var(--border-width) solid var(--border);
background: var(--bg);
}
-
-.pane:last-child {
- border-right: none;
-}
diff --git a/src/app/App.tsx b/src/app/App.tsx
index f0b0a42..59a8b4b 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -1,19 +1,25 @@
import { ConfirmDialog } from './components/ConfirmDialog';
import { LivePreview } from './components/LivePreview';
+import { ResizeHandle } from './components/ResizeHandle';
import { SnippetLibrary } from './components/SnippetLibrary';
import { SpecEditor } from './components/SpecEditor';
import { ThemeToggle } from './components/ThemeToggle';
+import { usePanesStore } from './stores/PanesStore';
import styles from './App.module.css';
/**
* Application shell — the three-pane workspace from spec §01A
* (library · editor · preview) under a fixed header.
*
- * M1 fills the panes with the MVP authoring loop. Pane resizing/toggling,
- * modals, routing, and shortcuts arrive in later milestones (see
+ * The center editor flexes; the library and preview carry remembered widths and
+ * are resized via the drag handles between them (spec §01A). Pane show/hide
+ * toggling, modals, routing, and shortcuts arrive in later milestones (see
* docs/IMPLEMENTATION-PLAN.md).
*/
export function App() {
+ const libraryWidth = usePanesStore((s) => s.libraryWidth);
+ const previewWidth = usePanesStore((s) => s.previewWidth);
+
return (
@@ -24,13 +30,19 @@ export function App() {
-
diff --git a/src/app/components/ResizeHandle.module.css b/src/app/components/ResizeHandle.module.css
new file mode 100644
index 0000000..3d82cff
--- /dev/null
+++ b/src/app/components/ResizeHandle.module.css
@@ -0,0 +1,35 @@
+/*
+ * 6px-wide hit target with a thin visible grip. Width is part of the layout
+ * budget (HANDLES_TOTAL in PanesStore); keep them in sync if this changes.
+ */
+.handle {
+ flex: 0 0 6px;
+ position: relative;
+ cursor: col-resize;
+ background: var(--border);
+ touch-action: none; /* let pointer drags own the gesture, not scroll */
+ transition: background var(--dur-fast) var(--ease);
+}
+
+.handle:hover,
+.handle:focus-visible {
+ background: var(--accent);
+ outline: none;
+}
+
+/* A short centered grip line, so the handle reads as a draggable divider. */
+.grip {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 2px;
+ height: 24px;
+ transform: translate(-50%, -50%);
+ background: var(--border-strong);
+ border-radius: var(--radius);
+}
+
+.handle:hover .grip,
+.handle:focus-visible .grip {
+ background: var(--accent-contrast);
+}
diff --git a/src/app/components/ResizeHandle.tsx b/src/app/components/ResizeHandle.tsx
new file mode 100644
index 0000000..6f401fd
--- /dev/null
+++ b/src/app/components/ResizeHandle.tsx
@@ -0,0 +1,99 @@
+/**
+ * Vertical drag handle between two panes (spec §01A).
+ *
+ * Sits between a side pane and the editor; dragging resizes the side pane while
+ * the editor absorbs the change, so the opposite side pane is unaffected. Width
+ * is clamped (pure `clampSideWidth`) so neither the dragged pane nor the editor
+ * falls below its minimum. Keyboard accessible: focus and use ←/→ to nudge.
+ *
+ * The handle reads the panes-row width from its own parent at interaction time,
+ * so it needs no layout props — it just controls the `side` it is told to.
+ */
+
+import { useRef } from 'react';
+import { clampSideWidth, usePanesStore, type PaneSide } from '../stores/PanesStore';
+import styles from './ResizeHandle.module.css';
+
+/** Keyboard nudge step (px) per arrow press. */
+const KEY_STEP = 16;
+
+interface ResizeHandleProps {
+ /** Which side pane this handle resizes. */
+ side: PaneSide;
+ /** Accessible label, e.g. "Resize snippet library". */
+ label: string;
+}
+
+export function ResizeHandle({ side, label }: ResizeHandleProps) {
+ const ref = useRef
(null);
+
+ /** Full panes-row width — the handle's parent (`.panes`). */
+ const containerWidth = (): number => ref.current?.parentElement?.clientWidth ?? 0;
+
+ /** Apply a desired width for this side, clamped against the current layout. */
+ const applyWidth = (desired: number) => {
+ const { libraryWidth, previewWidth, setWidth } = usePanesStore.getState();
+ const other = side === 'library' ? previewWidth : libraryWidth;
+ setWidth(side, clampSideWidth(side, desired, containerWidth(), other));
+ };
+
+ const onPointerDown = (e: React.PointerEvent) => {
+ if (e.button !== 0) return; // primary button only
+ e.preventDefault();
+ const startX = e.clientX;
+ const startWidth =
+ side === 'library'
+ ? usePanesStore.getState().libraryWidth
+ : usePanesStore.getState().previewWidth;
+
+ const onMove = (ev: PointerEvent) => {
+ const delta = ev.clientX - startX;
+ // The left handle grows its pane as it moves right; the right handle (left
+ // of the preview) shrinks the preview as it moves right.
+ const desired = side === 'library' ? startWidth + delta : startWidth - delta;
+ applyWidth(desired);
+ };
+ const onUp = () => {
+ window.removeEventListener('pointermove', onMove);
+ window.removeEventListener('pointerup', onUp);
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ };
+
+ window.addEventListener('pointermove', onMove);
+ window.addEventListener('pointerup', onUp);
+ // While dragging, force the resize cursor and suppress text selection.
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+ };
+
+ const onKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
+ e.preventDefault();
+ const dir = e.key === 'ArrowRight' ? 1 : -1;
+ const current =
+ side === 'library'
+ ? usePanesStore.getState().libraryWidth
+ : usePanesStore.getState().previewWidth;
+ const delta = side === 'library' ? dir * KEY_STEP : -dir * KEY_STEP;
+ applyWidth(current + delta);
+ };
+
+ return (
+ // TODO: a focusable window-splitter should also expose aria-valuenow/min/max
+ // (the side pane's current/min/max width) so assistive tech can announce the
+ // size as it changes. Wire it when the pane toggle strip lands in M6.
+
+
+
+ );
+}
diff --git a/src/app/infrastructure/ux-prefs.test.ts b/src/app/infrastructure/ux-prefs.test.ts
new file mode 100644
index 0000000..9cb0613
--- /dev/null
+++ b/src/app/infrastructure/ux-prefs.test.ts
@@ -0,0 +1,63 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { loadPanelLayout, savePanelLayout } from './ux-prefs';
+
+const KEY = 'astrolabe:ux-prefs';
+
+/** In-memory localStorage stub (Node's global one is non-functional). */
+function makeStorageStub() {
+ const map = new Map();
+ return {
+ getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
+ setItem: (k: string, v: string) => void map.set(k, String(v)),
+ removeItem: (k: string) => void map.delete(k),
+ clear: () => map.clear(),
+ key: (i: number) => [...map.keys()][i] ?? null,
+ get length() {
+ return map.size;
+ },
+ };
+}
+
+describe('ux-prefs · panelLayout', () => {
+ beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
+ afterEach(() => vi.unstubAllGlobals());
+
+ it('returns empty widths when nothing is stored', () => {
+ expect(loadPanelLayout()).toEqual({ libraryWidth: undefined, previewWidth: undefined });
+ });
+
+ it('round-trips stored widths', () => {
+ savePanelLayout({ libraryWidth: 300, previewWidth: 420 });
+ expect(loadPanelLayout()).toEqual({ libraryWidth: 300, previewWidth: 420 });
+ });
+
+ it('merges partial writes without dropping the other width', () => {
+ savePanelLayout({ libraryWidth: 300, previewWidth: 420 });
+ savePanelLayout({ libraryWidth: 250 });
+ expect(loadPanelLayout()).toEqual({ libraryWidth: 250, previewWidth: 420 });
+ });
+
+ it('rejects non-positive / non-finite junk', () => {
+ localStorage.setItem(
+ KEY,
+ JSON.stringify({ panelLayout: { libraryWidth: -5, previewWidth: 'wide' } }),
+ );
+ expect(loadPanelLayout()).toEqual({ libraryWidth: undefined, previewWidth: undefined });
+ });
+
+ it('tolerates malformed JSON', () => {
+ localStorage.setItem(KEY, '{ not json');
+ expect(loadPanelLayout()).toEqual({ libraryWidth: undefined, previewWidth: undefined });
+ });
+
+ it('preserves unrelated keys in the record (forward-compatible merge)', () => {
+ localStorage.setItem(KEY, JSON.stringify({ sort: { sortBy: 'name', sortOrder: 'asc' } }));
+ savePanelLayout({ libraryWidth: 260 });
+ const stored = JSON.parse(localStorage.getItem(KEY)!) as {
+ sort: { sortBy: string };
+ panelLayout: { libraryWidth: number };
+ };
+ expect(stored.sort.sortBy).toBe('name');
+ expect(stored.panelLayout.libraryWidth).toBe(260);
+ });
+});
diff --git a/src/app/infrastructure/ux-prefs.ts b/src/app/infrastructure/ux-prefs.ts
new file mode 100644
index 0000000..afa5be9
--- /dev/null
+++ b/src/app/infrastructure/ux-prefs.ts
@@ -0,0 +1,78 @@
+/**
+ * UX preferences persistence (localStorage) — docs/architecture/02 §5, spec §09D.
+ *
+ * These preferences persist **separately** from UserSettings so they can change
+ * frequently (a drag emits many width updates) without rewriting the settings
+ * record. Its own key, `astrolabe:ux-prefs`, holds the snippet sort preference
+ * (lands with M5/M6) and the panel layout (per-pane widths + visibility).
+ *
+ * This slice persists the panel **widths** only; visibility joins it when the
+ * toggle strip lands. Read-with-fallback + write-through merge, the same
+ * contract as the settings adapter, so adding fields later upgrades cleanly.
+ *
+ * Per the architecture rule this is one of the only modules that may touch
+ * `localStorage`; everything else goes through these typed functions.
+ */
+
+const KEY = 'astrolabe:ux-prefs';
+
+/** Per-pane widths (px). Optional — a missing field falls back to its default. */
+export interface PanelLayout {
+ libraryWidth?: number;
+ previewWidth?: number;
+}
+
+interface StoredPrefs {
+ panelLayout?: PanelLayout;
+ [k: string]: unknown;
+}
+
+function available(): boolean {
+ try {
+ return typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function';
+ } catch {
+ return false;
+ }
+}
+
+function readRaw(): StoredPrefs {
+ if (!available()) return {};
+ try {
+ const raw = localStorage.getItem(KEY);
+ if (!raw) return {};
+ const parsed: unknown = JSON.parse(raw);
+ return parsed && typeof parsed === 'object' ? (parsed as StoredPrefs) : {};
+ } catch (err) {
+ console.warn('[ux-prefs] failed to read, using defaults', err);
+ return {};
+ }
+}
+
+function writeRaw(next: StoredPrefs): void {
+ if (!available()) return;
+ try {
+ localStorage.setItem(KEY, JSON.stringify(next));
+ } catch (err) {
+ console.warn('[ux-prefs] failed to write', err);
+ }
+}
+
+/** A finite positive number, or undefined — guards against junk in storage. */
+function posNumber(v: unknown): number | undefined {
+ return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
+}
+
+/** The persisted panel layout, with only valid numeric widths surfaced. */
+export function loadPanelLayout(): PanelLayout {
+ const stored = readRaw().panelLayout ?? {};
+ return {
+ libraryWidth: posNumber(stored.libraryWidth),
+ previewWidth: posNumber(stored.previewWidth),
+ };
+}
+
+/** Persist the panel layout, preserving every other key already in the record. */
+export function savePanelLayout(layout: PanelLayout): void {
+ const current = readRaw();
+ writeRaw({ ...current, panelLayout: { ...current.panelLayout, ...layout } });
+}
diff --git a/src/app/orchestration/panes.ts b/src/app/orchestration/panes.ts
new file mode 100644
index 0000000..08771ab
--- /dev/null
+++ b/src/app/orchestration/panes.ts
@@ -0,0 +1,35 @@
+/**
+ * Pane-layout orchestration — bridges the (browser-free) PanesStore to the
+ * ux-prefs adapter. Same store↔adapter pattern as theme/preferences.
+ *
+ * `initPanes` hydrates persisted widths into the store before render. `wirePanes`
+ * persists changes, **debounced**, because a drag emits a width update on every
+ * pointer move — writing localStorage on each would thrash. The debounce settles
+ * the write to the final resting widths (the same approach as snippet auto-save).
+ */
+
+import { loadPanelLayout, savePanelLayout } from '../infrastructure/ux-prefs';
+import { usePanesStore } from '../stores/PanesStore';
+
+/** Delay before a settled resize is persisted. */
+export const PANES_PERSIST_DEBOUNCE_MS = 300;
+
+/** Hydrate persisted pane widths into the store. Call before render. */
+export function initPanes(): void {
+ usePanesStore.getState().hydrate(loadPanelLayout());
+}
+
+/** Persist width changes (debounced). Returns a teardown that detaches the subscriber. */
+export function wirePanes(): () => void {
+ let timer: ReturnType | undefined;
+ return usePanesStore.subscribe((state, prev) => {
+ if (state.libraryWidth === prev.libraryWidth && state.previewWidth === prev.previewWidth) {
+ return;
+ }
+ clearTimeout(timer);
+ timer = setTimeout(() => {
+ const { libraryWidth, previewWidth } = usePanesStore.getState();
+ savePanelLayout({ libraryWidth, previewWidth });
+ }, PANES_PERSIST_DEBOUNCE_MS);
+ });
+}
diff --git a/src/app/stores/PanesStore.test.ts b/src/app/stores/PanesStore.test.ts
new file mode 100644
index 0000000..701080d
--- /dev/null
+++ b/src/app/stores/PanesStore.test.ts
@@ -0,0 +1,55 @@
+import { beforeEach, describe, expect, test } from 'vitest';
+import { clampSideWidth, HANDLES_TOTAL, PANE_DEFAULT, PANE_MIN, usePanesStore } from './PanesStore';
+
+const store = () => usePanesStore.getState();
+
+describe('clampSideWidth', () => {
+ // A roomy container where nothing is constrained.
+ const W = 1400;
+
+ test('passes a comfortable width through unchanged', () => {
+ expect(clampSideWidth('library', 300, W, PANE_DEFAULT.preview)).toBe(300);
+ expect(clampSideWidth('preview', 400, W, PANE_DEFAULT.library)).toBe(400);
+ });
+
+ test('never goes below the pane minimum', () => {
+ expect(clampSideWidth('library', 50, W, PANE_DEFAULT.preview)).toBe(PANE_MIN.library);
+ expect(clampSideWidth('preview', 10, W, PANE_DEFAULT.library)).toBe(PANE_MIN.preview);
+ });
+
+ test('caps the width so the editor keeps at least its minimum', () => {
+ const other = PANE_DEFAULT.preview;
+ const max = W - other - HANDLES_TOTAL - PANE_MIN.editor;
+ // Asking for far more than the editor can spare clamps to that maximum.
+ expect(clampSideWidth('library', W, W, other)).toBe(max);
+ // The editor sits exactly at its minimum at that point.
+ expect(W - max - other - HANDLES_TOTAL).toBe(PANE_MIN.editor);
+ });
+
+ test('a container too narrow for all minimums still never drops below the min', () => {
+ // 500px total can't fit library(180)+preview(240)+editor(320)+handles.
+ expect(clampSideWidth('library', 300, 500, PANE_MIN.preview)).toBe(PANE_MIN.library);
+ });
+});
+
+describe('usePanesStore', () => {
+ beforeEach(() =>
+ store().hydrate({ libraryWidth: PANE_DEFAULT.library, previewWidth: PANE_DEFAULT.preview }),
+ );
+
+ test('setWidth updates the targeted side only', () => {
+ store().setWidth('library', 320);
+ expect(store().libraryWidth).toBe(320);
+ expect(store().previewWidth).toBe(PANE_DEFAULT.preview);
+
+ store().setWidth('preview', 420);
+ expect(store().previewWidth).toBe(420);
+ expect(store().libraryWidth).toBe(320);
+ });
+
+ test('hydrate fills missing values from defaults', () => {
+ store().hydrate({ libraryWidth: 250 });
+ expect(store().libraryWidth).toBe(250);
+ expect(store().previewWidth).toBe(PANE_DEFAULT.preview);
+ });
+});
diff --git a/src/app/stores/PanesStore.ts b/src/app/stores/PanesStore.ts
new file mode 100644
index 0000000..be4e745
--- /dev/null
+++ b/src/app/stores/PanesStore.ts
@@ -0,0 +1,74 @@
+/**
+ * Pane layout state — the resizable three-pane shell (spec §01A, persisted per
+ * §09D "Panel layout").
+ *
+ * Model: the two **side** panes (library, preview) carry explicit remembered
+ * widths; the **center** editor flexes to fill the remainder. This is what makes
+ * a drag "leave the rest of the layout unaffected" (§01A): dragging the left
+ * handle trades width between library and editor, the right handle between
+ * preview and editor — the opposite side pane never moves.
+ *
+ * Per-pane visibility / the toggle strip (the other half of §01A) is not here
+ * yet; this slice is resize + persistence, pulled forward from M6 because it is
+ * coupled to the Live Preview's container sizing.
+ *
+ * Pure clamp helpers live alongside the store so the resize math is unit-tested
+ * without a DOM. Persistence is a startup subscriber (orchestration/panes), not
+ * done here — the store stays browser-free.
+ */
+
+import { create } from 'zustand';
+
+/** Which side pane a drag handle controls. */
+export type PaneSide = 'library' | 'preview';
+
+/** Minimum widths (px) enforced while resizing, so no pane collapses (§01A). */
+export const PANE_MIN = { library: 180, preview: 240, editor: 320 } as const;
+
+/** Initial side-pane widths (px) on first run, before any persisted layout. */
+export const PANE_DEFAULT = { library: 280, preview: 360 } as const;
+
+/** Combined width (px) the resize handles occupy between the panes. */
+export const HANDLES_TOTAL = 12;
+
+/**
+ * Clamp a desired side-pane width so it stays at least its own minimum and
+ * leaves the editor at least its minimum. Pure — the single place the resize
+ * constraint lives. `containerWidth` is the full panes-row width; `otherWidth`
+ * is the opposite side pane's current width.
+ */
+export function clampSideWidth(
+ side: PaneSide,
+ desired: number,
+ containerWidth: number,
+ otherWidth: number,
+): number {
+ const min = PANE_MIN[side];
+ // The widest this pane can be while the editor still meets its minimum.
+ const max = containerWidth - otherWidth - HANDLES_TOTAL - PANE_MIN.editor;
+ // If the container is too narrow for everyone, the min wins (never below it).
+ return Math.max(min, Math.min(desired, Math.max(min, max)));
+}
+
+export interface PanesState {
+ libraryWidth: number;
+ previewWidth: number;
+ /** Set a side pane's width (already clamped by the caller). */
+ setWidth: (side: PaneSide, width: number) => void;
+ /** Restore persisted widths on startup; missing values keep their defaults. */
+ hydrate: (layout: { libraryWidth?: number; previewWidth?: number }) => void;
+}
+
+export const usePanesStore = create((set) => ({
+ libraryWidth: PANE_DEFAULT.library,
+ previewWidth: PANE_DEFAULT.preview,
+
+ setWidth: (side, width) =>
+ set(side === 'library' ? { libraryWidth: width } : { previewWidth: width }),
+
+ hydrate: (layout) =>
+ set({
+ libraryWidth: layout.libraryWidth ?? PANE_DEFAULT.library,
+ previewWidth: layout.previewWidth ?? PANE_DEFAULT.preview,
+ }),
+}));
diff --git a/src/main.tsx b/src/main.tsx
index 948ed44..5136733 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,5 +1,6 @@
import { createRoot } from 'react-dom/client';
import { App } from './app/App';
+import { initPanes, wirePanes } from './app/orchestration/panes';
import { initPreviewFitMode, wirePreviewFitMode } from './app/orchestration/preferences';
import { initApp } from './app/orchestration/startup';
import { initTheme, wireTheme } from './app/orchestration/theme';
@@ -11,10 +12,12 @@ import '../styles/base.css';
initTheme();
wireTheme();
-// Hydrate + persist the preview fit mode the same way (pulled ahead of the M5
-// Settings modal); hydrating before render keeps the store authoritative.
+// Hydrate + persist the small UI preferences (preview fit mode, pane widths) the
+// same way. Pane widths hydrate before render so the layout opens as left.
initPreviewFitMode();
wirePreviewFitMode();
+initPanes();
+wirePanes();
// Load the library from IndexedDB (seeding a sample on first run) and wire
// persistence. Fire-and-forget: the UI renders immediately and fills in when