pi-weave 0.3.1 → 0.3.2

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/")) {
@@ -41,14 +41,13 @@ const ALPHA_MIN = 0.001;
41
41
  * Mutable because picking these numbers is an act of taste, not of
42
42
  * derivation: the difference between "one hairball" and "legible groups" is a
43
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
44
+ * easier to *see* than to reason about. The settings sliders (see
45
+ * `client/graph/tuner.model.ts` and docs/weave-workspace.md §15.7) update this record
46
+ * and re-run the layout live, so a human can find the values by eye and they then
47
47
  * get frozen back into {@link FORCE_DEFAULTS}.
48
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.
49
+ * `computeLayout(model, { seed })` stays deterministic for a given set of forces.
50
+ * Settings are the production writer of this record.
52
51
  *
53
52
  * ponytail: module-level mutable state, fine at one graph per page — thread it
54
53
  * as a `LayoutOptions` field if a second concurrent consumer ever appears.
@@ -0,0 +1,76 @@
1
+ /** Validated preferences, stored alongside the workspace's existing snapshots. */
2
+ import { FORCE_DEFAULTS } from "./layout";
3
+ import type { ForceConstants } from "./layout";
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
  }