declare-cc 1.0.7 → 1.0.8

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.
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Declare DAG Visualizer — app.js
2
+ * Declare — app.js
3
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.
4
+ * Fetches /api/graph and /api/status, renders a drill browser with detail panels,
5
+ * and live-updates via SSE when .planning/ changes.
6
6
  *
7
7
  * Zero external dependencies. Vanilla JS, no build step.
8
8
  */
@@ -25,9 +25,6 @@ let statusData = null;
25
25
  /** @type {string | null} */
26
26
  let selectedNodeId = null;
27
27
 
28
- /** @type {string | null} Focus node ID — the declaration or milestone being focused */
29
- let focusNodeId = null;
30
-
31
28
  /** @type {Set<string>} Tracks which action IDs are currently executing */
32
29
  let runningActions = new Set();
33
30
 
@@ -66,10 +63,8 @@ const drillNavState = {
66
63
  actions: { focusIndex: 0, scrollTop: 0 },
67
64
  };
68
65
 
69
- /** @type {'dag'|'columns'|'execution'} Current view mode, persisted in localStorage */
70
- let viewMode = localStorage.getItem('declare-view-mode') || 'columns';
71
- // Execution mode is only valid during active play; fall back to columns on reload
72
- if (viewMode === 'execution') viewMode = 'columns';
66
+ /** @type {'columns'|'execution'} Current view mode */
67
+ let viewMode = 'columns';
73
68
 
74
69
  /** @type {boolean} Whether the declaration input form is visible */
75
70
  let declFormVisible = false;
@@ -186,17 +181,12 @@ if ($projectName) {
186
181
  });
187
182
  }
188
183
 
189
- const $nodesDecls = document.getElementById('nodes-declarations');
190
- const $nodesMiles = document.getElementById('nodes-milestones');
191
- const $nodesActs = document.getElementById('nodes-actions');
192
- const $edgesSvg = document.getElementById('edges-svg');
193
-
194
184
  const $sidePanel = document.getElementById('side-panel');
195
185
  const $panelBody = document.getElementById('panel-body');
196
186
  const $panelEmpty = document.getElementById('panel-empty');
197
187
 
198
- const $colBrowser = document.getElementById('column-browser');
199
188
  const $readinessBanner = document.getElementById('readiness-banner');
189
+ const $colBrowser = document.getElementById('column-browser');
200
190
  const $colDeclList = document.getElementById('col-decl-list');
201
191
  const $colMileList = document.getElementById('col-mile-list');
202
192
  const $colActList = document.getElementById('col-act-list');
@@ -215,9 +205,6 @@ const $drillPrompt = document.getElementById('drill-prompt');
215
205
  if (drillBody && actFeed) drillBody.appendChild(actFeed);
216
206
  })();
217
207
 
218
- const $viewToggle = document.getElementById('view-toggle');
219
- const $viewToggleLabel = document.getElementById('view-toggle-label');
220
- const $canvasWrap = document.getElementById('canvas-wrap');
221
208
  const $execView = document.getElementById('execution-view');
222
209
  const $execOutputHeader = document.getElementById('exec-output-header');
223
210
  const $execOutputLog = document.getElementById('exec-output-log');
@@ -378,7 +365,6 @@ async function loadData() {
378
365
 
379
366
  hideOverlay();
380
367
  renderStatusBar();
381
- renderGraph();
382
368
  parseDrillHash(); // Restore drill state from URL hash on load
383
369
  renderDrillView();
384
370
  updateLastUpdated();
@@ -548,164 +534,6 @@ function deriveDeclarationStatus(declaration, enrichedMilestones) {
548
534
  return 'PENDING';
549
535
  }
550
536
 
551
- /**
552
- * Build a node DOM element.
553
- * @param {object} item
554
- * @param {'declaration'|'milestone'|'action'} type
555
- * @param {{ displayStatus?: string, doneCount?: number, totalCount?: number }} [derived]
556
- * @returns {HTMLElement}
557
- */
558
- function buildNodeEl(item, type, derived = {}) {
559
- const displayStatus = derived.displayStatus || item.status || 'PENDING';
560
- const el = document.createElement('div');
561
- el.className = `node node-${type} status-${statusClass(displayStatus)}`;
562
- el.dataset.nodeId = item.id;
563
- el.dataset.nodeType = type;
564
-
565
- // Wholeness left-border indicator
566
- const wh = item.wholeness;
567
- if (wh === 'whole' || wh === 'partial' || wh === 'broken') {
568
- el.classList.add(`wholeness-${wh}`);
569
- }
570
-
571
- const title = item.title || item.statement || item.id;
572
-
573
- // Progress bar for milestones with actions
574
- let progressHtml = '';
575
- if (type === 'milestone' && derived.totalCount > 0) {
576
- const pct = Math.round((derived.doneCount / derived.totalCount) * 100);
577
- const countLabel = `${derived.doneCount}/${derived.totalCount}`;
578
- progressHtml = `
579
- <div class="node-progress" title="${countLabel} actions done">
580
- <div class="node-progress-fill" style="width:${pct}%"></div>
581
- </div>`;
582
- }
583
-
584
- // Badge label — show progress count for executing milestones
585
- let badgeLabel = displayStatus;
586
- if (type === 'milestone' && displayStatus === 'EXECUTING' && derived.totalCount > 0) {
587
- badgeLabel = `${derived.doneCount}/${derived.totalCount} DONE`;
588
- }
589
-
590
- // Integrity indicator — small colored dot next to status badge
591
- // Skip for "broken" when node has no children (treat as pending/not-yet-computable)
592
- let integrityDotHtml = '';
593
- if (wh === 'whole' || wh === 'partial') {
594
- integrityDotHtml = `<span class="integrity-dot integrity-${wh}" title="Integrity: ${wh}"></span>`;
595
- } else if (wh === 'broken') {
596
- // Only show broken dot if this node actually has children (not just "nothing to compute")
597
- if (type === 'action') {
598
- integrityDotHtml = `<span class="integrity-dot integrity-broken" title="Integrity: broken"></span>`;
599
- } else if (type === 'milestone' && derived.totalCount > 0) {
600
- integrityDotHtml = `<span class="integrity-dot integrity-broken" title="Integrity: broken"></span>`;
601
- } else if (type === 'declaration') {
602
- // Declarations: check if they have child milestones
603
- const hasChildren = graphData && (graphData.milestones || []).some(m => (m.realizes || []).includes(item.id));
604
- if (hasChildren) {
605
- integrityDotHtml = `<span class="integrity-dot integrity-broken" title="Integrity: broken"></span>`;
606
- }
607
- }
608
- // If none of the above matched, no dot shown (pending/not-computable)
609
- }
610
-
611
- // Classification icon for milestones (robot for agent, person for human)
612
- let classIconHtml = '';
613
- if (type === 'milestone' && item.classification) {
614
- const isHuman = item.classification === 'human';
615
- const icon = isHuman ? '\u{1F464}' : '\u{1F916}';
616
- const label = isHuman ? 'Human' : 'Agent';
617
- classIconHtml = `<span class="class-icon" title="${label}">${icon}</span>`;
618
- }
619
-
620
- // Dependency indicator for milestones
621
- let depIndicatorHtml = '';
622
- if (type === 'milestone' && item.dependsOn && item.dependsOn.length > 0) {
623
- depIndicatorHtml = `<span class="dep-indicator" title="Blocked by: ${item.dependsOn.join(', ')}">&#8592; ${item.dependsOn.length}</span>`;
624
- }
625
-
626
- // Readiness badge for milestones
627
- let readinessBadgeHtml = '';
628
- if (type === 'milestone' && item.readiness) {
629
- const rs = item.readiness.state;
630
- if (rs === 'ready') {
631
- readinessBadgeHtml = '<span class="readiness-badge readiness-ready">READY</span>';
632
- } else if (rs === 'blocked') {
633
- const blockers = (item.readiness.blockedBy || []).join(', ');
634
- readinessBadgeHtml = `<span class="readiness-badge readiness-blocked" title="Blocked by: ${blockers}">BLOCKED</span>`;
635
- } else if (rs === 'no-actions') {
636
- readinessBadgeHtml = '<span class="readiness-badge readiness-no-actions">NO ACTIONS</span>';
637
- }
638
- // done state: no badge needed (status already shows DONE)
639
- }
640
-
641
- el.innerHTML = `
642
- <div class="node-id">${classIconHtml}${item.id}${depIndicatorHtml}</div>
643
- <div class="node-title">${truncate(title, 55)}</div>
644
- <span class="status-badge">${badgeLabel}</span>${readinessBadgeHtml}${integrityDotHtml}${reviewBadgeHtml(item.id, item.reviewState)}
645
- ${progressHtml}
646
- `;
647
-
648
- el.addEventListener('click', () => selectNode(item.id, type));
649
- return el;
650
- }
651
-
652
- // ─── Graph renderer ───────────────────────────────────────────────────────────
653
-
654
- function renderGraph() {
655
- if (!graphData) return;
656
-
657
- const { declarations, milestones, actions } = graphData;
658
-
659
- // ── Compute derived statuses from action data (always reflects reality) ──────
660
- // Milestones
661
- const enrichedMilestones = (milestones || []).map(m => ({
662
- ...m,
663
- ...deriveMilestoneStatus(m, actions || []),
664
- }));
665
-
666
- // Declarations
667
- const enrichedDeclarations = (declarations || []).map(d => ({
668
- ...d,
669
- displayStatus: deriveDeclarationStatus(d, enrichedMilestones),
670
- }));
671
-
672
- // Clear containers
673
- $nodesDecls.innerHTML = '';
674
- $nodesMiles.innerHTML = '';
675
- $nodesActs.innerHTML = '';
676
-
677
- // Render
678
- enrichedDeclarations.forEach(d => {
679
- $nodesDecls.appendChild(buildNodeEl(d, 'declaration', { displayStatus: d.displayStatus }));
680
- });
681
-
682
- // Sort milestones: ready first, then no-actions, then blocked, then done
683
- const readinessOrder = { ready: 0, 'no-actions': 1, blocked: 2, done: 3 };
684
- const sortedMilestones = [...enrichedMilestones].sort((a, b) => {
685
- const aState = (a.readiness && a.readiness.state) || 'blocked';
686
- const bState = (b.readiness && b.readiness.state) || 'blocked';
687
- return (readinessOrder[aState] ?? 2) - (readinessOrder[bState] ?? 2);
688
- });
689
-
690
- sortedMilestones.forEach(m => {
691
- $nodesMiles.appendChild(buildNodeEl(m, 'milestone', {
692
- displayStatus: m.displayStatus,
693
- doneCount: m.doneCount,
694
- totalCount: m.totalCount,
695
- }));
696
- });
697
-
698
- (actions || []).forEach(a => {
699
- $nodesActs.appendChild(buildNodeEl(a, 'action'));
700
- });
701
-
702
- // Draw edges after layout settles
703
- requestAnimationFrame(() => drawEdges());
704
-
705
- // Mark running actions with pulsing indicator
706
- updateRunningIndicators();
707
- }
708
-
709
537
  // ─── Declaration input form ───────────────────────────────────────────────────
710
538
 
711
539
  /**
@@ -864,7 +692,7 @@ function renderColumnBrowser() {
864
692
 
865
693
  const { declarations, milestones, actions } = graphData;
866
694
 
867
- // Compute enriched milestones and declarations (same logic as renderGraph)
695
+ // Compute enriched milestones and declarations
868
696
  const enrichedMilestones = (milestones || []).map(m => ({
869
697
  ...m,
870
698
  ...deriveMilestoneStatus(m, actions || []),
@@ -3363,147 +3191,6 @@ document.addEventListener('click', async (e) => {
3363
3191
  }
3364
3192
  });
3365
3193
 
3366
- // ─── Edge drawing ─────────────────────────────────────────────────────────────
3367
-
3368
- /**
3369
- * Get the center-bottom point of a DOM element relative to #canvas-container.
3370
- * @param {Element} el
3371
- * @returns {{ x: number, y: number }}
3372
- */
3373
- function getBottomCenter(el) {
3374
- const containerRect = document.getElementById('canvas-container').getBoundingClientRect();
3375
- const scrollLeft = document.getElementById('canvas-wrap').scrollLeft;
3376
- const scrollTop = document.getElementById('canvas-wrap').scrollTop;
3377
- const r = el.getBoundingClientRect();
3378
- return {
3379
- x: r.left - containerRect.left + scrollLeft + r.width / 2,
3380
- y: r.top - containerRect.top + scrollTop + r.height,
3381
- };
3382
- }
3383
-
3384
- /**
3385
- * Get the center-top point of a DOM element relative to #canvas-container.
3386
- * @param {Element} el
3387
- * @returns {{ x: number, y: number }}
3388
- */
3389
- function getTopCenter(el) {
3390
- const containerRect = document.getElementById('canvas-container').getBoundingClientRect();
3391
- const scrollLeft = document.getElementById('canvas-wrap').scrollLeft;
3392
- const scrollTop = document.getElementById('canvas-wrap').scrollTop;
3393
- const r = el.getBoundingClientRect();
3394
- return {
3395
- x: r.left - containerRect.left + scrollLeft + r.width / 2,
3396
- y: r.top - containerRect.top + scrollTop,
3397
- };
3398
- }
3399
-
3400
- /**
3401
- * Draw a cubic bezier SVG path from (x1,y1) to (x2,y2).
3402
- * @param {number} x1
3403
- * @param {number} y1
3404
- * @param {number} x2
3405
- * @param {number} y2
3406
- * @returns {string}
3407
- */
3408
- function curvePath(x1, y1, x2, y2) {
3409
- const cy = (y1 + y2) / 2;
3410
- return `M ${x1} ${y1} C ${x1} ${cy}, ${x2} ${cy}, ${x2} ${y2}`;
3411
- }
3412
-
3413
- /**
3414
- * Build an SVG path element for an edge.
3415
- * @param {string} d
3416
- * @param {boolean} highlight
3417
- * @returns {SVGPathElement}
3418
- */
3419
- function makePath(d, highlight) {
3420
- const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
3421
- path.setAttribute('d', d);
3422
- path.setAttribute('class', highlight ? 'edge highlight' : 'edge');
3423
- return path;
3424
- }
3425
-
3426
- function drawEdges() {
3427
- if (!graphData) return;
3428
-
3429
- const { milestones, actions } = graphData;
3430
- const container = document.getElementById('canvas-container');
3431
-
3432
- // Resize SVG to container dimensions
3433
- $edgesSvg.setAttribute('width', String(container.scrollWidth));
3434
- $edgesSvg.setAttribute('height', String(container.scrollHeight));
3435
- $edgesSvg.innerHTML = '';
3436
-
3437
- const fragment = document.createDocumentFragment();
3438
-
3439
- // Milestone → Declaration edges (realizes)
3440
- (milestones || []).forEach(m => {
3441
- const mEl = document.querySelector(`[data-node-id="${m.id}"]`);
3442
- if (!mEl) return;
3443
- const mTop = getTopCenter(mEl);
3444
-
3445
- (m.realizes || []).forEach(dId => {
3446
- const dEl = document.querySelector(`[data-node-id="${dId}"]`);
3447
- if (!dEl) return;
3448
- const dBot = getBottomCenter(dEl);
3449
- const isHighlighted = selectedNodeId === m.id || selectedNodeId === dId;
3450
- fragment.appendChild(makePath(curvePath(dBot.x, dBot.y, mTop.x, mTop.y), isHighlighted));
3451
- });
3452
- });
3453
-
3454
- // Action → Milestone edges (causes)
3455
- (actions || []).forEach(a => {
3456
- const aEl = document.querySelector(`[data-node-id="${a.id}"]`);
3457
- if (!aEl) return;
3458
- const aTop = getTopCenter(aEl);
3459
-
3460
- (a.causes || []).forEach(mId => {
3461
- const mEl = document.querySelector(`[data-node-id="${mId}"]`);
3462
- if (!mEl) return;
3463
- const mBot = getBottomCenter(mEl);
3464
- const isHighlighted = selectedNodeId === a.id || selectedNodeId === mId;
3465
- fragment.appendChild(makePath(curvePath(mBot.x, mBot.y, aTop.x, aTop.y), isHighlighted));
3466
- });
3467
- });
3468
-
3469
- // Milestone → Milestone dependency edges (dashed, horizontal)
3470
- (milestones || []).forEach(m => {
3471
- const deps = m.dependsOn || [];
3472
- if (deps.length === 0) return;
3473
- const mEl = document.querySelector(`[data-node-id="${m.id}"]`);
3474
- if (!mEl) return;
3475
-
3476
- deps.forEach(depId => {
3477
- const depEl = document.querySelector(`[data-node-id="${depId}"]`);
3478
- if (!depEl) return;
3479
-
3480
- // Draw from right side of dependency to left side of dependent
3481
- const containerRect = document.getElementById('canvas-container').getBoundingClientRect();
3482
- const scrollLeft = document.getElementById('canvas-wrap').scrollLeft;
3483
- const scrollTop = document.getElementById('canvas-wrap').scrollTop;
3484
-
3485
- const depRect = depEl.getBoundingClientRect();
3486
- const mRect = mEl.getBoundingClientRect();
3487
-
3488
- const x1 = depRect.right - containerRect.left + scrollLeft;
3489
- const y1 = depRect.top - containerRect.top + scrollTop + depRect.height / 2;
3490
- const x2 = mRect.left - containerRect.left + scrollLeft;
3491
- const y2 = mRect.top - containerRect.top + scrollTop + mRect.height / 2;
3492
-
3493
- const cx = (x1 + x2) / 2;
3494
- const d = `M ${x1} ${y1} C ${cx} ${y1}, ${cx} ${y2}, ${x2} ${y2}`;
3495
- const isHighlighted = selectedNodeId === m.id || selectedNodeId === depId;
3496
-
3497
- const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
3498
- path.setAttribute('d', d);
3499
- path.setAttribute('class', isHighlighted ? 'edge dep-edge highlight' : 'edge dep-edge');
3500
- fragment.appendChild(path);
3501
- });
3502
- });
3503
-
3504
- $edgesSvg.appendChild(fragment);
3505
- }
3506
-
3507
3194
  // ─── Annotation panel ─────────────────────────────────────────────────────────
3508
3195
 
3509
3196
  /**
@@ -4147,7 +3834,6 @@ function selectNode(nodeId, type) {
4147
3834
  if (selectedNodeId === nodeId) {
4148
3835
  // Toggle off
4149
3836
  selectedNodeId = null;
4150
- exitFocusMode();
4151
3837
  if ($panelEmpty) $panelEmpty.style.display = '';
4152
3838
  return;
4153
3839
  }
@@ -4158,16 +3844,6 @@ function selectNode(nodeId, type) {
4158
3844
  const el = document.querySelector(`[data-node-id="${nodeId}"]`);
4159
3845
  if (el) el.classList.add('selected');
4160
3846
 
4161
- // If clicking a node already visible in the current focused subtree, skip re-animation
4162
- const alreadyInFocus = focusNodeId && getFocusSubtree(
4163
- focusNodeId,
4164
- document.querySelector(`[data-node-id="${focusNodeId}"]`)?.dataset.nodeType || 'declaration'
4165
- ).has(nodeId);
4166
-
4167
- if (!alreadyInFocus) {
4168
- enterFocusMode(nodeId, type);
4169
- }
4170
-
4171
3847
  // Populate panel
4172
3848
  let item = null;
4173
3849
  if (graphData) {
@@ -4182,13 +3858,11 @@ function selectNode(nodeId, type) {
4182
3858
  renderPanelChain(item, type);
4183
3859
  renderAnnotationPanel(nodeId, type);
4184
3860
 
4185
- // In columns mode, auto-scroll to review controls for review-mode flow
4186
- if (viewMode === 'columns') {
4187
- setTimeout(() => {
4188
- const reviewEl = document.getElementById('review-actions');
4189
- if (reviewEl) reviewEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
4190
- }, 100);
4191
- }
3861
+ // Auto-scroll to review controls for review-mode flow
3862
+ setTimeout(() => {
3863
+ const reviewEl = document.getElementById('review-actions');
3864
+ if (reviewEl) reviewEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
3865
+ }, 100);
4192
3866
  }
4193
3867
 
4194
3868
  /**
@@ -6531,367 +6205,6 @@ async function cancelActionDerivation() {
6531
6205
  if (btn) { btn.disabled = false; btn.textContent = 'Plan Actions'; }
6532
6206
  }
6533
6207
 
6534
- const $focusHint = document.getElementById('focus-hint');
6535
- const FOCUS_DUR = 380;
6536
-
6537
- /**
6538
- * Compute the set of node IDs that belong to a focused subtree.
6539
- * - declaration: itself + all its milestones + all their actions
6540
- * - milestone: itself + its declarations + its actions
6541
- * - action: itself + its parent milestone + that milestone's declaration + all sibling actions
6542
- * @param {string} nodeId
6543
- * @param {string} type
6544
- * @returns {Set<string>}
6545
- */
6546
- function getFocusSubtree(nodeId, type) {
6547
- if (!graphData) return new Set();
6548
- const { milestones, actions } = graphData;
6549
- const visible = new Set();
6550
-
6551
- if (type === 'declaration') {
6552
- visible.add(nodeId);
6553
- milestones.filter(m => (m.realizes || []).includes(nodeId)).forEach(m => {
6554
- visible.add(m.id);
6555
- actions.filter(a => (a.causes || []).includes(m.id)).forEach(a => visible.add(a.id));
6556
- });
6557
- } else if (type === 'milestone') {
6558
- visible.add(nodeId);
6559
- const m = milestones.find(x => x.id === nodeId);
6560
- if (m) {
6561
- (m.realizes || []).forEach(dId => visible.add(dId));
6562
- actions.filter(a => (a.causes || []).includes(nodeId)).forEach(a => visible.add(a.id));
6563
- }
6564
- } else if (type === 'action') {
6565
- visible.add(nodeId);
6566
- const a = actions.find(x => x.id === nodeId);
6567
- if (a) {
6568
- (a.causes || []).forEach(mId => {
6569
- visible.add(mId);
6570
- const m = milestones.find(x => x.id === mId);
6571
- if (m) {
6572
- // parent declarations
6573
- (m.realizes || []).forEach(dId => visible.add(dId));
6574
- // sibling actions (all actions that cause the same milestone)
6575
- actions.filter(sa => (sa.causes || []).includes(mId)).forEach(sa => visible.add(sa.id));
6576
- }
6577
- });
6578
- }
6579
- }
6580
-
6581
- return visible;
6582
- }
6583
-
6584
- /** Clear all focus-mode inline styles from a node element. */
6585
- function clearNodeFocusStyles(el) {
6586
- el.style.cssText = ''; // wipe all inline styles at once
6587
- el.classList.remove('focus-exiting', 'focus-active');
6588
- el.dataset.focusDir = '';
6589
- }
6590
-
6591
- /** @type {Array<{el: HTMLElement, rect: DOMRect, dirLeft: boolean}>} */
6592
- let exitedNodes = [];
6593
- /** @type {ReturnType<typeof setTimeout> | null} Shared cleanup timer — covers both enter and exit cleanups so each cancels the other */
6594
- let focusCleanupTimer = null;
6595
-
6596
- /**
6597
- * Snapshot getBoundingClientRect for a set of node IDs.
6598
- * @param {Set<string>} ids
6599
- * @returns {Map<string, DOMRect>}
6600
- */
6601
- function snapshotRects(ids) {
6602
- const map = new Map();
6603
- ids.forEach(id => {
6604
- const el = document.querySelector(`[data-node-id="${id}"]`);
6605
- if (el) map.set(id, el.getBoundingClientRect());
6606
- });
6607
- return map;
6608
- }
6609
-
6610
- /**
6611
- * Enter focus mode using FLIP:
6612
- * 1. Snapshot subtree node positions (FIRST)
6613
- * 2. Remove exiting nodes from flow + overlay them fixed at their original positions
6614
- * 3. Flex re-centers remaining nodes instantly
6615
- * 4. Snapshot new subtree positions (LAST)
6616
- * 5. INVERT: push subtree nodes back to original positions via transform (no transition)
6617
- * 6. PLAY: animate subtree to new center + animate fixed-overlay exits to slide out
6618
- */
6619
- function enterFocusMode(nodeId, type) {
6620
- if (!graphData) return;
6621
- // Always cancel any pending cleanup (enter or exit) before starting a new animation
6622
- if (focusCleanupTimer) { clearTimeout(focusCleanupTimer); focusCleanupTimer = null; }
6623
- if (focusNodeId) {
6624
- // Restore everything cleanly before re-entering
6625
- exitedNodes.forEach(({ el }) => {
6626
- el.style.cssText = '';
6627
- el.classList.remove('focus-exiting', 'focus-active');
6628
- });
6629
- document.querySelectorAll('.node.focus-active').forEach(el => el.classList.remove('focus-active'));
6630
- exitedNodes = [];
6631
- }
6632
-
6633
- focusNodeId = nodeId;
6634
- const subtree = getFocusSubtree(nodeId, type);
6635
-
6636
- // Determine focus center X for directional exits
6637
- const focusEl = document.querySelector(`[data-node-id="${nodeId}"]`);
6638
- if (!focusEl) return;
6639
- const focusCenterX = focusEl.getBoundingClientRect().left + focusEl.getBoundingClientRect().width / 2;
6640
-
6641
- // Classify all nodes
6642
- const subtreeEls = new Map(); // id → el
6643
- exitedNodes = [];
6644
- document.querySelectorAll('.node').forEach(el => {
6645
- const id = el.dataset.nodeId;
6646
- if (!id) return;
6647
- el.style.cssText = '';
6648
- el.classList.remove('focus-exiting', 'focus-active');
6649
- if (subtree.has(id)) {
6650
- el.classList.add('focus-active');
6651
- subtreeEls.set(id, el);
6652
- } else {
6653
- const rect = el.getBoundingClientRect();
6654
- exitedNodes.push({ el, rect, dirLeft: (rect.left + rect.width / 2) < focusCenterX });
6655
- }
6656
- });
6657
-
6658
- // FIRST: snapshot subtree positions before layout change
6659
- const firstRects = snapshotRects(subtree);
6660
-
6661
- // Remove exiting nodes from flow (instantly invisible — opacity handled separately)
6662
- // Pin them fixed at their current viewport positions for the slide-out overlay
6663
- exitedNodes.forEach(({ el, rect, dirLeft }) => {
6664
- el.dataset.focusDir = dirLeft ? 'left' : 'right';
6665
- el.classList.add('focus-exiting');
6666
- el.style.position = 'fixed';
6667
- el.style.left = rect.left + 'px';
6668
- el.style.top = rect.top + 'px';
6669
- el.style.width = rect.width + 'px';
6670
- el.style.height = rect.height + 'px';
6671
- el.style.margin = '0';
6672
- el.style.zIndex = '15';
6673
- el.style.pointerEvents = 'none';
6674
- el.style.opacity = '1';
6675
- el.style.transform = 'none';
6676
- });
6677
-
6678
- // Force reflow: flex now sees only subtree nodes → re-centers them
6679
- void document.body.offsetWidth;
6680
-
6681
- // LAST: snapshot new positions
6682
- const lastRects = snapshotRects(subtree);
6683
-
6684
- // INVERT: push subtree nodes to appear at their old positions (no transition)
6685
- subtreeEls.forEach((el, id) => {
6686
- const first = firstRects.get(id);
6687
- const last = lastRects.get(id);
6688
- if (!first || !last) return;
6689
- const dx = first.left - last.left;
6690
- const dy = first.top - last.top;
6691
- if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
6692
- el.style.transition = 'none';
6693
- el.style.transform = `translate(${dx}px, ${dy}px)`;
6694
- }
6695
- });
6696
-
6697
- // Force reflow so invert transforms are painted before we play
6698
- void document.body.offsetWidth;
6699
-
6700
- const dur = FOCUS_DUR + 'ms';
6701
- const easeIn = 'cubic-bezier(0,0,0.2,1)';
6702
- const easeOut = 'cubic-bezier(0.4,0,1,1)';
6703
-
6704
- // PLAY: animate subtree nodes to their new centered positions
6705
- subtreeEls.forEach(el => {
6706
- el.style.transition = `transform ${dur} ${easeIn}`;
6707
- el.style.transform = '';
6708
- });
6709
-
6710
- // Clear edges immediately (no transition lag) — redraw only after animation settles
6711
- $edgesSvg.innerHTML = '';
6712
- $edgesSvg.style.opacity = '0';
6713
-
6714
- // PLAY: slide + fade exiting nodes out from their fixed positions
6715
- requestAnimationFrame(() => {
6716
- exitedNodes.forEach(({ el, dirLeft }) => {
6717
- el.style.transition = `opacity ${Math.round(FOCUS_DUR * 0.7)}ms ease, transform ${dur} ${easeOut}`;
6718
- el.style.opacity = '0';
6719
- el.style.transform = `translateX(${dirLeft ? -130 : 130}%)`;
6720
- });
6721
- });
6722
-
6723
- // After animation: clean up + redraw edges at final positions, then fade back in
6724
- // Stored in focusCleanupTimer so exitFocusMode can cancel it if user exits early
6725
- focusCleanupTimer = setTimeout(() => {
6726
- focusCleanupTimer = null;
6727
- exitedNodes.forEach(({ el }) => {
6728
- el.style.cssText = '';
6729
- el.style.display = 'none';
6730
- el.classList.remove('focus-exiting');
6731
- });
6732
- subtreeEls.forEach(el => { el.style.transition = ''; el.style.transform = ''; });
6733
- drawEdgesForSubtree(subtree);
6734
- $edgesSvg.style.opacity = '1';
6735
- }, FOCUS_DUR + 50);
6736
-
6737
- $focusHint.classList.add('visible');
6738
- }
6739
-
6740
- /**
6741
- * Exit focus mode — proper reverse FLIP:
6742
- * 1. Snapshot current visual positions of subtree nodes (may be centered)
6743
- * 2. Restore ALL nodes to normal flow (clear all inline styles)
6744
- * 3. Force layout → nodes at natural positions
6745
- * 4. Snapshot natural positions (LAST)
6746
- * 5. INVERT: push subtree nodes back to where they were + push returned nodes off-screen
6747
- * 6. Force reflow
6748
- * 7. PLAY: animate everything to natural (transform:'')
6749
- */
6750
- function exitFocusMode() {
6751
- if (!focusNodeId) return;
6752
- const prevSubtree = getFocusSubtree(
6753
- focusNodeId,
6754
- document.querySelector(`[data-node-id="${focusNodeId}"]`)?.dataset.nodeType || 'declaration'
6755
- );
6756
- focusNodeId = null;
6757
- if (focusCleanupTimer) { clearTimeout(focusCleanupTimer); focusCleanupTimer = null; }
6758
-
6759
- const dur = FOCUS_DUR + 'ms';
6760
- const easeIn = 'cubic-bezier(0,0,0.2,1)';
6761
-
6762
- // FIRST: snapshot current visual positions of subtree nodes (before any style changes)
6763
- const firstRects = snapshotRects(prevSubtree);
6764
-
6765
- // Capture dirLeft for each exited node, then clear all inline styles on every node
6766
- const capturedExits = exitedNodes.map(({ el, dirLeft }) => ({ el, dirLeft }));
6767
- exitedNodes = [];
6768
-
6769
- document.querySelectorAll('.node').forEach(el => {
6770
- el.style.cssText = '';
6771
- el.classList.remove('focus-exiting', 'focus-active');
6772
- });
6773
-
6774
- // Force layout: all nodes now at their natural flex positions
6775
- void document.body.offsetWidth;
6776
-
6777
- // LAST: snapshot natural positions of subtree nodes
6778
- const lastRects = snapshotRects(prevSubtree);
6779
-
6780
- // Build subtree element map
6781
- const subtreeEls = new Map();
6782
- prevSubtree.forEach(id => {
6783
- const el = document.querySelector(`[data-node-id="${id}"]`);
6784
- if (el) subtreeEls.set(id, el);
6785
- });
6786
-
6787
- // INVERT subtree: push nodes back to where they appeared (centered)
6788
- subtreeEls.forEach((el, id) => {
6789
- const first = firstRects.get(id);
6790
- const last = lastRects.get(id);
6791
- if (!first || !last) return;
6792
- const dx = first.left - last.left;
6793
- const dy = first.top - last.top;
6794
- if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
6795
- el.style.transition = 'none';
6796
- el.style.transform = `translate(${dx}px, ${dy}px)`;
6797
- }
6798
- });
6799
-
6800
- // INVERT returned nodes: push off-screen so they slide in
6801
- capturedExits.forEach(({ el, dirLeft }) => {
6802
- el.style.transition = 'none';
6803
- el.style.opacity = '0';
6804
- el.style.transform = `translateX(${dirLeft ? -130 : 130}%)`;
6805
- });
6806
-
6807
- // Force reflow to paint inverted state before PLAY
6808
- void document.body.offsetWidth;
6809
-
6810
- // Clear edges immediately
6811
- $edgesSvg.innerHTML = '';
6812
- $edgesSvg.style.opacity = '0';
6813
-
6814
- // PLAY: animate all nodes to natural positions (transform:'')
6815
- requestAnimationFrame(() => {
6816
- subtreeEls.forEach(el => {
6817
- el.style.transition = `transform ${dur} ${easeIn}`;
6818
- el.style.transform = '';
6819
- });
6820
- capturedExits.forEach(({ el }) => {
6821
- el.style.transition = `opacity ${Math.round(FOCUS_DUR * 0.8)}ms ease ${Math.round(FOCUS_DUR * 0.1)}ms, transform ${dur} ${easeIn}`;
6822
- el.style.opacity = '1';
6823
- el.style.transform = '';
6824
- });
6825
- });
6826
-
6827
- // Cleanup: remove inline styles, redraw edges at settled positions
6828
- focusCleanupTimer = setTimeout(() => {
6829
- document.querySelectorAll('.node').forEach(el => {
6830
- el.style.transition = '';
6831
- el.style.transform = '';
6832
- el.style.opacity = '';
6833
- });
6834
- void document.body.offsetWidth;
6835
- requestAnimationFrame(() => {
6836
- drawEdges();
6837
- $edgesSvg.style.opacity = '1';
6838
- });
6839
- focusCleanupTimer = null;
6840
- }, FOCUS_DUR + 80);
6841
-
6842
- $focusHint.classList.remove('visible');
6843
- }
6844
-
6845
- /**
6846
- * Draw edges but dim those outside the given subtree.
6847
- * @param {Set<string>} subtree
6848
- */
6849
- function drawEdgesForSubtree(subtree) {
6850
- if (!graphData) return;
6851
- const { milestones, actions } = graphData;
6852
- const container = document.getElementById('canvas-container');
6853
-
6854
- $edgesSvg.setAttribute('width', String(container.scrollWidth));
6855
- $edgesSvg.setAttribute('height', String(container.scrollHeight));
6856
- $edgesSvg.innerHTML = '';
6857
-
6858
- const fragment = document.createDocumentFragment();
6859
-
6860
- (milestones || []).forEach(m => {
6861
- const mEl = document.querySelector(`[data-node-id="${m.id}"]`);
6862
- if (!mEl) return;
6863
- const mTop = getTopCenter(mEl);
6864
-
6865
- (m.realizes || []).forEach(dId => {
6866
- const dEl = document.querySelector(`[data-node-id="${dId}"]`);
6867
- if (!dEl) return;
6868
- const dBot = getBottomCenter(dEl);
6869
- const inSubtree = subtree.has(m.id) && subtree.has(dId);
6870
- const path = makePath(curvePath(dBot.x, dBot.y, mTop.x, mTop.y), inSubtree);
6871
- if (!inSubtree) path.classList.add('focus-dim');
6872
- fragment.appendChild(path);
6873
- });
6874
- });
6875
-
6876
- (actions || []).forEach(a => {
6877
- const aEl = document.querySelector(`[data-node-id="${a.id}"]`);
6878
- if (!aEl) return;
6879
- const aTop = getTopCenter(aEl);
6880
-
6881
- (a.causes || []).forEach(mId => {
6882
- const mEl = document.querySelector(`[data-node-id="${mId}"]`);
6883
- if (!mEl) return;
6884
- const mBot = getBottomCenter(mEl);
6885
- const inSubtree = subtree.has(a.id) && subtree.has(mId);
6886
- const path = makePath(curvePath(mBot.x, mBot.y, aTop.x, aTop.y), inSubtree);
6887
- if (!inSubtree) path.classList.add('focus-dim');
6888
- fragment.appendChild(path);
6889
- });
6890
- });
6891
-
6892
- $edgesSvg.appendChild(fragment);
6893
- }
6894
-
6895
6208
  // ─── Execution pipeline view ──────────────────────────────────────────────────
6896
6209
 
6897
6210
  /**
@@ -7281,8 +6594,8 @@ function canEnterExecution() {
7281
6594
  }
7282
6595
 
7283
6596
  /**
7284
- * Switch between DAG, column browser, and execution views.
7285
- * @param {'dag'|'columns'|'execution'} mode
6597
+ * Switch between column browser and execution views.
6598
+ * @param {'columns'|'execution'} mode
7286
6599
  */
7287
6600
  function switchView(mode) {
7288
6601
  if (mode === 'execution' && !canEnterExecution()) {
@@ -7290,35 +6603,20 @@ function switchView(mode) {
7290
6603
  return;
7291
6604
  }
7292
6605
  viewMode = mode;
7293
- localStorage.setItem('declare-view-mode', mode);
7294
6606
 
7295
6607
  // Hide all views first
7296
- $canvasWrap.style.display = 'none';
7297
6608
  if ($drillBrowser) $drillBrowser.classList.remove('active');
7298
6609
  if ($readinessBanner) $readinessBanner.classList.remove('active');
7299
6610
  if ($execView) $execView.classList.remove('active');
7300
6611
  document.body.classList.remove('exec-mode');
7301
- document.body.classList.remove('dag-mode');
7302
-
7303
- if (mode === 'dag') {
7304
- $canvasWrap.style.display = '';
7305
- document.body.classList.add('dag-mode');
7306
- clearColumnBrowserKbFocus();
7307
- if ($viewToggle) $viewToggleLabel.innerHTML = '&#x2630;'; // hamburger = list view
7308
- // Redraw edges since layout changed
7309
- requestAnimationFrame(() => drawEdges());
7310
- } else if (mode === 'columns') {
7311
- // Exit focus mode before switching to columns
7312
- if (focusNodeId) exitFocusMode();
6612
+
6613
+ if (mode === 'columns') {
7313
6614
  if ($drillBrowser) $drillBrowser.classList.add('active');
7314
- if ($viewToggle) $viewToggleLabel.innerHTML = '&#x2B13;'; // graph icon
7315
6615
  // Refresh drill browser data
7316
6616
  renderDrillView();
7317
6617
  } else if (mode === 'execution') {
7318
- if (focusNodeId) exitFocusMode();
7319
6618
  if ($execView) $execView.classList.add('active');
7320
6619
  document.body.classList.add('exec-mode');
7321
- if ($viewToggle) $viewToggleLabel.innerHTML = '&#x2630;';
7322
6620
  orderConfirmed = false;
7323
6621
  preExecWaves = null;
7324
6622
  renderPreExecutionView();
@@ -7339,17 +6637,6 @@ document.addEventListener('keydown', (e) => {
7339
6637
  }
7340
6638
  });
7341
6639
 
7342
- // View toggle button — cycles: columns -> dag (execution excluded, entered only via play)
7343
- if ($viewToggle) {
7344
- $viewToggle.addEventListener('click', () => {
7345
- if (viewMode === 'columns') {
7346
- switchView('dag');
7347
- } else {
7348
- switchView('columns');
7349
- }
7350
- });
7351
- }
7352
-
7353
6640
  // Execution topbar buttons
7354
6641
  if ($execExitBtn) {
7355
6642
  $execExitBtn.addEventListener('click', () => switchView('columns'));
@@ -7487,53 +6774,12 @@ if ($playStopBtn) {
7487
6774
  $playStopBtn.addEventListener('click', stopPlay);
7488
6775
  }
7489
6776
 
7490
- // ESC to exit focus mode; arrow keys to navigate between declarations (DAG view only)
7491
- document.addEventListener('keydown', (e) => {
7492
- // Skip DAG keyboard handling when column browser is active
7493
- if (isColumnBrowserActive()) return;
7494
-
7495
- if (e.key === 'Escape' && focusNodeId) {
7496
- document.querySelectorAll('.node.selected').forEach(el => el.classList.remove('selected'));
7497
- selectedNodeId = null;
7498
- exitFocusMode();
7499
- if ($panelEmpty) $panelEmpty.style.display = '';
7500
- }
7501
-
7502
- if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && selectedNodeId && graphData) {
7503
- const declarations = graphData.declarations;
7504
- const idx = declarations.findIndex(d => d.id === selectedNodeId);
7505
- if (idx === -1) return; // selected node is not a declaration
7506
- const next = e.key === 'ArrowRight'
7507
- ? (idx + 1) % declarations.length
7508
- : (idx - 1 + declarations.length) % declarations.length;
7509
- selectNode(declarations[next].id, 'declaration');
7510
- }
7511
- });
7512
-
7513
- // Click on canvas background to exit focus mode
7514
- document.getElementById('canvas-wrap').addEventListener('click', (e) => {
7515
- if (!focusNodeId) return;
7516
- if (!e.target.closest('.node')) {
7517
- document.querySelectorAll('.node.selected').forEach(el => el.classList.remove('selected'));
7518
- selectedNodeId = null;
7519
- exitFocusMode();
7520
- if ($panelEmpty) $panelEmpty.style.display = '';
7521
- }
7522
- });
7523
6777
 
7524
6778
  $overlayRetry.addEventListener('click', () => {
7525
6779
  showLoading();
7526
6780
  loadData();
7527
6781
  });
7528
6782
 
7529
- // Redraw edges on window resize or scroll (layout may shift)
7530
- window.addEventListener('resize', () => {
7531
- if (graphData) requestAnimationFrame(() => drawEdges());
7532
- });
7533
-
7534
- document.getElementById('canvas-wrap').addEventListener('scroll', () => {
7535
- if (graphData) requestAnimationFrame(() => drawEdges());
7536
- });
7537
6783
 
7538
6784
  // ─── Confetti ────────────────────────────────────────────────────────────────
7539
6785
  // Fires once when all declarations reach a completed state (DONE/KEPT/HONORED).
@@ -8155,7 +7401,6 @@ function connectSSE() {
8155
7401
  });
8156
7402
  let sseChangeTimer = null;
8157
7403
  es.addEventListener('change', () => {
8158
- if (focusNodeId || focusCleanupTimer) return; // skip during animation
8159
7404
  // Debounce rapid SSE change events (e.g. approve writes multiple files)
8160
7405
  clearTimeout(sseChangeTimer);
8161
7406
  sseChangeTimer = setTimeout(() => {