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.
- package/bin/iikit-dashboard.js +4 -2
- package/package.json +1 -1
- package/src/public/index.html +308 -48
- package/src/server.js +36 -2
package/bin/iikit-dashboard.js
CHANGED
|
@@ -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
package/src/public/index.html
CHANGED
|
@@ -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
|
-
|
|
2605
|
-
|
|
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">${
|
|
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
|
-
|
|
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 →</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&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
|
-
//
|
|
3495
|
-
|
|
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
|
-
|
|
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 };
|