pi-weave 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -44,6 +44,9 @@
44
44
  */
45
45
 
46
46
  import { createHash, randomUUID } from "node:crypto";
47
+ import { createReadStream } from "node:fs";
48
+ import { pipeline } from "node:stream/promises";
49
+ import { createVaultBackup } from "./backup";
47
50
  import { readFile } from "node:fs/promises";
48
51
  import type { IncomingMessage, ServerResponse } from "node:http";
49
52
  import type { WorkspaceSnapshot } from "../../core/cache/workspace";
@@ -379,8 +382,16 @@ async function route(
379
382
  if (method === "GET" && path === "/") return sendShell(deps, res);
380
383
  if (method === "GET" && path === "/app.js") return sendBundle(deps, res);
381
384
  if (method === "GET" && path === "/api/graph") return sendGraph(deps, req, res);
385
+ if (method === "GET" && path === "/api/backup") {
386
+ const backup = await createVaultBackup(deps.vaultRoot);
387
+ try {
388
+ res.writeHead(200, { ...baseHeaders(), "content-type": "application/zip", "content-disposition": `attachment; filename="weave-vault-${new Date().toISOString().slice(0, 10)}.zip"`, "cache-control": "no-store" });
389
+ await pipeline(createReadStream(backup.path), res);
390
+ } finally { await backup.dispose(); }
391
+ return;
392
+ }
382
393
  if (method === "GET" && path === "/api/workspace-state") {
383
- return sendJson(res, 200, { viewId: randomUUID(), layout: await deps.workspaceState.readLatest() }, { "cache-control": "no-store" });
394
+ return sendJson(res, 200, { viewId: randomUUID(), layout: await deps.workspaceState.readLatest(), info: { vaultRoot: deps.vaultRoot, version: (JSON.parse(await readFile(new URL("../../../package.json", import.meta.url), "utf8")) as { version: string }).version } }, { "cache-control": "no-store" });
384
395
  }
385
396
  if (method === "POST" && path === "/api/workspace-state") return saveWorkspaceState(deps, req, res);
386
397
  if (path.startsWith("/api/folder/")) {
@@ -0,0 +1,70 @@
1
+ /** Graph force settings shared without loading the d3 simulation. */
2
+
3
+ /**
4
+ * The force constants, as one **mutable** record.
5
+ *
6
+ * Mutable because picking these numbers is an act of taste, not of
7
+ * derivation: the difference between "one hairball" and "legible groups" is a
8
+ * ratio between containment cohesion, charge and centre gravity that is far
9
+ * easier to *see* than to reason about. The settings sliders (see
10
+ * `client/graph/tuner.model.ts` and docs/weave-workspace.md §15.7) update this record
11
+ * and re-run the layout live, so a human can find the values by eye and they then
12
+ * get frozen back into {@link FORCE_DEFAULTS}.
13
+ *
14
+ * `computeLayout(model, { seed })` stays deterministic for a given set of forces.
15
+ * Settings are the production writer of this record.
16
+ *
17
+ * ponytail: module-level mutable state, fine at one graph per page — thread it
18
+ * as a `LayoutOptions` field if a second concurrent consumer ever appears.
19
+ */
20
+ export interface ForceConstants {
21
+ /** Rest length of a `contains` / `anchored-at` spring: a rosette's radius. */
22
+ containsRest: number;
23
+ /** Stiffness of that spring. This is what makes a group *be* a group. */
24
+ containsStrength: number;
25
+ /** Rest length of a `links-to` / `mentions` spring: the inter-group reach. */
26
+ relationDistance: number;
27
+ /** Stiffness of that spring. Weak, so associations bend without merging. */
28
+ relationStrength: number;
29
+ /** `forceManyBody` strength. Negative is repulsion. */
30
+ charge: number;
31
+ /**
32
+ * Distance past which charge is ignored (`forceManyBody.distanceMax`).
33
+ *
34
+ * A cap rather than `Infinity` is what keeps a strong charge from simply
35
+ * inflating the whole graph uniformly: beyond it, neighbours stop pushing
36
+ * and only the springs and centre gravity speak, so groups separate at
37
+ * local scale without the picture growing without bound.
38
+ */
39
+ chargeMax: number;
40
+ /** `forceX`/`forceY` pull toward the origin. The no-escape guarantee. */
41
+ center: number;
42
+ }
43
+
44
+ /**
45
+ * The live constants. See {@link ForceConstants} for why this is mutable.
46
+ *
47
+ * These values were **found by eye** through the `?sliders=1` tuner and then
48
+ * frozen here, which is the loop §15.7 describes working as intended. They are
49
+ * not derived and should not be "corrected" toward rounder numbers: the
50
+ * previous set (`containsStrength: 0.02`, `charge: -50`, `center: 0.09`) was
51
+ * defensible on paper and produced a single hairball on screen.
52
+ */
53
+ export const FORCES: ForceConstants = {
54
+ containsRest: 55,
55
+ containsStrength: 0.12,
56
+ relationDistance: 50,
57
+ relationStrength: 0.07,
58
+ charge: -200,
59
+ chargeMax: 800,
60
+ center: 0.05,
61
+ };
62
+
63
+ /** The shipped values, for the tuner's reset and for a test to restore from. */
64
+ export const FORCE_DEFAULTS: Readonly<ForceConstants> = { ...FORCES };
65
+
66
+ /** Overwrite the live constants in place. The tuner's one write. */
67
+ export function setForces(next: Partial<ForceConstants>): void {
68
+ Object.assign(FORCES, next);
69
+ }
70
+
@@ -4,6 +4,10 @@ import { forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY
4
4
  import type { Simulation, SimulationNodeDatum } from "d3-force";
5
5
  import type { WireEdgeKind as EdgeKind, WireGraphEdge as GraphEdge, WireGraphModel as GraphModel } from "./graph";
6
6
  import type { Point } from "./metrics";
7
+ import { FORCES } from "./forces";
8
+
9
+ export { FORCES, FORCE_DEFAULTS, setForces } from "./forces";
10
+ export type { ForceConstants } from "./forces";
7
11
 
8
12
  export type { Point } from "./metrics";
9
13
 
@@ -35,75 +39,6 @@ const DEFAULT_TICKS = 300;
35
39
  const DEFAULT_SEED = 1;
36
40
  const ALPHA_MIN = 0.001;
37
41
 
38
- /**
39
- * The force constants, as one **mutable** record.
40
- *
41
- * Mutable because picking these numbers is an act of taste, not of
42
- * derivation: the difference between "one hairball" and "legible groups" is a
43
- * ratio between containment cohesion, charge and centre gravity that is far
44
- * easier to *see* than to reason about. The hidden tuner (`?sliders=1`, see
45
- * `client/graph/tuner.model.ts` and docs/weave-workspace.md §15.7) writes here
46
- * and re-lays out live, so a human can find the values by eye and they then
47
- * get frozen back into {@link FORCE_DEFAULTS}.
48
- *
49
- * Determinism is untouched: every production and test path only ever *reads*
50
- * this, so `computeLayout(model, { seed })` stays byte-identical. The tuner is
51
- * the one writer, and it is unreachable without the query flag.
52
- *
53
- * ponytail: module-level mutable state, fine at one graph per page — thread it
54
- * as a `LayoutOptions` field if a second concurrent consumer ever appears.
55
- */
56
- export interface ForceConstants {
57
- /** Rest length of a `contains` / `anchored-at` spring: a rosette's radius. */
58
- containsRest: number;
59
- /** Stiffness of that spring. This is what makes a group *be* a group. */
60
- containsStrength: number;
61
- /** Rest length of a `links-to` / `mentions` spring: the inter-group reach. */
62
- relationDistance: number;
63
- /** Stiffness of that spring. Weak, so associations bend without merging. */
64
- relationStrength: number;
65
- /** `forceManyBody` strength. Negative is repulsion. */
66
- charge: number;
67
- /**
68
- * Distance past which charge is ignored (`forceManyBody.distanceMax`).
69
- *
70
- * A cap rather than `Infinity` is what keeps a strong charge from simply
71
- * inflating the whole graph uniformly: beyond it, neighbours stop pushing
72
- * and only the springs and centre gravity speak, so groups separate at
73
- * local scale without the picture growing without bound.
74
- */
75
- chargeMax: number;
76
- /** `forceX`/`forceY` pull toward the origin. The no-escape guarantee. */
77
- center: number;
78
- }
79
-
80
- /**
81
- * The live constants. See {@link ForceConstants} for why this is mutable.
82
- *
83
- * These values were **found by eye** through the `?sliders=1` tuner and then
84
- * frozen here, which is the loop §15.7 describes working as intended. They are
85
- * not derived and should not be "corrected" toward rounder numbers: the
86
- * previous set (`containsStrength: 0.02`, `charge: -50`, `center: 0.09`) was
87
- * defensible on paper and produced a single hairball on screen.
88
- */
89
- export const FORCES: ForceConstants = {
90
- containsRest: 55,
91
- containsStrength: 0.12,
92
- relationDistance: 50,
93
- relationStrength: 0.07,
94
- charge: -200,
95
- chargeMax: 800,
96
- center: 0.05,
97
- };
98
-
99
- /** The shipped values, for the tuner's reset and for a test to restore from. */
100
- export const FORCE_DEFAULTS: Readonly<ForceConstants> = { ...FORCES };
101
-
102
- /** Overwrite the live constants in place. The tuner's one write. */
103
- export function setForces(next: Partial<ForceConstants>): void {
104
- Object.assign(FORCES, next);
105
- }
106
-
107
42
  export function isContainment(kind: EdgeKind): boolean {
108
43
  return kind === "contains" || kind === "anchored-at";
109
44
  }
@@ -0,0 +1,76 @@
1
+ /** Validated preferences, stored alongside the workspace's existing snapshots. */
2
+ import { FORCE_DEFAULTS } from "./forces";
3
+ import type { ForceConstants } from "./forces";
4
+ import { ACCENTS, THEMES, isThemeChoice } from "./themes";
5
+ import type { AccentChoice, PaletteChoice } from "./themes";
6
+
7
+ /** One slider: which constant it drives, and the range it may drive it over. */
8
+ export interface SliderSpec {
9
+ readonly key: keyof ForceConstants;
10
+ readonly label: string;
11
+ /** One line under the label saying what moving it does. */
12
+ readonly hint: string;
13
+ readonly min: number;
14
+ readonly max: number;
15
+ readonly step: number;
16
+ }
17
+
18
+ /** Graph forces, ordered from group cohesion to repulsion and centering. */
19
+ export const FORCE_SLIDERS: readonly SliderSpec[] = [
20
+ { key: "containsStrength", label: "Group cohesion", hint: "how hard a parent holds its children — group cohesion", min: 0, max: 1, step: 0.01 },
21
+ { key: "containsRest", label: "Group radius", hint: "how far children sit from their parent — rosette radius", min: 10, max: 300, step: 5 },
22
+ { key: "relationStrength", label: "Link strength", hint: "pull of links-to / mentions across groups", min: 0, max: 1, step: 0.01 },
23
+ { key: "relationDistance", label: "Link distance", hint: "how long those cross-group springs are", min: 20, max: 600, step: 10 },
24
+ { key: "charge", label: "Repulsion", hint: "node-to-node repulsion — more negative pushes harder", min: -1200, max: 0, step: 10 },
25
+ { key: "chargeMax", label: "Repulsion range", hint: "distance past which repulsion stops — caps overall spread", min: 100, max: 4000, step: 50 },
26
+ { key: "center", label: "Center gravity", hint: "pull toward the origin — too much makes one blob", min: 0, max: 0.3, step: 0.005 },
27
+ ];
28
+
29
+
30
+ export interface Preferences {
31
+ readonly lightTheme: PaletteChoice;
32
+ readonly darkTheme: PaletteChoice;
33
+ readonly accent: AccentChoice;
34
+ readonly fontSize: number;
35
+ readonly readable: boolean;
36
+ readonly spellcheck: boolean;
37
+ readonly defaultEdit: boolean;
38
+ readonly startup: "restore" | "empty";
39
+ readonly focusNewTabs: boolean;
40
+ readonly groupColors: boolean;
41
+ readonly forces: ForceConstants;
42
+ }
43
+
44
+ export const DEFAULT_PREFERENCES: Preferences = {
45
+ lightTheme: "light", darkTheme: "dark", accent: "theme", fontSize: 14, readable: true, spellcheck: true, defaultEdit: false,
46
+ startup: "restore", focusNewTabs: true, groupColors: true, forces: { ...FORCE_DEFAULTS },
47
+ };
48
+
49
+ function record(value: unknown): value is Record<string, unknown> {
50
+ return typeof value === "object" && value !== null && !Array.isArray(value);
51
+ }
52
+
53
+ export function parsePreferences(input: unknown): Preferences | null {
54
+ if (!record(input)) return null;
55
+ const legacy = !Object.hasOwn(input, "lightTheme") && !Object.hasOwn(input, "darkTheme");
56
+ const value = legacy ? { ...input, lightTheme: "light", darkTheme: "dark" } : input;
57
+ if (Object.keys(value).length !== Object.keys(DEFAULT_PREFERENCES).length) return null;
58
+ for (const scheme of ["light", "dark"] as const) {
59
+ const theme = value[`${scheme}Theme`];
60
+ if (!isThemeChoice(theme) || theme === "system" || THEMES[theme].scheme !== scheme) return null;
61
+ }
62
+ if (value["accent"] !== "theme" && (typeof value["accent"] !== "string" || !Object.hasOwn(ACCENTS, value["accent"]))) return null;
63
+ if (typeof value["fontSize"] !== "number" || !Number.isInteger(value["fontSize"]) || value["fontSize"] < 12 || value["fontSize"] > 22) return null;
64
+ for (const key of ["readable", "spellcheck", "defaultEdit", "focusNewTabs", "groupColors"] as const) {
65
+ if (typeof value[key] !== "boolean") return null;
66
+ }
67
+ if (value["startup"] !== "restore" && value["startup"] !== "empty") return null;
68
+ const forces = value["forces"];
69
+ if (!record(forces) || Object.keys(forces).length !== FORCE_SLIDERS.length) return null;
70
+ for (const { key, min, max } of FORCE_SLIDERS) {
71
+ const force = forces[key];
72
+ if (typeof force !== "number" || !Number.isFinite(force) || force < min || force > max) return null;
73
+ }
74
+ return value as unknown as Preferences;
75
+ }
76
+
@@ -0,0 +1,92 @@
1
+ /** Theme candidates. Catppuccin colors: https://github.com/catppuccin/palette (MIT).
2
+ * Latte colors are deepened and Frappé red lifted for contrast on raised controls.
3
+ * `light` and `dark` keep existing saved choices compatible.
4
+ */
5
+ const latte = {
6
+ bg: "#eff1f5", panel: "#e6e9ef", page: "#dce0e8", raise: "#ccd0da",
7
+ fg: "#4c4f69", dim: "#56586a", faint: "#606274", line: "#ccd0da", "line-strong": "#bcc0cc",
8
+ accent: "#7113ec", ok: "#28641b", warn: "#7c4f10", bad: "#b20d30",
9
+ };
10
+ const macchiato = {
11
+ bg: "#24273a", panel: "#1e2030", page: "#181926", raise: "#363a4f",
12
+ fg: "#cad3f5", dim: "#a5adcb", faint: "#939ab7", line: "#363a4f", "line-strong": "#494d64",
13
+ accent: "#c6a0f6", ok: "#a6da95", warn: "#eed49f", bad: "#ed8796",
14
+ };
15
+
16
+ /** One table drives CSS, WebGL and the appearance theme choices. */
17
+ export const THEMES = {
18
+ light: { name: "Catppuccin Latte · Mauve", scheme: "light", colors: latte },
19
+ "latte-blue": { name: "Catppuccin Latte · Blue", scheme: "light", colors: { ...latte, accent: "#1750bf" } },
20
+ "frappe-teal": { name: "Catppuccin Frappé · Teal", scheme: "dark", colors: {
21
+ bg: "#303446", panel: "#292c3c", page: "#232634", raise: "#414559",
22
+ fg: "#c6d0f5", dim: "#b5bfe2", faint: "#a5adce", line: "#414559", "line-strong": "#51576d",
23
+ accent: "#81c8be", ok: "#a6d189", warn: "#e5c890", bad: "#f4a4a6",
24
+ } },
25
+ dark: { name: "Catppuccin Macchiato · Mauve", scheme: "dark", colors: macchiato },
26
+ "macchiato-peach": { name: "Catppuccin Macchiato · Peach", scheme: "dark", colors: { ...macchiato, accent: "#f5a97f" } },
27
+ "mocha-lavender": { name: "Catppuccin Mocha · Lavender", scheme: "dark", colors: {
28
+ bg: "#1e1e2e", panel: "#181825", page: "#11111b", raise: "#313244",
29
+ fg: "#cdd6f4", dim: "#a6adc8", faint: "#9399b2", line: "#313244", "line-strong": "#45475a",
30
+ accent: "#b4befe", ok: "#a6e3a1", warn: "#f9e2af", bad: "#f38ba8",
31
+ } },
32
+ "paper-blue": { name: "Paper · Blue", scheme: "light", colors: {
33
+ bg: "#f4f6f8", panel: "#e9edf2", page: "#ffffff", raise: "#dce3eb",
34
+ fg: "#182334", dim: "#42536a", faint: "#526176", line: "#dce3eb", "line-strong": "#bac6d4",
35
+ accent: "#174ea6", ok: "#21623b", warn: "#805300", bad: "#a3223c",
36
+ } },
37
+ "ink-blue": { name: "Ink · Blue", scheme: "dark", colors: {
38
+ bg: "#1b2430", panel: "#141c27", page: "#101720", raise: "#2b3748",
39
+ fg: "#edf2f8", dim: "#becadc", faint: "#a5b5cb", line: "#2b3748", "line-strong": "#46566d",
40
+ accent: "#8bbcff", ok: "#91d5a5", warn: "#edc47b", bad: "#ff9fae",
41
+ } },
42
+ "sand-amber": { name: "Sand · Amber", scheme: "light", colors: {
43
+ bg: "#f7f3ec", panel: "#ede6da", page: "#fffdf8", raise: "#e2d8c8",
44
+ fg: "#30291f", dim: "#5b4e3f", faint: "#675847", line: "#e2d8c8", "line-strong": "#c7b8a2",
45
+ accent: "#814500", ok: "#365c2a", warn: "#795000", bad: "#a12c32",
46
+ } },
47
+ "ember-amber": { name: "Ember · Amber", scheme: "dark", colors: {
48
+ bg: "#29231f", panel: "#211c19", page: "#191512", raise: "#3c322b",
49
+ fg: "#f3e9dc", dim: "#d0bfab", faint: "#bfa991", line: "#3c322b", "line-strong": "#5c4d40",
50
+ accent: "#edb96b", ok: "#b5cf8a", warn: "#efc57a", bad: "#f69a91",
51
+ } },
52
+ "mist-teal": { name: "Mist · Teal", scheme: "light", colors: {
53
+ bg: "#eff5f3", panel: "#e1ece8", page: "#fafffd", raise: "#d2e2dc",
54
+ fg: "#18342e", dim: "#3c5b52", faint: "#48655b", line: "#d2e2dc", "line-strong": "#acc5bc",
55
+ accent: "#006457", ok: "#2a5e35", warn: "#795300", bad: "#a52a43",
56
+ } },
57
+ "forest-teal": { name: "Forest · Teal", scheme: "dark", colors: {
58
+ bg: "#1c2b27", panel: "#16221e", page: "#101b17", raise: "#2b4038",
59
+ fg: "#e6f2ec", dim: "#b8d0c4", faint: "#a2bfb0", line: "#2b4038", "line-strong": "#456255",
60
+ accent: "#7cd8bb", ok: "#a5d6a0", warn: "#e6c582", bad: "#f5a2ac",
61
+ } },
62
+ } as const;
63
+
64
+ export type PaletteChoice = keyof typeof THEMES;
65
+ export type ThemeChoice = "system" | PaletteChoice;
66
+ export const THEME_CHOICES: readonly ThemeChoice[] = ["system", ...Object.keys(THEMES) as PaletteChoice[]];
67
+
68
+ export function isThemeChoice(value: unknown): value is ThemeChoice {
69
+ return typeof value === "string" && THEME_CHOICES.includes(value as ThemeChoice);
70
+ }
71
+
72
+ /** Accent selection is independent of the theme's surface palette. */
73
+ export const ACCENTS = {
74
+ mauve: { name: "Mauve", light: "#7113ec", dark: "#cba9f8" },
75
+ blue: { name: "Blue", light: "#1750bf", dark: "#8bbcff" },
76
+ teal: { name: "Teal", light: "#006457", dark: "#7cd8bb" },
77
+ peach: { name: "Peach", light: "#814500", dark: "#f5a97f" },
78
+ rose: { name: "Rose", light: "#a52a43", dark: "#f4a4a6" },
79
+ green: { name: "Green", light: "#28641b", dark: "#a6da95" },
80
+ amber: { name: "Amber", light: "#775000", dark: "#edb96b" },
81
+ lavender: { name: "Lavender", light: "#445198", dark: "#b4befe" },
82
+ } as const;
83
+ export type AccentChoice = "theme" | keyof typeof ACCENTS;
84
+ export type GraphTheme = PaletteChoice | { theme: PaletteChoice; accent: AccentChoice };
85
+
86
+ export function themeId(theme: GraphTheme): PaletteChoice {
87
+ return typeof theme === "string" ? theme : theme.theme;
88
+ }
89
+
90
+ export function accentColor(theme: PaletteChoice, accent: AccentChoice): string {
91
+ return accent === "theme" ? THEMES[theme].colors.accent : ACCENTS[accent][THEMES[theme].scheme];
92
+ }
@@ -1,4 +1,8 @@
1
1
  /** Serializable tab and pane state shared by the browser and persistence API. */
2
+ import { isThemeChoice, THEMES } from "./themes";
3
+ import type { ThemeChoice } from "./themes";
4
+ import { DEFAULT_PREFERENCES, parsePreferences } from "./preferences";
5
+ import type { Preferences } from "./preferences";
2
6
 
3
7
  export interface WorkspaceTab {
4
8
  readonly id: string;
@@ -25,7 +29,8 @@ export interface WorkspaceLayout {
25
29
  readonly contextVisible: boolean;
26
30
  readonly treeWidth: number;
27
31
  readonly contextWidth: number;
28
- readonly theme: "system" | "light" | "dark";
32
+ readonly theme: ThemeChoice;
33
+ readonly preferences: Preferences;
29
34
  }
30
35
 
31
36
  const MAX_TABS = 40;
@@ -46,6 +51,7 @@ export function initialLayout(): WorkspaceLayout {
46
51
  treeWidth: 240,
47
52
  contextWidth: 240,
48
53
  theme: "system",
54
+ preferences: DEFAULT_PREFERENCES,
49
55
  };
50
56
  }
51
57
 
@@ -193,6 +199,16 @@ export function closePane(layout: WorkspaceLayout, paneId: string): WorkspaceLay
193
199
  return { ...layout, panes: [{ ...kept, tabs: [...kept.tabs, ...closed.tabs] }], activePane: kept.id };
194
200
  }
195
201
 
202
+ /** Move a tab before another tab, or to the end, without changing its identity or selection. */
203
+ export function reorderTab(layout: WorkspaceLayout, paneId: string, tabId: string, before: string | null): WorkspaceLayout {
204
+ const found = findTab(layout, paneId, tabId);
205
+ if (!found || before === tabId || before !== null && !found.pane.tabs.some((tab) => tab.id === before)) return layout;
206
+ const tabs = found.pane.tabs.filter((tab) => tab.id !== tabId);
207
+ tabs.splice(before === null ? tabs.length : tabs.findIndex((tab) => tab.id === before), 0, found.tab);
208
+ if (tabs.every((tab, index) => tab === found.pane.tabs[index])) return layout;
209
+ return { ...layout, panes: layout.panes.map((pane) => pane.id === paneId ? { ...pane, tabs } : pane) };
210
+ }
211
+
196
212
  /** Accept a tab drop only in another existing pane. */
197
213
  export function tabDropSource(layout: WorkspaceLayout, targetPane: string, tabId: string | null): string | null {
198
214
  if (!layout.panes.some((pane) => pane.id === targetPane)) return null;
@@ -230,14 +246,20 @@ function boundedId(value: unknown, prefix?: "pane" | "tab"): value is string {
230
246
  }
231
247
 
232
248
  export function parseWorkspaceLayout(value: unknown): WorkspaceLayout | null {
233
- if (!record(value) || !exactKeys(value, ["version", "panes", "activePane", "split", "ratio", "treeVisible", "contextVisible", "treeWidth", "contextWidth", "theme"])) return null;
249
+ if (!record(value) || !exactKeys(value, ["version", "panes", "activePane", "split", "ratio", "treeVisible", "contextVisible", "treeWidth", "contextWidth", "theme", ...(Object.hasOwn(value, "preferences") ? ["preferences"] : [])])) return null;
234
250
  if (value["version"] !== 1 || !Array.isArray(value["panes"]) || value["panes"].length < 1 || value["panes"].length > 2 || !boundedId(value["activePane"], "pane")) return null;
235
251
  if (value["split"] !== "right" && value["split"] !== "down") return null;
236
252
  if (typeof value["ratio"] !== "number" || !Number.isFinite(value["ratio"]) || value["ratio"] < 0.1 || value["ratio"] > 0.9) return null;
237
253
  if (typeof value["treeVisible"] !== "boolean" || typeof value["contextVisible"] !== "boolean") return null;
238
254
  if (typeof value["treeWidth"] !== "number" || !Number.isFinite(value["treeWidth"]) || value["treeWidth"] < 120 || value["treeWidth"] > 800) return null;
239
255
  if (typeof value["contextWidth"] !== "number" || !Number.isFinite(value["contextWidth"]) || value["contextWidth"] < 120 || value["contextWidth"] > 800) return null;
240
- if (value["theme"] !== "system" && value["theme"] !== "light" && value["theme"] !== "dark") return null;
256
+ if (!isThemeChoice(value["theme"])) return null;
257
+ let preferences = Object.hasOwn(value, "preferences") ? parsePreferences(value["preferences"]) : DEFAULT_PREFERENCES;
258
+ if (preferences === null) return null;
259
+ const previousPreferences = value["preferences"];
260
+ if (value["theme"] !== "system" && (!record(previousPreferences) || !Object.hasOwn(previousPreferences, "lightTheme"))) {
261
+ preferences = { ...preferences, [`${THEMES[value["theme"]].scheme}Theme`]: value["theme"] };
262
+ }
241
263
  const ids = new Set<string>();
242
264
  let count = 0;
243
265
  let graphs = 0;
@@ -266,5 +288,23 @@ export function parseWorkspaceLayout(value: unknown): WorkspaceLayout | null {
266
288
  panes.push({ id: rawPane["id"], tabs, activeTab: rawPane["activeTab"], ...(typeof lastDocumentTab === "string" ? { lastDocumentTab } : {}) });
267
289
  }
268
290
  if (count > MAX_TABS || graphs > 1 || !panes.some((pane) => pane.id === value["activePane"])) return null;
269
- return { version: 1, panes, activePane: value["activePane"], split: value["split"], ratio: value["ratio"], treeVisible: value["treeVisible"], contextVisible: value["contextVisible"], treeWidth: value["treeWidth"], contextWidth: value["contextWidth"], theme: value["theme"] };
291
+ return { version: 1, panes, activePane: value["activePane"], split: value["split"], ratio: value["ratio"], treeVisible: value["treeVisible"], contextVisible: value["contextVisible"], treeWidth: value["treeWidth"], contextWidth: value["contextWidth"], theme: value["theme"], preferences };
292
+ }
293
+
294
+ /** Startup choice resets navigation only; appearance and sidebar preferences survive. */
295
+ export function restoreWorkspace(layout: WorkspaceLayout): WorkspaceLayout {
296
+ if (layout.preferences.startup === "restore") return layout;
297
+ const fresh = initialLayout();
298
+ return { ...layout, panes: fresh.panes, activePane: fresh.activePane };
299
+ }
300
+
301
+ /** Opening in the background preserves the active pane and tab, including from Graph. */
302
+ export function openWithPreferences(layout: WorkspaceLayout, id: string | null, options: { newTab?: boolean; paneId?: string } = {}): WorkspaceLayout {
303
+ const next = openDocument(layout, id, options);
304
+ if (!options.newTab || layout.preferences.focusNewTabs) return next;
305
+ return { ...next, activePane: layout.activePane, panes: next.panes.map((pane) => {
306
+ const previous = layout.panes.find((item) => item.id === pane.id)!;
307
+ const { lastDocumentTab: _remembered, ...rest } = pane;
308
+ return { ...rest, activeTab: previous.activeTab, ...(previous.lastDocumentTab ? { lastDocumentTab: previous.lastDocumentTab } : {}) };
309
+ }) };
270
310
  }