@unbrained/pm-web 2026.8.10 → 2026.8.17

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 (182) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/app.d.ts +16 -0
  3. package/dist/app.js +16 -0
  4. package/dist/app.js.map +1 -1
  5. package/dist/auth.d.ts +5 -0
  6. package/dist/auth.js.map +1 -1
  7. package/dist/board.d.ts +53 -0
  8. package/dist/board.js +48 -0
  9. package/dist/board.js.map +1 -1
  10. package/dist/crypto.d.ts +23 -0
  11. package/dist/crypto.js +32 -0
  12. package/dist/crypto.js.map +1 -1
  13. package/dist/db.d.ts +19 -0
  14. package/dist/db.js +19 -0
  15. package/dist/db.js.map +1 -1
  16. package/dist/ical.d.ts +72 -0
  17. package/dist/ical.js +73 -0
  18. package/dist/ical.js.map +1 -1
  19. package/dist/index.d.ts +11 -0
  20. package/dist/index.js +52 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/middleware/auth.d.ts +8 -0
  23. package/dist/middleware/auth.js.map +1 -1
  24. package/dist/oidc.d.ts +87 -0
  25. package/dist/oidc.js +113 -0
  26. package/dist/oidc.js.map +1 -1
  27. package/dist/routes/admin.js +15 -0
  28. package/dist/routes/admin.js.map +1 -1
  29. package/dist/routes/extensions.js +10 -0
  30. package/dist/routes/extensions.js.map +1 -1
  31. package/dist/routes/github.js +13 -0
  32. package/dist/routes/github.js.map +1 -1
  33. package/dist/routes/oidc.d.ts +10 -0
  34. package/dist/routes/oidc.js +51 -0
  35. package/dist/routes/oidc.js.map +1 -1
  36. package/dist/routes/pm.js +132 -1
  37. package/dist/routes/pm.js.map +1 -1
  38. package/dist/routes/sharing.js +8 -0
  39. package/dist/routes/sharing.js.map +1 -1
  40. package/dist/services/mutation-event-watcher.js +13 -0
  41. package/dist/services/mutation-event-watcher.js.map +1 -1
  42. package/dist/services/package-catalog.d.ts +24 -0
  43. package/dist/services/package-catalog.js +22 -2
  44. package/dist/services/package-catalog.js.map +1 -1
  45. package/dist/services/pm-runner.d.ts +82 -0
  46. package/dist/services/pm-runner.js +121 -0
  47. package/dist/services/pm-runner.js.map +1 -1
  48. package/dist/services/project-watcher.d.ts +22 -0
  49. package/dist/services/project-watcher.js +51 -0
  50. package/dist/services/project-watcher.js.map +1 -1
  51. package/dist/services/realtime-bus.d.ts +46 -0
  52. package/dist/services/realtime-bus.js +59 -0
  53. package/dist/services/realtime-bus.js.map +1 -1
  54. package/dist/services/sse.d.ts +134 -0
  55. package/dist/services/sse.js +145 -0
  56. package/dist/services/sse.js.map +1 -1
  57. package/manifest.json +1 -1
  58. package/package.json +5 -3
  59. package/public/src/app.js +44 -1
  60. package/public/src/app.js.map +1 -1
  61. package/public/src/app.ts +44 -1
  62. package/public/src/components/modals.js +11 -0
  63. package/public/src/components/modals.js.map +1 -1
  64. package/public/src/components/modals.ts +11 -0
  65. package/public/src/components/toast.js +2 -0
  66. package/public/src/components/toast.js.map +1 -1
  67. package/public/src/components/toast.ts +2 -0
  68. package/public/src/constants.js +21 -4
  69. package/public/src/constants.js.map +1 -1
  70. package/public/src/constants.ts +21 -4
  71. package/public/src/filters.js +10 -7
  72. package/public/src/filters.js.map +1 -1
  73. package/public/src/filters.ts +10 -7
  74. package/public/src/i18n/de.json +3 -1
  75. package/public/src/i18n/en.json +3 -1
  76. package/public/src/i18n/es.json +3 -1
  77. package/public/src/i18n/zh.json +3 -1
  78. package/public/src/i18n.js +11 -1
  79. package/public/src/i18n.js.map +1 -1
  80. package/public/src/i18n.ts +13 -1
  81. package/public/src/state.js +3 -0
  82. package/public/src/state.js.map +1 -1
  83. package/public/src/state.ts +3 -0
  84. package/public/src/sw.ts +71 -7
  85. package/public/src/theme.js +13 -1
  86. package/public/src/theme.js.map +1 -1
  87. package/public/src/theme.ts +15 -1
  88. package/public/src/types.ts +2 -0
  89. package/public/src/utils.js +26 -1
  90. package/public/src/utils.js.map +1 -1
  91. package/public/src/utils.ts +26 -1
  92. package/public/src/views/activity.js +1 -0
  93. package/public/src/views/activity.js.map +1 -1
  94. package/public/src/views/activity.ts +1 -0
  95. package/public/src/views/admin.js +134 -0
  96. package/public/src/views/admin.js.map +1 -1
  97. package/public/src/views/admin.ts +134 -0
  98. package/public/src/views/auth.js +31 -0
  99. package/public/src/views/auth.js.map +1 -1
  100. package/public/src/views/auth.ts +31 -0
  101. package/public/src/views/calendar.js +3 -0
  102. package/public/src/views/calendar.js.map +1 -1
  103. package/public/src/views/calendar.ts +3 -0
  104. package/public/src/views/comments-audit.js +1 -0
  105. package/public/src/views/comments-audit.js.map +1 -1
  106. package/public/src/views/comments-audit.ts +1 -0
  107. package/public/src/views/config.js +67 -0
  108. package/public/src/views/config.js.map +1 -1
  109. package/public/src/views/config.ts +67 -0
  110. package/public/src/views/context.js +2 -0
  111. package/public/src/views/context.js.map +1 -1
  112. package/public/src/views/context.ts +2 -0
  113. package/public/src/views/create.js +3 -0
  114. package/public/src/views/create.js.map +1 -1
  115. package/public/src/views/create.ts +3 -0
  116. package/public/src/views/dedupe.js +1 -0
  117. package/public/src/views/dedupe.js.map +1 -1
  118. package/public/src/views/dedupe.ts +1 -0
  119. package/public/src/views/export.js +6 -0
  120. package/public/src/views/export.js.map +1 -1
  121. package/public/src/views/export.ts +6 -0
  122. package/public/src/views/github.js +58 -0
  123. package/public/src/views/github.js.map +1 -1
  124. package/public/src/views/github.ts +58 -0
  125. package/public/src/views/graph-canvas.js +18 -0
  126. package/public/src/views/graph-canvas.js.map +1 -1
  127. package/public/src/views/graph-canvas.ts +20 -0
  128. package/public/src/views/graph.js +41 -0
  129. package/public/src/views/graph.js.map +1 -1
  130. package/public/src/views/graph.ts +42 -1
  131. package/public/src/views/groups.js +45 -0
  132. package/public/src/views/groups.js.map +1 -1
  133. package/public/src/views/groups.ts +45 -0
  134. package/public/src/views/guide.js +3 -0
  135. package/public/src/views/guide.js.map +1 -1
  136. package/public/src/views/guide.ts +3 -0
  137. package/public/src/views/health.js +3 -0
  138. package/public/src/views/health.js.map +1 -1
  139. package/public/src/views/health.ts +3 -0
  140. package/public/src/views/items.js +196 -6
  141. package/public/src/views/items.js.map +1 -1
  142. package/public/src/views/items.ts +196 -6
  143. package/public/src/views/normalize.js +2 -0
  144. package/public/src/views/normalize.js.map +1 -1
  145. package/public/src/views/normalize.ts +2 -0
  146. package/public/src/views/packages.js +67 -5
  147. package/public/src/views/packages.js.map +1 -1
  148. package/public/src/views/packages.ts +74 -5
  149. package/public/src/views/plan-execution.js +6 -0
  150. package/public/src/views/plan-execution.js.map +1 -1
  151. package/public/src/views/plan-execution.ts +8 -0
  152. package/public/src/views/plan.js +145 -0
  153. package/public/src/views/plan.js.map +1 -1
  154. package/public/src/views/plan.ts +146 -1
  155. package/public/src/views/projects.js +60 -0
  156. package/public/src/views/projects.js.map +1 -1
  157. package/public/src/views/projects.ts +60 -0
  158. package/public/src/views/router.js +43 -0
  159. package/public/src/views/router.js.map +1 -1
  160. package/public/src/views/router.ts +43 -0
  161. package/public/src/views/search.js +5 -0
  162. package/public/src/views/search.js.map +1 -1
  163. package/public/src/views/search.ts +5 -0
  164. package/public/src/views/settings.js +36 -0
  165. package/public/src/views/settings.js.map +1 -1
  166. package/public/src/views/settings.ts +36 -0
  167. package/public/src/views/shared.js +1 -0
  168. package/public/src/views/shared.js.map +1 -1
  169. package/public/src/views/shared.ts +1 -0
  170. package/public/src/views/sharing.js +32 -0
  171. package/public/src/views/sharing.js.map +1 -1
  172. package/public/src/views/sharing.ts +32 -0
  173. package/public/src/views/stats.js +1 -0
  174. package/public/src/views/stats.js.map +1 -1
  175. package/public/src/views/stats.ts +1 -0
  176. package/public/src/views/templates.js +4 -0
  177. package/public/src/views/templates.js.map +1 -1
  178. package/public/src/views/templates.ts +4 -0
  179. package/public/src/views/validate.js +1 -0
  180. package/public/src/views/validate.js.map +1 -1
  181. package/public/src/views/validate.ts +1 -0
  182. package/public/sw.js +60 -5
@@ -6,6 +6,7 @@
6
6
  // spatial partitioning, edge bundling, hierarchical layout
7
7
  // ═══════════════════════════════════════════════════════════════
8
8
 
9
+ /** Display model for a single node rendered on the graph canvas: the id and label plus type, status, lane, degree, and optional tags, priority, and assignee used for sizing and coloring. */
9
10
  export interface CanvasNode {
10
11
  id: string;
11
12
  label: string;
@@ -26,6 +27,7 @@ export interface CanvasEdge {
26
27
 
27
28
  export type LayoutMode = 'force' | 'hierarchical';
28
29
 
30
+ /** Configuration passed to the GraphCanvas constructor: selection, open, and context-menu callbacks plus optional layout, edge-bundling, and PNG export hooks. */
29
31
  export interface GraphCanvasOptions {
30
32
  onSelectNode(id: string | null): void;
31
33
  onOpenNode(id: string): void;
@@ -161,6 +163,7 @@ function truncate(text: string, maxLen: number): string {
161
163
  return text.length > maxLen ? text.slice(0, maxLen - 1) + '…' : text;
162
164
  }
163
165
 
166
+ /** Parses a six-digit `#rrggbb` hex color string into red, green, and blue byte values, returning null when the input does not match that format. */
164
167
  function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
165
168
  const m = /^#([0-9a-f]{6})$/i.exec(hex);
166
169
  if (!m) return null;
@@ -174,6 +177,7 @@ function hexAlpha(hex: string, a: number): string {
174
177
  return `rgba(${c.r},${c.g},${c.b},${a})`;
175
178
  }
176
179
 
180
+ /** Computes starting positions for the given nodes arranged along a golden-angle (sunflower) spiral so the force simulation begins evenly spread. */
177
181
  function initialPositions(nodes: CanvasNode[]): Array<{ x: number; y: number }> {
178
182
  const golden = 2.399963;
179
183
  return nodes.map((_, i) => {
@@ -217,6 +221,7 @@ function convexHull(pts: Array<{ x: number; y: number }>): Array<{ x: number; y:
217
221
  }
218
222
 
219
223
  // escHtml is imported from utils.ts in graph.ts — canvas uses its own for DOM tooltip only
224
+ /** Escapes the four HTML-significant characters (ampersand, angle brackets, and double quote) so a string can be safely interpolated into DOM markup such as the canvas tooltip. */
220
225
  function escHtml(s: string): string {
221
226
  return s
222
227
  .replace(/&/g, '&amp;')
@@ -228,6 +233,7 @@ function escHtml(s: string): string {
228
233
  // ═══════════════════════════════════════════════════════════════
229
234
  // GraphCanvas class
230
235
  // ═══════════════════════════════════════════════════════════════
236
+ /** Canvas 2D renderer and force-directed physics simulation for the knowledge graph: draws nodes, edges, particles, minimap, and HUD, and handles pan, zoom, drag, select, and keyboard interaction. */
231
237
  export class GraphCanvas {
232
238
  private canvas: HTMLCanvasElement;
233
239
  private ctx: CanvasRenderingContext2D;
@@ -365,6 +371,7 @@ export class GraphCanvas {
365
371
 
366
372
  // ── Public API ─────────────────────────────────────────────
367
373
 
374
+ /** Loads the given nodes and edges into the simulation, preserving prior node positions where possible, precomputing bidirectional edge pairs, recoloring nodes, scheduling the initial zoom-to-fit, and applying the hierarchical layout when selected. */
368
375
  setData(nodes: CanvasNode[], edges: CanvasEdge[]): void {
369
376
  const prevPos = new Map(this.nodes.map((n) => [n.id, { x: n.x, y: n.y }]));
370
377
  const initPos = initialPositions(nodes);
@@ -425,6 +432,7 @@ export class GraphCanvas {
425
432
  this.hideTooltip();
426
433
  }
427
434
 
435
+ /** Merges a partial filter into the current filter, rebuilding keyboard-navigation order, resetting flow particles when the selection changes, and recoloring nodes when the color mode or tag changes. */
428
436
  setFilter(filter: Partial<CanvasFilter>): void {
429
437
  const prevMode = this.filter.colorMode;
430
438
  const prevTag = this.filter.colorTag;
@@ -440,9 +448,11 @@ export class GraphCanvas {
440
448
  else if (filter.colorTag !== undefined && filter.colorTag !== prevTag) this.recolorNodes();
441
449
  }
442
450
 
451
+ /** Returns the current tag-to-color map used when coloring nodes by tag. */
443
452
  getTagColorMap(): Map<string, string> { return this.tagColorMap; }
444
453
 
445
454
  // Live physics control — used by the physics sliders panel
455
+ /** Updates one or more live physics constants (repulsion, link distance, center gravity, link strength) and reheats the simulation so the new values take effect. */
446
456
  setPhysicsParams(params: {
447
457
  repulsion?: number;
448
458
  linkDistance?: number;
@@ -456,10 +466,12 @@ export class GraphCanvas {
456
466
  this.reheat();
457
467
  }
458
468
 
469
+ /** Returns the current physics constants (repulsion, link distance, center force, and link strength) so external sliders can reflect them. */
459
470
  getPhysicsParams(): { repulsion: number; linkDistance: number; centerForce: number; linkStrength: number } {
460
471
  return { repulsion: this.REPULSE, linkDistance: this.REST_LEN, centerForce: this.CENTER, linkStrength: this.SPRING };
461
472
  }
462
473
 
474
+ /** Sets the selected node id, resets flow particles, and flies the camera to the node when an id is given. */
463
475
  setSelected(id: string | null): void {
464
476
  this.filter = { ...this.filter, selectedId: id };
465
477
  this.particles = [];
@@ -470,11 +482,13 @@ export class GraphCanvas {
470
482
  }
471
483
  }
472
484
 
485
+ /** Flies the camera to center on the node with the given id, if it exists. */
473
486
  jumpToNode(id: string): void {
474
487
  const node = this.nodeMap.get(id);
475
488
  if (node) this.flyTo(node);
476
489
  }
477
490
 
491
+ /** Animates the camera to frame all visible nodes with padding by setting a fly-to target computed from their bounding box. */
478
492
  fitView(): void {
479
493
  if (!this.nodes.length) return;
480
494
  const vis = this.filter.visibleNodeIds;
@@ -499,17 +513,20 @@ export class GraphCanvas {
499
513
  };
500
514
  }
501
515
 
516
+ /** Pauses or resumes the physics simulation and returns the new paused state; resuming also bumps the simulation alpha. */
502
517
  togglePhysics(): boolean {
503
518
  this.paused = !this.paused;
504
519
  if (!this.paused) this.alpha = Math.max(this.alpha, 0.05);
505
520
  return this.paused;
506
521
  }
507
522
 
523
+ /** Resumes the simulation and sets the alpha to 0.3 so nodes re-settle into a new layout. */
508
524
  reheat(): void {
509
525
  this.alpha = 0.3;
510
526
  this.paused = false;
511
527
  }
512
528
 
529
+ /** Switches to the given layout, applying the topological hierarchical layout or, for force mode, jittering node positions and reheating the simulation. */
513
530
  setLayout(layout: LayoutMode): void {
514
531
  if (this.layout === layout) return;
515
532
  this.layout = layout;
@@ -525,10 +542,12 @@ export class GraphCanvas {
525
542
  }
526
543
  }
527
544
 
545
+ /** Enables or disables edge bundling, which routes many same-type edges with control points biased toward a shared centroid. */
528
546
  setEdgeBundling(enabled: boolean): void {
529
547
  this.edgeBundling = enabled;
530
548
  }
531
549
 
550
+ /** Exports the canvas as a PNG, delegating to the configured callback if provided, otherwise triggering a browser download of the canvas image data. */
532
551
  exportPng(): void {
533
552
  if (this.onExportPng) {
534
553
  this.onExportPng(this.canvas);
@@ -783,6 +802,7 @@ export class GraphCanvas {
783
802
  return best;
784
803
  }
785
804
 
805
+ /** Tears down the canvas: stops the animation loop and initial-fit timer, aborts bound event listeners, disconnects the ResizeObserver, and removes the canvas and its tooltip from the DOM. */
786
806
  destroy(): void {
787
807
  this.destroyed = true;
788
808
  if (this.rafId !== null) cancelAnimationFrame(this.rafId);
@@ -50,6 +50,7 @@ function removeCtxMenu() {
50
50
  ctxMenuEl = null;
51
51
  }
52
52
  }
53
+ /** Builds and shows the right-click context menu for a graph node, with actions to open the item, select and focus, show its neighborhood, expand two hops, and copy its id. */
53
54
  function showCtxMenu(nodeId, x, y) {
54
55
  removeCtxMenu();
55
56
  const graph = currentGraph?.graph || {};
@@ -121,6 +122,7 @@ function showCtxMenu(nodeId, x, y) {
121
122
  function nodeTitle(node) {
122
123
  return String(node.properties?.title || node.id);
123
124
  }
125
+ /** Returns a display type for a node, preferring the `kind` property, then `type`, then a non-core label, defaulting to 'Item'. */
124
126
  function nodeType(node) {
125
127
  return String(node.properties?.kind
126
128
  || node.properties?.type
@@ -143,6 +145,7 @@ function nodeLane(node) {
143
145
  return 'external';
144
146
  return 'item';
145
147
  }
148
+ /** Extracts a short user-facing error message from a raw error string (JSON detail, title, or code, otherwise the trimmed raw text), returning an empty string when the error is just the missing pm-graph extension. */
146
149
  function compactError(raw) {
147
150
  if (!raw)
148
151
  return '';
@@ -157,6 +160,7 @@ function compactError(raw) {
157
160
  }
158
161
  }
159
162
  // ── Graph data processing ─────────────────────────────────────
163
+ /** Returns the set of ids directly connected to the given node (plus the node itself) across the provided relationships. */
160
164
  function directNeighborIds(nodeId, rels) {
161
165
  const ids = new Set([nodeId]);
162
166
  for (const r of rels) {
@@ -167,6 +171,7 @@ function directNeighborIds(nodeId, rels) {
167
171
  }
168
172
  return ids;
169
173
  }
174
+ /** Returns the neighbor id(s) on the other end of a relationship relative to a node, filtered by traversal direction (incoming, outgoing, or connected). */
170
175
  function walkDirectionMatch(rel, nodeId, dir) {
171
176
  if (dir === 'incoming') {
172
177
  return rel.to === nodeId ? [rel.from] : [];
@@ -176,6 +181,7 @@ function walkDirectionMatch(rel, nodeId, dir) {
176
181
  }
177
182
  return rel.to === nodeId ? [rel.from] : rel.from === nodeId ? [rel.to] : [];
178
183
  }
184
+ /** Performs a bounded breadth-first traversal out to `depth` hops from a node, returning all reachable ids (including the node itself) while respecting the traversal direction. */
179
185
  function expandedNeighborIds(nodeId, rels, depth, direction) {
180
186
  const ids = new Set([nodeId]);
181
187
  let frontier = new Set([nodeId]);
@@ -198,6 +204,7 @@ function expandedNeighborIds(nodeId, rels, depth, direction) {
198
204
  }
199
205
  return ids;
200
206
  }
207
+ /** Counts both endpoints of every relationship, returning a map of node id to its total edge degree (incrementing the count for both `from` and `to`). */
201
208
  function degreeMap(rels) {
202
209
  const m = new Map();
203
210
  for (const r of rels) {
@@ -216,6 +223,7 @@ function blockingPair(rel) {
216
223
  return { blocked: rel.from, blocker: rel.to };
217
224
  return null;
218
225
  }
226
+ /** Maps a relationship type to a human-readable dependency label (for example 'Depends on'), falling back to the lowercased type with underscores replaced by spaces. */
219
227
  function dependencyLabel(rel) {
220
228
  const labels = {
221
229
  DEPENDS_ON: 'Depends on',
@@ -230,6 +238,7 @@ function dependencyLabel(rel) {
230
238
  };
231
239
  return labels[rel.type] ?? rel.type.replace(/_/g, ' ').toLowerCase();
232
240
  }
241
+ /** Computes the critical (longest) dependency path through the given relationships and returns its node ids as a set, or an empty set when there is no multi-step chain. */
233
242
  function computeCriticalPath(rels) {
234
243
  const depRels = rels.filter(isDependencyRel);
235
244
  if (!depRels.length)
@@ -247,6 +256,7 @@ function computeCriticalPath(rels) {
247
256
  allIds.add(pair.blocker);
248
257
  }
249
258
  const memo = new Map();
259
+ /** Memoized depth-first search returning the longest chain of blockers starting from the given id, guarding against cycles with a visited set. */
250
260
  function longestPathFrom(id, seen = new Set()) {
251
261
  if (memo.has(id))
252
262
  return memo.get(id);
@@ -271,6 +281,7 @@ function computeCriticalPath(rels) {
271
281
  .sort((a, b) => b.length - a.length)[0] ?? [];
272
282
  return longest.length < 2 ? new Set() : new Set(longest);
273
283
  }
284
+ /** Builds per-node dependency stats from blocking relationships, mapping each node id to the sets of items that block it and that it blocks. */
274
285
  function blockerStats(rels) {
275
286
  const stats = new Map();
276
287
  const entry = (id) => {
@@ -287,6 +298,7 @@ function blockerStats(rels) {
287
298
  }
288
299
  return stats;
289
300
  }
301
+ /** Applies the current filters (dep mode, focus neighborhood, node kind, status, text query, relationship type, and direction) to a graph, returning the surviving nodes, relationships, and the set of all connected node ids. */
290
302
  function visibleGraph(graph) {
291
303
  const nodes = graph.nodes || [];
292
304
  let rels = graph.relationships || [];
@@ -341,6 +353,7 @@ function visibleGraph(graph) {
341
353
  return { nodes: visNodes, rels: visRels, connected };
342
354
  }
343
355
  // ── Canvas data conversion ────────────────────────────────────
356
+ /** Converts graph nodes into canvas display nodes, attaching each node's edge degree (computed from the relationships) so the renderer can size them. */
344
357
  function toCanvasNodes(nodes, rels) {
345
358
  const deg = degreeMap(rels);
346
359
  return nodes.map((n) => ({
@@ -361,6 +374,7 @@ function toCanvasEdges(rels) {
361
374
  return rels.map((r) => ({ from: r.from, to: r.to, type: r.type }));
362
375
  }
363
376
  // ── Info panel rendering ──────────────────────────────────────
377
+ /** Renders the graph-coverage HTML: a linked-item percentage bar, the external cross-project reference count, and the top relationship type with its edge count. */
364
378
  function renderCoverage(itemNodes, rels, connected, relCounts) {
365
379
  const linked = itemNodes.filter((n) => connected.has(n.id)).length;
366
380
  const linkedPct = itemNodes.length > 0 ? Math.round((linked / itemNodes.length) * 100) : 0;
@@ -390,6 +404,7 @@ function renderCoverage(itemNodes, rels, connected, relCounts) {
390
404
  </div>
391
405
  </div>`;
392
406
  }
407
+ /** Renders the selected-node HTML panel, including metadata, action buttons, blocker counts, the property grid, tags, an optional description, and its direct relationships. */
393
408
  function renderSelectedNode(node, rels, byId, fullItem) {
394
409
  if (!node)
395
410
  return '<div class="graph-node-empty">Click any node in the graph to inspect it.</div>';
@@ -454,6 +469,7 @@ function renderSelectedNode(node, rels, byId, fullItem) {
454
469
  }).join('') + (direct.length > 16 ? `<div class="graph-limit-note">+${direct.length - 16} more — use Focus scope to narrow</div>` : '')}
455
470
  </div>`;
456
471
  }
472
+ /** Renders the neighborhood HTML for the selected node, showing clickable one-hop and two-hop neighbor chips. */
457
473
  function renderPaths(node, rels, byId) {
458
474
  if (!node)
459
475
  return '<div class="graph-node-empty">Select a node to explore paths.</div>';
@@ -471,6 +487,7 @@ function renderPaths(node, rels, byId) {
471
487
  <div class="graph-path-section"><div class="graph-path-label">One hop (${oneHop.size})</div><div class="graph-path-chips">${chips(oneHop) || '<span>None.</span>'}</div></div>
472
488
  <div class="graph-path-section"><div class="graph-path-label">Two hops (${twoHop.size})</div><div class="graph-path-chips">${chips(twoHop) || '<span>None.</span>'}</div></div>`;
473
489
  }
490
+ /** Renders the item-hubs HTML, listing the top eight most-connected items by degree with their outgoing and incoming edge counts. */
474
491
  function renderHubs(nodes, rels) {
475
492
  const deg = degreeMap(rels);
476
493
  const hubs = nodes.filter(isItemNode)
@@ -487,6 +504,7 @@ function renderHubs(nodes, rels) {
487
504
  <small>${e.out}↑ ${e.inn}↓</small>
488
505
  </button>`).join('');
489
506
  }
507
+ /** Renders the dependency-blocker HTML, listing the top eight items that block or are blocked by the most other items and flagging those on the critical path. */
490
508
  function renderBlockingInsights(nodes, rels) {
491
509
  const byId = new Map(nodes.map((n) => [n.id, n]));
492
510
  const stats = blockerStats(rels);
@@ -504,6 +522,7 @@ function renderBlockingInsights(nodes, rels) {
504
522
  <small>${row.blockers.size} blockers · ${row.blocked.size} items blocked${criticalPath.has(row.id) ? ' · critical path' : ''}</small>
505
523
  </button>`).join('');
506
524
  }
525
+ /** Renders the dependency-chains HTML: the longest dependency chains, potential cycle warnings, and root blockers, with critical-path highlighting. */
507
526
  function renderDependencyChains(nodes, rels) {
508
527
  const byId = new Map(nodes.map((n) => [n.id, n]));
509
528
  const depRels = rels.filter(isDependencyRel);
@@ -525,6 +544,7 @@ function renderDependencyChains(nodes, rels) {
525
544
  .map(([id, blocked]) => ({ id, blocked: blocked.length }))
526
545
  .sort((a, b) => b.blocked - a.blocked || nodeTitle(byId.get(a.id) || { id: a.id }).localeCompare(nodeTitle(byId.get(b.id) || { id: b.id })))
527
546
  .slice(0, 4);
547
+ /** Depth-first trace of a dependency chain from the given id, recording detected cycle starts and returning the longest chain of blockers found. */
528
548
  function traceFrom(id, seen = new Set()) {
529
549
  if (seen.has(id)) {
530
550
  cycleStarts.add(id);
@@ -570,6 +590,7 @@ function renderDependencyChains(nodes, rels) {
570
590
  ${leafRows}
571
591
  </div>`;
572
592
  }
593
+ /** Renders the full graph-analysis info panel HTML by composing the coverage, selected node, neighborhood, hubs, blockers, dependency-chain, and node/relationship count sections. */
573
594
  function renderInfoPanel(data, fullItem) {
574
595
  const graph = data.graph || {};
575
596
  const nodes = graph.nodes || [];
@@ -607,6 +628,7 @@ function renderInfoPanel(data, fullItem) {
607
628
  </div>`;
608
629
  }
609
630
  // ── Rel list (inside bottom drawer) ──────────────────────────
631
+ /** Renders the relationship list shown in the bottom drawer: add/remove dependency buttons and up to 100 filtered relationship rows, each linking its two endpoints. */
610
632
  function renderRelList(data) {
611
633
  const graph = data.graph || {};
612
634
  const nodes = graph.nodes || [];
@@ -640,6 +662,7 @@ function renderRelList(data) {
640
662
  return editBtns + rows + limitNote;
641
663
  }
642
664
  // ── Immersive shell ───────────────────────────────────────────
665
+ /** Returns whether the given preset id (knowledge, dependency, unlinked, metadata, critical, or tags) matches the current filter configuration. */
643
666
  function graphPresetActive(id) {
644
667
  if (id === 'knowledge') {
645
668
  return !filter.depMode && (filter.kind === 'items' || filter.kind === 'all') && filter.rel === 'all' && filter.scope === 'all' && filter.colorMode === 'status';
@@ -656,6 +679,7 @@ function graphPresetActive(id) {
656
679
  return !filter.depMode && filter.colorMode === 'tag';
657
680
  return false;
658
681
  }
682
+ /** Renders the preset rail toolbar HTML, with one button per view preset (Knowledge, Dependencies, Unlinked, Tags, Metadata, Critical) showing live counts and active state. */
659
683
  function renderGraphPresets(depRels, isolatedCount) {
660
684
  const allNodes = currentGraph?.graph?.nodes ?? [];
661
685
  const tagSet = new Set();
@@ -681,6 +705,7 @@ function renderGraphPresets(depRels, isolatedCount) {
681
705
  </button>`).join('')}
682
706
  </div>`;
683
707
  }
708
+ /** Renders the full immersive graph shell HTML: the canvas host, the top HUD bar with search and presets, physics controls, filter overlay, legend, info drawer, and relationship drawer. */
684
709
  function renderGraphShell(data) {
685
710
  const graph = data.graph || {};
686
711
  const nodes = graph.nodes || [];
@@ -878,6 +903,7 @@ function renderGraphShell(data) {
878
903
  </div>`;
879
904
  }
880
905
  // ── Canvas init / update ──────────────────────────────────────
906
+ /** Fetches the full item record for the selected item-lane node and re-renders the info panel with its description; clears the cached item for non-item nodes or on failure. */
881
907
  async function fetchAndUpdateSelectedItem(nodeId) {
882
908
  if (!state.currentProject || !nodeId)
883
909
  return;
@@ -903,6 +929,7 @@ async function fetchAndUpdateSelectedItem(nodeId) {
903
929
  selectedItemCache = null;
904
930
  }
905
931
  }
932
+ /** Pushes the current filters and selection onto the canvas and, when searching with no node selected, jumps the camera to the first matching node. */
906
933
  function syncCanvas() {
907
934
  if (!canvasRef.current || !currentGraph)
908
935
  return;
@@ -926,6 +953,7 @@ function syncCanvas() {
926
953
  canvasRef.current.jumpToNode(match.id);
927
954
  }
928
955
  }
956
+ /** Creates the GraphCanvas instance for the current graph, wires its selection, open, and context-menu callbacks, computes the critical path, loads the data, and applies the initial filter. */
929
957
  function initCanvas() {
930
958
  const host = document.getElementById('graph-canvas-host');
931
959
  if (!host || !currentGraph)
@@ -965,6 +993,7 @@ function initCanvas() {
965
993
  syncCanvas();
966
994
  }
967
995
  // ── Panel / drawer updates ────────────────────────────────────
996
+ /** Re-renders the info panel and relationship list into the DOM from the current graph (and cached selected item) and re-binds their event handlers. */
968
997
  function updateInfoPanel() {
969
998
  if (!currentGraph)
970
999
  return;
@@ -976,6 +1005,7 @@ function updateInfoPanel() {
976
1005
  relList.innerHTML = renderRelList(currentGraph);
977
1006
  bindInfoPanelEvents();
978
1007
  }
1008
+ /** Synchronizes the filter toolbar DOM (direction select enabled state, depth slider and label, scope button text, and preset active states) with the current selection and filter. */
979
1009
  function updateFilterToolbarState() {
980
1010
  const dirSel = document.getElementById('graph-filter-direction');
981
1011
  const depthSldr = document.getElementById('graph-filter-depth');
@@ -1001,6 +1031,7 @@ const TYPE_COLORS_MAP = {
1001
1031
  milestone: '#fbbf24', story: '#34d399', chore: '#94a3b8', release: '#38bdf8',
1002
1032
  };
1003
1033
  const TAG_PALETTE_JS = ['#2dd4bf', '#60a5fa', '#a78bfa', '#f87171', '#fbbf24', '#34d399', '#fb923c', '#e879f9'];
1034
+ /** Computes a tag-to-color map by ranking tags by how often they appear across the nodes and assigning each of the most frequent ones a palette color. */
1004
1035
  function computeTagColorMap(nodes) {
1005
1036
  const freq = new Map();
1006
1037
  for (const n of nodes) {
@@ -1011,6 +1042,7 @@ function computeTagColorMap(nodes) {
1011
1042
  const top = [...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, TAG_PALETTE_JS.length).map(([t]) => t);
1012
1043
  return new Map(top.map((t, i) => [t, TAG_PALETTE_JS[i]]));
1013
1044
  }
1045
+ /** Rebuilds the legend HUD HTML to match the current color mode (status, type, or tag) or dependency mode, including clickable tag chips when coloring by tag. */
1014
1046
  function updateLegend() {
1015
1047
  const legend = document.getElementById('graph-legend-hud');
1016
1048
  if (!legend)
@@ -1086,6 +1118,7 @@ function updateLegend() {
1086
1118
  }
1087
1119
  }
1088
1120
  // ── Event bindings ────────────────────────────────────────────
1121
+ /** Binds click handlers for the info-panel controls (open and clear the selected item, add and remove dependency) and for neighbor, tag, and relationship rows so they update the selection and canvas. */
1089
1122
  function bindInfoPanelEvents() {
1090
1123
  document.getElementById('graph-open-selected')?.addEventListener('click', () => {
1091
1124
  if (selectedNodeId)
@@ -1138,6 +1171,7 @@ function bindInfoPanelEvents() {
1138
1171
  });
1139
1172
  });
1140
1173
  }
1174
+ /** Binds all HUD control handlers for the graph view: back/refresh/sync/fit buttons, presets, physics toggles and sliders, filter selects, search input, drawer toggles, color mode, and global keyboard shortcuts. */
1141
1175
  function bindHudEvents() {
1142
1176
  // Back button
1143
1177
  document.getElementById('graph-back-btn')?.addEventListener('click', () => {
@@ -1482,6 +1516,7 @@ function bindHudEvents() {
1482
1516
  window.__graphKeyHandler = graphKeyHandler;
1483
1517
  }
1484
1518
  // ── URL routing (pushState) ─────────────────────────────────
1519
+ /** Serializes the current selection and filter into URL query parameters and replaces the browser history entry so the view can be restored or shared. */
1485
1520
  function pushGraphState() {
1486
1521
  if (!state.currentProject)
1487
1522
  return;
@@ -1505,6 +1540,7 @@ function pushGraphState() {
1505
1540
  history.replaceState(null, '', url);
1506
1541
  }
1507
1542
  let urlStateRestored = false;
1543
+ /** Reads graph view parameters from the current URL query string and applies them to the selection and filter, marking state as restored. */
1508
1544
  function restoreGraphState() {
1509
1545
  const params = new URLSearchParams(window.location.search);
1510
1546
  if (!params.has('graph'))
@@ -1524,6 +1560,7 @@ function restoreGraphState() {
1524
1560
  filter = { ...filter, layout: (params.get('layout') || 'force') };
1525
1561
  }
1526
1562
  // ── Dependency editing modals ────────────────────────────────
1563
+ /** Shows the add-dependency modal listing item nodes as source and target selects plus a relationship type, and POSTs the new dependency then refreshes the graph on submit. */
1527
1564
  function showAddDependencyModal() {
1528
1565
  if (!state.currentProject || !currentGraph)
1529
1566
  return;
@@ -1598,6 +1635,7 @@ function showAddDependencyModal() {
1598
1635
  }
1599
1636
  });
1600
1637
  }
1638
+ /** Shows the remove-dependency modal listing existing dependency relationships and DELETEs the chosen one on submit, then refreshes the graph. */
1601
1639
  function showRemoveDependencyModal() {
1602
1640
  if (!state.currentProject || !currentGraph)
1603
1641
  return;
@@ -1663,6 +1701,7 @@ function showRemoveDependencyModal() {
1663
1701
  });
1664
1702
  }
1665
1703
  // ── Main entry point ──────────────────────────────────────────
1704
+ /** Renders the full graph view into the `content-graph` element: clears any prior canvas, restores URL state, fetches the project graph, computes the critical path, renders the shell, binds events, and initializes the canvas. */
1666
1705
  export async function renderGraphView() {
1667
1706
  const el = document.getElementById('content-graph');
1668
1707
  if (!el)
@@ -1735,6 +1774,7 @@ export async function refreshGraphData() {
1735
1774
  // ── Local graph (embedded mini-graph for item detail) ─────────
1736
1775
  // Registry of active local graph canvases so they can be cleaned up
1737
1776
  const localGraphRegistry = new Map();
1777
+ /** Destroys and unregisters the local mini-graph canvas previously created for the given container id, if any. */
1738
1778
  export function destroyLocalGraph(containerId) {
1739
1779
  const existing = localGraphRegistry.get(containerId);
1740
1780
  if (existing) {
@@ -1742,6 +1782,7 @@ export function destroyLocalGraph(containerId) {
1742
1782
  localGraphRegistry.delete(containerId);
1743
1783
  }
1744
1784
  }
1785
+ /** Renders an embedded mini-graph for a single item showing its neighborhood out to the given depth, with neighbor-node clicks navigating to that item's detail view. */
1745
1786
  export async function renderLocalGraph(containerId, nodeId, depth = 2) {
1746
1787
  const container = document.getElementById(containerId);
1747
1788
  if (!container || !state.currentProject)