pi-weave 0.3.0 → 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.
Files changed (50) hide show
  1. package/README.md +40 -16
  2. package/package.json +2 -2
  3. package/src/core/frontmatter.ts +3 -3
  4. package/src/core/graph/model.ts +2 -2
  5. package/src/core/graph/wikilinks.ts +1 -1
  6. package/src/core/types.ts +1 -1
  7. package/src/pi/viewer/tui/surface/detail.ts +1 -1
  8. package/src/web/client/dist/app.js +239 -243
  9. package/src/web/client/graph/ForceTuner.tsx +14 -98
  10. package/src/web/client/graph/Graph.tsx +29 -74
  11. package/src/web/client/graph/column.model.ts +22 -15
  12. package/src/web/client/graph/graph.model.ts +31 -63
  13. package/src/web/client/graph/groups.ts +9 -7
  14. package/src/web/client/graph/positions.ts +3 -3
  15. package/src/web/client/graph/renderer.dom.ts +2 -2
  16. package/src/web/client/graph/renderer.ts +14 -5
  17. package/src/web/client/graph/tuner.model.ts +6 -180
  18. package/src/web/client/main.tsx +1 -4
  19. package/src/web/client/note/Note.tsx +77 -92
  20. package/src/web/client/note/drafts.ts +172 -0
  21. package/src/web/client/note/note.model.ts +2 -34
  22. package/src/web/client/search/SearchPalette.tsx +9 -8
  23. package/src/web/client/shell/ContextRail.tsx +8 -9
  24. package/src/web/client/shell/Settings.tsx +71 -0
  25. package/src/web/client/shell/Shell.tsx +409 -255
  26. package/src/web/client/shell/StatusBar.tsx +2 -6
  27. package/src/web/client/shell/focus.model.ts +2 -2
  28. package/src/web/client/shell/keys.model.ts +40 -39
  29. package/src/web/client/shell/keys.ts +1 -0
  30. package/src/web/client/shell/shell.model.ts +32 -128
  31. package/src/web/client/shell/theme.model.ts +27 -97
  32. package/src/web/client/shell/theme.ts +32 -238
  33. package/src/web/client/shell/workspace.css.ts +140 -0
  34. package/src/web/client/tree/Tree.tsx +43 -14
  35. package/src/web/client/tree/tree.model.ts +19 -0
  36. package/src/web/client/workspace.ts +19 -1
  37. package/src/web/server/backup.ts +59 -0
  38. package/src/web/server/routes.ts +45 -3
  39. package/src/web/server/server.ts +5 -0
  40. package/src/web/server/workspace-state.ts +121 -0
  41. package/src/web/shared/layout.ts +5 -6
  42. package/src/web/shared/preferences.ts +76 -0
  43. package/src/web/shared/themes.ts +92 -0
  44. package/src/web/shared/workspace.ts +310 -0
  45. package/src/web/client/selection.storage.ts +0 -69
  46. package/src/web/client/shell/Columns.tsx +0 -139
  47. package/src/web/client/shell/Divider.tsx +0 -15
  48. package/src/web/client/shell/Header.tsx +0 -119
  49. package/src/web/client/shell/drag.model.ts +0 -35
  50. package/src/web/client/shell/layout.model.ts +0 -132
@@ -1,99 +1,15 @@
1
- /**
2
- * The hidden force tuner panel (docs/weave-workspace.md §15.7).
3
- *
4
- * Props in, JSX out. Every range, label, parse and clamp comes from
5
- * `tuner.model.ts`; what is left here is seven `<input type="range">` elements
6
- * and two buttons. Native range inputs rather than a slider component, for the
7
- * obvious reason: the platform has had this element since 2011.
8
- *
9
- * Rendered only when `Graph.tsx` was handed `tuner` — that is, only when
10
- * `?sliders=1` was on the URL. See `slidersFlag`.
11
- */
12
-
13
- import { useState } from "preact/hooks";
14
- import { FORCES, FORCE_DEFAULTS, setForces } from "../../shared/layout";
15
- import type { SliderSpec } from "./tuner.model";
16
- import { FORCE_SLIDERS, HAIRBALL, forcesSnippet, formatValue, isDefault, parseSlider, sliderValue } from "./tuner.model";
17
-
18
- export interface ForceTunerProps {
19
- /**
20
- * Called after every write to `FORCES`, so the column can drop its cached
21
- * layout and re-run the simulation. The panel never lays out anything
22
- * itself — it mutates the constants and says so.
23
- */
24
- onChange: () => void;
25
- /** Whether nodes are coloured by group hue (§15.8). */
26
- groupColors: boolean;
27
- /** Toggle that. Owned by the column, which persists it. */
28
- onGroupColors: (next: boolean) => void;
29
- }
30
-
31
- export function ForceTuner(props: ForceTunerProps) {
32
- // The panel's own re-render trigger. `FORCES` is a plain mutable object, so
33
- // preact cannot observe it; a counter is the whole subscription.
34
- const [, bump] = useState(0);
35
- const [copied, setCopied] = useState(false);
36
-
37
- const apply = (spec: SliderSpec, raw: string): void => {
38
- setForces({ [spec.key]: parseSlider(spec, raw) });
39
- setCopied(false);
40
- bump((n) => n + 1);
41
- props.onChange();
42
- };
43
-
44
- const applyAll = (next: Partial<typeof FORCES>): void => {
45
- setForces(next);
46
- setCopied(false);
47
- bump((n) => n + 1);
48
- props.onChange();
49
- };
50
-
51
- return (
52
- <div class="weave-tuner">
53
- <p class="weave-tuner-title">graph forces · ?sliders=1</p>
54
- <label class="weave-tuner-toggle" title="hue per containment group, shade per kind">
55
- <input type="checkbox" checked={props.groupColors} onChange={(event) => props.onGroupColors((event.currentTarget as HTMLInputElement).checked)} />
56
- <span>colour by group</span>
57
- </label>
58
- {FORCE_SLIDERS.map((spec) => (
59
- <label class="weave-tuner-row" key={spec.key} title={spec.hint}>
60
- <span class="weave-tuner-label">
61
- {spec.label}
62
- <b>{formatValue(FORCES[spec.key])}</b>
63
- </span>
64
- <input
65
- type="range"
66
- min={spec.min}
67
- max={spec.max}
68
- step={spec.step}
69
- value={sliderValue(FORCES, spec)}
70
- onInput={(event) => apply(spec, (event.currentTarget as HTMLInputElement).value)}
71
- />
72
- </label>
73
- ))}
74
- <div class="weave-tuner-actions">
75
- <button type="button" class="weave-chip" disabled={isDefault(FORCES)} onClick={() => applyAll(FORCE_DEFAULTS)}>
76
- reset
77
- </button>
78
- <button type="button" class="weave-chip" title="the pre-tuner constants, for comparison — see HAIRBALL" onClick={() => applyAll(HAIRBALL)}>
79
- before
80
- </button>
81
- <button
82
- type="button"
83
- class="weave-chip"
84
- onClick={() => {
85
- // Best-effort: `navigator.clipboard` is absent over plain HTTP in
86
- // some browsers, and the snippet is on screen in the panel anyway.
87
- void navigator.clipboard?.writeText(forcesSnippet(FORCES)).then(
88
- () => setCopied(true),
89
- () => setCopied(false),
90
- );
91
- }}
92
- >
93
- {copied ? "copied ✓" : "copy values"}
94
- </button>
95
- </div>
96
- <pre class="weave-tuner-snippet">{forcesSnippet(FORCES)}</pre>
97
- </div>
98
- );
1
+ /** Native graph layout sliders, available under Settings → Interface. */
2
+ import { FORCE_DEFAULTS, type ForceConstants } from "../../shared/layout";
3
+ import { FORCE_SLIDERS, formatValue, isDefault, parseSlider, sliderValue } from "./tuner.model";
4
+
5
+ export function ForceTuner(props: { forces: ForceConstants; onChange: (forces: ForceConstants) => void }) {
6
+ return <div class="weave-settings-forces">
7
+ {FORCE_SLIDERS.map((spec) => <label class="weave-setting-row" key={spec.key}>
8
+ <span><strong>{spec.label}</strong><small>{spec.hint}</small></span>
9
+ <span class="weave-setting-range"><input aria-label={spec.label} type="range" min={spec.min} max={spec.max} step={spec.step}
10
+ value={sliderValue(props.forces, spec)} onInput={(event) => props.onChange({ ...props.forces, [spec.key]: parseSlider(spec, event.currentTarget.value) })} />
11
+ <output>{formatValue(props.forces[spec.key])}</output></span>
12
+ </label>)}
13
+ <button type="button" disabled={isDefault(props.forces)} onClick={() => props.onChange({ ...FORCE_DEFAULTS })}>Reset graph layout</button>
14
+ </div>;
99
15
  }
@@ -35,30 +35,30 @@ import type { GraphViewState } from "./column.model";
35
35
  import {
36
36
  FIT_HINT,
37
37
  FIT_LABEL,
38
- FORCES_HINT,
39
- FORCES_LABEL,
40
38
  LEGEND,
41
39
  effectiveView,
42
40
  graphClick,
43
41
  graphColumnModel,
44
42
  graphCountLabel,
43
+ graphPreview,
45
44
  hoverHighlight,
46
45
  } from "./column.model";
47
46
  import type { PositionStorage } from "./positions";
48
47
  import type { GraphRenderer, RendererFactory } from "./renderer";
49
48
  import { schemeOf } from "./scheme";
50
49
  import type { SchemeHost } from "./scheme";
51
- import type { ColorScheme } from "./graph.model";
50
+ import type { GraphTheme } from "../../shared/themes";
51
+ import { setForces, type ForceConstants } from "../../shared/layout";
52
52
  import { createGraphSimulation } from "./dynamics";
53
53
  import type { GraphSimulation } from "./dynamics";
54
- import { ForceTuner } from "./ForceTuner";
55
- import { POSITIONS_STORAGE_KEY } from "./positions";
56
- import { loadGroupColors, saveGroupColors } from "./tuner.model";
54
+ import { Icon } from "../tree/Tree";
57
55
 
58
56
  export interface GraphProps {
59
57
  graph: GraphPayload | null;
60
58
  selectedId: string | null;
61
59
  onSelect: (id: string | null) => void;
60
+ previewId: string | null;
61
+ onOpen: (id: string) => void;
62
62
  /** Injected: `createSigmaRenderer` in the browser, a fake in a test. */
63
63
  renderer: RendererFactory;
64
64
  /** Injected: `localStorage`. See `positions.ts` for why it is a port. */
@@ -72,7 +72,9 @@ export interface GraphProps {
72
72
  * palette change `renderer.ts` calls honest — because a WebGL palette is
73
73
  * fixed at construction.
74
74
  */
75
- scheme: ColorScheme | null;
75
+ scheme: GraphTheme | null;
76
+ forces: ForceConstants;
77
+ groupColors: boolean;
76
78
  /**
77
79
  * Whether the boot graph fetch failed (`state.ts`'s `graphFailed`). The
78
80
  * empty column's sentence switches from "Loading…" to a named recovery:
@@ -90,14 +92,7 @@ export interface GraphProps {
90
92
  * keeps the ownership where it is and costs one line at each end.
91
93
  */
92
94
  fit: { current: (() => void) | null };
93
- /**
94
- * Show the hidden force tuner (`?sliders=1`, docs/weave-workspace.md §15.7).
95
- *
96
- * A prop rather than a `location.search` read here, for the same reason
97
- * `cwd` and `platform` are props: the decision is `slidersFlag`'s, and it is
98
- * testable where a `location` read is not.
99
- */
100
- tuner?: boolean;
95
+
101
96
  }
102
97
 
103
98
  export function Graph(props: GraphProps) {
@@ -130,30 +125,8 @@ export function Graph(props: GraphProps) {
130
125
  // `null` means "the user has not touched the expansion" — not "nothing is
131
126
  // expanded". `effectiveView` resolves the difference; see its doc comment.
132
127
  const [state, setState] = useState<GraphViewState | null>(null);
133
- /**
134
- * Bumped by the tuner after each write to `FORCES`. It enters the model memo
135
- * below purely as a cache-buster: the graph's *shape* has not changed, so
136
- * nothing else would recompute, and the whole point is that the same shape
137
- * now lays out differently.
138
- */
139
- const [forceRev, setForceRev] = useState(0);
140
- /**
141
- * Colour nodes by group hue (§15.8). Persisted, because it is a taste the
142
- * user holds across sessions rather than a per-visit mode, and read through
143
- * the same `PositionStorage` port the layout cache uses so the column still
144
- * names no browser global.
145
- */
146
- const [groupColors, setGroupColors] = useState(() => loadGroupColors(props.storage));
147
- /**
148
- * Whether the tuner panel is open.
149
- *
150
- * Seeded from the `?sliders=1` flag, then owned by the `[sliders]` chip — so
151
- * the URL is still the way to arrive with it open, and the button is the way
152
- * to get at it once you are here. The chip is always present: the panel was
153
- * unreachable without knowing a query string, which is the right gate for a
154
- * half-built instrument and the wrong one for a finished control.
155
- */
156
- const [tunerOpen, setTunerOpen] = useState(props.tuner === true);
128
+ const forceKey = useMemo(() => { setForces(props.forces); return JSON.stringify(props.forces); }, [props.forces]);
129
+ const groupColors = props.groupColors;
157
130
 
158
131
  // The shell's decision wins; `schemeOf` stays for a host-driven default.
159
132
  const scheme = props.scheme ?? schemeOf(props.host);
@@ -168,8 +141,9 @@ export function Graph(props: GraphProps) {
168
141
  // comparing set contents, the memo makes comparison unnecessary.
169
142
  const model = useMemo(
170
143
  () => graphColumnModel(props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed, groupColors),
171
- [props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed, forceRev, groupColors],
144
+ [props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed, forceKey, groupColors],
172
145
  );
146
+ const preview = graphPreview(props.graph, props.previewId);
173
147
 
174
148
  // Read by the mount-time `onSelect`, which outlives this render.
175
149
  const live = useRef({ view, model, onSelect: props.onSelect, selectedId: props.selectedId });
@@ -233,7 +207,7 @@ export function Graph(props: GraphProps) {
233
207
 
234
208
  useEffect(() => {
235
209
  renderer.current?.setGraph(model.graph);
236
- }, [model.key, forceRev, groupColors]);
210
+ }, [model.key, forceKey, groupColors]);
237
211
 
238
212
  // Live layout, in two effects so pause/resume and re-layout are independent.
239
213
  //
@@ -249,7 +223,7 @@ export function Graph(props: GraphProps) {
249
223
  return () => {
250
224
  dynamics.current = null;
251
225
  };
252
- }, [model.key, armClock, forceRev]);
226
+ }, [model.key, armClock, forceKey]);
253
227
 
254
228
  // Effect 2 owns the clock's unmount cleanup: the engine's own lifecycle is
255
229
  // effect 1's, and the step self-terminates whenever the engine settles, so
@@ -276,42 +250,23 @@ export function Graph(props: GraphProps) {
276
250
  {/* `tabIndex={-1}` is the `⌘3` focus target — see `Note.tsx`'s matching
277
251
  comment. The tree's target is the rows `<ul>`, which has its own. */}
278
252
  <div class="weave-graph-canvas" ref={canvas} role="img" aria-label="Knowledge graph" tabIndex={-1} />
279
- {tunerOpen ? (
280
- <ForceTuner
281
- groupColors={groupColors}
282
- onGroupColors={(next) => {
283
- saveGroupColors(props.storage, next);
284
- setGroupColors(next);
285
- }}
286
- onChange={() => {
287
- // Poison the stored layout rather than reading it: the cache is
288
- // keyed by graph *shape*, which a force change does not touch, so
289
- // a hit would hand back the arrangement of the previous constants
290
- // and the sliders would appear to do nothing. An unparseable entry
291
- // is a miss by `deserializePositions`' contract, and the two-method
292
- // `PositionStorage` port has no `removeItem` to call instead.
293
- try {
294
- props.storage.setItem(POSITIONS_STORAGE_KEY, "");
295
- } catch {
296
- // A storage that refuses writes still lays out; see `savePositions`.
297
- }
298
- setForceRev((n) => n + 1);
299
- }}
300
- />
301
- ) : null}
253
+ {preview === null ? null : (
254
+ <aside class="weave-graph-preview" aria-label="Selected graph node">
255
+ <div class="weave-graph-preview-info">
256
+ <span class="weave-kind" aria-hidden="true"><Icon name={preview.icon} class="weave-icon" /></span>
257
+ <div class="weave-graph-preview-copy">
258
+ <span class="weave-graph-preview-kind">{preview.kind}</span>
259
+ <strong class="weave-graph-preview-title" title={preview.title}>{preview.title}</strong>
260
+ </div>
261
+ </div>
262
+ <button type="button" class="weave-graph-preview-close" aria-label="Clear graph selection" onClick={() => props.onSelect(null)}>×</button>
263
+ <button type="button" class="weave-chip" onClick={() => props.onOpen(preview.id)}>Open in new tab</button>
264
+ </aside>
265
+ )}
302
266
  <div class="weave-graph-controls">
303
267
  <button type="button" class="weave-chip" title={FIT_HINT} onClick={() => renderer.current?.fit()}>
304
268
  {FIT_LABEL}
305
269
  </button>
306
- <button
307
- type="button"
308
- class={tunerOpen ? "weave-chip weave-chip-on" : "weave-chip"}
309
- title={FORCES_HINT}
310
- aria-pressed={tunerOpen}
311
- onClick={() => setTunerOpen(!tunerOpen)}
312
- >
313
- {FORCES_LABEL}
314
- </button>
315
270
  <span class="weave-graph-legend">
316
271
  <span class="weave-legend-on">◉ {LEGEND.selected}</span>
317
272
  <span class="weave-legend-near">● {LEGEND.neighborhood}</span>
@@ -1,3 +1,4 @@
1
+ import type { GraphTheme } from "../../shared/themes";
1
2
  /**
2
3
  * The graph column's own state: what is expanded, what is highlighted, and
3
4
  * what the control strip says (weave-workspace §1.2, §1.3, §7.4, §10, P3).
@@ -43,8 +44,9 @@ import type { Point } from "../../shared/layout";
43
44
  import type { ClusterAggregate, ClusterInfo, ViewGraphModel } from "../../shared/view";
44
45
  import { clusterAggregate, focusNeighborhood } from "../../shared/view";
45
46
  import type { GraphPayload, WireGraphEdge } from "../../shared/wire";
46
- import { viewModel } from "../tree/tree.model";
47
- import type { ColorScheme, RenderGraph } from "./graph.model";
47
+ import { kindIcon, viewModel } from "../tree/tree.model";
48
+ import type { IconName } from "../shell/icons.model";
49
+ import type { RenderGraph } from "./graph.model";
48
50
  import { EMPTY_RENDER_GRAPH, renderGraph } from "./graph.model";
49
51
  import { groupNodeColors } from "./groups";
50
52
  import type { PositionStorage } from "./positions";
@@ -82,6 +84,22 @@ export interface GraphViewState {
82
84
  readonly expanded: ReadonlySet<string>;
83
85
  }
84
86
 
87
+ export interface GraphPreview {
88
+ readonly id: string;
89
+ readonly title: string;
90
+ readonly kind: string;
91
+ readonly icon: IconName;
92
+ }
93
+
94
+ /** The original graph node selected for preview, including collapsed folders. */
95
+ export function graphPreview(payload: GraphPayload | null, id: string | null): GraphPreview | null {
96
+ if (payload === null || id === null) return null;
97
+ const node = payload.model.nodes.find((candidate) => candidate.id === id);
98
+ if (node === undefined) return null;
99
+ const kind = id.startsWith("artifact:") ? "HTML artifact" : id.startsWith("vfolder:") ? "Folder" : node.kind;
100
+ return { id, title: node.label, kind, icon: kindIcon(node.kind) };
101
+ }
102
+
85
103
  /**
86
104
  * The state a freshly loaded graph opens in: **fully expanded**.
87
105
  *
@@ -169,7 +187,7 @@ export function highlightFor(edges: readonly WireGraphEdge[], selectedId: string
169
187
  * The highlight while the pointer is over a node — **hover wins, selection is
170
188
  * what it falls back to**.
171
189
  *
172
- * Obsidian's gesture, and the reason it is the same function as
190
+ * The reference interaction, and the reason it is the same function as
173
191
  * {@link highlightFor} rather than a second visual language: hovering asks
174
192
  * exactly the question clicking asks ("what is one hop from here?"), so the
175
193
  * two must produce the same set and reach the same reducers. Anything else
@@ -196,17 +214,6 @@ export function hoverHighlight(
196
214
  export const FIT_LABEL = "fit";
197
215
  export const FIT_HINT = "frame the whole graph";
198
216
 
199
- /**
200
- * The `[sliders]` control, which shows and hides the tuner panel (§15.7).
201
- *
202
- * Labelled for what the button *opens* rather than for what the panel edits:
203
- * "forces" names the physics, which is the one thing a reader who has not read
204
- * `layout.ts` has no word for. The constants keep the `FORCES_` prefix because
205
- * the module they drive is still the force layout.
206
- */
207
- export const FORCES_LABEL = "sliders";
208
- export const FORCES_HINT = "tune the layout physics and colours";
209
-
210
217
  /**
211
218
  * The legend under the canvas, from the §1.2 mock:
212
219
  * `◉ selected ● neighborhood`.
@@ -307,7 +314,7 @@ export function graphColumnModel(
307
314
  selectedId: string | null,
308
315
  state: GraphViewState,
309
316
  storage: PositionStorage,
310
- scheme: ColorScheme,
317
+ scheme: GraphTheme,
311
318
  // Optional so every existing caller and test keeps its shape; only the
312
319
  // shell's boot-failure signal has a reason to pass it.
313
320
  bootFailed = false,
@@ -27,21 +27,12 @@
27
27
  * graphology — so it compiles under the root `tsconfig.json` (which has no
28
28
  * `DOM` lib) whenever a test imports it.
29
29
  *
30
- * ## Why the palette is duplicated from the stylesheet
31
- *
32
- * WebGL cannot read a CSS custom property. `sigma` needs a concrete `#rrggbb`
33
- * per node, and `getComputedStyle` is both a DOM global and an untestable
34
- * read. So {@link GRAPH_PALETTE} restates the six theme colours the graph
35
- * uses, and {@link KIND_SLOT} maps a node kind onto the *same slot vocabulary*
36
- * the TUI already uses (`src/pi/viewer/tui/theme.ts`'s `kindStyle`) rather
37
- * than inventing a second one.
38
- *
39
- * A copy drifts, so drift is a failing test: `tests/web/client-graph.test.ts`
40
- * asserts every hex in {@link GRAPH_PALETTE} literally appears in
41
- * `shell/theme.ts`'s `THEME_CSS`. Change a swatch in the stylesheet and the
42
- * graph's copy goes red on the same commit.
30
+ * CSS and WebGL use the shared theme table, so a candidate changes the
31
+ * canvas and workspace together without copying hex values.
43
32
  */
44
33
 
34
+ import { THEMES, accentColor, themeId } from "../../shared/themes";
35
+ import type { PaletteChoice, GraphTheme } from "../../shared/themes";
45
36
  import { COLLIDE_RADIUS, MAX_NODE_SIZE, MIN_NODE_SIZE, nodeSize } from "../../shared/layout";
46
37
  import type { Point } from "../../shared/layout";
47
38
  /* Re-exported for the renderer and the tests, under the render model's name.
@@ -65,40 +56,17 @@ export type ColorSlot = "accent" | "success" | "warning" | "dim" | "text" | "mut
65
56
  /** Dark or light. Chosen by the shell from `prefers-color-scheme`, never read here. */
66
57
  export type ColorScheme = "dark" | "light";
67
58
 
68
- /**
69
- * Slot → hex, per scheme.
70
- *
71
- * Every value is copied from `shell/theme.ts`'s `THEME_CSS`:
72
- * `accent`→`--weave-accent`, `success`→`--weave-ok`, `warning`→`--weave-warn`,
73
- * `dim`→`--weave-dim`, `text`→`--weave-fg`, `muted`→`--weave-faint`,
74
- * `line`→`--weave-line-strong`, `ground`→`--weave-bg` (the canvas the WebGL
75
- * floats on, which no slot previously named because nothing painted it — the
76
- * recession blend below needs it as a *value*, not as a contrast judgement).
77
- * The dark block is `:root`; the light block is the
78
- * `prefers-color-scheme: light` override.
79
- */
80
- export const GRAPH_PALETTE: Readonly<Record<ColorScheme, Readonly<Record<ColorSlot, string>>>> = {
81
- dark: {
82
- accent: "#c6a0f6",
83
- success: "#a6da95",
84
- warning: "#eed49f",
85
- dim: "#a5adcb",
86
- text: "#cad3f5",
87
- muted: "#939ab7",
88
- line: "#494d64",
89
- ground: "#24273a",
90
- },
91
- light: {
92
- accent: "#7113ec",
93
- success: "#28641b",
94
- warning: "#7c4f10",
95
- dim: "#56586a",
96
- text: "#4c4f69",
97
- muted: "#606274",
98
- line: "#bcc0cc",
99
- ground: "#eff1f5",
100
- },
101
- };
59
+ /** Graph slots come from the same colors as the stylesheet. */
60
+ export const GRAPH_PALETTE = Object.fromEntries(Object.entries(THEMES).map(([id, { colors }]) => [id, {
61
+ accent: colors.accent, success: colors.ok, warning: colors.warn, dim: colors.dim,
62
+ text: colors.fg, muted: colors.faint, line: colors["line-strong"], ground: colors.bg,
63
+ }])) as Readonly<Record<PaletteChoice, Readonly<Record<ColorSlot, string>>>>;
64
+
65
+ /** Resolve independent accents for every WebGL drawing path. */
66
+ export function graphPalette(theme: GraphTheme): Readonly<Record<ColorSlot, string>> {
67
+ const palette = GRAPH_PALETTE[themeId(theme)];
68
+ return typeof theme === "string" ? palette : { ...palette, accent: accentColor(theme.theme, theme.accent) };
69
+ }
102
70
 
103
71
  /**
104
72
  * Node kind → colour slot.
@@ -156,13 +124,13 @@ export const EDGE_SLOT: Readonly<Record<WireEdgeKind, ColorSlot>> = {
156
124
  };
157
125
 
158
126
  /** The colour a node kind is drawn in. */
159
- export function kindColor(kind: WireNodeKind, scheme: ColorScheme): string {
160
- return GRAPH_PALETTE[scheme][KIND_SLOT[kind]];
127
+ export function kindColor(kind: WireNodeKind, scheme: GraphTheme): string {
128
+ return graphPalette(scheme)[KIND_SLOT[kind]];
161
129
  }
162
130
 
163
131
  /** The colour an edge kind is drawn in. */
164
- export function edgeColor(kind: WireEdgeKind, scheme: ColorScheme): string {
165
- return GRAPH_PALETTE[scheme][EDGE_SLOT[kind]];
132
+ export function edgeColor(kind: WireEdgeKind, scheme: GraphTheme): string {
133
+ return graphPalette(scheme)[EDGE_SLOT[kind]];
166
134
  }
167
135
 
168
136
  /**
@@ -178,7 +146,7 @@ export function edgeColor(kind: WireEdgeKind, scheme: ColorScheme): string {
178
146
  * exact slot colour — the accent is the only chroma voice the sheet has, and
179
147
  * a wikilink is what the eye is here to find.
180
148
  */
181
- export function edgeDrawColor(kind: WireEdgeKind, scheme: ColorScheme): string {
149
+ export function edgeDrawColor(kind: WireEdgeKind, scheme: GraphTheme): string {
182
150
  return isStructuralEdge(kind) ? recessColor(edgeColor(kind, scheme), scheme, EDGE_RECESS_STRENGTH) : edgeColor(kind, scheme);
183
151
  }
184
152
 
@@ -253,8 +221,8 @@ export function blendHex(color: string, toward: string, t: number): string {
253
221
  * *palette* fact, and the palette-mirror test already ties this module's hexes
254
222
  * to the stylesheet's.
255
223
  */
256
- export function recessColor(color: string, scheme: ColorScheme, t: number = RECESS_STRENGTH): string {
257
- return blendHex(color, GRAPH_PALETTE[scheme].ground, t);
224
+ export function recessColor(color: string, scheme: GraphTheme, t: number = RECESS_STRENGTH): string {
225
+ return blendHex(color, graphPalette(scheme).ground, t);
258
226
  }
259
227
 
260
228
  // --- sizes ------------------------------------------------------------------------
@@ -443,7 +411,7 @@ export const HOVER_LABEL_SHADOW = 6;
443
411
  /**
444
412
  * Sigma's `defaultDrawNodeHover`, as a scheme-bound pure function (§7.4).
445
413
  *
446
- * Obsidian's gesture, which is the reference: the hovered node's name floats
414
+ * The reference gesture: the hovered node's name floats
447
415
  * **centred underneath it**, in the theme's own text colour, with no
448
416
  * container. The alternative shipped by sigma is a white rounded box with the
449
417
  * text to the node's *right* — wrong colour in both schemes, and wrong place
@@ -460,13 +428,13 @@ export const HOVER_LABEL_SHADOW = 6;
460
428
  * re-align them all.
461
429
  */
462
430
  export function hoverLabelPainter(
463
- scheme: ColorScheme,
431
+ scheme: GraphTheme,
464
432
  ): (context: HoverLabelContext, data: HoverLabelNode, settings: HoverLabelSettings) => void {
465
433
  return (context, data, settings) => {
466
434
  // `nodeReducer` blanks the label of everything outside the highlight, and
467
435
  // a node with no name has nothing to float.
468
436
  if (data.label === null || data.label === "") return;
469
- const palette = GRAPH_PALETTE[scheme];
437
+ const palette = graphPalette(scheme);
470
438
  context.save();
471
439
  context.font = `${settings.labelSize}px ${settings.labelFont}`;
472
440
  context.textAlign = "center";
@@ -629,7 +597,7 @@ export function renderGraph(
629
597
  nodes: readonly WireGraphNode[],
630
598
  edges: readonly WireGraphEdge[],
631
599
  positions: ReadonlyMap<string, Point>,
632
- scheme: ColorScheme,
600
+ scheme: GraphTheme,
633
601
  /**
634
602
  * Per-node fills that override the kind palette — `groups.ts`'s
635
603
  * group-hue assignment, or absent for the kind-only colouring.
@@ -747,7 +715,7 @@ export function nodeReducer(
747
715
  * about the clock.
748
716
  */
749
717
  progress: number = 1,
750
- ): (id: string, data: RenderNode, scheme: ColorScheme) => NodeDisplayOverride {
718
+ ): (id: string, data: RenderNode, scheme: GraphTheme) => NodeDisplayOverride {
751
719
  return (id, data, scheme) => {
752
720
  // `progress` at zero is indistinguishable from no highlight, and saying so
753
721
  // here is what lets the fade end by *returning* to the unhighlighted graph
@@ -888,7 +856,7 @@ export function edgeReducer(
888
856
  selectedId?: string | null,
889
857
  /** The fade's progress — see {@link nodeReducer}'s. */
890
858
  progress: number = 1,
891
- ): (key: string, data: RenderEdge, scheme: ColorScheme) => EdgeDisplayOverride {
859
+ ): (key: string, data: RenderEdge, scheme: GraphTheme) => EdgeDisplayOverride {
892
860
  return (_key, data, scheme) => {
893
861
  if (highlight === null || progress <= 0) return {};
894
862
  if (!highlight.has(data.source) || !highlight.has(data.target)) {
@@ -905,7 +873,7 @@ export function edgeReducer(
905
873
  size: data.size * growth(EDGE_PRESENCE * 1.35, progress),
906
874
  // Blended rather than switched, so the link arrives *as* the accent
907
875
  // instead of flicking to it a frame before the rest of the fade.
908
- color: blendHex(data.color, GRAPH_PALETTE[scheme].accent, progress),
876
+ color: blendHex(data.color, graphPalette(scheme).accent, progress),
909
877
  };
910
878
  }
911
879
  // Between two neighbours, but not touching the selection: real context,
@@ -975,8 +943,8 @@ export const LABEL_GRID_CELL_SIZE = COLLIDE_RADIUS * 4;
975
943
  export const LABEL_DENSITY = 1;
976
944
 
977
945
  /** The settings for a scheme. */
978
- export function graphSettings(scheme: ColorScheme): GraphSettings {
979
- const palette = GRAPH_PALETTE[scheme];
946
+ export function graphSettings(scheme: GraphTheme): GraphSettings {
947
+ const palette = graphPalette(scheme);
980
948
  return {
981
949
  // §7.4. A pan over a few thousand edges is the one interaction that drops
982
950
  // frames, and the edges are the part nobody is reading mid-gesture.
@@ -56,8 +56,10 @@
56
56
  */
57
57
 
58
58
  import type { WireEdgeKind, WireGraphEdge, WireGraphNode } from "../../shared/wire";
59
+ import type { GraphTheme } from "../../shared/themes";
60
+ import { THEMES, themeId } from "../../shared/themes";
59
61
  import type { ColorScheme } from "./graph.model";
60
- import { GRAPH_PALETTE, blendHex } from "./graph.model";
62
+ import { graphPalette, blendHex } from "./graph.model";
61
63
 
62
64
  function isContainment(kind: WireEdgeKind): boolean {
63
65
  return kind === "contains" || kind === "anchored-at";
@@ -219,8 +221,8 @@ export const HUE_STRIDE = 3;
219
221
  * is honest: at eleven simultaneous groups the colour channel is saturated and
220
222
  * position is doing the work anyway.
221
223
  */
222
- export function groupColors(keys: ReadonlyMap<string, string>, scheme: ColorScheme): Map<string, string> {
223
- const ring = GROUP_HUES[scheme];
224
+ export function groupColors(keys: ReadonlyMap<string, string>, scheme: GraphTheme): Map<string, string> {
225
+ const ring = GROUP_HUES[THEMES[themeId(scheme)].scheme];
224
226
  const sizes = groupSizes(keys);
225
227
  const ordered = [...sizes.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
226
228
  const out = new Map<string, string>();
@@ -249,9 +251,9 @@ export const DEPTH_SHADE = 0.11;
249
251
  export const MAX_SHADE = 0.34;
250
252
 
251
253
  /** A member's fill: its group's one hue, stepped back by its depth. */
252
- export function shadeFor(hue: string, depth: number, scheme: ColorScheme): string {
254
+ export function shadeFor(hue: string, depth: number, scheme: GraphTheme): string {
253
255
  const steps = Number.isFinite(depth) && depth > 0 ? depth : 0;
254
- return blendHex(hue, GRAPH_PALETTE[scheme].ground, Math.min(MAX_SHADE, steps * DEPTH_SHADE));
256
+ return blendHex(hue, graphPalette(scheme).ground, Math.min(MAX_SHADE, steps * DEPTH_SHADE));
255
257
  }
256
258
 
257
259
  // --- bridges between groups ----------------------------------------------------------
@@ -279,7 +281,7 @@ export const MAX_BRIDGE = 0.55;
279
281
  * promiscuously-linked hub does not end up painted entirely in other people's
280
282
  * colours.
281
283
  */
282
- export function bridgeBlend(own: string, foreign: readonly string[], scheme: ColorScheme): string {
284
+ export function bridgeBlend(own: string, foreign: readonly string[], scheme: GraphTheme): string {
283
285
  if (foreign.length === 0) return own;
284
286
  let out = own;
285
287
  let spent = 0;
@@ -307,7 +309,7 @@ export function bridgeBlend(own: string, foreign: readonly string[], scheme: Col
307
309
  export function groupNodeColors(
308
310
  nodes: readonly WireGraphNode[],
309
311
  edges: readonly WireGraphEdge[],
310
- scheme: ColorScheme,
312
+ scheme: GraphTheme,
311
313
  ): Map<string, string> {
312
314
  const places = groupPlaces(nodes, edges);
313
315
  const keys = new Map<string, string>();
@@ -34,7 +34,7 @@
34
34
  * the root `tsconfig.json` project compiles the tests.
35
35
  */
36
36
 
37
- import { computeLayout } from "../../shared/layout";
37
+ import { computeLayout, FORCES } from "../../shared/layout";
38
38
  import type { LayoutOptions, Point } from "../../shared/layout";
39
39
  import type { WireGraphEdge, WireGraphNode } from "../../shared/wire";
40
40
 
@@ -164,7 +164,7 @@ export function graphShapeKey(nodes: readonly WireGraphNode[], edges: readonly W
164
164
  sum = (sum + h) >>> 0;
165
165
  }
166
166
  const parts = [nodes.length, edges.length, xor >>> 0, sum].map((n) => n.toString(16));
167
- return parts.join("-");
167
+ return [...parts, hashId(JSON.stringify(FORCES)).toString(16)].join("-");
168
168
  }
169
169
 
170
170
  /**
@@ -175,7 +175,7 @@ export function graphShapeKey(nodes: readonly WireGraphNode[], edges: readonly W
175
175
  * watcher fires, the graph refetches, and a picture the user was reading
176
176
  * rearranges itself for no visible reason. Never re-running leaves new nodes
177
177
  * unplaced and therefore undrawn (`renderGraph` drops a node with no
178
- * position), so a note created in Obsidian would never appear.
178
+ * position), so a note created outside this application would never appear.
179
179
  *
180
180
  * The shape key is exactly the line between those: it changes iff a node or an
181
181
  * edge was added or removed, which is precisely when the existing positions
@@ -1,6 +1,6 @@
1
1
  /** Browser-only Sigma adapter; the lifecycle remains testable in renderer.ts. */
2
2
  import Sigma from "sigma";
3
- import type { ColorScheme } from "./graph.model";
3
+ import type { GraphTheme } from "../../shared/themes";
4
4
  import type { GraphRenderer, RendererFactory, SigmaLike } from "./renderer";
5
5
  import { rafClock, sigmaRenderer } from "./renderer";
6
6
 
@@ -8,7 +8,7 @@ import { rafClock, sigmaRenderer } from "./renderer";
8
8
  type SigmaContainer = ConstructorParameters<typeof Sigma>[1];
9
9
 
10
10
  /** The browser's renderer factory. Passed to the graph column by the shell. */
11
- export const createSigmaRenderer: RendererFactory = (scheme: ColorScheme): GraphRenderer =>
11
+ export const createSigmaRenderer: RendererFactory = (scheme: GraphTheme): GraphRenderer =>
12
12
  sigmaRenderer(
13
13
  (graph, container, settings) =>
14
14
  new Sigma(graph, container as unknown as SigmaContainer, settings) as unknown as SigmaLike,