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.
- package/README.md +14 -1
- package/package.json +2 -1
- package/src/web/client/dist/app.js +88 -124
- package/src/web/client/graph/ForceTuner.tsx +14 -98
- package/src/web/client/graph/Graph.tsx +11 -74
- package/src/web/client/graph/column.model.ts +3 -13
- package/src/web/client/graph/graph.model.ts +30 -62
- package/src/web/client/graph/groups.ts +9 -7
- package/src/web/client/graph/positions.ts +2 -2
- package/src/web/client/graph/renderer.dom.ts +2 -2
- package/src/web/client/graph/renderer.ts +5 -4
- package/src/web/client/graph/tuner.model.ts +6 -180
- package/src/web/client/main.tsx +1 -4
- package/src/web/client/note/Note.tsx +11 -1
- package/src/web/client/shell/Settings.tsx +71 -0
- package/src/web/client/shell/Shell.tsx +61 -24
- package/src/web/client/shell/StatusBar.tsx +1 -5
- package/src/web/client/shell/keys.model.ts +14 -8
- package/src/web/client/shell/shell.model.ts +1 -21
- package/src/web/client/shell/theme.model.ts +26 -96
- package/src/web/client/shell/theme.ts +20 -145
- package/src/web/client/shell/workspace.css.ts +27 -4
- package/src/web/server/backup.ts +59 -0
- package/src/web/server/routes.ts +12 -1
- package/src/web/shared/forces.ts +70 -0
- package/src/web/shared/layout.ts +4 -69
- package/src/web/shared/preferences.ts +76 -0
- package/src/web/shared/themes.ts +92 -0
- package/src/web/shared/workspace.ts +44 -4
package/src/web/server/routes.ts
CHANGED
|
@@ -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
|
+
|
package/src/web/shared/layout.ts
CHANGED
|
@@ -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:
|
|
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"]
|
|
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
|
}
|