iikit-dashboard 1.3.1 → 1.5.0

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.
@@ -2,11 +2,11 @@
2
2
  'use strict';
3
3
 
4
4
  const path = require('path');
5
- const { createServer } = require('../src/server');
5
+ const { createServer, removePidfile } = require('../src/server');
6
6
 
7
7
  // Parse arguments
8
8
  const args = process.argv.slice(2);
9
- let projectPath = process.cwd();
9
+ let projectPath = path.resolve(process.cwd());
10
10
  let port = 3000;
11
11
 
12
12
  for (let i = 0; i < args.length; i++) {
@@ -46,6 +46,7 @@ async function main() {
46
46
  // Handle graceful shutdown
47
47
  process.on('SIGINT', () => {
48
48
  console.log('\n Shutting down...');
49
+ removePidfile(projectPath);
49
50
  result.server.close(() => {
50
51
  if (result.watcher) result.watcher.close();
51
52
  process.exit(0);
@@ -53,6 +54,7 @@ async function main() {
53
54
  });
54
55
 
55
56
  process.on('SIGTERM', () => {
57
+ removePidfile(projectPath);
56
58
  result.server.close(() => {
57
59
  if (result.watcher) result.watcher.close();
58
60
  process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iikit-dashboard",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "description": "Real-time dashboard for Intent Integrity Kit (IIKit) — visualizes every phase of specification-driven AI development",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -93,6 +93,17 @@
93
93
  flex: 1 1 auto;
94
94
  }
95
95
 
96
+ .project-label {
97
+ font-size: 12px;
98
+ color: var(--color-text-muted);
99
+ max-width: 160px;
100
+ overflow: hidden;
101
+ text-overflow: ellipsis;
102
+ white-space: nowrap;
103
+ border-left: 1px solid var(--color-border);
104
+ padding-left: 12px;
105
+ }
106
+
96
107
  .feature-selector {
97
108
  position: relative;
98
109
  min-width: 100px;
@@ -1675,6 +1686,64 @@
1675
1686
  100% { outline-color: var(--color-accent); }
1676
1687
  }
1677
1688
 
1689
+ /* ====== Cross-Link Navigation ====== */
1690
+ .cross-link {
1691
+ cursor: pointer;
1692
+ text-decoration: underline;
1693
+ text-decoration-style: dotted;
1694
+ text-decoration-color: var(--color-accent);
1695
+ text-underline-offset: 2px;
1696
+ transition: text-decoration-color var(--transition-fast);
1697
+ }
1698
+ .cross-link:hover {
1699
+ text-decoration-style: solid;
1700
+ text-decoration-color: var(--color-accent-hover);
1701
+ }
1702
+
1703
+ .card.highlighted {
1704
+ outline: 2px solid var(--color-accent);
1705
+ outline-offset: 2px;
1706
+ animation: highlight-pulse 1.5s ease-out;
1707
+ }
1708
+
1709
+ .task-item.highlighted {
1710
+ background: color-mix(in srgb, var(--color-accent) 15%, transparent);
1711
+ border-radius: var(--radius-sm);
1712
+ animation: highlight-pulse 1.5s ease-out;
1713
+ }
1714
+
1715
+ .checklist-item.highlighted {
1716
+ background: color-mix(in srgb, var(--color-accent) 15%, transparent);
1717
+ border-radius: var(--radius-sm);
1718
+ animation: highlight-pulse 1.5s ease-out;
1719
+ }
1720
+
1721
+ .clarify-entry.highlighted {
1722
+ outline: 2px solid var(--color-accent);
1723
+ outline-offset: 2px;
1724
+ border-radius: var(--radius-sm);
1725
+ animation: highlight-pulse 1.5s ease-out;
1726
+ }
1727
+
1728
+ .sankey-node.task-checked .sankey-node-rect {
1729
+ stroke: var(--color-done);
1730
+ stroke-width: 2;
1731
+ }
1732
+
1733
+ .cross-link-tip {
1734
+ font-size: 11px;
1735
+ font-style: italic;
1736
+ color: var(--color-text-muted);
1737
+ padding: 6px 12px;
1738
+ user-select: none;
1739
+ }
1740
+
1741
+ @keyframes highlight-pulse {
1742
+ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-accent) 40%, transparent); }
1743
+ 70% { box-shadow: 0 0 0 8px transparent; }
1744
+ 100% { box-shadow: 0 0 0 0 transparent; }
1745
+ }
1746
+
1678
1747
  /* Detail Panel — floating left sidebar */
1679
1748
  .detail-panel {
1680
1749
  position: fixed;
@@ -2310,6 +2379,7 @@
2310
2379
  <div class="logo-icon" aria-hidden="true">D</div>
2311
2380
  <span>IIKit Dashboard</span>
2312
2381
  </div>
2382
+ <div class="project-label" id="projectLabel" title=""></div>
2313
2383
  <div class="feature-selector" role="navigation" aria-label="Feature selector">
2314
2384
  <select id="featureSelect" aria-label="Select feature to display" tabindex="0">
2315
2385
  <option value="">Loading features...</option>
@@ -2358,6 +2428,7 @@
2358
2428
  let currentTestify = null;
2359
2429
  let currentAnalyze = null;
2360
2430
  let activeTab = null;
2431
+ let externalSankeyHighlight = null;
2361
2432
  let ws = null;
2362
2433
  let reconnectTimer = null;
2363
2434
  let previousCardColumns = {}; // Track card positions for animations
@@ -2453,9 +2524,160 @@
2453
2524
  }
2454
2525
  }
2455
2526
 
2527
+ // ====== Cross-Panel Navigation ======
2528
+ const CROSS_LINK_MOD_KEY = /Mac|iPhone|iPad/.test(navigator.platform || '') ? '\u2318' : 'Ctrl';
2529
+
2530
+ async function navigateToPanel(panelId, entityId) {
2531
+ switchTab(panelId);
2532
+
2533
+ // Sentinel elements per panel — poll until the panel has rendered
2534
+ const sentinels = {
2535
+ spec: '.graph-node', testify: '.sankey-node', implement: '.card',
2536
+ checklist: '.checklist-item', clarify: '.clarify-entry', analyze: '.heatmap-row'
2537
+ };
2538
+ const sel = sentinels[panelId];
2539
+ if (sel) {
2540
+ for (let i = 0; i < 30; i++) {
2541
+ if (contentArea.querySelector(sel)) break;
2542
+ await new Promise(r => setTimeout(r, 50));
2543
+ }
2544
+ }
2545
+
2546
+ // Dispatch to panel-specific highlight
2547
+ const highlighters = {
2548
+ spec: highlightSpecEntity,
2549
+ testify: highlightTestifyEntity,
2550
+ implement: highlightBoardEntity,
2551
+ checklist: highlightChecklistEntity,
2552
+ clarify: highlightClarifyEntity
2553
+ };
2554
+ const fn = highlighters[panelId];
2555
+ if (fn) fn(entityId);
2556
+ }
2557
+
2558
+ // Delegated Cmd/Ctrl+click handler for all cross-links
2559
+ contentArea.addEventListener('click', (e) => {
2560
+ if (!(e.metaKey || e.ctrlKey)) return;
2561
+ const link = e.target.closest('[data-cross-target]');
2562
+ if (link) {
2563
+ e.preventDefault();
2564
+ e.stopPropagation();
2565
+ navigateToPanel(link.dataset.crossTarget, link.dataset.crossId);
2566
+ }
2567
+ });
2568
+
2569
+ // ====== Panel-Specific Highlight Functions ======
2570
+
2571
+ function highlightSpecEntity(entityId) {
2572
+ // Only normalize US refs (US-2 → US2); FR/SC keep their hyphens (SC-007 stays SC-007)
2573
+ const nodeId = entityId.replace(/^US-/i, 'US');
2574
+ highlightGraphNode(nodeId);
2575
+
2576
+ // Scroll to story card (for US refs) or graph node (for FR/SC)
2577
+ const card = contentArea.querySelector(`.story-card[data-story-id="${nodeId}"]`);
2578
+ if (card) {
2579
+ card.scrollIntoView({ behavior: 'smooth', block: 'center' });
2580
+ card.classList.add('highlighted');
2581
+ setTimeout(() => card.classList.remove('highlighted'), 2000);
2582
+ const story = currentStoryMap?.stories?.find(s => s.id === nodeId);
2583
+ if (story) showDetailPanel(nodeId, 'us', story.title, story.body || '');
2584
+ return;
2585
+ }
2586
+ const nodeEl = contentArea.querySelector(`.graph-node[data-id="${nodeId}"]`);
2587
+ if (nodeEl) {
2588
+ nodeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
2589
+ const type = nodeId.startsWith('FR') ? 'fr' : 'sc';
2590
+ showDetailPanel(nodeId, type, nodeId, nodeEl.dataset.desc || '');
2591
+ }
2592
+ }
2593
+
2594
+ function highlightTestifyEntity(entityId) {
2595
+ if (externalSankeyHighlight) {
2596
+ externalSankeyHighlight(entityId);
2597
+ }
2598
+ const nodeEl = contentArea.querySelector(`.sankey-node[data-node-id="${entityId}"]`);
2599
+ if (nodeEl) {
2600
+ nodeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
2601
+ }
2602
+ }
2603
+
2604
+ function highlightBoardEntity(entityId) {
2605
+ // Task IDs (T prefix): find .task-id span by text, expand list, highlight
2606
+ if (/^T\d+$/i.test(entityId)) {
2607
+ const taskIds = contentArea.querySelectorAll('.task-id');
2608
+ for (const span of taskIds) {
2609
+ if (span.textContent.trim() === entityId) {
2610
+ const taskItem = span.closest('.task-item');
2611
+ const taskList = span.closest('.task-list');
2612
+ // Expand collapsed task list
2613
+ if (taskList && taskList.classList.contains('collapsed')) {
2614
+ const btn = taskList.previousElementSibling;
2615
+ if (btn && btn.classList.contains('task-toggle')) {
2616
+ btn.click();
2617
+ }
2618
+ }
2619
+ if (taskItem) {
2620
+ taskItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
2621
+ taskItem.classList.add('highlighted');
2622
+ setTimeout(() => taskItem.classList.remove('highlighted'), 2000);
2623
+ }
2624
+ return;
2625
+ }
2626
+ }
2627
+ }
2628
+
2629
+ // Story IDs (US prefix): find .card[data-card-id]
2630
+ const card = contentArea.querySelector(`.card[data-card-id="${entityId}"]`);
2631
+ if (card) {
2632
+ card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
2633
+ card.classList.add('highlighted');
2634
+ setTimeout(() => card.classList.remove('highlighted'), 2000);
2635
+ }
2636
+ }
2637
+
2638
+ function highlightChecklistEntity(entityId) {
2639
+ // Search tags and inline cross-link spans for the entity ID
2640
+ const matches = contentArea.querySelectorAll(`.checklist-item-tag, .checklist-item .cross-link[data-cross-id="${entityId}"]`);
2641
+ for (const el of matches) {
2642
+ if (el.dataset.crossId === entityId || el.textContent.trim() === entityId) {
2643
+ const item = el.closest('.checklist-item');
2644
+ // Expand parent section if collapsed
2645
+ const detail = el.closest('.checklist-detail');
2646
+ if (detail && !detail.classList.contains('open')) {
2647
+ const filename = detail.id?.replace('checklist-detail-', '');
2648
+ if (filename && currentChecklist) {
2649
+ toggleChecklistExpand(filename, currentChecklist);
2650
+ // Re-query after re-render
2651
+ setTimeout(() => highlightChecklistEntity(entityId), 100);
2652
+ return;
2653
+ }
2654
+ }
2655
+ if (item) {
2656
+ item.scrollIntoView({ behavior: 'smooth', block: 'center' });
2657
+ item.classList.add('highlighted');
2658
+ setTimeout(() => item.classList.remove('highlighted'), 2000);
2659
+ }
2660
+ return;
2661
+ }
2662
+ }
2663
+ }
2664
+
2665
+ function highlightClarifyEntity(entityId) {
2666
+ const ref = contentArea.querySelector(`.clarify-ref[data-ref-id="${entityId}"]`);
2667
+ if (ref) {
2668
+ const entry = ref.closest('.clarify-entry');
2669
+ if (entry) {
2670
+ entry.scrollIntoView({ behavior: 'smooth', block: 'center' });
2671
+ entry.classList.add('highlighted');
2672
+ setTimeout(() => entry.classList.remove('highlighted'), 2000);
2673
+ }
2674
+ }
2675
+ }
2676
+
2456
2677
  function renderBoardView() {
2457
2678
  contentArea.innerHTML = `
2458
2679
  <div class="board-container">
2680
+ <div class="cross-link-tip">Tip: ${CROSS_LINK_MOD_KEY}+click any identifier to navigate to its linked panel</div>
2459
2681
  <div id="board" class="board" role="region" aria-label="Dashboard board"></div>
2460
2682
  </div>`;
2461
2683
  // Re-assign boardEl reference
@@ -2542,6 +2764,8 @@
2542
2764
  });
2543
2765
  html += '</div>';
2544
2766
 
2767
+ html += `<div class="cross-link-tip">Tip: ${CROSS_LINK_MOD_KEY}+click any FR/SC tag to navigate to its linked panel</div>`;
2768
+
2545
2769
  // Accordion detail panels
2546
2770
  data.files.forEach((file) => {
2547
2771
  const isExpanded = expandedChecklist === file.filename;
@@ -2600,13 +2824,22 @@
2600
2824
  ? `<span class="checklist-item-id">${escapeHtml(item.chkId)}</span>`
2601
2825
  : '';
2602
2826
 
2603
- const tags = (item.tags || []).map(t =>
2604
- `<span class="checklist-item-tag">${escapeHtml(t)}</span>`
2605
- ).join('');
2827
+ const tags = (item.tags || []).map(t => {
2828
+ const isCrossLink = /^(FR|SC)-?\d+$/i.test(t);
2829
+ if (isCrossLink) {
2830
+ return `<span class="checklist-item-tag cross-link" data-cross-target="spec" data-cross-id="${escapeHtml(t)}">${escapeHtml(t)}</span>`;
2831
+ }
2832
+ return `<span class="checklist-item-tag">${escapeHtml(t)}</span>`;
2833
+ }).join('');
2834
+
2835
+ const linkedText = escapeHtml(item.text).replace(
2836
+ /\b((?:FR|SC)-\d+)\b/g,
2837
+ '<span class="cross-link" data-cross-target="spec" data-cross-id="$1">$1</span>'
2838
+ );
2606
2839
 
2607
2840
  html += `<div class="checklist-item">
2608
2841
  ${icon}${chkId}
2609
- <span style="flex:1">${escapeHtml(item.text)}</span>
2842
+ <span style="flex:1">${linkedText}</span>
2610
2843
  ${tags}
2611
2844
  </div>`;
2612
2845
  });
@@ -2657,6 +2890,8 @@
2657
2890
  // Integrity seal
2658
2891
  html += renderIntegritySeal(data.integrity);
2659
2892
 
2893
+ html += `<div class="cross-link-tip">Tip: ${CROSS_LINK_MOD_KEY}+click any identifier to navigate to its linked panel</div>`;
2894
+
2660
2895
  // Sankey diagram
2661
2896
  html += renderSankeyDiagram(data);
2662
2897
 
@@ -2743,10 +2978,20 @@
2743
2978
  midY += groupH + GROUP_GAP;
2744
2979
  }
2745
2980
 
2746
- // Right column: tasks
2981
+ // Right column: tasks — mark completed tasks from board data
2982
+ const checkedTaskIds = new Set();
2983
+ if (currentBoard) {
2984
+ for (const cards of [currentBoard.todo, currentBoard.in_progress, currentBoard.done]) {
2985
+ for (const card of (cards || [])) {
2986
+ for (const t of (card.tasks || [])) {
2987
+ if (t.checked) checkedTaskIds.add(t.id);
2988
+ }
2989
+ }
2990
+ }
2991
+ }
2747
2992
  const taskNodes = tasks.map((t, i) => ({
2748
2993
  ...t, x: COL_RIGHT, y: PAD + 30 + i * (NODE_H + NODE_GAP),
2749
- col: 'right', isGap: false
2994
+ col: 'right', isGap: false, isChecked: checkedTaskIds.has(t.id)
2750
2995
  }));
2751
2996
 
2752
2997
  // SVG height
@@ -2866,7 +3111,7 @@
2866
3111
  for (const n of nodes) {
2867
3112
  const w = n.w || NODE_W;
2868
3113
  const chain = Array.from(getDirectedChain(n.id)).join(' ');
2869
- const gapClass = n.isGap ? ' gap-node' : '';
3114
+ const gapClass = n.isGap ? ' gap-node' : (n.isChecked ? ' task-checked' : '');
2870
3115
  const isUntestedReq = gaps.untestedRequirements.includes(n.id);
2871
3116
  const isUnimplTest = gaps.unimplementedTests.includes(n.id);
2872
3117
  let ariaDesc = n.id;
@@ -2887,8 +3132,17 @@
2887
3132
  const desc = n.text || n.title || n.description || '';
2888
3133
  const truncDesc = desc.length > 22 ? desc.substring(0, 22) + '...' : desc;
2889
3134
 
3135
+ // Cross-link: requirements → spec, tasks → implement, gap nodes → checklist
3136
+ let crossAttr = '';
3137
+ if (n.isGap) {
3138
+ crossAttr = ` data-cross-target="checklist" data-cross-id="${escapeHtml(n.id)}"`;
3139
+ } else if (n.col === 'left') {
3140
+ crossAttr = ` data-cross-target="spec" data-cross-id="${escapeHtml(n.id)}"`;
3141
+ } else if (n.col === 'right') {
3142
+ crossAttr = ` data-cross-target="implement" data-cross-id="${escapeHtml(n.id)}"`;
3143
+ }
2890
3144
  s += `<g class="sankey-node${gapClass}" tabindex="0" role="button"
2891
- data-node-id="${escapeHtml(n.id)}" data-chain="${escapeHtml(chain)}"
3145
+ data-node-id="${escapeHtml(n.id)}" data-chain="${escapeHtml(chain)}"${crossAttr}
2892
3146
  aria-describedby="desc-${escapeHtml(n.id)}">
2893
3147
  <title>${escapeHtml(n.id)}: ${escapeHtml(desc)}${isUntestedReq ? ' (untested)' : ''}${isUnimplTest ? ' (unimplemented)' : ''}</title>
2894
3148
  <desc id="desc-${escapeHtml(n.id)}">${escapeHtml(ariaDesc)}</desc>
@@ -2936,6 +3190,9 @@
2936
3190
  const allElements = [...allNodes, ...allFlows];
2937
3191
  let lockedChain = null; // Click locks the highlight
2938
3192
 
3193
+ // Expose highlight to cross-panel navigation
3194
+ externalSankeyHighlight = (nodeId) => { lockedChain = nodeId; highlightChain(nodeId); };
3195
+
2939
3196
  function highlightChain(nodeId) {
2940
3197
  // Get full chain from data-chain attribute
2941
3198
  const nodeEl = svgEl.querySelector(`[data-node-id="${nodeId}"]`);
@@ -2981,8 +3238,9 @@
2981
3238
  if (!lockedChain) highlightChain(nodeId);
2982
3239
  });
2983
3240
  node.addEventListener('mouseleave', clearHighlight);
2984
- // Click: lock/unlock chain highlight
2985
- node.addEventListener('click', () => {
3241
+ // Click: lock/unlock chain highlight (skip if Cmd/Ctrl for cross-navigation)
3242
+ node.addEventListener('click', (e) => {
3243
+ if (e.metaKey || e.ctrlKey) return;
2986
3244
  if (lockedChain === nodeId) {
2987
3245
  lockedChain = null;
2988
3246
  clearHighlight();
@@ -3057,6 +3315,7 @@
3057
3315
  contentArea.innerHTML = `
3058
3316
  <div class="storymap-view" role="region" aria-label="Spec Story Map">
3059
3317
  <div class="storymap-main">
3318
+ <div class="cross-link-tip">Tip: ${CROSS_LINK_MOD_KEY}+click any identifier to navigate to its linked panel</div>
3060
3319
  <div class="storymap-section-title">Story Map</div>
3061
3320
  <div class="swim-lanes" role="list" aria-label="User stories by priority"></div>
3062
3321
  <div class="storymap-section-title">Requirements Graph</div>
@@ -3095,7 +3354,7 @@
3095
3354
  <div class="swim-lane-cards">`;
3096
3355
  for (const story of stories) {
3097
3356
  const refs = (story.requirementRefs || []).map(r => `<span class="story-card-badge">${escapeHtml(r)}</span>`).join('');
3098
- html += `<div class="story-card" data-story-id="${escapeHtml(story.id)}" tabindex="0" role="button" aria-label="${escapeHtml(story.title)}">
3357
+ html += `<div class="story-card" data-story-id="${escapeHtml(story.id)}" data-cross-target="implement" data-cross-id="${escapeHtml(story.id)}" tabindex="0" role="button" aria-label="${escapeHtml(story.title)}">
3099
3358
  <div class="story-card-header">
3100
3359
  <span class="story-card-id">${escapeHtml(story.id)}</span>
3101
3360
  <span class="story-card-priority ${story.priority.toLowerCase()}">${escapeHtml(story.priority)}</span>
@@ -3114,6 +3373,7 @@
3114
3373
  // Story card click → highlight graph node + show detail (FR-016)
3115
3374
  container.querySelectorAll('.story-card').forEach(card => {
3116
3375
  card.addEventListener('click', (e) => {
3376
+ if (e.metaKey || e.ctrlKey) return; // Let cross-link handler handle Cmd/Ctrl+click
3117
3377
  const storyId = card.dataset.storyId;
3118
3378
  highlightGraphNode(storyId);
3119
3379
  const story = data.stories.find(s => s.id === storyId);
@@ -3229,7 +3489,8 @@
3229
3489
  const radius = { us: 18, fr: 14, sc: 12 };
3230
3490
  for (const n of nodes) {
3231
3491
  const r = radius[n.type] || 14;
3232
- svgContent += `<g class="graph-node graph-node-${n.type}" data-id="${n.id}" data-desc="${escapeHtml(n.desc)}" tabindex="0" role="button" aria-label="${n.label}: ${escapeHtml(n.desc)}">
3492
+ const crossTarget = n.type === 'us' ? 'implement' : 'testify';
3493
+ svgContent += `<g class="graph-node graph-node-${n.type}" data-id="${n.id}" data-desc="${escapeHtml(n.desc)}" data-cross-target="${crossTarget}" data-cross-id="${n.id}" tabindex="0" role="button" aria-label="${n.label}: ${escapeHtml(n.desc)}">
3233
3494
  <circle cx="${n.x}" cy="${n.y}" r="${r}" stroke="var(--color-bg)" stroke-width="2"/>
3234
3495
  <text x="${n.x}" y="${n.y + r + 14}">${n.label}</text>
3235
3496
  </g>`;
@@ -3239,6 +3500,7 @@
3239
3500
 
3240
3501
  // Click-to-highlight + detail panel (FR-006)
3241
3502
  svg.addEventListener('click', (e) => {
3503
+ if (e.metaKey || e.ctrlKey) return; // Let cross-link handler handle Cmd/Ctrl+click
3242
3504
  const nodeEl = e.target.closest('.graph-node');
3243
3505
  if (nodeEl) {
3244
3506
  const id = nodeEl.dataset.id;
@@ -3283,6 +3545,7 @@
3283
3545
  let dragOffset = { x: 0, y: 0 };
3284
3546
 
3285
3547
  svg.addEventListener('mousedown', (e) => {
3548
+ if (e.metaKey || e.ctrlKey) return; // Don't drag on Cmd/Ctrl+click
3286
3549
  const nodeEl = e.target.closest('.graph-node');
3287
3550
  if (!nodeEl) return;
3288
3551
  e.preventDefault();
@@ -3383,6 +3646,19 @@
3383
3646
  .replace(/\*(.+?)\*/g, '<em>$1</em>')
3384
3647
  .replace(/^(\d+)\.\s+/gm, '<br>$1. ');
3385
3648
 
3649
+ // Check for related clarifications
3650
+ let clarifyLink = '';
3651
+ if (currentStoryMap && currentStoryMap.clarifications) {
3652
+ const hasClarify = currentStoryMap.clarifications.some(c =>
3653
+ c.refs && c.refs.some(r => r === id || r.replace(/^(US|FR|SC)-/i, (_, p) => p.toUpperCase()) === id)
3654
+ );
3655
+ if (hasClarify) {
3656
+ clarifyLink = `<div style="margin-top:12px;padding-top:8px;border-top:1px solid var(--color-border-subtle)">
3657
+ <a href="#" class="clarify-nav-link" data-entity-id="${escapeHtml(id)}" style="font-size:12px;color:var(--color-accent);cursor:pointer;text-decoration:underline">View Clarifications &rarr;</a>
3658
+ </div>`;
3659
+ }
3660
+ }
3661
+
3386
3662
  slot.innerHTML = `
3387
3663
  <div class="detail-panel" role="region" aria-label="Detail view for ${escapeHtml(id)}">
3388
3664
  <div class="detail-panel-header">
@@ -3391,9 +3667,17 @@
3391
3667
  </div>
3392
3668
  <div class="detail-panel-title">${escapeHtml(title)}</div>
3393
3669
  <div class="detail-panel-body">${rendered}</div>
3670
+ ${clarifyLink}
3394
3671
  </div>`;
3395
3672
 
3396
3673
  slot.querySelector('.detail-panel-close').addEventListener('click', closeDetailPanel);
3674
+ const clarifyNav = slot.querySelector('.clarify-nav-link');
3675
+ if (clarifyNav) {
3676
+ clarifyNav.addEventListener('click', (e) => {
3677
+ e.preventDefault();
3678
+ navigateToPanel('clarify', clarifyNav.dataset.entityId);
3679
+ });
3680
+ }
3397
3681
  slot.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
3398
3682
  }
3399
3683
 
@@ -3458,6 +3742,7 @@
3458
3742
  <span class="clarify-title">Clarification Trail</span>
3459
3743
  <span class="clarify-count">${clarifications.length} Q&amp;A${clarifications.length !== 1 ? 's' : ''}</span>
3460
3744
  </div>
3745
+ <div class="cross-link-tip">Tip: ${CROSS_LINK_MOD_KEY}+click any identifier to navigate to its linked panel</div>
3461
3746
  <div class="clarify-sessions">`;
3462
3747
 
3463
3748
  for (const [session, entries] of Object.entries(sessions)) {
@@ -3491,40 +3776,8 @@
3491
3776
  }
3492
3777
 
3493
3778
  async function navigateToSpecItem(refId) {
3494
- // Normalize: clarification refs use US-2 but parser creates US2
3495
- const nodeId = refId.replace(/^US-/, 'US');
3496
-
3497
- // Switch to Spec tab and wait for async render to complete
3498
- switchTab('spec');
3499
- // Poll until the graph SVG has rendered (async fetch may take time)
3500
- for (let i = 0; i < 20; i++) {
3501
- if (contentArea.querySelector('.graph-node')) break;
3502
- await new Promise(r => setTimeout(r, 50));
3503
- }
3504
-
3505
- // Highlight the node in the graph
3506
- highlightGraphNode(nodeId);
3507
-
3508
- // For US refs, scroll to and highlight the story card
3509
- const card = contentArea.querySelector(`.story-card[data-story-id="${nodeId}"]`);
3510
- if (card) {
3511
- card.scrollIntoView({ behavior: 'smooth', block: 'center' });
3512
- card.classList.add('highlighted');
3513
- setTimeout(() => card.classList.remove('highlighted'), 2000);
3514
- // Show detail for the story
3515
- const story = currentStoryMap?.stories?.find(s => s.id === nodeId);
3516
- if (story) showDetailPanel(nodeId, 'us', story.title, story.body || '');
3517
- return;
3518
- }
3519
-
3520
- // For FR/SC refs, scroll to the graph node
3521
- const nodeEl = contentArea.querySelector(`.graph-node[data-id="${nodeId}"]`);
3522
- if (nodeEl) {
3523
- nodeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
3524
- const type = nodeId.startsWith('FR') ? 'fr' : 'sc';
3525
- const desc = nodeEl.dataset.desc || '';
3526
- showDetailPanel(nodeId, type, nodeId, desc);
3527
- }
3779
+ // Thin wrapper delegates to generic cross-panel navigation
3780
+ navigateToPanel('spec', refId);
3528
3781
  }
3529
3782
 
3530
3783
  // ====== Constitution View ======
@@ -3754,6 +4007,7 @@
3754
4007
 
3755
4008
  // Coverage Heatmap Section
3756
4009
  html += '<div class="analyze-section" style="animation-delay: 0.1s">';
4010
+ html += `<div class="cross-link-tip">Tip: ${CROSS_LINK_MOD_KEY}+click any identifier to navigate to its linked panel</div>`;
3757
4011
  html += '<div class="analyze-section-header">Coverage Heatmap</div>';
3758
4012
  html += renderHeatmap(data.heatmap);
3759
4013
  html += '</div>';
@@ -4337,7 +4591,7 @@
4337
4591
  const priorityClass = card.priority ? card.priority.toLowerCase() : 'p3';
4338
4592
 
4339
4593
  el.innerHTML = `
4340
- <div class="card-id">${card.id}</div>
4594
+ <div class="card-id cross-link" data-cross-target="spec" data-cross-id="${card.id}">${card.id}</div>
4341
4595
  <div class="card-header">
4342
4596
  <div class="card-title" title="${escapeHtml(card.title)}">${escapeHtml(card.title)}</div>
4343
4597
  <span class="priority-badge ${priorityClass}" aria-label="Priority ${card.priority}">${card.priority}</span>
@@ -4359,7 +4613,7 @@
4359
4613
  ${(card.tasks || []).map(t => `
4360
4614
  <li class="task-item ${t.checked ? 'checked' : ''}">
4361
4615
  <span class="task-checkbox ${t.checked ? 'checked' : ''}" aria-hidden="true"></span>
4362
- <span class="task-id">${t.id}</span>
4616
+ <span class="task-id cross-link" data-cross-target="testify" data-cross-id="${t.id}">${t.id}</span>
4363
4617
  <span class="task-description">${escapeHtml(t.description)}</span>
4364
4618
  </li>
4365
4619
  `).join('')}
@@ -4946,6 +5200,12 @@
4946
5200
  applyTheme(themeMode);
4947
5201
 
4948
5202
  // ====== Init ======
5203
+ fetch('/api/meta').then(r => r.json()).then(meta => {
5204
+ const label = document.getElementById('projectLabel');
5205
+ const dirName = meta.projectPath.split('/').pop();
5206
+ label.textContent = dirName;
5207
+ label.title = meta.projectPath;
5208
+ }).catch(() => {});
4949
5209
  loadFeatures();
4950
5210
  connectWebSocket();
4951
5211
  })();
package/src/server.js CHANGED
@@ -98,6 +98,33 @@ function getBoardState(projectPath, featureId) {
98
98
  return { ...board, integrity };
99
99
  }
100
100
 
101
+ /**
102
+ * Write a pidfile with metadata so external scripts can identify this dashboard instance.
103
+ */
104
+ function writePidfile(projectPath, port) {
105
+ const resolved = path.resolve(projectPath);
106
+ const specifyDir = path.join(resolved, '.specify');
107
+ fs.mkdirSync(specifyDir, { recursive: true });
108
+ const pidData = {
109
+ pid: process.pid,
110
+ port,
111
+ directory: resolved,
112
+ startedAt: new Date().toISOString()
113
+ };
114
+ fs.writeFileSync(path.join(specifyDir, 'dashboard.pid.json'), JSON.stringify(pidData, null, 2));
115
+ }
116
+
117
+ /**
118
+ * Remove the pidfile on shutdown.
119
+ */
120
+ function removePidfile(projectPath) {
121
+ try {
122
+ fs.unlinkSync(path.join(path.resolve(projectPath), '.specify', 'dashboard.pid.json'));
123
+ } catch (err) {
124
+ if (err.code !== 'ENOENT') throw err;
125
+ }
126
+ }
127
+
101
128
  /**
102
129
  * Create and configure the Express server with WebSocket support.
103
130
  *
@@ -107,11 +134,17 @@ function getBoardState(projectPath, featureId) {
107
134
  * @returns {Promise<{server: http.Server, port: number, wss: WebSocketServer}>}
108
135
  */
109
136
  function createServer({ projectPath, port = 3000 }) {
137
+ const resolvedPath = path.resolve(projectPath);
110
138
  const app = express();
111
139
 
112
140
  // Serve static files from src/public
113
141
  app.use(express.static(path.join(__dirname, 'public')));
114
142
 
143
+ // API: project metadata
144
+ app.get('/api/meta', (req, res) => {
145
+ res.json({ projectPath: resolvedPath });
146
+ });
147
+
115
148
  // API: list features
116
149
  app.get('/api/features', (req, res) => {
117
150
  try {
@@ -363,9 +396,10 @@ function createServer({ projectPath, port = 3000 }) {
363
396
  return new Promise((resolve) => {
364
397
  server.listen(port, () => {
365
398
  const actualPort = server.address().port;
366
- resolve({ server, port: actualPort, wss, watcher });
399
+ writePidfile(resolvedPath, actualPort);
400
+ resolve({ server, port: actualPort, wss, watcher, projectPath: resolvedPath });
367
401
  });
368
402
  });
369
403
  }
370
404
 
371
- module.exports = { createServer, listFeatures, getBoardState };
405
+ module.exports = { createServer, listFeatures, getBoardState, removePidfile };