pi-weave 0.3.8 → 0.3.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 (80) hide show
  1. package/README.md +21 -17
  2. package/package.json +4 -3
  3. package/skills/weave-explore/SKILL.md +3 -5
  4. package/skills/weave-notepad/SKILL.md +22 -2
  5. package/skills/weave-notepad/references/second-thoughts.md +57 -0
  6. package/src/core/graph/build.ts +1 -1
  7. package/src/core/graph/current.ts +10 -8
  8. package/src/core/history.ts +417 -0
  9. package/src/core/index.ts +19 -0
  10. package/src/core/links/repair.ts +5 -68
  11. package/src/core/noteAction.ts +72 -2
  12. package/src/core/openInEditor.ts +3 -8
  13. package/src/core/repoAction.ts +20 -61
  14. package/src/core/repoIndex.ts +38 -0
  15. package/src/core/trail.ts +124 -0
  16. package/src/core/types.ts +1 -3
  17. package/src/core/vault.ts +116 -29
  18. package/src/core/view/focus.ts +2 -2
  19. package/src/core/view/index.ts +2 -3
  20. package/src/core/view/rawTail.ts +153 -0
  21. package/src/core/workspace.ts +20 -26
  22. package/src/core/workspaceCommands.ts +14 -14
  23. package/src/opencode/tools.ts +3 -3
  24. package/src/opencode/tui.ts +1 -7
  25. package/src/pi/index.ts +68 -80
  26. package/src/pi/tools/noteTool.ts +1 -1
  27. package/src/pi/tools/repoTool.ts +3 -6
  28. package/src/pi/viewer/web/run.ts +6 -8
  29. package/src/web/client/api.ts +103 -5
  30. package/src/web/client/context/context.model.ts +5 -0
  31. package/src/web/client/dist/app.js +499 -129
  32. package/src/web/client/graph/Graph.tsx +166 -14
  33. package/src/web/client/graph/column.model.ts +59 -2
  34. package/src/web/client/graph/graph.model.ts +183 -7
  35. package/src/web/client/graph/renderer.ts +39 -9
  36. package/src/web/client/graph/spotlight.model.ts +472 -0
  37. package/src/web/client/note/Note.tsx +370 -66
  38. package/src/web/client/note/Replay.tsx +139 -0
  39. package/src/web/client/note/drafts.ts +115 -11
  40. package/src/web/client/note/editbar.model.ts +153 -0
  41. package/src/web/client/note/note.model.ts +82 -1
  42. package/src/web/client/note/replay.model.ts +248 -0
  43. package/src/web/client/search/SearchPalette.tsx +103 -34
  44. package/src/web/client/search/search.model.ts +533 -26
  45. package/src/web/client/shell/ContextRail.tsx +7 -0
  46. package/src/web/client/shell/Settings.tsx +21 -1
  47. package/src/web/client/shell/Shell.tsx +93 -27
  48. package/src/web/client/shell/StatusBar.tsx +1 -0
  49. package/src/web/client/shell/icons.model.ts +68 -1
  50. package/src/web/client/shell/keys.model.ts +31 -8
  51. package/src/web/client/shell/shell.model.ts +48 -1
  52. package/src/web/client/shell/theme.model.ts +3 -0
  53. package/src/web/client/shell/theme.ts +354 -32
  54. package/src/web/client/shell/workspace.css.ts +40 -9
  55. package/src/web/client/trail/TrailStrip.tsx +55 -0
  56. package/src/web/client/trail/trail.model.ts +195 -0
  57. package/src/web/client/trail/trail.ts +45 -0
  58. package/src/web/client/tree/Tree.tsx +7 -3
  59. package/src/web/client/tree/tree.model.ts +2 -5
  60. package/src/web/server/page.ts +1 -1
  61. package/src/web/server/routes.ts +83 -3
  62. package/src/web/shared/logo.ts +1 -1
  63. package/src/web/shared/themes.ts +1 -1
  64. package/src/web/shared/view.ts +10 -4
  65. package/src/web/shared/wire.ts +74 -1
  66. package/src/web/shared/workspace.ts +10 -2
  67. package/src/core/view/health.ts +0 -160
  68. package/src/pi/viewer/tui/bodyStore.ts +0 -95
  69. package/src/pi/viewer/tui/branding.ts +0 -14
  70. package/src/pi/viewer/tui/model.ts +0 -390
  71. package/src/pi/viewer/tui/openNote.ts +0 -11
  72. package/src/pi/viewer/tui/run.ts +0 -71
  73. package/src/pi/viewer/tui/surface/base.ts +0 -203
  74. package/src/pi/viewer/tui/surface/detail.ts +0 -315
  75. package/src/pi/viewer/tui/surface/explore.ts +0 -241
  76. package/src/pi/viewer/tui/surface/focus.ts +0 -139
  77. package/src/pi/viewer/tui/surface/health.ts +0 -102
  78. package/src/pi/viewer/tui/theme.ts +0 -90
  79. package/src/pi/viewer/tui/workspace.ts +0 -53
  80. package/src/pi/viewer/tui/workspaceRoot.ts +0 -389
@@ -40,11 +40,32 @@ import {
40
40
  graphClick,
41
41
  graphColumnModel,
42
42
  graphCountLabel,
43
+ graphPicture,
43
44
  graphPreview,
44
- hoverHighlight,
45
+ hoverPicture,
45
46
  } from "./column.model";
47
+ import {
48
+ CONNECTED_HEADING,
49
+ LIT_BECAUSE,
50
+ SHEET_CLOSE,
51
+ SHEET_LABEL,
52
+ SHEET_OPEN,
53
+ SHEET_OPEN_SPLIT,
54
+ SPOTLIGHT_CLEAR,
55
+ SPOTLIGHT_CLEAR_HINT,
56
+ SPOTLIGHT_LABEL,
57
+ SPOTLIGHT_LEGEND,
58
+ SPOTLIGHT_PLACEHOLDER,
59
+ connectedMore,
60
+ graphSheet,
61
+ litLabel,
62
+ litOnCanvas,
63
+ spotlightEscapes,
64
+ spotlightOf,
65
+ } from "./spotlight.model";
46
66
  import type { PositionStorage } from "./positions";
47
67
  import type { GraphRenderer, RendererFactory } from "./renderer";
68
+ import type { TrailGlow } from "./graph.model";
48
69
  import { schemeOf } from "./scheme";
49
70
  import type { SchemeHost } from "./scheme";
50
71
  import type { GraphTheme } from "../../shared/themes";
@@ -92,14 +113,43 @@ export interface GraphProps {
92
113
  * keeps the ownership where it is and costs one line at each end.
93
114
  */
94
115
  fit: { current: (() => void) | null };
95
-
116
+ /** Recall-trail glows by node id (`trail/trail.model.ts`). Omitted means none. */
117
+ trail?: ReadonlyMap<string, TrailGlow>;
118
+ /**
119
+ * The spotlight query (`spotlight.model.ts`), controlled by the shell so
120
+ * other UI — the search palette's "light up in graph" — can set it.
121
+ * Omitted or `""` means no spotlight.
122
+ */
123
+ query?: string;
124
+ /**
125
+ * Node ids the search palette found for `query`, when it lit the graph up.
126
+ * They light alongside the spotlight's own matches; omitted means none.
127
+ */
128
+ found?: readonly string[];
129
+ /**
130
+ * Where the spotlight field writes. **Its presence is what makes this the
131
+ * full graph view**: with it the column shows the floating spotlight field
132
+ * and the side sheet; without it (the context rail's note graph, a narrow
133
+ * column) it keeps the compact preview card it always had.
134
+ */
135
+ onQuery?: (query: string) => void;
136
+ /** The sheet's "Open in split". Omitted hides the button. */
137
+ onOpenSplit?: (id: string) => void;
138
+ /** The shell's clock, for the sheet's "2h ago". Omitted reads the wall clock. */
139
+ now?: number;
96
140
  }
97
141
 
142
+ const NO_GLOWS: ReadonlyMap<string, TrailGlow> = new Map();
143
+
98
144
  export function Graph(props: GraphProps) {
99
145
  const canvas = useRef<HTMLDivElement | null>(null);
100
146
  const renderer = useRef<GraphRenderer | null>(null);
101
147
  const dynamics = useRef<GraphSimulation | null>(null);
102
148
  const clock = useRef<number | null>(null);
149
+ // Whether the camera has been fitted to the first settled layout. The
150
+ // mount frames the *starting* positions, which a settling simulation then
151
+ // pushes past the edges — a two-note vault opened with clipped circles.
152
+ const fitted = useRef(false);
103
153
  /**
104
154
  * Arm the simulation clock — idempotent.
105
155
  *
@@ -116,7 +166,13 @@ export function Graph(props: GraphProps) {
116
166
  if (engine === null) return;
117
167
  engine.tick();
118
168
  // Asleep: let the clock die. The next pin re-arms it.
119
- if (!engine.awake()) return;
169
+ if (!engine.awake()) {
170
+ if (!fitted.current) {
171
+ fitted.current = true;
172
+ renderer.current?.fit();
173
+ }
174
+ return;
175
+ }
120
176
  renderer.current?.setPositions(engine.positions());
121
177
  clock.current = requestAnimationFrame(step);
122
178
  };
@@ -143,11 +199,25 @@ export function Graph(props: GraphProps) {
143
199
  () => graphColumnModel(props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed, groupColors),
144
200
  [props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed, forceKey, groupColors],
145
201
  );
146
- const preview = graphPreview(props.graph, props.previewId);
202
+ // The full graph view has a spotlight and a side sheet; the context rail's
203
+ // note graph keeps the compact card. See `GraphProps.onQuery`.
204
+ const full = props.onQuery !== undefined;
205
+ const query = full ? props.query ?? "" : "";
206
+ const spot = useMemo(() => spotlightOf(props.graph, query, props.found), [props.graph, query, props.found]);
207
+ // Same identity contract as `model` above: the highlight effect keys on the
208
+ // picture's identity, so each step is memoized on exactly what it reads.
209
+ const lit = useMemo(() => litOnCanvas(spot, model.representative), [spot, model.representative]);
210
+ const picture = useMemo(() => graphPicture(model.highlight, props.selectedId, lit), [model.highlight, props.selectedId, lit]);
211
+ const preview = full ? null : graphPreview(props.graph, props.previewId);
212
+ const now = props.now ?? Date.now();
213
+ const sheet = useMemo(() => (full ? graphSheet(props.graph, props.previewId, spot, now) : null), [full, props.graph, props.previewId, spot, now]);
214
+ const field = useRef<HTMLInputElement | null>(null);
147
215
 
148
216
  // Read by the mount-time `onSelect`, which outlives this render.
149
- const live = useRef({ view, model, onSelect: props.onSelect, selectedId: props.selectedId });
150
- live.current = { view, model, onSelect: props.onSelect, selectedId: props.selectedId };
217
+ const trailRef = useRef<ReadonlyMap<string, TrailGlow>>(NO_GLOWS);
218
+ trailRef.current = props.trail ?? NO_GLOWS;
219
+ const live = useRef({ view, model, picture, onSelect: props.onSelect, selectedId: props.selectedId });
220
+ live.current = { view, model, picture, onSelect: props.onSelect, selectedId: props.selectedId };
151
221
 
152
222
  useEffect(() => {
153
223
  const instance = props.renderer(scheme);
@@ -164,11 +234,13 @@ export function Graph(props: GraphProps) {
164
234
  // state would enter the `model` memo below and re-derive the layout,
165
235
  // the group colours and the whole `RenderGraph` on every pointer move.
166
236
  instance.onHover((id) => {
167
- const { model: current, selectedId } = live.current;
237
+ const { model: current, picture: shown } = live.current;
168
238
  // The *drawn* edges, which are exactly the visible ones the column's own
169
239
  // highlight was computed over — a neighbour inside a collapsed cluster
170
240
  // has already been retargeted onto the cluster standing in for it.
171
- instance.setHighlight(hoverHighlight(current.graph.edges, id, current.highlight), id ?? selectedId);
241
+ // Under a spotlight `hoverPicture` hands the spotlight straight back.
242
+ const next = hoverPicture(current.graph.edges, id, shown);
243
+ instance.setHighlight(next.highlight, next.selectedId, next.style);
172
244
  });
173
245
  instance.onDragStart((id) => {
174
246
  const at = renderer.current?.positions().get(id);
@@ -194,7 +266,9 @@ export function Graph(props: GraphProps) {
194
266
  // the render this effect was created in), so a remount carries whatever
195
267
  // the column is already showing.
196
268
  instance.setGraph(live.current.model.graph);
197
- instance.setHighlight(live.current.model.highlight, live.current.selectedId);
269
+ const shown = live.current.picture;
270
+ instance.setHighlight(shown.highlight, shown.selectedId, shown.style);
271
+ instance.setTrail(trailRef.current);
198
272
  props.fit.current = () => instance.fit();
199
273
  return () => {
200
274
  instance.destroy();
@@ -239,17 +313,93 @@ export function Graph(props: GraphProps) {
239
313
  );
240
314
 
241
315
  useEffect(() => {
242
- renderer.current?.setHighlight(model.highlight, props.selectedId);
243
- }, [model.highlight, props.selectedId]);
316
+ renderer.current?.setHighlight(picture.highlight, picture.selectedId, picture.style);
317
+ }, [picture]);
318
+
319
+ useEffect(() => {
320
+ renderer.current?.setTrail(trailRef.current);
321
+ }, [props.trail]);
322
+
323
+ // `Esc` clears the spotlight before the shell's document listener sees it —
324
+ // or, when `spotlightEscapes` says no, passes through to "clear selection".
325
+ const escape = (event: KeyboardEvent): void => {
326
+ if (event.key !== "Escape" || !spotlightEscapes(query, event.target === field.current, sheet !== null)) return;
327
+ event.preventDefault();
328
+ props.onQuery?.("");
329
+ };
330
+ const more = sheet === null ? null : connectedMore(sheet.connected.length, sheet.connectedTotal);
244
331
 
245
332
  return (
246
- <div class="weave-graph">
333
+ <div class="weave-graph" onKeyDown={escape}>
247
334
  {/* Same shape as `ContextRail`'s: the *decision* is `graphEmptyMessage`,
248
335
  and what is left here is whether to render the paragraph it returned. */}
249
336
  {model.empty === null ? null : <p class="weave-graph-empty">{model.empty}</p>}
250
337
  {/* `tabIndex={-1}` is the `⌘3` focus target — see `Note.tsx`'s matching
251
338
  comment. The tree's target is the rows `<ul>`, which has its own. */}
252
339
  <div class="weave-graph-canvas" ref={canvas} role="img" aria-label="Knowledge graph" tabIndex={-1} />
340
+ {!full ? null : (
341
+ <div class={`weave-spotlight${spot === null ? "" : " weave-spotlight-on"}`} role="search">
342
+ <svg class="weave-spotlight-icon" viewBox="0 0 20 20" width={14} height={14} fill="none" stroke="currentColor" stroke-width={1.8} aria-hidden="true">
343
+ <circle cx="8.5" cy="8.5" r="5.5" />
344
+ <path d="m13 13 4 4" />
345
+ </svg>
346
+ <input ref={field} class="weave-spotlight-input" type="search" value={query} placeholder={SPOTLIGHT_PLACEHOLDER}
347
+ aria-label={SPOTLIGHT_LABEL} spellcheck={false} autoComplete="off" onInput={(event) => props.onQuery?.(event.currentTarget.value)} />
348
+ {spot === null ? null : (
349
+ <>
350
+ <span class={`weave-spotlight-count${spot.count === 0 ? " weave-spotlight-none" : ""}`} role="status">{litLabel(spot.count)}</span>
351
+ <button type="button" class="weave-spotlight-clear" title={SPOTLIGHT_CLEAR_HINT} aria-label={SPOTLIGHT_CLEAR_HINT}
352
+ onClick={() => { props.onQuery?.(""); field.current?.focus(); }}>{SPOTLIGHT_CLEAR}</button>
353
+ </>
354
+ )}
355
+ </div>
356
+ )}
357
+ {sheet === null ? null : (
358
+ <aside class="weave-sheet" aria-label={SHEET_LABEL}>
359
+ <button type="button" class="weave-sheet-close" aria-label={SHEET_CLOSE} title={SHEET_CLOSE} onClick={() => props.onSelect(null)}>×</button>
360
+ <p class="weave-sheet-crumb">
361
+ <Icon name={sheet.crumb === "" ? sheet.icon : "module"} class="weave-icon" />
362
+ <span>{sheet.crumb === "" ? sheet.kind : sheet.crumb}</span>
363
+ </p>
364
+ <h2 class="weave-sheet-title">{sheet.title}</h2>
365
+ <p class="weave-sheet-meta">
366
+ {sheet.who === null ? null : <span class={`weave-prov weave-prov-${sheet.who.provenance}`} title={sheet.who.title} aria-hidden="true">{sheet.who.glyph}</span>}
367
+ <span>{sheet.who === null ? sheet.kind : sheet.who.word}</span>
368
+ {sheet.when === "" ? null : <span class="weave-sheet-when">· {sheet.when}</span>}
369
+ </p>
370
+ {sheet.text === null ? null : (
371
+ <div class="weave-sheet-text">
372
+ <span class="weave-sheet-text-head">{sheet.text.heading}</span>
373
+ {sheet.text.parts.map((part) => (part.hit ? <mark class="weave-sheet-mark">{part.text}</mark> : part.text))}
374
+ </div>
375
+ )}
376
+ {sheet.litBecause === null ? null : (
377
+ <p class="weave-sheet-why"><span aria-hidden="true">✳</span>{LIT_BECAUSE} {sheet.litBecause}</p>
378
+ )}
379
+ {sheet.connectedTotal === 0 ? null : (
380
+ <section class="weave-sheet-section" aria-label={CONNECTED_HEADING}>
381
+ <h3 class="weave-sheet-heading">{CONNECTED_HEADING}<span class="weave-sheet-count">{sheet.connectedTotal}</span></h3>
382
+ <ul class="weave-sheet-rows">
383
+ {sheet.connected.map((row) => (
384
+ <li key={row.id}>
385
+ <button type="button" class="weave-sheet-row" title={row.label} aria-label={row.ariaLabel} onClick={() => props.onSelect(row.id)}>
386
+ <span class={`weave-sheet-dot${row.lit ? " weave-sheet-dot-lit" : ""}`} aria-hidden="true" />
387
+ <span class="weave-sheet-row-label">{row.label}</span>
388
+ </button>
389
+ </li>
390
+ ))}
391
+ </ul>
392
+ {more === null ? null : <p class="weave-sheet-more">{more}</p>}
393
+ </section>
394
+ )}
395
+ <div class="weave-sheet-actions">
396
+ <button type="button" class="weave-sheet-open" onClick={() => props.onOpen(sheet.id)}>{SHEET_OPEN}</button>
397
+ {props.onOpenSplit === undefined ? null : (
398
+ <button type="button" class="weave-sheet-split" onClick={() => props.onOpenSplit?.(sheet.id)}>{SHEET_OPEN_SPLIT}</button>
399
+ )}
400
+ </div>
401
+ </aside>
402
+ )}
253
403
  {preview === null ? null : (
254
404
  <aside class="weave-graph-preview" aria-label="Selected graph node">
255
405
  <div class="weave-graph-preview-info">
@@ -269,8 +419,10 @@ export function Graph(props: GraphProps) {
269
419
  </button>
270
420
  <span class="weave-graph-legend">
271
421
  <span class="weave-legend-on">◉ {LEGEND.selected}</span>
272
- <span class="weave-legend-near">● {LEGEND.neighborhood}</span>
273
- <span class="weave-legend-dim">· {LEGEND.dimmed}</span>
422
+ {spot === null
423
+ ? <span class="weave-legend-near">● {LEGEND.neighborhood}</span>
424
+ : <span class="weave-legend-lit">● {SPOTLIGHT_LEGEND.lit}</span>}
425
+ <span class="weave-legend-dim">· {spot === null ? LEGEND.dimmed : SPOTLIGHT_LEGEND.dimmed}</span>
274
426
  </span>
275
427
  </div>
276
428
  <p class="weave-graph-count">{graphCountLabel(model.visible, model.total)}</p>
@@ -46,7 +46,7 @@ import { clusterAggregate, focusNeighborhood } from "../../shared/view";
46
46
  import type { GraphPayload, WireGraphEdge } from "../../shared/wire";
47
47
  import { kindIcon, viewModel } from "../tree/tree.model";
48
48
  import type { IconName } from "../shell/icons.model";
49
- import type { RenderGraph } from "./graph.model";
49
+ import type { HighlightStyle, RenderGraph } from "./graph.model";
50
50
  import { EMPTY_RENDER_GRAPH, renderGraph } from "./graph.model";
51
51
  import { groupNodeColors } from "./groups";
52
52
  import type { PositionStorage } from "./positions";
@@ -208,6 +208,55 @@ export function hoverHighlight(
208
208
  return hoveredId === null ? fallback : focusNeighborhood(hoveredId, edges);
209
209
  }
210
210
 
211
+ // --- the picture: neighbourhood or spotlight ------------------------------------------------
212
+
213
+ /**
214
+ * What the renderer is told to draw: a set, the node it is about, and which
215
+ * treatment the set gets (`graph.model.ts`'s {@link HighlightStyle}).
216
+ *
217
+ * The three travel together because they are one decision — the renderer's
218
+ * `setHighlight(highlight, selectedId, style)` takes exactly this, and a
219
+ * caller that passed them separately could pair a spotlight's set with a
220
+ * neighbourhood's treatment.
221
+ */
222
+ export interface GraphPicture {
223
+ readonly highlight: Set<string> | null;
224
+ readonly selectedId: string | null;
225
+ readonly style: HighlightStyle;
226
+ }
227
+
228
+ /**
229
+ * The column's picture: **a spotlight wins over the selection's neighbourhood**.
230
+ *
231
+ * `lit` is the spotlight's set already mapped onto the canvas
232
+ * (`spotlight.model.ts`'s `litOnCanvas`), or `null` when no query is set — and
233
+ * then the picture is the selection's neighbourhood exactly as it was before
234
+ * the spotlight existed. The selection still rides along under a spotlight,
235
+ * because the node the side sheet is about has to stand out among the lit ones.
236
+ */
237
+ export function graphPicture(neighbourhood: Set<string> | null, selectedId: string | null, lit: Set<string> | null): GraphPicture {
238
+ return lit === null
239
+ ? { highlight: neighbourhood, selectedId, style: "neighbourhood" }
240
+ : { highlight: lit, selectedId, style: "spotlight" };
241
+ }
242
+
243
+ /**
244
+ * The picture while the pointer is over a node.
245
+ *
246
+ * Without a spotlight, {@link hoverHighlight} as ever: hover wins and the
247
+ * hovered node is the subject. **Under a spotlight the lit set holds** — it is
248
+ * something the user typed, and moving the pointer across the canvas to reach
249
+ * a lit node must not keep swapping it for neighbourhoods. Only the subject
250
+ * moves: the hovered node takes the selection's place while the pointer is on
251
+ * it, so a faint dot answers with its full colour and its name (a dimmed
252
+ * node has no label for the hover painter to float), and leaving it hands the
253
+ * emphasis back to the selection.
254
+ */
255
+ export function hoverPicture(edges: readonly WireGraphEdge[], hoveredId: string | null, picture: GraphPicture): GraphPicture {
256
+ if (picture.style === "spotlight") return hoveredId === null ? picture : { ...picture, selectedId: hoveredId };
257
+ return { highlight: hoverHighlight(edges, hoveredId, picture.highlight), selectedId: hoveredId ?? picture.selectedId, style: "neighbourhood" };
258
+ }
259
+
211
260
  // --- the control strip (§1.2) ---------------------------------------------------------------
212
261
 
213
262
  /** The `[fit]` control. Constant, but named here so the component holds no copy. */
@@ -235,7 +284,7 @@ export function graphCountLabel(visible: number, total: number): string {
235
284
  /** What the column says instead of a canvas. `null` means "there is a graph". */
236
285
  export function graphEmptyMessage(payload: GraphPayload | null, visible: number): string | null {
237
286
  if (payload === null) return "Loading…";
238
- if (payload.model.nodes.length === 0) return "Nothing indexed yet — add a note or run /weave-scan.";
287
+ if (payload.model.nodes.length === 0) return "Nothing here yet — add a note to get started.";
239
288
  // A payload with nodes but nothing visible cannot happen through the UI
240
289
  // (`clusterAggregate` always keeps the roots), but a truncated response
241
290
  // could produce it, and a blank canvas with no explanation reads as a crash.
@@ -269,6 +318,12 @@ export interface GraphColumnModel {
269
318
  readonly cached: boolean;
270
319
  /** Every cluster in the *unreduced* model — what `[expand]` operates on. */
271
320
  readonly clusters: ReadonlyMap<string, ClusterInfo>;
321
+ /**
322
+ * Hidden id → the visible cluster standing in for it (`clusterAggregate`'s
323
+ * mapping), so a set computed over the whole payload — the spotlight's —
324
+ * can be retargeted onto what is drawn.
325
+ */
326
+ readonly representative: ReadonlyMap<string, string>;
272
327
  /** Visible / total, for the count line. */
273
328
  readonly visible: number;
274
329
  readonly total: number;
@@ -283,6 +338,7 @@ export const EMPTY_COLUMN: GraphColumnModel = {
283
338
  key: "",
284
339
  cached: false,
285
340
  clusters: new Map(),
341
+ representative: new Map(),
286
342
  visible: 0,
287
343
  total: 0,
288
344
  empty: "Loading…",
@@ -351,6 +407,7 @@ export function graphColumnModel(
351
407
  key: layout.key,
352
408
  cached: layout.cached,
353
409
  clusters: reduced.clusters,
410
+ representative: reduced.representative,
354
411
  visible: reduced.nodes.length,
355
412
  total: model.nodes.length,
356
413
  empty: graphEmptyMessage(payload, reduced.nodes.length),
@@ -83,11 +83,8 @@ export function graphPalette(theme: GraphTheme): Readonly<Record<ColorSlot, stri
83
83
  * - `note` keeps `text` — notes are the product (§1.1), and their identity
84
84
  * is carried by the provenance badge, not by a hue.
85
85
  * - Generated code — modules, packages, entry points, git, externals, files
86
- * — takes `dim`, the same grey a `generated` row takes in the tree. The
87
- * TUI's `kindStyle` (src/pi/viewer/tui/theme.ts) still maps these kinds to
88
- * success/warning; the two media disagree on purpose, because a pi theme
89
- * has no dark/light stylesheet to violate and a terminal can restyle
90
- * everything. Here the sheet decides, and the sheet says calm.
86
+ * — takes `dim`, the same grey a `generated` row takes in the tree. Here
87
+ * the sheet decides, and the sheet says calm.
91
88
  */
92
89
  export const KIND_SLOT: Readonly<Record<WireNodeKind, ColorSlot>> = {
93
90
  vault: "accent",
@@ -378,6 +375,8 @@ export interface HoverLabelNode {
378
375
  readonly y: number;
379
376
  readonly size: number;
380
377
  readonly label: string | null;
378
+ /** Set by the spotlight on a node whose label already stands — see {@link hoverLabelPainter}. */
379
+ readonly forceLabel?: boolean;
381
380
  }
382
381
 
383
382
  /** The settings a hover painter reads. Both `GraphSettings` and sigma's satisfy it. */
@@ -434,6 +433,9 @@ export function hoverLabelPainter(
434
433
  // `nodeReducer` blanks the label of everything outside the highlight, and
435
434
  // a node with no name has nothing to float.
436
435
  if (data.label === null || data.label === "") return;
436
+ // A forced label (the spotlight's lit nodes) is already standing beside the
437
+ // disc; floating it again underneath would print the same name twice.
438
+ if (data.forceLabel === true) return;
437
439
  const palette = graphPalette(scheme);
438
440
  context.save();
439
441
  context.font = `${settings.labelSize}px ${settings.labelFont}`;
@@ -513,7 +515,8 @@ export interface ViewBox {
513
515
  * session: the node stays under the cursor, the canvas bounds the drag, and
514
516
  * `[fit]` re-frames onto whatever the current positions are.
515
517
  *
516
- * Use the position extent, with a minimum span of four collision radii.
518
+ * Use the position extent padded by the largest node size, so no circle
519
+ * at the edge is cut off, with a minimum span of four collision radii.
517
520
  * A single node (or coincident nodes) has no extent; Sigma scales layout-unit
518
521
  * node sizes by that span, so a near-zero box magnifies one node across the
519
522
  * canvas. Pixel-based `stagePadding` cannot prevent that.
@@ -536,7 +539,9 @@ export function frameBox(points: readonly Point[]): ViewBox | null {
536
539
  if (p.y > maxY) maxY = p.y;
537
540
  }
538
541
  if (seen === 0) return null;
539
- const padding = Math.max(0, (COLLIDE_RADIUS * 4 - Math.max(maxX - minX, maxY - minY)) / 2);
542
+ // The extent is of node *centres*; the circles reach past it by up to a
543
+ // full node size, which clipped the outermost nodes on a small graph.
544
+ const padding = Math.max(MAX_NODE_SIZE, (COLLIDE_RADIUS * 4 - Math.max(maxX - minX, maxY - minY)) / 2);
540
545
  return { x: [minX - padding, maxX + padding], y: [minY - padding, maxY + padding] };
541
546
  }
542
547
 
@@ -670,8 +675,31 @@ export interface NodeDisplayOverride {
670
675
  readonly label?: string | null;
671
676
  readonly zIndex?: number;
672
677
  readonly size?: number;
678
+ /**
679
+ * Draw the label whatever the zoom and the label grid say — sigma's own
680
+ * display attribute. Only the spotlight sets it: a lit node has to name
681
+ * itself, and on a graph over {@link SMALL_GRAPH_NODES} the size threshold
682
+ * would otherwise keep a lit leaf silent.
683
+ */
684
+ readonly forceLabel?: boolean;
673
685
  }
674
686
 
687
+ /**
688
+ * Which picture a highlight set paints.
689
+ *
690
+ * - `neighbourhood` — a selection (or hover) and its one-hop neighbours: the
691
+ * selection is the subject, its links are drawn in the accent.
692
+ * - `spotlight` — the nodes a query lit (`spotlight.model.ts`): every lit node
693
+ * is a subject, grown and labelled, the edges *between* lit nodes are drawn
694
+ * in the highlighter's warm slot, and everything else drops back further
695
+ * than a neighbourhood's cloud does.
696
+ *
697
+ * One set, one fade, two treatments — the spotlight rides the exact
698
+ * `setHighlight` path a selection does instead of a second layer, so the two
699
+ * can never be on screen at once and argue.
700
+ */
701
+ export type HighlightStyle = "neighbourhood" | "spotlight";
702
+
675
703
  /** What sigma's `edgeReducer` may override. */
676
704
  export interface EdgeDisplayOverride {
677
705
  readonly color?: string;
@@ -715,12 +743,15 @@ export function nodeReducer(
715
743
  * about the clock.
716
744
  */
717
745
  progress: number = 1,
746
+ /** Which treatment the set gets — see {@link HighlightStyle}. */
747
+ style: HighlightStyle = "neighbourhood",
718
748
  ): (id: string, data: RenderNode, scheme: GraphTheme) => NodeDisplayOverride {
719
749
  return (id, data, scheme) => {
720
750
  // `progress` at zero is indistinguishable from no highlight, and saying so
721
751
  // here is what lets the fade end by *returning* to the unhighlighted graph
722
752
  // rather than by approaching it.
723
753
  if (highlight === null || progress <= 0) return {};
754
+ if (style === "spotlight") return spotlightNode(highlight, selectedId ?? null, progress, id, data, scheme);
724
755
  if (id === selectedId && highlight.has(id)) {
725
756
  // The subject of the gesture: lifted clear of its own neighbourhood and
726
757
  // grown by a ratio rather than to a fixed radius, so a hub still reads
@@ -743,6 +774,75 @@ export function nodeReducer(
743
774
  };
744
775
  }
745
776
 
777
+ // --- the spotlight treatment (G1) ------------------------------------------------------
778
+
779
+ /**
780
+ * How far an unlit node or edge recedes while a spotlight is up.
781
+ *
782
+ * Further than a neighbourhood's {@link RECESS_STRENGTH}: a selection dims the
783
+ * cloud to *place* a node inside it, where a spotlight asks "where are the
784
+ * things I named?" and the answer is the lit nodes alone — the rest is
785
+ * context for position, faint dots and hairlines and nothing more.
786
+ */
787
+ export const SPOTLIGHT_RECESS = 0.92;
788
+
789
+ /** A lit node's growth — a step past a neighbour's, because every lit node is a subject. */
790
+ export const SPOTLIGHT_GROWTH = 1.4;
791
+
792
+ /** The selected node while a spotlight is up: the one the side sheet is about. */
793
+ export const SPOTLIGHT_SELECTED_GROWTH = 1.7;
794
+
795
+ /**
796
+ * An unlit node shrinks toward a dot as it recedes.
797
+ *
798
+ * Unlike a neighbourhood's cloud (which keeps its size so the graph does not
799
+ * appear to move on a click), the spotlight is a mode the user typed into,
800
+ * and the shrink is what makes "everything else drops back to faint dots"
801
+ * true at a glance. Positions are untouched, so nothing reflows.
802
+ */
803
+ export const SPOTLIGHT_SHRINK = 0.75;
804
+
805
+ /**
806
+ * Above this many lit nodes, labels go back to the size threshold.
807
+ *
808
+ * Forcing every label is right for the handful of matches a query usually
809
+ * names, and wrong for a query like `src` that lights a whole subtree: two
810
+ * hundred forced labels are one unreadable smear. Past the cap the lit nodes
811
+ * still grow and keep full colour; sigma's grid decides which of them speak.
812
+ */
813
+ export const SPOTLIGHT_LABEL_LIMIT = 40;
814
+
815
+ /** The slot an edge between two lit nodes is painted in — the highlighter's warm voice. */
816
+ export const SPOTLIGHT_EDGE_SLOT: ColorSlot = "warning";
817
+
818
+ /** One node under a spotlight. The selection is always a subject, lit or not. */
819
+ function spotlightNode(
820
+ lit: ReadonlySet<string>,
821
+ selectedId: string | null,
822
+ progress: number,
823
+ id: string,
824
+ data: RenderNode,
825
+ scheme: GraphTheme,
826
+ ): NodeDisplayOverride {
827
+ const speaks = progress >= LABEL_DROP_AT;
828
+ if (id === selectedId) {
829
+ return { zIndex: data.zIndex + HIGHLIGHT_Z_LIFT * 2, size: data.size * growth(SPOTLIGHT_SELECTED_GROWTH, progress), forceLabel: speaks };
830
+ }
831
+ if (lit.has(id)) {
832
+ return {
833
+ zIndex: data.zIndex + HIGHLIGHT_Z_LIFT,
834
+ size: data.size * growth(SPOTLIGHT_GROWTH, progress),
835
+ forceLabel: speaks && lit.size <= SPOTLIGHT_LABEL_LIMIT,
836
+ };
837
+ }
838
+ return {
839
+ color: recessColor(data.color, scheme, SPOTLIGHT_RECESS * progress),
840
+ label: speaks ? null : data.label,
841
+ size: data.size * growth(SPOTLIGHT_SHRINK, progress),
842
+ zIndex: 0,
843
+ };
844
+ }
845
+
746
846
  // --- the highlight fade (§7.4) -----------------------------------------------------
747
847
 
748
848
  /**
@@ -856,9 +956,23 @@ export function edgeReducer(
856
956
  selectedId?: string | null,
857
957
  /** The fade's progress — see {@link nodeReducer}'s. */
858
958
  progress: number = 1,
959
+ /** Which treatment the set gets — see {@link HighlightStyle}. */
960
+ style: HighlightStyle = "neighbourhood",
859
961
  ): (key: string, data: RenderEdge, scheme: GraphTheme) => EdgeDisplayOverride {
860
962
  return (_key, data, scheme) => {
861
963
  if (highlight === null || progress <= 0) return {};
964
+ if (style === "spotlight") {
965
+ // Lit only when it joins two lit nodes: the lines *between* matches are
966
+ // what the spotlight is for — how the things you named connect.
967
+ if (!highlight.has(data.source) || !highlight.has(data.target)) {
968
+ return { color: recessColor(data.color, scheme, SPOTLIGHT_RECESS * progress) };
969
+ }
970
+ return {
971
+ zIndex: 2,
972
+ size: data.size * growth(EDGE_PRESENCE * 1.35, progress),
973
+ color: blendHex(data.color, graphPalette(scheme)[SPOTLIGHT_EDGE_SLOT], progress),
974
+ };
975
+ }
862
976
  if (!highlight.has(data.source) || !highlight.has(data.target)) {
863
977
  return { color: recessColor(data.color, scheme, RECESS_STRENGTH * progress) };
864
978
  }
@@ -882,6 +996,54 @@ export function edgeReducer(
882
996
  };
883
997
  }
884
998
 
999
+ // --- the recall trail (issue #78) -----------------------------------------------------
1000
+
1001
+ /** The slice of `trail.model.ts`'s `TrailMark` this layer reads, restated to keep this file import-free of it. */
1002
+ export interface TrailGlow {
1003
+ readonly kind: "searched" | "read" | "wrote";
1004
+ readonly strength: number;
1005
+ }
1006
+
1007
+ /**
1008
+ * How each kind of touch is painted: which slot it blends toward, how far, and
1009
+ * how much the node grows. A search hit is a soft amber hint, a read is a
1010
+ * filled glow, and a write is the accent at full presence — the one place
1011
+ * this canvas spends the accent on a note, because a write is the event.
1012
+ */
1013
+ export const TRAIL_STYLE: Readonly<Record<TrailGlow["kind"], { slot: ColorSlot; blend: number; growth: number }>> = {
1014
+ searched: { slot: "warning", blend: 0.35, growth: 1.08 },
1015
+ read: { slot: "success", blend: 0.85, growth: 1.3 },
1016
+ wrote: { slot: "accent", blend: 1, growth: 1.5 },
1017
+ };
1018
+
1019
+ /**
1020
+ * The recall-trail layer, applied **over** the selection reducer's answer.
1021
+ *
1022
+ * Over, because the trail is the live event: a note the agent just wrote must
1023
+ * read as such even when it sits in the receded cloud of an unrelated
1024
+ * selection. It is handed the *reduced* node, and colour and size scale with
1025
+ * the glow's strength, so the fade is a smooth return to whatever the
1026
+ * selection layer said; the label is kept so a glowing node always names itself.
1027
+ */
1028
+ export function trailOverride(
1029
+ glow: TrailGlow | undefined,
1030
+ /** The node as the selection layer left it, so the glow composes over recession and growth. */
1031
+ data: Pick<RenderNode, "color" | "size" | "zIndex">,
1032
+ scheme: GraphTheme,
1033
+ /** The node's own label — the selection layer may have dropped it, and a glowing node always names itself. */
1034
+ label: string,
1035
+ ): NodeDisplayOverride {
1036
+ if (glow === undefined || !(glow.strength > 0)) return {};
1037
+ const style = TRAIL_STYLE[glow.kind];
1038
+ const strength = Math.min(1, glow.strength);
1039
+ return {
1040
+ color: blendHex(data.color, graphPalette(scheme)[style.slot], style.blend * strength),
1041
+ size: data.size * growth(style.growth, strength),
1042
+ zIndex: data.zIndex + HIGHLIGHT_Z_LIFT * 3,
1043
+ label,
1044
+ };
1045
+ }
1046
+
885
1047
  // --- sigma settings (§7.4) -------------------------------------------------------------
886
1048
 
887
1049
  /**
@@ -929,6 +1091,20 @@ export interface GraphSettings {
929
1091
  */
930
1092
  export const LABEL_SIZE_THRESHOLD = (MIN_NODE_SIZE + MAX_NODE_SIZE) / 2;
931
1093
 
1094
+ /**
1095
+ * Below this many nodes every node carries its name.
1096
+ *
1097
+ * The size threshold exists to keep a dense graph legible; on a vault of
1098
+ * two or three notes it left all but one circle unnamed, and a small graph
1099
+ * has no density to protect.
1100
+ */
1101
+ export const SMALL_GRAPH_NODES = 30;
1102
+
1103
+ /** `labelRenderedSizeThreshold` for a graph of `nodeCount` nodes. */
1104
+ export function labelThreshold(nodeCount: number): number {
1105
+ return nodeCount < SMALL_GRAPH_NODES ? 0 : LABEL_SIZE_THRESHOLD;
1106
+ }
1107
+
932
1108
  /**
933
1109
  * Label collision grid, in screen pixels.
934
1110
  *