declare-cc 0.4.0 → 0.4.5

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.
@@ -0,0 +1,1112 @@
1
+ /**
2
+ * Declare DAG Visualizer — app.js
3
+ *
4
+ * Fetches /api/graph and /api/status, renders a layered DAG with SVG edges,
5
+ * supports node click for full details in a side panel, and live-updates via SSE when .planning/ changes.
6
+ *
7
+ * Zero external dependencies. Vanilla JS, no build step.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ // ─── Constants ───────────────────────────────────────────────────────────────
13
+
14
+ const API_GRAPH = '/api/graph';
15
+ const API_STATUS = '/api/status';
16
+
17
+ // ─── State ───────────────────────────────────────────────────────────────────
18
+
19
+ /** @type {{ declarations: any[], milestones: any[], actions: any[], stats: any } | null} */
20
+ let graphData = null;
21
+
22
+ /** @type {any} */
23
+ let statusData = null;
24
+
25
+ /** @type {string | null} */
26
+ let selectedNodeId = null;
27
+
28
+ /** @type {string | null} Focus node ID — the declaration or milestone being focused */
29
+ let focusNodeId = null;
30
+
31
+
32
+ // ─── DOM refs ─────────────────────────────────────────────────────────────────
33
+
34
+ const $overlay = document.getElementById('overlay');
35
+ const $overlayMsg = document.getElementById('overlay-message');
36
+ const $overlayErr = document.getElementById('overlay-error');
37
+ const $overlayRetry = document.getElementById('overlay-retry');
38
+ const $spinner = document.querySelector('.spinner');
39
+
40
+ const $projectName = document.getElementById('project-name');
41
+ const $statDecls = document.getElementById('stat-decls');
42
+ const $statMiles = document.getElementById('stat-miles');
43
+ const $statActs = document.getElementById('stat-acts');
44
+ const $healthBadge = document.getElementById('health-badge');
45
+ const $lastUpdated = document.getElementById('last-updated');
46
+ const $refreshBtn = document.getElementById('refresh-btn');
47
+
48
+ const $nodesDecls = document.getElementById('nodes-declarations');
49
+ const $nodesMiles = document.getElementById('nodes-milestones');
50
+ const $nodesActs = document.getElementById('nodes-actions');
51
+ const $edgesSvg = document.getElementById('edges-svg');
52
+
53
+ const $sidePanel = document.getElementById('side-panel');
54
+ const $panelBody = document.getElementById('panel-body');
55
+ const $panelEmpty = document.getElementById('panel-empty');
56
+
57
+ // ─── Utilities ────────────────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * Truncate text to maxLen characters, appending ellipsis if needed.
61
+ * @param {string} text
62
+ * @param {number} maxLen
63
+ * @returns {string}
64
+ */
65
+ function truncate(text, maxLen) {
66
+ if (!text) return '';
67
+ return text.length > maxLen ? text.slice(0, maxLen - 1) + '…' : text;
68
+ }
69
+
70
+ /**
71
+ * Format a Date as "HH:MM:SS".
72
+ * @param {Date} d
73
+ * @returns {string}
74
+ */
75
+ function fmtTime(d) {
76
+ return d.toTimeString().slice(0, 8);
77
+ }
78
+
79
+ /**
80
+ * Derive a CSS class suffix for a status string.
81
+ * @param {string} status
82
+ * @returns {string}
83
+ */
84
+ function statusClass(status) {
85
+ if (!status) return 'pending';
86
+ return status.toLowerCase().replace(/[^a-z]/g, '-');
87
+ }
88
+
89
+ // ─── Fetch helpers ────────────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Fetch JSON from a URL, returning null on network/parse errors.
93
+ * @param {string} url
94
+ * @returns {Promise<any>}
95
+ */
96
+ async function fetchJson(url) {
97
+ const res = await fetch(url);
98
+ if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);
99
+ return res.json();
100
+ }
101
+
102
+ // ─── Overlay helpers ──────────────────────────────────────────────────────────
103
+
104
+ function showLoading() {
105
+ $overlayMsg.textContent = 'Loading graph…';
106
+ $overlayErr.textContent = '';
107
+ $overlayRetry.style.display = 'none';
108
+ $spinner.style.display = 'block';
109
+ $overlay.classList.remove('hidden');
110
+ }
111
+
112
+ function showError(msg) {
113
+ $overlayMsg.textContent = '';
114
+ $overlayErr.textContent = msg;
115
+ $overlayRetry.style.display = 'inline-block';
116
+ $spinner.style.display = 'none';
117
+ $overlay.classList.remove('hidden');
118
+ }
119
+
120
+ function hideOverlay() {
121
+ $overlay.classList.add('hidden');
122
+ }
123
+
124
+ // ─── Data loading ─────────────────────────────────────────────────────────────
125
+
126
+ /**
127
+ * Load /api/graph and /api/status in parallel.
128
+ * On success: hide overlay, render.
129
+ * On failure: show error overlay.
130
+ */
131
+ async function loadData() {
132
+ try {
133
+ const [graph, status] = await Promise.all([
134
+ fetchJson(API_GRAPH),
135
+ fetchJson(API_STATUS).catch(() => null), // status is supplementary
136
+ ]);
137
+
138
+ if (graph && graph.error) {
139
+ throw new Error(graph.error);
140
+ }
141
+
142
+ graphData = graph;
143
+ statusData = status;
144
+
145
+ hideOverlay();
146
+ renderStatusBar();
147
+ renderGraph();
148
+ updateLastUpdated();
149
+
150
+ // Re-apply selection highlight if node still exists
151
+ if (selectedNodeId) {
152
+ const el = document.querySelector(`[data-node-id="${selectedNodeId}"]`);
153
+ if (el) el.classList.add('selected');
154
+ }
155
+ } catch (err) {
156
+ showError(
157
+ `Could not reach the Declare server.\n${err.message}\n\nMake sure the server is running:\n node dist/declare-tools.cjs serve`
158
+ );
159
+ }
160
+ }
161
+
162
+ function updateLastUpdated() {
163
+ $lastUpdated.textContent = `Last updated: ${fmtTime(new Date())}`;
164
+ }
165
+
166
+ // ─── Status bar ───────────────────────────────────────────────────────────────
167
+
168
+ function renderStatusBar() {
169
+ // Project name from status or graph stats
170
+ const project = statusData ? statusData.project : null;
171
+ if (project) $projectName.textContent = project;
172
+
173
+ // Counts
174
+ const stats = (graphData && graphData.stats) || {};
175
+ $statDecls.textContent = stats.declarations != null ? stats.declarations : '–';
176
+ $statMiles.textContent = stats.milestones != null ? stats.milestones : '–';
177
+ $statActs.textContent = stats.actions != null ? stats.actions : '–';
178
+
179
+ // Health badge
180
+ const health = statusData ? statusData.health : null;
181
+ const healthLabel = health || 'unknown';
182
+ $healthBadge.textContent = healthLabel;
183
+ $healthBadge.className = `health-badge health-${healthLabel}`;
184
+
185
+ // Performance summary from /api/status
186
+ if (statusData && statusData.performance && statusData.performance.rollup) {
187
+ const rollup = statusData.performance.rollup;
188
+ // Inject a performance pill next to health if not already present
189
+ let perfPill = document.getElementById('perf-pill');
190
+ if (!perfPill) {
191
+ perfPill = document.createElement('span');
192
+ perfPill.id = 'perf-pill';
193
+ perfPill.style.cssText = 'font-size:11px;color:var(--text-dim);';
194
+ $healthBadge.after(perfPill);
195
+ }
196
+ const align = rollup.alignment ? rollup.alignment.level : '–';
197
+ const integ = rollup.integrity ? rollup.integrity.level : '–';
198
+ const perf = rollup.performance || '–';
199
+ perfPill.textContent = `Alignment: ${align} · Integrity: ${integ} · Performance: ${perf}`;
200
+ }
201
+ }
202
+
203
+ // ─── Node element builder ─────────────────────────────────────────────────────
204
+
205
+ /**
206
+ * Build a node DOM element.
207
+ * @param {{ id: string, title?: string, statement?: string, status?: string }} item
208
+ * @param {'declaration'|'milestone'|'action'} type
209
+ * @returns {HTMLElement}
210
+ */
211
+ function buildNodeEl(item, type) {
212
+ const el = document.createElement('div');
213
+ el.className = `node node-${type} status-${statusClass(item.status || 'pending')}`;
214
+ el.dataset.nodeId = item.id;
215
+ el.dataset.nodeType = type;
216
+
217
+ const title = item.title || item.statement || item.id;
218
+
219
+ el.innerHTML = `
220
+ <div class="node-id">${item.id}</div>
221
+ <div class="node-title">${truncate(title, 55)}</div>
222
+ <span class="status-badge">${item.status || 'PENDING'}</span>
223
+ `;
224
+
225
+ el.addEventListener('click', () => selectNode(item.id, type));
226
+ return el;
227
+ }
228
+
229
+ // ─── Graph renderer ───────────────────────────────────────────────────────────
230
+
231
+ function renderGraph() {
232
+ if (!graphData) return;
233
+
234
+ const { declarations, milestones, actions } = graphData;
235
+
236
+ // Clear containers
237
+ $nodesDecls.innerHTML = '';
238
+ $nodesMiles.innerHTML = '';
239
+ $nodesActs.innerHTML = '';
240
+
241
+ // Render declarations
242
+ (declarations || []).forEach(d => {
243
+ $nodesDecls.appendChild(buildNodeEl(d, 'declaration'));
244
+ });
245
+
246
+ // Render milestones
247
+ (milestones || []).forEach(m => {
248
+ $nodesMiles.appendChild(buildNodeEl(m, 'milestone'));
249
+ });
250
+
251
+ // Render actions
252
+ (actions || []).forEach(a => {
253
+ $nodesActs.appendChild(buildNodeEl(a, 'action'));
254
+ });
255
+
256
+ // Draw edges after layout settles
257
+ requestAnimationFrame(() => drawEdges());
258
+ }
259
+
260
+ // ─── Edge drawing ─────────────────────────────────────────────────────────────
261
+
262
+ /**
263
+ * Get the center-bottom point of a DOM element relative to #canvas-container.
264
+ * @param {Element} el
265
+ * @returns {{ x: number, y: number }}
266
+ */
267
+ function getBottomCenter(el) {
268
+ const containerRect = document.getElementById('canvas-container').getBoundingClientRect();
269
+ const scrollLeft = document.getElementById('canvas-wrap').scrollLeft;
270
+ const scrollTop = document.getElementById('canvas-wrap').scrollTop;
271
+ const r = el.getBoundingClientRect();
272
+ return {
273
+ x: r.left - containerRect.left + scrollLeft + r.width / 2,
274
+ y: r.top - containerRect.top + scrollTop + r.height,
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Get the center-top point of a DOM element relative to #canvas-container.
280
+ * @param {Element} el
281
+ * @returns {{ x: number, y: number }}
282
+ */
283
+ function getTopCenter(el) {
284
+ const containerRect = document.getElementById('canvas-container').getBoundingClientRect();
285
+ const scrollLeft = document.getElementById('canvas-wrap').scrollLeft;
286
+ const scrollTop = document.getElementById('canvas-wrap').scrollTop;
287
+ const r = el.getBoundingClientRect();
288
+ return {
289
+ x: r.left - containerRect.left + scrollLeft + r.width / 2,
290
+ y: r.top - containerRect.top + scrollTop,
291
+ };
292
+ }
293
+
294
+ /**
295
+ * Draw a cubic bezier SVG path from (x1,y1) to (x2,y2).
296
+ * @param {number} x1
297
+ * @param {number} y1
298
+ * @param {number} x2
299
+ * @param {number} y2
300
+ * @returns {string}
301
+ */
302
+ function curvePath(x1, y1, x2, y2) {
303
+ const cy = (y1 + y2) / 2;
304
+ return `M ${x1} ${y1} C ${x1} ${cy}, ${x2} ${cy}, ${x2} ${y2}`;
305
+ }
306
+
307
+ /**
308
+ * Build an SVG path element for an edge.
309
+ * @param {string} d
310
+ * @param {boolean} highlight
311
+ * @returns {SVGPathElement}
312
+ */
313
+ function makePath(d, highlight) {
314
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
315
+ path.setAttribute('d', d);
316
+ path.setAttribute('class', highlight ? 'edge highlight' : 'edge');
317
+ return path;
318
+ }
319
+
320
+ function drawEdges() {
321
+ if (!graphData) return;
322
+
323
+ const { milestones, actions } = graphData;
324
+ const container = document.getElementById('canvas-container');
325
+
326
+ // Resize SVG to container dimensions
327
+ $edgesSvg.setAttribute('width', String(container.scrollWidth));
328
+ $edgesSvg.setAttribute('height', String(container.scrollHeight));
329
+ $edgesSvg.innerHTML = '';
330
+
331
+ const fragment = document.createDocumentFragment();
332
+
333
+ // Milestone → Declaration edges (realizes)
334
+ (milestones || []).forEach(m => {
335
+ const mEl = document.querySelector(`[data-node-id="${m.id}"]`);
336
+ if (!mEl) return;
337
+ const mTop = getTopCenter(mEl);
338
+
339
+ (m.realizes || []).forEach(dId => {
340
+ const dEl = document.querySelector(`[data-node-id="${dId}"]`);
341
+ if (!dEl) return;
342
+ const dBot = getBottomCenter(dEl);
343
+ const isHighlighted = selectedNodeId === m.id || selectedNodeId === dId;
344
+ fragment.appendChild(makePath(curvePath(dBot.x, dBot.y, mTop.x, mTop.y), isHighlighted));
345
+ });
346
+ });
347
+
348
+ // Action → Milestone edges (causes)
349
+ (actions || []).forEach(a => {
350
+ const aEl = document.querySelector(`[data-node-id="${a.id}"]`);
351
+ if (!aEl) return;
352
+ const aTop = getTopCenter(aEl);
353
+
354
+ (a.causes || []).forEach(mId => {
355
+ const mEl = document.querySelector(`[data-node-id="${mId}"]`);
356
+ if (!mEl) return;
357
+ const mBot = getBottomCenter(mEl);
358
+ const isHighlighted = selectedNodeId === a.id || selectedNodeId === mId;
359
+ fragment.appendChild(makePath(curvePath(mBot.x, mBot.y, aTop.x, aTop.y), isHighlighted));
360
+ });
361
+ });
362
+
363
+ $edgesSvg.appendChild(fragment);
364
+ }
365
+
366
+ // ─── Side panel ───────────────────────────────────────────────────────────────
367
+
368
+ /**
369
+ * Select a node and show its details.
370
+ * @param {string} nodeId
371
+ * @param {string} type
372
+ */
373
+ function selectNode(nodeId, type) {
374
+ // Deselect previous
375
+ document.querySelectorAll('.node.selected').forEach(el => el.classList.remove('selected'));
376
+
377
+ if (selectedNodeId === nodeId) {
378
+ // Toggle off
379
+ selectedNodeId = null;
380
+ exitFocusMode();
381
+ if ($panelEmpty) $panelEmpty.style.display = '';
382
+ return;
383
+ }
384
+
385
+ selectedNodeId = nodeId;
386
+
387
+ // Highlight node
388
+ const el = document.querySelector(`[data-node-id="${nodeId}"]`);
389
+ if (el) el.classList.add('selected');
390
+
391
+ // If clicking a node already visible in the current focused subtree, skip re-animation
392
+ const alreadyInFocus = focusNodeId && getFocusSubtree(
393
+ focusNodeId,
394
+ document.querySelector(`[data-node-id="${focusNodeId}"]`)?.dataset.nodeType || 'declaration'
395
+ ).has(nodeId);
396
+
397
+ if (!alreadyInFocus) {
398
+ enterFocusMode(nodeId, type);
399
+ }
400
+
401
+ // Populate panel
402
+ let item = null;
403
+ if (graphData) {
404
+ if (type === 'declaration') item = graphData.declarations.find(d => d.id === nodeId);
405
+ if (type === 'milestone') item = graphData.milestones.find(m => m.id === nodeId);
406
+ if (type === 'action') item = graphData.actions.find(a => a.id === nodeId);
407
+ }
408
+ if (!item) return;
409
+
410
+ if ($panelEmpty) $panelEmpty.style.display = 'none';
411
+ renderPanelChain(item, type);
412
+ }
413
+
414
+ /**
415
+ * Render the detail panel for a selected node.
416
+ * @param {any} item
417
+ * @param {string} type
418
+ */
419
+ function renderPanelContent(item, type) {
420
+ const status = item.status || 'PENDING';
421
+
422
+ // Color for badge
423
+ const colorMap = {
424
+ declaration: 'var(--decl-color)',
425
+ milestone: 'var(--mile-color)',
426
+ action: 'var(--act-color)',
427
+ };
428
+ const bgMap = {
429
+ declaration: 'var(--decl-bg)',
430
+ milestone: 'var(--mile-bg)',
431
+ action: 'var(--act-bg)',
432
+ };
433
+
434
+ let badgeStyle = `background:${bgMap[type]};color:${colorMap[type]};border:1px solid;`;
435
+ if (['DONE', 'HONORED', 'KEPT'].includes(status)) {
436
+ badgeStyle = 'background:var(--done-bg);color:var(--done-color);border:1px solid var(--done-border);';
437
+ } else if (status === 'BROKEN') {
438
+ badgeStyle = 'background:var(--broken-bg);color:var(--broken-color);border:1px solid var(--broken-border);';
439
+ } else if (status === 'RENEGOTIATED') {
440
+ badgeStyle = 'background:var(--renegotiated-bg);color:var(--renegotiated-color);border:1px solid var(--renegotiated-border);';
441
+ }
442
+
443
+ const title = item.title || item.statement || item.id;
444
+
445
+ let html = `
446
+ <div class="detail-id">${type.toUpperCase()} · ${item.id}</div>
447
+ <div class="detail-title">${escHtml(title)}</div>
448
+ <div class="detail-badge" style="${badgeStyle}">${status}</div>
449
+ `;
450
+
451
+ // Type-specific fields
452
+ if (type === 'declaration') {
453
+ if (item.statement) {
454
+ html += section('Statement', escHtml(item.statement));
455
+ }
456
+ if (item.integrity) {
457
+ html += section('Integrity', escHtml(String(item.integrity)));
458
+ }
459
+ // Realized by
460
+ const realizedBy = (graphData.milestones || []).filter(m =>
461
+ (m.realizes || []).some(r => r === item.id)
462
+ );
463
+ if (realizedBy.length) {
464
+ html += tagSection('Realized by', realizedBy, 'milestone');
465
+ }
466
+ }
467
+
468
+ if (type === 'milestone') {
469
+ if (item.produces) {
470
+ html += section('Produces', escHtml(item.produces));
471
+ }
472
+ if (item.realizes && item.realizes.length) {
473
+ const decls = (graphData.declarations || []).filter(d => item.realizes.includes(d.id));
474
+ html += tagSection('Realizes', decls.length ? decls : item.realizes.map(id => ({ id })), 'declaration');
475
+ }
476
+ // Actions that cause this milestone
477
+ const causedBy = (graphData.actions || []).filter(a =>
478
+ (a.causes || []).some(c => c === item.id)
479
+ );
480
+ if (causedBy.length) {
481
+ html += tagSection('Caused by actions', causedBy, 'action');
482
+ }
483
+ if (item.integrity) {
484
+ html += section('Integrity', escHtml(String(item.integrity)));
485
+ }
486
+ }
487
+
488
+ if (type === 'action') {
489
+ if (item.produces) {
490
+ html += section('Produces', escHtml(item.produces));
491
+ }
492
+ if (item.causes && item.causes.length) {
493
+ const miles = (graphData.milestones || []).filter(m => item.causes.includes(m.id));
494
+ html += tagSection('Causes milestones', miles.length ? miles : item.causes.map(id => ({ id })), 'milestone');
495
+ }
496
+ if (item.integrity) {
497
+ html += section('Integrity', escHtml(String(item.integrity)));
498
+ }
499
+ if (item.wave != null) {
500
+ html += section('Wave', escHtml(String(item.wave)));
501
+ }
502
+ }
503
+
504
+ $panelBody.innerHTML = html;
505
+
506
+ // Wire tag clicks to jump to that node
507
+ $panelBody.querySelectorAll('.detail-tag').forEach(tag => {
508
+ tag.addEventListener('click', () => {
509
+ const tid = tag.dataset.nodeId;
510
+ const ttype = tag.dataset.nodeType;
511
+ if (tid && ttype) selectNode(tid, ttype);
512
+ });
513
+ });
514
+ }
515
+
516
+ /**
517
+ * Build a detail section with label and text value.
518
+ * @param {string} label
519
+ * @param {string} value
520
+ * @returns {string}
521
+ */
522
+ function section(label, value) {
523
+ return `
524
+ <div class="detail-section">
525
+ <div class="detail-label">${label}</div>
526
+ <div class="detail-value">${value}</div>
527
+ </div>
528
+ `;
529
+ }
530
+
531
+ /**
532
+ * Build a tag list section for linked nodes.
533
+ * @param {string} label
534
+ * @param {Array<{id: string, title?: string, statement?: string}>} items
535
+ * @param {string} type
536
+ * @returns {string}
537
+ */
538
+ function tagSection(label, items, type) {
539
+ if (!items.length) return '';
540
+ const tags = items.map(item => {
541
+ const name = item.title || item.statement || item.id;
542
+ return `<span class="detail-tag" data-node-id="${item.id}" data-node-type="${type}">${item.id}: ${truncate(name, 30)}</span>`;
543
+ }).join('');
544
+ return `
545
+ <div class="detail-section">
546
+ <div class="detail-label">${label}</div>
547
+ <div class="detail-tag-list">${tags}</div>
548
+ </div>
549
+ `;
550
+ }
551
+
552
+ /**
553
+ * Escape HTML special characters.
554
+ * @param {string} str
555
+ * @returns {string}
556
+ */
557
+ function escHtml(str) {
558
+ return String(str)
559
+ .replace(/&/g, '&amp;')
560
+ .replace(/</g, '&lt;')
561
+ .replace(/>/g, '&gt;')
562
+ .replace(/"/g, '&quot;');
563
+ }
564
+
565
+ // ─── Chain panel renderer ─────────────────────────────────────────────────────
566
+
567
+ /**
568
+ * Render the sidebar with the full chain from declarations down to the clicked node.
569
+ * Clicking a milestone shows: parent declaration(s) → the milestone.
570
+ * Clicking an action shows: declaration(s) → parent milestone(s) → the action.
571
+ */
572
+ function renderPanelChain(item, type) {
573
+ if (!graphData) return;
574
+ const { declarations, milestones, actions } = graphData;
575
+ const sections = [];
576
+
577
+ if (type === 'action') {
578
+ // Parent milestones
579
+ const parentMilestones = milestones.filter(m => (item.causes || []).includes(m.id));
580
+ parentMilestones.forEach(m => {
581
+ // Parent declarations of the milestone
582
+ const parentDecls = declarations.filter(d => (m.realizes || []).includes(d.id));
583
+ parentDecls.forEach(d => sections.push({ item: d, type: 'declaration', role: 'context' }));
584
+ sections.push({ item: m, type: 'milestone', role: 'context' });
585
+ });
586
+ sections.push({ item, type: 'action', role: 'focus' });
587
+ } else if (type === 'milestone') {
588
+ const parentDecls = declarations.filter(d => (item.realizes || []).includes(d.id));
589
+ parentDecls.forEach(d => sections.push({ item: d, type: 'declaration', role: 'context' }));
590
+ sections.push({ item, type: 'milestone', role: 'focus' });
591
+ } else {
592
+ sections.push({ item, type: 'declaration', role: 'focus' });
593
+ }
594
+
595
+ const colorMap = { declaration: 'var(--decl-color)', milestone: 'var(--mile-color)', action: 'var(--act-color)' };
596
+ const bgMap = { declaration: 'var(--decl-bg)', milestone: 'var(--mile-bg)', action: 'var(--act-bg)' };
597
+ const borderMap = { declaration: 'var(--decl-border)', milestone: 'var(--mile-border)', action: 'var(--act-border)' };
598
+
599
+ let html = '';
600
+ sections.forEach((s, idx) => {
601
+ const isFocus = s.role === 'focus';
602
+ const title = s.item.title || s.item.statement || s.item.id;
603
+ const status = s.item.status || 'PENDING';
604
+ const isDone = ['DONE','KEPT','HONORED'].includes(status);
605
+ const isBroken = status === 'BROKEN';
606
+
607
+ const cardBg = isFocus ? bgMap[s.type] : 'var(--surface2)';
608
+ const cardBorder = isFocus ? borderMap[s.type] : 'var(--border)';
609
+ const cardOpacity = isFocus ? '1' : '0.7';
610
+
611
+ const badgeStyle = isDone
612
+ ? 'background:var(--done-bg);color:var(--done-color);border:1px solid var(--done-border)'
613
+ : isBroken
614
+ ? 'background:var(--broken-bg);color:var(--broken-color);border:1px solid var(--broken-border)'
615
+ : `background:${bgMap[s.type]};color:${colorMap[s.type]};border:1px solid ${borderMap[s.type]}`;
616
+
617
+ // Connector line between sections
618
+ if (idx > 0) {
619
+ html += `<div style="display:flex;justify-content:center;margin:2px 0">
620
+ <div style="width:1px;height:20px;background:var(--border)"></div>
621
+ </div>`;
622
+ }
623
+
624
+ html += `<div style="
625
+ background:${cardBg};
626
+ border:1px solid ${cardBorder};
627
+ border-radius:8px;
628
+ padding:12px 14px;
629
+ opacity:${cardOpacity};
630
+ cursor:${isFocus ? 'default' : 'pointer'};
631
+ " ${!isFocus ? `onclick="selectNode('${s.item.id}','${s.type}')"` : ''}>
632
+ <div style="font-size:10px;font-weight:700;letter-spacing:0.08em;opacity:0.6;margin-bottom:3px;color:${colorMap[s.type]}">${s.type.toUpperCase()} · ${s.item.id}</div>
633
+ <div style="font-size:13px;font-weight:${isFocus ? '600' : '500'};color:var(--text-bright);line-height:1.35;margin-bottom:8px">${escHtml(title)}</div>
634
+ <span style="${badgeStyle};display:inline-block;padding:2px 9px;border-radius:8px;font-size:10px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase">${status}</span>
635
+ </div>`;
636
+
637
+ // If this is the focus node, show its type-specific details below
638
+ if (isFocus) {
639
+ if (s.type === 'declaration' && s.item.statement) {
640
+ html += `<div style="margin-top:14px">
641
+ <div class="detail-label">Statement</div>
642
+ <div class="detail-value" style="margin-top:5px">${escHtml(s.item.statement)}</div>
643
+ </div>`;
644
+ const realizedBy = milestones.filter(m => (m.realizes || []).includes(s.item.id));
645
+ if (realizedBy.length) {
646
+ html += chainTagSection('Milestones', realizedBy, 'milestone');
647
+ }
648
+ }
649
+ if (s.type === 'milestone') {
650
+ const causedBy = actions.filter(a => (a.causes || []).includes(s.item.id));
651
+ if (causedBy.length) html += chainTagSection('Actions', causedBy, 'action');
652
+ }
653
+ if (s.type === 'action' && s.item.produces) {
654
+ html += `<div style="margin-top:14px">
655
+ <div class="detail-label">Produces</div>
656
+ <div class="detail-value" style="margin-top:5px">${escHtml(s.item.produces)}</div>
657
+ </div>`;
658
+ }
659
+ }
660
+ });
661
+
662
+ $panelBody.innerHTML = html;
663
+
664
+ // Wire tag clicks
665
+ $panelBody.querySelectorAll('[data-chain-id]').forEach(tag => {
666
+ tag.addEventListener('click', () => {
667
+ selectNode(tag.dataset.chainId, tag.dataset.chainType);
668
+ });
669
+ });
670
+ }
671
+
672
+ function chainTagSection(label, items, type) {
673
+ const tags = items.map(item => {
674
+ const name = item.title || item.id;
675
+ return `<span class="detail-tag" data-chain-id="${item.id}" data-chain-type="${type}" style="cursor:pointer">${item.id}: ${truncate(name, 28)}</span>`;
676
+ }).join('');
677
+ return `<div style="margin-top:14px">
678
+ <div class="detail-label">${label}</div>
679
+ <div class="detail-tag-list" style="margin-top:6px">${tags}</div>
680
+ </div>`;
681
+ }
682
+
683
+ // ─── Focus mode — FLIP technique ──────────────────────────────────────────────
684
+ // Exiting nodes: removed from flow instantly (→ flex re-centers), then overlaid
685
+ // at their original positions via position:fixed for the directional slide-out.
686
+ // Subtree nodes: FLIP'd from old positions to new centered positions simultaneously.
687
+
688
+ const $focusHint = document.getElementById('focus-hint');
689
+ const FOCUS_DUR = 380;
690
+
691
+ /**
692
+ * Compute the set of node IDs that belong to a focused subtree.
693
+ * - declaration: itself + all its milestones + all their actions
694
+ * - milestone: itself + its declarations + its actions
695
+ * - action: itself + its parent milestone + that milestone's declaration + all sibling actions
696
+ * @param {string} nodeId
697
+ * @param {string} type
698
+ * @returns {Set<string>}
699
+ */
700
+ function getFocusSubtree(nodeId, type) {
701
+ if (!graphData) return new Set();
702
+ const { milestones, actions } = graphData;
703
+ const visible = new Set();
704
+
705
+ if (type === 'declaration') {
706
+ visible.add(nodeId);
707
+ milestones.filter(m => (m.realizes || []).includes(nodeId)).forEach(m => {
708
+ visible.add(m.id);
709
+ actions.filter(a => (a.causes || []).includes(m.id)).forEach(a => visible.add(a.id));
710
+ });
711
+ } else if (type === 'milestone') {
712
+ visible.add(nodeId);
713
+ const m = milestones.find(x => x.id === nodeId);
714
+ if (m) {
715
+ (m.realizes || []).forEach(dId => visible.add(dId));
716
+ actions.filter(a => (a.causes || []).includes(nodeId)).forEach(a => visible.add(a.id));
717
+ }
718
+ } else if (type === 'action') {
719
+ visible.add(nodeId);
720
+ const a = actions.find(x => x.id === nodeId);
721
+ if (a) {
722
+ (a.causes || []).forEach(mId => {
723
+ visible.add(mId);
724
+ const m = milestones.find(x => x.id === mId);
725
+ if (m) {
726
+ // parent declarations
727
+ (m.realizes || []).forEach(dId => visible.add(dId));
728
+ // sibling actions (all actions that cause the same milestone)
729
+ actions.filter(sa => (sa.causes || []).includes(mId)).forEach(sa => visible.add(sa.id));
730
+ }
731
+ });
732
+ }
733
+ }
734
+
735
+ return visible;
736
+ }
737
+
738
+ /** Clear all focus-mode inline styles from a node element. */
739
+ function clearNodeFocusStyles(el) {
740
+ el.style.cssText = ''; // wipe all inline styles at once
741
+ el.classList.remove('focus-exiting', 'focus-active');
742
+ el.dataset.focusDir = '';
743
+ }
744
+
745
+ /** @type {Array<{el: HTMLElement, rect: DOMRect, dirLeft: boolean}>} */
746
+ let exitedNodes = [];
747
+ /** @type {ReturnType<typeof setTimeout> | null} Shared cleanup timer — covers both enter and exit cleanups so each cancels the other */
748
+ let focusCleanupTimer = null;
749
+
750
+ /**
751
+ * Snapshot getBoundingClientRect for a set of node IDs.
752
+ * @param {Set<string>} ids
753
+ * @returns {Map<string, DOMRect>}
754
+ */
755
+ function snapshotRects(ids) {
756
+ const map = new Map();
757
+ ids.forEach(id => {
758
+ const el = document.querySelector(`[data-node-id="${id}"]`);
759
+ if (el) map.set(id, el.getBoundingClientRect());
760
+ });
761
+ return map;
762
+ }
763
+
764
+ /**
765
+ * Enter focus mode using FLIP:
766
+ * 1. Snapshot subtree node positions (FIRST)
767
+ * 2. Remove exiting nodes from flow + overlay them fixed at their original positions
768
+ * 3. Flex re-centers remaining nodes instantly
769
+ * 4. Snapshot new subtree positions (LAST)
770
+ * 5. INVERT: push subtree nodes back to original positions via transform (no transition)
771
+ * 6. PLAY: animate subtree to new center + animate fixed-overlay exits to slide out
772
+ */
773
+ function enterFocusMode(nodeId, type) {
774
+ if (!graphData) return;
775
+ // Always cancel any pending cleanup (enter or exit) before starting a new animation
776
+ if (focusCleanupTimer) { clearTimeout(focusCleanupTimer); focusCleanupTimer = null; }
777
+ if (focusNodeId) {
778
+ // Restore everything cleanly before re-entering
779
+ exitedNodes.forEach(({ el }) => {
780
+ el.style.cssText = '';
781
+ el.classList.remove('focus-exiting', 'focus-active');
782
+ });
783
+ document.querySelectorAll('.node.focus-active').forEach(el => el.classList.remove('focus-active'));
784
+ exitedNodes = [];
785
+ }
786
+
787
+ focusNodeId = nodeId;
788
+ const subtree = getFocusSubtree(nodeId, type);
789
+
790
+ // Determine focus center X for directional exits
791
+ const focusEl = document.querySelector(`[data-node-id="${nodeId}"]`);
792
+ if (!focusEl) return;
793
+ const focusCenterX = focusEl.getBoundingClientRect().left + focusEl.getBoundingClientRect().width / 2;
794
+
795
+ // Classify all nodes
796
+ const subtreeEls = new Map(); // id → el
797
+ exitedNodes = [];
798
+ document.querySelectorAll('.node').forEach(el => {
799
+ const id = el.dataset.nodeId;
800
+ if (!id) return;
801
+ el.style.cssText = '';
802
+ el.classList.remove('focus-exiting', 'focus-active');
803
+ if (subtree.has(id)) {
804
+ el.classList.add('focus-active');
805
+ subtreeEls.set(id, el);
806
+ } else {
807
+ const rect = el.getBoundingClientRect();
808
+ exitedNodes.push({ el, rect, dirLeft: (rect.left + rect.width / 2) < focusCenterX });
809
+ }
810
+ });
811
+
812
+ // FIRST: snapshot subtree positions before layout change
813
+ const firstRects = snapshotRects(subtree);
814
+
815
+ // Remove exiting nodes from flow (instantly invisible — opacity handled separately)
816
+ // Pin them fixed at their current viewport positions for the slide-out overlay
817
+ exitedNodes.forEach(({ el, rect, dirLeft }) => {
818
+ el.dataset.focusDir = dirLeft ? 'left' : 'right';
819
+ el.classList.add('focus-exiting');
820
+ el.style.position = 'fixed';
821
+ el.style.left = rect.left + 'px';
822
+ el.style.top = rect.top + 'px';
823
+ el.style.width = rect.width + 'px';
824
+ el.style.height = rect.height + 'px';
825
+ el.style.margin = '0';
826
+ el.style.zIndex = '15';
827
+ el.style.pointerEvents = 'none';
828
+ el.style.opacity = '1';
829
+ el.style.transform = 'none';
830
+ });
831
+
832
+ // Force reflow: flex now sees only subtree nodes → re-centers them
833
+ void document.body.offsetWidth;
834
+
835
+ // LAST: snapshot new positions
836
+ const lastRects = snapshotRects(subtree);
837
+
838
+ // INVERT: push subtree nodes to appear at their old positions (no transition)
839
+ subtreeEls.forEach((el, id) => {
840
+ const first = firstRects.get(id);
841
+ const last = lastRects.get(id);
842
+ if (!first || !last) return;
843
+ const dx = first.left - last.left;
844
+ const dy = first.top - last.top;
845
+ if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
846
+ el.style.transition = 'none';
847
+ el.style.transform = `translate(${dx}px, ${dy}px)`;
848
+ }
849
+ });
850
+
851
+ // Force reflow so invert transforms are painted before we play
852
+ void document.body.offsetWidth;
853
+
854
+ const dur = FOCUS_DUR + 'ms';
855
+ const easeIn = 'cubic-bezier(0,0,0.2,1)';
856
+ const easeOut = 'cubic-bezier(0.4,0,1,1)';
857
+
858
+ // PLAY: animate subtree nodes to their new centered positions
859
+ subtreeEls.forEach(el => {
860
+ el.style.transition = `transform ${dur} ${easeIn}`;
861
+ el.style.transform = '';
862
+ });
863
+
864
+ // Clear edges immediately (no transition lag) — redraw only after animation settles
865
+ $edgesSvg.innerHTML = '';
866
+ $edgesSvg.style.opacity = '0';
867
+
868
+ // PLAY: slide + fade exiting nodes out from their fixed positions
869
+ requestAnimationFrame(() => {
870
+ exitedNodes.forEach(({ el, dirLeft }) => {
871
+ el.style.transition = `opacity ${Math.round(FOCUS_DUR * 0.7)}ms ease, transform ${dur} ${easeOut}`;
872
+ el.style.opacity = '0';
873
+ el.style.transform = `translateX(${dirLeft ? -130 : 130}%)`;
874
+ });
875
+ });
876
+
877
+ // After animation: clean up + redraw edges at final positions, then fade back in
878
+ // Stored in focusCleanupTimer so exitFocusMode can cancel it if user exits early
879
+ focusCleanupTimer = setTimeout(() => {
880
+ focusCleanupTimer = null;
881
+ exitedNodes.forEach(({ el }) => {
882
+ el.style.cssText = '';
883
+ el.style.display = 'none';
884
+ el.classList.remove('focus-exiting');
885
+ });
886
+ subtreeEls.forEach(el => { el.style.transition = ''; el.style.transform = ''; });
887
+ drawEdgesForSubtree(subtree);
888
+ $edgesSvg.style.opacity = '1';
889
+ }, FOCUS_DUR + 50);
890
+
891
+ $focusHint.classList.add('visible');
892
+ }
893
+
894
+ /**
895
+ * Exit focus mode — proper reverse FLIP:
896
+ * 1. Snapshot current visual positions of subtree nodes (may be centered)
897
+ * 2. Restore ALL nodes to normal flow (clear all inline styles)
898
+ * 3. Force layout → nodes at natural positions
899
+ * 4. Snapshot natural positions (LAST)
900
+ * 5. INVERT: push subtree nodes back to where they were + push returned nodes off-screen
901
+ * 6. Force reflow
902
+ * 7. PLAY: animate everything to natural (transform:'')
903
+ */
904
+ function exitFocusMode() {
905
+ if (!focusNodeId) return;
906
+ const prevSubtree = getFocusSubtree(
907
+ focusNodeId,
908
+ document.querySelector(`[data-node-id="${focusNodeId}"]`)?.dataset.nodeType || 'declaration'
909
+ );
910
+ focusNodeId = null;
911
+ if (focusCleanupTimer) { clearTimeout(focusCleanupTimer); focusCleanupTimer = null; }
912
+
913
+ const dur = FOCUS_DUR + 'ms';
914
+ const easeIn = 'cubic-bezier(0,0,0.2,1)';
915
+
916
+ // FIRST: snapshot current visual positions of subtree nodes (before any style changes)
917
+ const firstRects = snapshotRects(prevSubtree);
918
+
919
+ // Capture dirLeft for each exited node, then clear all inline styles on every node
920
+ const capturedExits = exitedNodes.map(({ el, dirLeft }) => ({ el, dirLeft }));
921
+ exitedNodes = [];
922
+
923
+ document.querySelectorAll('.node').forEach(el => {
924
+ el.style.cssText = '';
925
+ el.classList.remove('focus-exiting', 'focus-active');
926
+ });
927
+
928
+ // Force layout: all nodes now at their natural flex positions
929
+ void document.body.offsetWidth;
930
+
931
+ // LAST: snapshot natural positions of subtree nodes
932
+ const lastRects = snapshotRects(prevSubtree);
933
+
934
+ // Build subtree element map
935
+ const subtreeEls = new Map();
936
+ prevSubtree.forEach(id => {
937
+ const el = document.querySelector(`[data-node-id="${id}"]`);
938
+ if (el) subtreeEls.set(id, el);
939
+ });
940
+
941
+ // INVERT subtree: push nodes back to where they appeared (centered)
942
+ subtreeEls.forEach((el, id) => {
943
+ const first = firstRects.get(id);
944
+ const last = lastRects.get(id);
945
+ if (!first || !last) return;
946
+ const dx = first.left - last.left;
947
+ const dy = first.top - last.top;
948
+ if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
949
+ el.style.transition = 'none';
950
+ el.style.transform = `translate(${dx}px, ${dy}px)`;
951
+ }
952
+ });
953
+
954
+ // INVERT returned nodes: push off-screen so they slide in
955
+ capturedExits.forEach(({ el, dirLeft }) => {
956
+ el.style.transition = 'none';
957
+ el.style.opacity = '0';
958
+ el.style.transform = `translateX(${dirLeft ? -130 : 130}%)`;
959
+ });
960
+
961
+ // Force reflow to paint inverted state before PLAY
962
+ void document.body.offsetWidth;
963
+
964
+ // Clear edges immediately
965
+ $edgesSvg.innerHTML = '';
966
+ $edgesSvg.style.opacity = '0';
967
+
968
+ // PLAY: animate all nodes to natural positions (transform:'')
969
+ requestAnimationFrame(() => {
970
+ subtreeEls.forEach(el => {
971
+ el.style.transition = `transform ${dur} ${easeIn}`;
972
+ el.style.transform = '';
973
+ });
974
+ capturedExits.forEach(({ el }) => {
975
+ el.style.transition = `opacity ${Math.round(FOCUS_DUR * 0.8)}ms ease ${Math.round(FOCUS_DUR * 0.1)}ms, transform ${dur} ${easeIn}`;
976
+ el.style.opacity = '1';
977
+ el.style.transform = '';
978
+ });
979
+ });
980
+
981
+ // Cleanup: remove inline styles, redraw edges at settled positions
982
+ focusCleanupTimer = setTimeout(() => {
983
+ document.querySelectorAll('.node').forEach(el => {
984
+ el.style.transition = '';
985
+ el.style.transform = '';
986
+ el.style.opacity = '';
987
+ });
988
+ void document.body.offsetWidth;
989
+ requestAnimationFrame(() => {
990
+ drawEdges();
991
+ $edgesSvg.style.opacity = '1';
992
+ });
993
+ focusCleanupTimer = null;
994
+ }, FOCUS_DUR + 80);
995
+
996
+ $focusHint.classList.remove('visible');
997
+ }
998
+
999
+ /**
1000
+ * Draw edges but dim those outside the given subtree.
1001
+ * @param {Set<string>} subtree
1002
+ */
1003
+ function drawEdgesForSubtree(subtree) {
1004
+ if (!graphData) return;
1005
+ const { milestones, actions } = graphData;
1006
+ const container = document.getElementById('canvas-container');
1007
+
1008
+ $edgesSvg.setAttribute('width', String(container.scrollWidth));
1009
+ $edgesSvg.setAttribute('height', String(container.scrollHeight));
1010
+ $edgesSvg.innerHTML = '';
1011
+
1012
+ const fragment = document.createDocumentFragment();
1013
+
1014
+ (milestones || []).forEach(m => {
1015
+ const mEl = document.querySelector(`[data-node-id="${m.id}"]`);
1016
+ if (!mEl) return;
1017
+ const mTop = getTopCenter(mEl);
1018
+
1019
+ (m.realizes || []).forEach(dId => {
1020
+ const dEl = document.querySelector(`[data-node-id="${dId}"]`);
1021
+ if (!dEl) return;
1022
+ const dBot = getBottomCenter(dEl);
1023
+ const inSubtree = subtree.has(m.id) && subtree.has(dId);
1024
+ const path = makePath(curvePath(dBot.x, dBot.y, mTop.x, mTop.y), inSubtree);
1025
+ if (!inSubtree) path.classList.add('focus-dim');
1026
+ fragment.appendChild(path);
1027
+ });
1028
+ });
1029
+
1030
+ (actions || []).forEach(a => {
1031
+ const aEl = document.querySelector(`[data-node-id="${a.id}"]`);
1032
+ if (!aEl) return;
1033
+ const aTop = getTopCenter(aEl);
1034
+
1035
+ (a.causes || []).forEach(mId => {
1036
+ const mEl = document.querySelector(`[data-node-id="${mId}"]`);
1037
+ if (!mEl) return;
1038
+ const mBot = getBottomCenter(mEl);
1039
+ const inSubtree = subtree.has(a.id) && subtree.has(mId);
1040
+ const path = makePath(curvePath(mBot.x, mBot.y, aTop.x, aTop.y), inSubtree);
1041
+ if (!inSubtree) path.classList.add('focus-dim');
1042
+ fragment.appendChild(path);
1043
+ });
1044
+ });
1045
+
1046
+ $edgesSvg.appendChild(fragment);
1047
+ }
1048
+
1049
+ // ─── Event wiring ─────────────────────────────────────────────────────────────
1050
+
1051
+ $refreshBtn.addEventListener('click', () => {
1052
+ loadData();
1053
+ });
1054
+
1055
+ // ESC to exit focus mode
1056
+ document.addEventListener('keydown', (e) => {
1057
+ if (e.key === 'Escape' && focusNodeId) {
1058
+ document.querySelectorAll('.node.selected').forEach(el => el.classList.remove('selected'));
1059
+ selectedNodeId = null;
1060
+ exitFocusMode();
1061
+ if ($panelEmpty) $panelEmpty.style.display = '';
1062
+ }
1063
+ });
1064
+
1065
+ // Click on canvas background to exit focus mode
1066
+ document.getElementById('canvas-wrap').addEventListener('click', (e) => {
1067
+ if (!focusNodeId) return;
1068
+ if (!e.target.closest('.node')) {
1069
+ document.querySelectorAll('.node.selected').forEach(el => el.classList.remove('selected'));
1070
+ selectedNodeId = null;
1071
+ exitFocusMode();
1072
+ if ($panelEmpty) $panelEmpty.style.display = '';
1073
+ }
1074
+ });
1075
+
1076
+ $overlayRetry.addEventListener('click', () => {
1077
+ showLoading();
1078
+ loadData();
1079
+ });
1080
+
1081
+ // Redraw edges on window resize or scroll (layout may shift)
1082
+ window.addEventListener('resize', () => {
1083
+ if (graphData) requestAnimationFrame(() => drawEdges());
1084
+ });
1085
+
1086
+ document.getElementById('canvas-wrap').addEventListener('scroll', () => {
1087
+ if (graphData) requestAnimationFrame(() => drawEdges());
1088
+ });
1089
+
1090
+ // ─── Live updates via Server-Sent Events ─────────────────────────────────────
1091
+ // Server watches .planning/ with fs.watch and pushes a 'change' event.
1092
+ // Client re-renders only when idle (not mid-animation).
1093
+
1094
+ function connectSSE() {
1095
+ const es = new EventSource('/events');
1096
+ es.addEventListener('change', () => {
1097
+ if (focusNodeId || focusCleanupTimer) return; // skip during animation
1098
+ loadData();
1099
+ });
1100
+ es.addEventListener('error', () => {
1101
+ // Connection dropped — reconnect after 3s
1102
+ es.close();
1103
+ setTimeout(connectSSE, 3000);
1104
+ });
1105
+ }
1106
+
1107
+ connectSSE();
1108
+
1109
+ // ─── Bootstrap ───────────────────────────────────────────────────────────────
1110
+
1111
+ showLoading();
1112
+ loadData();