pi-weave 0.1.8 → 0.1.10

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 (52) hide show
  1. package/README.md +9 -3
  2. package/package.json +2 -2
  3. package/skills/weave-notepad/SKILL.md +13 -4
  4. package/src/core/concurrency.ts +36 -0
  5. package/src/core/frontmatter.ts +53 -0
  6. package/src/core/graph/build.ts +60 -1
  7. package/src/core/graph/model.ts +15 -0
  8. package/src/core/graph/wikilinks.ts +5 -1
  9. package/src/core/index.ts +2 -0
  10. package/src/core/paths.ts +7 -0
  11. package/src/core/sessions.ts +929 -0
  12. package/src/core/summaries.ts +1 -19
  13. package/src/core/vault.ts +264 -16
  14. package/src/core/workspace.ts +3 -3
  15. package/src/pi/index.ts +144 -37
  16. package/src/pi/sessionScan.ts +104 -0
  17. package/src/pi/summarize.ts +24 -4
  18. package/src/pi/tools/noteTool.ts +13 -4
  19. package/src/pi/viewer/tui/branding.ts +8 -7
  20. package/src/pi/viewer/tui/explorer.ts +1 -1
  21. package/src/web/client/dist/app.js +272 -113
  22. package/src/web/client/graph/Graph.tsx +112 -22
  23. package/src/web/client/graph/column.model.ts +52 -94
  24. package/src/web/client/graph/dynamics.ts +181 -0
  25. package/src/web/client/graph/graph.model.ts +113 -34
  26. package/src/web/client/graph/positions.ts +58 -13
  27. package/src/web/client/graph/renderer.ts +89 -5
  28. package/src/web/client/graph/scheme.ts +51 -6
  29. package/src/web/client/main.tsx +12 -0
  30. package/src/web/client/note/Editor.tsx +16 -9
  31. package/src/web/client/note/Note.tsx +40 -7
  32. package/src/web/client/note/note.model.ts +1 -1
  33. package/src/web/client/search/SearchPalette.tsx +11 -2
  34. package/src/web/client/selection.storage.ts +69 -0
  35. package/src/web/client/shell/Columns.tsx +13 -0
  36. package/src/web/client/shell/Header.tsx +25 -1
  37. package/src/web/client/shell/Shell.tsx +76 -2
  38. package/src/web/client/shell/deeplink.model.ts +61 -0
  39. package/src/web/client/shell/focus.model.ts +14 -6
  40. package/src/web/client/shell/keys.model.ts +27 -3
  41. package/src/web/client/shell/layout.model.ts +12 -38
  42. package/src/web/client/shell/theme.model.ts +147 -0
  43. package/src/web/client/shell/theme.ts +289 -79
  44. package/src/web/client/state.ts +22 -0
  45. package/src/web/client/tree/Tree.tsx +11 -2
  46. package/src/web/client/tree/tree.model.ts +12 -1
  47. package/src/web/client/workspace.ts +78 -6
  48. package/src/web/server/page.ts +6 -4
  49. package/src/web/server/routes.ts +19 -7
  50. package/src/web/shared/graph.ts +7 -0
  51. package/src/web/shared/layout.ts +393 -307
  52. package/src/web/shared/logo.ts +11 -0
@@ -67,53 +67,78 @@ export type ColorScheme = "dark" | "light";
67
67
  */
68
68
  export const GRAPH_PALETTE: Readonly<Record<ColorScheme, Readonly<Record<ColorSlot, string>>>> = {
69
69
  dark: {
70
- accent: "#a48cff",
71
- success: "#4ade80",
72
- warning: "#fbbf24",
73
- dim: "#8f8a9c",
74
- text: "#e8e6ee",
75
- muted: "#5d5869",
76
- line: "#343141",
70
+ accent: "#b79fdd",
71
+ success: "#7fc49a",
72
+ warning: "#e8b04c",
73
+ dim: "#bb9ecf",
74
+ text: "#fde2f3",
75
+ muted: "#aca3d4",
76
+ line: "#4a527e",
77
77
  },
78
78
  light: {
79
- accent: "#6d4aff",
80
- success: "#15803d",
81
- warning: "#b45309",
82
- dim: "#6f6b66",
83
- text: "#1c1b19",
84
- muted: "#9a958e",
85
- line: "#d2cec8",
79
+ accent: "#85586f",
80
+ success: "#3f704e",
81
+ warning: "#a05a1c",
82
+ dim: "#7c6257",
83
+ text: "#43303a",
84
+ muted: "#7f6455",
85
+ line: "#d0b8a8",
86
86
  },
87
87
  };
88
88
 
89
89
  /**
90
90
  * Node kind → colour slot.
91
91
  *
92
- * The same assignment `kindStyle` makes in `src/pi/viewer/tui/theme.ts`, which
93
- * the client tier may not import. Restated rather than re-derived for the
94
- * reason `tree.model.ts`'s glyph table gives: someone who has used
95
- * `/weave-view tui` should recognise the graph, and a second vocabulary is a
96
- * cost with no benefit. `note` takes `text` there too — a note's identity is
97
- * carried by its provenance badge, not by its kind.
92
+ * The shell's design language (shell/theme.ts) is **three greys doing the
93
+ * structural work and one accent used sparingly** — success/warning are
94
+ * status colours there (provenance badges, the connection dot), not
95
+ * decoration. Mapping code kinds onto them painted the canvas green and
96
+ * amber and read as a different app inside the workspace, so every kind
97
+ * except the three that carry identity recedes into `dim`:
98
+ *
99
+ * - `vault` / `repository` keep `accent` — the two knowledge anchors, the
100
+ * same violet the note column's wikilinks are painted in.
101
+ * - `note` keeps `text` — notes are the product (§1.1), and their identity
102
+ * is carried by the provenance badge, not by a hue.
103
+ * - Generated code — modules, packages, entry points, git, externals, files
104
+ * — takes `dim`, the same grey a `generated` row takes in the tree. The
105
+ * TUI's `kindStyle` (src/pi/viewer/tui/theme.ts) still maps these kinds to
106
+ * success/warning; the two media disagree on purpose, because a pi theme
107
+ * has no dark/light stylesheet to violate and a terminal can restyle
108
+ * everything. Here the sheet decides, and the sheet says calm.
98
109
  */
99
110
  export const KIND_SLOT: Readonly<Record<WireNodeKind, ColorSlot>> = {
100
111
  vault: "accent",
101
112
  note: "text",
102
113
  repository: "accent",
103
- module: "success",
104
- package: "success",
105
- entryPoint: "warning",
106
- gitState: "warning",
107
- external: "warning",
114
+ module: "dim",
115
+ package: "dim",
116
+ entryPoint: "dim",
117
+ gitState: "dim",
118
+ external: "dim",
108
119
  file: "dim",
109
120
  };
110
121
 
111
- /** Edge kind → colour slot. Structure recedes; association is the accent. */
122
+ /**
123
+ * Edge kind → colour slot. Structure recedes; association is the accent.
124
+ *
125
+ * "Recedes" used to mean `line` (`--weave-line-strong`), which read as
126
+ * *absent* — 1.5:1 against either background, below the 3:1 non-text minimum,
127
+ * and the reason the skeleton of the graph was invisible until something was
128
+ * selected. The `dim` slot is 5.5:1 against the dark background and 4.9:1
129
+ * against the light one, so containment reads as the hairline scaffolding it
130
+ * is without vanishing.
131
+ *
132
+ * `mentions` joins `links-to` on the accent. Both are content associations
133
+ * between human knowledge and the rest of the graph — splitting them across
134
+ * two hues is what put a rainbow on the canvas — and §1.3's rail is where
135
+ * the two kinds are told apart, at reading size, with labels.
136
+ */
112
137
  export const EDGE_SLOT: Readonly<Record<WireEdgeKind, ColorSlot>> = {
113
- contains: "line",
114
- "anchored-at": "line",
138
+ contains: "dim",
139
+ "anchored-at": "dim",
115
140
  "links-to": "accent",
116
- mentions: "warning",
141
+ mentions: "accent",
117
142
  };
118
143
 
119
144
  /** The colour a node kind is drawn in. */
@@ -131,9 +156,13 @@ export function edgeColor(kind: WireEdgeKind, scheme: ColorScheme): string {
131
156
  /**
132
157
  * The smallest a node is ever drawn, in layout units.
133
158
  *
134
- * A leaf still has to be clickable, and sigma's hit test is the drawn radius.
159
+ * A leaf still has to be clickable, and sigma's hit test is the drawn radius —
160
+ * at fit zoom a leaf renders at `MIN_NODE_SIZE · cameraCorrection`, so a floor
161
+ * of 6 is what keeps every node inside a pointer's reach at overview zoom.
162
+ * (The d3 gallery's force-directed graphs draw every node the same radius for
163
+ * exactly this reason; size still varies by degree up to the ceiling.)
135
164
  */
136
- export const MIN_NODE_SIZE = 3;
165
+ export const MIN_NODE_SIZE = 6;
137
166
 
138
167
  /**
139
168
  * The degree at which a node reaches {@link NODE_RADIUS}.
@@ -167,10 +196,10 @@ export function nodeSize(degree: number): number {
167
196
  return MIN_NODE_SIZE + (NODE_RADIUS - MIN_NODE_SIZE) * share;
168
197
  }
169
198
 
170
- /** Edge thickness by kind. Containment is scaffolding; a wikilink is content. */
199
+ /** Edge thickness by kind. A wikilink keeps its weight; structure is hairline. */
171
200
  export const EDGE_SIZE: Readonly<Record<WireEdgeKind, number>> = {
172
- contains: 0.8,
173
- "anchored-at": 0.8,
201
+ contains: 1,
202
+ "anchored-at": 1,
174
203
  "links-to": 1.4,
175
204
  mentions: 1,
176
205
  };
@@ -247,6 +276,52 @@ export interface RenderGraph {
247
276
  readonly edges: readonly RenderEdge[];
248
277
  }
249
278
 
279
+ /** The normalization box the renderer freezes the view to. See {@link frameBox}. */
280
+ export interface ViewBox {
281
+ readonly x: readonly [number, number];
282
+ readonly y: readonly [number, number];
283
+ }
284
+
285
+ /**
286
+ * The box the graph is framed on — sigma's `customBBox`.
287
+ *
288
+ * ## Why a box is frozen at all
289
+ *
290
+ * Sigma's default `autoRescale` recomputes the graph→viewport normalization
291
+ * from the **current extent on every refresh**, and a drag repaints every
292
+ * frame. The consequence, measured on this repository's graph: the moment a
293
+ * dragged node crosses the extent boundary — at the edge of the view — the
294
+ * whole graph rescales under the cursor, so the view "suddenly makes a
295
+ * distance" and the node ends up dragged very far from the centre while the
296
+ * user chases it. A frozen box makes the coordinates stable for the whole
297
+ * session: the node stays under the cursor, the canvas bounds the drag, and
298
+ * `[fit]` re-frames onto whatever the current positions are.
299
+ *
300
+ * The box is the exact extent of the rendered positions — no padding of its
301
+ * own, because sigma's `stagePadding` (in pixels) already insets the fit, and
302
+ * the same box handed back on `fit()` re-frames dragged-apart graphs.
303
+ *
304
+ * `null` for an empty graph: there is nothing to frame, and the caller
305
+ * clears the override so sigma falls back to its own behaviour.
306
+ */
307
+ export function frameBox(points: readonly Point[]): ViewBox | null {
308
+ let minX = Infinity;
309
+ let minY = Infinity;
310
+ let maxX = -Infinity;
311
+ let maxY = -Infinity;
312
+ let seen = 0;
313
+ for (const p of points) {
314
+ if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) continue;
315
+ seen++;
316
+ if (p.x < minX) minX = p.x;
317
+ if (p.x > maxX) maxX = p.x;
318
+ if (p.y < minY) minY = p.y;
319
+ if (p.y > maxY) maxY = p.y;
320
+ }
321
+ if (seen === 0) return null;
322
+ return { x: [minX, maxX], y: [minY, maxY] };
323
+ }
324
+
250
325
  /** The empty graph. What the column renders before the first payload lands. */
251
326
  export const EMPTY_RENDER_GRAPH: RenderGraph = { nodes: [], edges: [] };
252
327
 
@@ -455,6 +530,7 @@ export function edgeReducer(
455
530
  */
456
531
  export interface GraphSettings {
457
532
  readonly hideEdgesOnMove: boolean;
533
+ readonly enableCameraPanning: boolean;
458
534
  readonly renderEdgeLabels: boolean;
459
535
  readonly labelDensity: number;
460
536
  readonly labelGridCellSize: number;
@@ -505,6 +581,9 @@ export function graphSettings(scheme: ColorScheme): GraphSettings {
505
581
  // §7.4. A pan over a few thousand edges is the one interaction that drops
506
582
  // frames, and the edges are the part nobody is reading mid-gesture.
507
583
  hideEdgesOnMove: true,
584
+ // The renderer toggles this off while a node is being dragged, so the
585
+ // gesture pins the node to the cursor instead of panning the camera.
586
+ enableCameraPanning: true,
508
587
  renderEdgeLabels: false,
509
588
  labelDensity: LABEL_DENSITY,
510
589
  labelGridCellSize: LABEL_GRID_CELL_SIZE,
@@ -34,10 +34,34 @@
34
34
  * the root `tsconfig.json` project compiles the tests.
35
35
  */
36
36
 
37
- import { computeLayout, hashId } from "../../shared/layout";
37
+ import { computeLayout } from "../../shared/layout";
38
38
  import type { LayoutOptions, Point } from "../../shared/layout";
39
39
  import type { WireGraphEdge, WireGraphNode } from "../../shared/wire";
40
40
 
41
+ /**
42
+ * FNV-1a (32-bit) followed by MurmurHash3's `fmix32` avalanche.
43
+ *
44
+ * The finalizer is not optional: FNV-1a mixes its *low* bits well but its high
45
+ * bits poorly for short, near-identical inputs — and the digest folds the hash
46
+ * into a bounded key, so the high bits are the significant part. Raw FNV-1a
47
+ * over `leaf001…leaf199` put 199 ids into six of twelve sectors; `fmix32`
48
+ * costs four lines and makes every bit depend on every input bit.
49
+ */
50
+ export function hashId(id: string): number {
51
+ let h = 0x811c9dc5;
52
+ for (let i = 0; i < id.length; i++) {
53
+ h ^= id.charCodeAt(i);
54
+ h = Math.imul(h, 0x01000193);
55
+ }
56
+ h ^= h >>> 16;
57
+ h = Math.imul(h, 0x85ebca6b);
58
+ h ^= h >>> 13;
59
+ h = Math.imul(h, 0xc2b2ae35);
60
+ h ^= h >>> 16;
61
+ return h >>> 0;
62
+ }
63
+
64
+
41
65
  // --- computing ----------------------------------------------------------------------
42
66
 
43
67
  /**
@@ -64,22 +88,39 @@ export const LAYOUT_SEED = 1;
64
88
  * re-running from a settled state is a near-fixed-point, which is the property
65
89
  * that makes an expand feel like an expand.
66
90
  *
67
- * The viewport is **not** a parameter, deliberately. `computeLayout` centres
68
- * its output on the width and height it is given, and sigma's camera fits
69
- * whatever extent it is handed — so feeding the real column width in would
70
- * re-run the simulation on every divider drag to produce a picture the camera
71
- * immediately normalises away. A fixed nominal viewport keeps the layout a
72
- * function of the *graph*, which is what makes it cacheable at all.
91
+ * **New containment children start at their parent** (d3's own collapse/
92
+ * expand pattern): a newly visible node without a stored position enters the
93
+ * simulation at its parent's warm position, so the relaxation it needs is
94
+ * local — the collide force makes it step out among its siblings — instead of
95
+ * d3's phyllotaxis spiral dropping it across the graph and dragging the whole
96
+ * arrangement along.
97
+ *
98
+ * The viewport is **not** a parameter, deliberately. The layout is centred on
99
+ * the origin by `forceX()`/`forceY()`, and sigma's camera fits whatever extent
100
+ * it is handed — so feeding the real column width in would re-run the
101
+ * simulation on every divider drag to produce a picture the camera
102
+ * immediately normalises away. Keeping the layout a function of the *graph*
103
+ * alone is what makes it cacheable at all.
73
104
  */
74
105
  export function layoutFor(
75
106
  nodes: readonly WireGraphNode[],
76
107
  edges: readonly WireGraphEdge[],
77
108
  warm?: ReadonlyMap<string, Point>,
78
109
  ): Map<string, Point> {
79
- const options: LayoutOptions = { ticks: LAYOUT_TICKS, seed: LAYOUT_SEED };
110
+ const seeded = new Map<string, Point>(warm ?? []);
111
+ if (seeded.size > 0) {
112
+ for (const e of edges) {
113
+ if (e.kind !== "contains" && e.kind !== "anchored-at") continue;
114
+ if (seeded.has(e.target)) continue;
115
+ const at = seeded.get(e.source);
116
+ if (at !== undefined) seeded.set(e.target, { x: at.x, y: at.y });
117
+ }
118
+ }
119
+ const options: LayoutOptions = { ticks: LAYOUT_TICKS, seed: LAYOUT_SEED, pinWarm: true };
120
+ if (seeded.size > 0) options.initial = seeded;
80
121
  return computeLayout(
81
- { generatedAt: "", staleness: null, nodes: [...nodes], edges: [...edges] },
82
- warm === undefined ? options : { ...options, initial: warm },
122
+ { generatedAt: "", staleness: null, nodes: [...nodes], edges: [...edges], contentDigest: "" },
123
+ options,
83
124
  );
84
125
  }
85
126
 
@@ -161,7 +202,7 @@ export interface PositionStorage {
161
202
  }
162
203
 
163
204
  /** The `localStorage` key. Namespaced and versioned, like the layout's. */
164
- export const POSITIONS_STORAGE_KEY = "pi-weave.graph.positions.v1";
205
+ export const POSITIONS_STORAGE_KEY = "pi-weave.graph.positions.v2";
165
206
 
166
207
  /**
167
208
  * Coordinates are rounded to **one** decimal before storage.
@@ -188,7 +229,11 @@ function round1(value: number): number {
188
229
  export function serializePositions(key: string, positions: ReadonlyMap<string, Point>): string {
189
230
  const at: Record<string, [number, number]> = {};
190
231
  for (const [id, point] of positions) at[id] = [round1(point.x), round1(point.y)];
191
- return JSON.stringify({ v: 1, key, at });
232
+ // `v: 2` — the branch-anchor layout (separated sibling blobs). A v1 entry
233
+ // is a tangled arrangement from the single-centre recipe, and the shape key
234
+ // cannot tell the two apart because the recipe changed under the same node
235
+ // and edge set. The version can.
236
+ return JSON.stringify({ v: 2, key, at });
192
237
  }
193
238
 
194
239
  /**
@@ -220,7 +265,7 @@ export function deserializePositions(raw: string | null, key: string): Map<strin
220
265
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
221
266
 
222
267
  const record = parsed as Record<string, unknown>;
223
- if (record["v"] !== 1) return null;
268
+ if (record["v"] !== 2) return null;
224
269
  if (record["key"] !== key) return null;
225
270
 
226
271
  const at = record["at"];
@@ -49,8 +49,8 @@
49
49
  */
50
50
 
51
51
  import type { Point } from "../../shared/layout";
52
- import type { ColorScheme, EdgeDisplayOverride, GraphSettings, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode } from "./graph.model";
53
- import { edgeReducer, graphSettings, nodeReducer } from "./graph.model";
52
+ import type { ColorScheme, EdgeDisplayOverride, GraphSettings, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, ViewBox } from "./graph.model";
53
+ import { edgeReducer, frameBox, graphSettings, nodeReducer } from "./graph.model";
54
54
  import type { ProjectedGraph } from "./project";
55
55
  import { positionsOf, project, syncPositions } from "./project";
56
56
 
@@ -94,6 +94,12 @@ export interface GraphRenderer {
94
94
  fit(): void;
95
95
  /** Current positions, for warm-starting a re-run. */
96
96
  positions(): Map<string, Point>;
97
+ /** Called when the user starts dragging a node. */
98
+ onDragStart(handler: (id: string) => void): void;
99
+ /** Called with graph-space coordinates while a node is dragged. */
100
+ onDragMove(handler: (id: string, at: Point) => void): void;
101
+ /** Called when a node drag ends. */
102
+ onDragEnd(handler: (id: string) => void): void;
97
103
  /** Tear down. Idempotent, and must leave no listener behind. */
98
104
  destroy(): void;
99
105
  }
@@ -130,6 +136,9 @@ export function nullRenderer(): GraphRenderer {
130
136
  setHighlight() {},
131
137
  onSelect() {},
132
138
  fit() {},
139
+ onDragStart() {},
140
+ onDragMove() {},
141
+ onDragEnd() {},
133
142
  positions() {
134
143
  return new Map();
135
144
  },
@@ -174,9 +183,23 @@ export interface CameraLike {
174
183
  export interface SigmaLike {
175
184
  on(event: "clickNode", handler: (payload: { node: string }) => void): unknown;
176
185
  on(event: "clickStage", handler: () => void): unknown;
186
+ on(event: "downNode", handler: (payload: { node: string }) => void): unknown;
187
+ on(event: "moveBody", handler: (payload: { event: { x: number; y: number }; preventSigmaDefault(): void }) => void): unknown;
188
+ on(event: "upNode" | "upStage", handler: () => void): unknown;
189
+ viewportToGraph(position: { x: number; y: number }): Point;
177
190
  setSetting(key: "nodeReducer", value: (id: string, data: RenderNode) => NodeDisplayOverride): unknown;
178
191
  setSetting(key: "edgeReducer", value: (key: string, data: RenderEdge) => EdgeDisplayOverride): unknown;
192
+ setSetting(key: "enableCameraPanning", value: boolean): unknown;
179
193
  setGraph(graph: ProjectedGraph): unknown;
194
+ /**
195
+ * Freeze the graph→viewport normalization onto a fixed box.
196
+ *
197
+ * Sigma's own `autoRescale` would recompute that mapping from the moving
198
+ * extent on every repaint — which a drag does every frame — so crossing the
199
+ * view's edge rescaled the whole graph under the cursor. `graph.model.ts`'s
200
+ * {@link frameBox} records the why in full.
201
+ */
202
+ setCustomBBox(box: ViewBox | null): unknown;
180
203
  refresh(): unknown;
181
204
  getCamera(): CameraLike;
182
205
  kill(): void;
@@ -212,6 +235,24 @@ export function sigmaRenderer(create: SigmaFactory, scheme: ColorScheme): GraphR
212
235
  let graph: ProjectedGraph = project({ nodes: [], edges: [] });
213
236
  let highlight: ReadonlySet<string> | null = null;
214
237
  let select: (id: string | null) => void = () => {};
238
+ let dragStart: (id: string) => void = () => {};
239
+ let dragMove: (id: string, at: Point) => void = () => {};
240
+ let dragEnd: (id: string) => void = () => {};
241
+ let dragging: string | null = null;
242
+
243
+ const endDrag = (id: string | null): void => {
244
+ if (dragging === null) return;
245
+ const current = dragging;
246
+ dragging = null;
247
+ // A node drag is a pin, not a pan: sigma's captor would otherwise move the
248
+ // camera on the same gesture that is moving the node, and the node would
249
+ // slide away from the cursor. Re-enable panning only when the drag ends.
250
+ sigma?.setSetting("enableCameraPanning", true);
251
+ if (id === null || id === current) dragEnd(current);
252
+ };
253
+
254
+ /** The box to freeze the view on, from the graph's *current* positions. */
255
+ const boxOf = (): ViewBox | null => frameBox([...positionsOf(graph).values()]);
215
256
 
216
257
  /**
217
258
  * Push the current highlight into sigma's reducers (§7.4).
@@ -236,13 +277,41 @@ export function sigmaRenderer(create: SigmaFactory, scheme: ColorScheme): GraphR
236
277
  // the tree and the context rail all recompute from it.
237
278
  instance.on("clickNode", ({ node }) => select(node));
238
279
  instance.on("clickStage", () => select(null));
280
+ instance.on("downNode", ({ node }) => {
281
+ dragging = node;
282
+ // Hold the view still while the node follows the cursor (see endDrag).
283
+ sigma?.setSetting("enableCameraPanning", false);
284
+ dragStart(node);
285
+ });
286
+ instance.on("moveBody", (payload) => {
287
+ if (dragging !== null) {
288
+ // Sigma's captor pans the camera on every mouse move while the
289
+ // button is down; during a node drag that is the pan the gesture
290
+ // must not be. `preventSigmaDefault` is the captor's own gate — it
291
+ // is consulted right after this handler returns — and without it
292
+ // the camera follows the cursor 1:1 while the pin follows it too,
293
+ // so the node appears to slide away under the view.
294
+ payload.preventSigmaDefault();
295
+ dragMove(dragging, instance.viewportToGraph({ x: payload.event.x, y: payload.event.y }));
296
+ }
297
+ });
298
+ instance.on("upNode", () => endDrag(dragging));
299
+ instance.on("upStage", () => endDrag(null));
239
300
  applyReducers(instance);
301
+ // Freeze the view box for whatever graph arrived before mount, so the
302
+ // first frame is already framed and stable — see `SigmaLike.setCustomBBox`.
303
+ instance.setCustomBBox(boxOf());
240
304
  sigma = instance;
241
305
  },
242
306
 
243
307
  setGraph(next) {
244
308
  graph = project(next);
245
309
  sigma?.setGraph(graph);
310
+ // Re-frame on a shape change: new nodes legitimately change the extent,
311
+ // and the box is what keeps the *drag* frames from also changing it.
312
+ // Deliberately not touched by `setPositions` — a settling simulation
313
+ // must not move the view under the user.
314
+ sigma?.setCustomBBox(boxOf());
246
315
  },
247
316
 
248
317
  setPositions(positions) {
@@ -261,16 +330,31 @@ export function sigmaRenderer(create: SigmaFactory, scheme: ColorScheme): GraphR
261
330
  select = handler;
262
331
  },
263
332
 
333
+ onDragStart(handler) {
334
+ dragStart = handler;
335
+ },
336
+
337
+ onDragMove(handler) {
338
+ dragMove = handler;
339
+ },
340
+
341
+ onDragEnd(handler) {
342
+ dragEnd = handler;
343
+ },
344
+
264
345
  fit() {
265
- // `animatedReset` settles when the animation ends. Nothing awaits a
266
- // camera move, and a rejection from a camera killed mid-flight is noise.
346
+ // Re-frame onto the **current** positions — a graph the user has pulled
347
+ // apart wants its dragged nodes inside the view — then reset the camera
348
+ // onto that box. `animatedReset` settles when the animation ends. Nothing
349
+ // awaits a camera move, and a rejection from a camera killed mid-flight
350
+ // is noise.
351
+ sigma?.setCustomBBox(boxOf());
267
352
  void sigma?.getCamera().animatedReset();
268
353
  },
269
354
 
270
355
  positions() {
271
356
  return positionsOf(graph);
272
357
  },
273
-
274
358
  destroy() {
275
359
  sigma?.kill();
276
360
  sigma = null;
@@ -18,14 +18,14 @@ import type { ColorScheme } from "./graph.model";
18
18
  /**
19
19
  * The slice of `window` this module reads.
20
20
  *
21
- * `matchMedia` returns far more than `matches`, and naming `MediaQueryList`
22
- * would make the module uncompilable by the root `tsconfig.json` project (no
23
- * `DOM` lib), which is what a test importing it runs under. The narrow shape
24
- * is the same trick `viewport.ts` and `cssvars.ts` use, and the real `window`
25
- * satisfies it structurally.
21
+ * `matchMedia` returns far more than a {@link SchemeList}, and naming
22
+ * `MediaQueryList` would make the module uncompilable by the root
23
+ * `tsconfig.json` project (no `DOM` lib), which is what a test importing it
24
+ * runs under. The narrow shape is the same trick `viewport.ts` and
25
+ * `cssvars.ts` use, and the real `window` satisfies it structurally.
26
26
  */
27
27
  export interface SchemeHost {
28
- matchMedia(query: string): { readonly matches: boolean };
28
+ matchMedia(query: string): SchemeList;
29
29
  }
30
30
 
31
31
  /** The query. Light is the exception; see the module header. */
@@ -42,3 +42,48 @@ export function schemeOf(host: SchemeHost | null | undefined): ColorScheme {
42
42
  if (host === null || host === undefined || typeof host.matchMedia !== "function") return "dark";
43
43
  return host.matchMedia(LIGHT_QUERY).matches ? "light" : "dark";
44
44
  }
45
+
46
+ // --- the live watcher ---------------------------------------------------------------
47
+
48
+ /**
49
+ * The slice of `MediaQueryList` the watcher reads.
50
+ *
51
+ * Three members, so the platform's list satisfies it structurally and a fake
52
+ * is an object literal with a listener slot.
53
+ */
54
+ export interface SchemeList {
55
+ readonly matches: boolean;
56
+ addEventListener(type: "change", listener: () => void): void;
57
+ removeEventListener(type: "change", listener: () => void): void;
58
+ }
59
+
60
+ /**
61
+ * Watch for an OS scheme flip, calling back with the resolved scheme.
62
+ *
63
+ * The stylesheet answers an OS flip on its own (the media query repaints
64
+ * without JavaScript), but the graph cannot: sigma's palette is fixed at
65
+ * renderer construction ("the scheme is fixed at construction" —
66
+ * `renderer.ts`), so the shell must learn about the flip to hand the column a
67
+ * new scheme and let it remount. This is that one listener.
68
+ *
69
+ * The returned `stop` removes the listener; the shell registers the watch in
70
+ * a mount effect, so a hot reload cannot stack listeners. A host without
71
+ * `matchMedia` gets a do-nothing cleanup — the same "legible, not throwing"
72
+ * answer `schemeOf` gives, because a graph in a stale palette beats a
73
+ * workspace that crashed on mount.
74
+ *
75
+ * A manual choice ("light" / "dark") ignores this callback entirely — the
76
+ * shell simply does not consult the OS while overridden, and the stylesheet's
77
+ * `:root[data-weave-theme=…]` attribute wins over the media query the same way.
78
+ */
79
+ export function watchScheme(
80
+ host: SchemeHost,
81
+ onChange: (scheme: ColorScheme) => void,
82
+ ): () => void {
83
+ if (typeof host.matchMedia !== "function") return () => {};
84
+ const list: SchemeList = host.matchMedia(LIGHT_QUERY);
85
+ if (typeof list.addEventListener !== "function") return () => {};
86
+ const listener = (): void => onChange(list.matches ? "light" : "dark");
87
+ list.addEventListener("change", listener);
88
+ return () => list.removeEventListener("change", listener);
89
+ }
@@ -15,6 +15,7 @@ import { BOOTSTRAP_ELEMENT_ID } from "../shared/wire";
15
15
  import { readBootstrap } from "./bootstrap";
16
16
  import { Shell } from "./shell/Shell";
17
17
  import { installTheme } from "./shell/theme";
18
+ import { loadTheme, themeAttr } from "./shell/theme.model";
18
19
 
19
20
  const host = document.getElementById("app");
20
21
  if (host !== null) {
@@ -22,6 +23,17 @@ if (host !== null) {
22
23
  // installing it after would show one unstyled frame. It copies the
23
24
  // per-response CSP nonce off the server's own `<style>` — see `theme.ts`.
24
25
  installTheme(document);
26
+ // And before the first paint: a stored manual choice must govern from frame
27
+ // one, or a light-theme user sees one dark frame. `null` (system mode)
28
+ // writes nothing — the media query already answered.
29
+ applyPrePaintTheme();
25
30
  const boot = readBootstrap(document.getElementById(BOOTSTRAP_ELEMENT_ID)?.textContent ?? null);
26
31
  render(<Shell cwd={boot.cwd} initialWidth={window.innerWidth} platform={navigator.platform} />, host);
27
32
  }
33
+
34
+ /** Apply the stored theme choice to `<html>` synchronously, pre-render. */
35
+ function applyPrePaintTheme(): void {
36
+ const attr = themeAttr(loadTheme(localStorage) ?? "system");
37
+ if (attr === null) return;
38
+ document.documentElement.dataset.weaveTheme = attr;
39
+ }
@@ -42,19 +42,26 @@ function Prompt({ prompt, send }: { prompt: EditorPrompt; send: (event: EditorEv
42
42
  );
43
43
  }
44
44
 
45
- /** The toolbar: edit/done, save, open-in-$EDITOR, and the status word. */
45
+ /** The toolbar while editing: done, save, open-in-$EDITOR, and the status word.
46
+ *
47
+ * There is no button in read mode: the note itself is the edit affordance —
48
+ * clicking its text opens the editor (see `Note.tsx`), and `⌘E` still works.
49
+ * Rendering the button only while editing keeps the read view down to prose
50
+ * and the `Open in $EDITOR` control. */
46
51
  export function EditorBar(props: EditorProps) {
47
52
  const { toolbar, send } = props;
48
53
  return (
49
54
  <div class="weave-note-bar">
50
- <button
51
- type="button"
52
- class="weave-note-toggle"
53
- aria-pressed={toolbar.editing}
54
- onClick={() => send({ type: "toggle" })}
55
- >
56
- {toolbar.toggleLabel}
57
- </button>
55
+ {toolbar.editing ? (
56
+ <button
57
+ type="button"
58
+ class="weave-note-toggle"
59
+ aria-pressed={toolbar.editing}
60
+ onClick={() => send({ type: "toggle" })}
61
+ >
62
+ {toolbar.toggleLabel}
63
+ </button>
64
+ ) : null}
58
65
  {toolbar.editing ? (
59
66
  <button type="button" class="weave-note-save" disabled={!toolbar.canSave} onClick={() => send({ type: "save" })}>
60
67
  {toolbar.saveLabel}