Closing the intent-to-code chasm - specification-driven development with BDD verification chain
93
93%
Does it follow best practices?
Impact
94%
1.84xAverage score across 14 eval scenarios
Advisory
Suggest reviewing before use
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IIKit Dashboard</title>
<style>
/* ====== CSS Custom Properties ====== */
:root {
--color-bg: #0f1117;
--color-surface: #1a1d27;
--color-surface-elevated: #222536;
--color-surface-hover: #2a2d40;
--color-border: #2e3148;
--color-border-subtle: #252839;
--color-text: #e8eaed;
--color-text-secondary: #9aa0b4;
--color-text-muted: #6b7189;
--color-accent: #3B82F6;
--color-accent-hover: #60A5FA;
--color-todo: #4a90d9;
--color-inprogress: #f5a623;
--color-done: #27c93f;
--color-p1: #ff4757;
--color-p2: #ffa502;
--color-p3: #3498db;
--color-verified: #27c93f;
--color-tampered: #ff4757;
--color-missing: #6b7189;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--shadow-card: 0 2px 8px rgba(0,0,0,0.3), 0 1px 3px rgba(0,0,0,0.2);
--shadow-card-hover: 0 8px 24px rgba(0,0,0,0.4), 0 2px 8px rgba(0,0,0,0.3);
--shadow-column: 0 1px 4px rgba(0,0,0,0.2);
--transition-fast: 0.15s ease;
--transition-normal: 0.25s ease;
--transition-slow: 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto, Oxygen, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
}
/* ====== Light Theme ====== */
[data-theme="light"] {
--color-bg: #f5f6f8;
--color-surface: #ffffff;
--color-surface-elevated: #f0f1f4;
--color-surface-hover: #e8e9ee;
--color-border: #d8dae0;
--color-border-subtle: #e4e6eb;
--color-text: #1a1d27;
--color-text-secondary: #5a5f72;
--color-text-muted: #8b90a0;
--color-accent: #2563EB;
--color-accent-hover: #3B82F6;
--shadow-card: 0 1px 4px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
--shadow-card-hover: 0 4px 12px rgba(0,0,0,0.12), 0 2px 4px rgba(0,0,0,0.06);
--shadow-column: 0 1px 3px rgba(0,0,0,0.06);
}
/* ====== Reset & Base ====== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font-sans);
background: var(--color-bg);
color: var(--color-text);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ====== Header ====== */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 28px;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(12px);
gap: 16px;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
flex: 1 1 auto;
}
.project-label {
font-size: 12px;
color: var(--color-text-muted);
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-left: 1px solid var(--color-border);
padding-left: 12px;
}
.feature-selector {
position: relative;
min-width: 100px;
flex: 0 1 300px;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
font-size: 16px;
letter-spacing: -0.3px;
color: var(--color-text);
}
.logo-icon {
width: 28px;
height: 28px;
background: linear-gradient(135deg, var(--color-accent), #1D4ED8);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: white;
font-weight: 800;
}
.header-right {
display: flex;
align-items: center;
gap: 14px;
flex-shrink: 0;
}
/* ====== Feature Selector ====== */
.feature-selector select {
appearance: none;
background: var(--color-surface-elevated);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 8px 36px 8px 12px;
font-size: 13px;
font-family: var(--font-sans);
font-weight: 500;
cursor: pointer;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
width: 100%;
}
.feature-selector select:hover {
border-color: var(--color-accent);
}
.feature-selector select:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.25);
}
.feature-selector::after {
content: '';
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid var(--color-text-secondary);
pointer-events: none;
}
/* ====== Integrity Badge ====== */
.integrity-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.3px;
text-transform: uppercase;
transition: all var(--transition-fast);
}
.integrity-badge.valid {
background: rgba(39, 201, 63, 0.12);
color: var(--color-verified);
border: 1px solid rgba(39, 201, 63, 0.25);
}
.integrity-badge.tampered {
background: rgba(255, 71, 87, 0.12);
color: var(--color-tampered);
border: 1px solid rgba(255, 71, 87, 0.25);
animation: pulse-warning 2s ease-in-out infinite;
}
.integrity-badge.missing {
background: rgba(107, 113, 137, 0.12);
color: var(--color-missing);
border: 1px solid rgba(107, 113, 137, 0.25);
}
@keyframes pulse-warning {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.integrity-dot {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
}
.integrity-badge.valid .integrity-dot { background: var(--color-verified); }
.integrity-badge.tampered .integrity-dot { background: var(--color-tampered); }
.integrity-badge.missing .integrity-dot { background: var(--color-missing); }
/* ====== Connection Status (reserved) ====== */
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 4px rgba(39, 201, 63, 0.4); }
50% { box-shadow: 0 0 12px rgba(39, 201, 63, 0.8); }
}
/* ====== Board Layout ====== */
.board-container {
padding: 24px 28px;
overflow-x: auto;
}
.board {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
min-width: 900px;
}
/* ====== Columns ====== */
.column {
background: var(--color-surface);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-subtle);
box-shadow: var(--shadow-column);
display: flex;
flex-direction: column;
min-height: 200px;
}
.column-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px 12px;
border-bottom: 1px solid var(--color-border-subtle);
}
.column-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--color-text-secondary);
}
.column-dot {
width: 9px;
height: 9px;
border-radius: 50%;
}
.column.todo .column-dot { background: var(--color-todo); }
.column.in-progress .column-dot { background: var(--color-inprogress); }
.column.done .column-dot { background: var(--color-done); }
.column-count {
background: var(--color-surface-elevated);
color: var(--color-text-muted);
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 10px;
min-width: 22px;
text-align: center;
}
.column-body {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
flex: 1;
}
/* ====== Cards ====== */
.card {
background: var(--color-surface-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 16px;
box-shadow: var(--shadow-card);
transition: transform var(--transition-normal), box-shadow var(--transition-normal), opacity var(--transition-slow);
cursor: default;
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-card-hover);
border-color: var(--color-accent);
}
/* Card slide animation classes */
.card.entering {
animation: cardEnter 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
.card.exiting {
animation: cardExit 0.3s ease-in forwards;
}
@keyframes cardEnter {
from { opacity: 0; transform: translateY(-10px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes cardExit {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(10px) scale(0.97); }
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
}
.card-title {
font-size: 14px;
font-weight: 600;
line-height: 1.4;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.card-title:hover {
-webkit-line-clamp: unset;
}
.card-id {
font-size: 11px;
font-family: var(--font-mono);
color: var(--color-text-muted);
margin-bottom: 4px;
}
/* ====== Priority Badges ====== */
.priority-badge {
display: inline-flex;
align-items: center;
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.priority-badge.p1 {
background: rgba(255, 71, 87, 0.15);
color: var(--color-p1);
}
.priority-badge.p2 {
background: rgba(255, 165, 2, 0.15);
color: var(--color-p2);
}
.priority-badge.p3 {
background: rgba(52, 152, 219, 0.15);
color: var(--color-p3);
}
/* ====== Progress Bar ====== */
.progress-container {
margin: 10px 0;
}
.progress-info {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.progress-label {
font-size: 11px;
color: var(--color-text-muted);
font-weight: 500;
}
.progress-value {
font-size: 11px;
font-family: var(--font-mono);
color: var(--color-text-secondary);
font-weight: 600;
}
.progress-bar {
width: 100%;
height: 4px;
background: var(--color-border);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 2px;
transition: width var(--transition-slow);
min-width: 0;
}
.column.todo .progress-fill { background: var(--color-todo); }
.column.in-progress .progress-fill { background: var(--color-inprogress); }
.column.done .progress-fill { background: var(--color-done); }
/* ====== Task List ====== */
.task-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 8px;
}
.task-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 5px 6px;
border-radius: var(--radius-sm);
font-size: 12px;
line-height: 1.5;
color: var(--color-text-secondary);
transition: background var(--transition-fast);
}
.task-item:hover {
background: var(--color-surface-hover);
}
.task-checkbox {
flex-shrink: 0;
width: 15px;
height: 15px;
border-radius: 3px;
border: 1.5px solid var(--color-border);
margin-top: 2px;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.task-checkbox.checked {
background: var(--color-done);
border-color: var(--color-done);
}
.task-checkbox.checked::after {
content: '';
display: block;
width: 4px;
height: 7px;
border: solid white;
border-width: 0 1.5px 1.5px 0;
transform: rotate(45deg) translate(-0.5px, -0.5px);
}
.task-item.checked .task-description {
text-decoration: line-through;
color: var(--color-text-muted);
}
.task-id {
font-family: var(--font-mono);
font-size: 10px;
color: var(--color-text-muted);
flex-shrink: 0;
margin-top: 1px;
}
.task-description {
flex: 1;
transition: color var(--transition-fast);
}
/* ====== Empty State ====== */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
grid-column: 1 / -1;
}
.empty-state-icon {
width: 64px;
height: 64px;
background: var(--color-surface-elevated);
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
margin-bottom: 16px;
}
.empty-state-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 6px;
}
.empty-state-text {
font-size: 13px;
color: var(--color-text-muted);
max-width: 300px;
}
.column-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
color: var(--color-text-muted);
font-size: 12px;
font-style: italic;
flex: 1;
}
/* ====== Completion Celebration ====== */
.card.just-completed {
animation: celebrateComplete 0.6s ease-out;
}
@keyframes celebrateComplete {
0% { transform: scale(1); }
30% { transform: scale(1.03); box-shadow: 0 0 20px rgba(39, 201, 63, 0.3); }
100% { transform: scale(1); }
}
/* ====== Loading ====== */
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 60px;
grid-column: 1 / -1;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--color-border);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ====== Responsive ====== */
@media (max-width: 1024px) {
.board {
min-width: unset;
grid-template-columns: 1fr;
}
.column { min-height: 100px; }
}
/* ====== Theme Toggle ====== */
.theme-toggle {
background: var(--color-surface-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 6px 10px;
cursor: pointer;
font-size: 16px;
line-height: 1;
transition: border-color var(--transition-fast), background var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
}
.theme-toggle:hover {
border-color: var(--color-accent);
background: var(--color-surface-hover);
}
.theme-toggle:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
/* ====== Collapsible Task List ====== */
.task-toggle {
display: flex;
align-items: center;
gap: 6px;
margin-top: 10px;
padding: 4px 6px;
border: none;
background: none;
color: var(--color-text-muted);
font-size: 11px;
font-family: var(--font-sans);
font-weight: 500;
cursor: pointer;
border-radius: var(--radius-sm);
transition: color var(--transition-fast), background var(--transition-fast);
width: 100%;
text-align: left;
}
.task-toggle:hover {
color: var(--color-text-secondary);
background: var(--color-surface-hover);
}
.task-toggle-icon {
transition: transform var(--transition-fast);
font-size: 9px;
}
.task-toggle-icon.expanded {
transform: rotate(90deg);
}
.task-list {
overflow: hidden;
transition: max-height var(--transition-normal), opacity var(--transition-fast);
}
.task-list.collapsed {
max-height: 0;
opacity: 0;
margin-top: 0;
}
.task-list.expanded {
max-height: 2000px;
opacity: 1;
}
/* ====== Pipeline Bar ====== */
.pipeline-bar {
display: flex;
align-items: center;
gap: 0;
padding: 14px 28px;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 58px;
z-index: 90;
}
.pipeline-node {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--transition-fast), transform var(--transition-fast);
flex: 1 1 0;
min-width: 72px;
max-width: 140px;
position: relative;
border: 1px solid var(--color-border-subtle);
background: transparent;
overflow: hidden;
}
.pipeline-node:hover {
background: var(--color-surface-hover);
transform: translateY(-1px);
}
.pipeline-node:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.pipeline-node.active {
border: 2px solid var(--color-accent);
background: var(--color-surface-elevated);
}
.pipeline-clarify-badge {
position: absolute;
top: 2px;
right: 2px;
font-size: 9px;
font-weight: 700;
color: #b8860b;
background: rgba(245, 166, 35, 0.18);
border-radius: 8px;
padding: 1px 5px;
line-height: 14px;
pointer-events: none;
}
.pipeline-dot {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
transition: all var(--transition-normal);
}
.pipeline-dot.not_started {
background: var(--color-surface-elevated);
border: 2px solid var(--color-border);
color: var(--color-text-muted);
}
.pipeline-dot.in_progress {
background: rgba(245, 166, 35, 0.15);
border: 2px solid var(--color-inprogress);
color: var(--color-inprogress);
}
.pipeline-dot.complete {
background: rgba(39, 201, 63, 0.15);
border: 2px solid var(--color-done);
color: var(--color-done);
}
.pipeline-dot.skipped {
background: var(--color-surface-elevated);
border: 2px dashed var(--color-text-muted);
color: var(--color-text-muted);
}
.pipeline-dot.available {
background: rgba(59, 130, 246, 0.12);
border: 2px solid var(--color-accent);
color: var(--color-accent);
}
.pipeline-label {
font-size: 10px;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.pipeline-label-multiline {
white-space: nowrap;
display: flex;
flex-direction: column;
align-items: center;
line-height: 1.3;
}
.pipeline-label-multiline .pipeline-label-line {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pipeline-progress {
font-size: 9px;
font-family: var(--font-mono);
color: var(--color-text-muted);
min-height: 13px;
}
.pipeline-connector {
flex: 0 1 24px;
min-width: 4px;
height: 2px;
background: var(--color-border);
margin-top: -16px;
}
.pipeline-connector.complete {
background: var(--color-done);
}
.pipeline-redirect {
font-size: 8px;
opacity: 0.5;
}
.pipeline-node.optional .pipeline-label {
opacity: 0.8;
}
/* ====== Bugs Tab (standalone, no connector) ====== */
.bugs-tab-gap {
width: 16px;
flex-shrink: 0;
}
.bugs-tab {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 6px 12px;
cursor: pointer;
border-radius: 8px;
border: 1px solid var(--color-border);
background: var(--color-surface);
transition: all 0.15s ease;
position: relative;
font-family: inherit;
color: inherit;
}
.bugs-tab:hover {
border-color: var(--color-text-secondary);
background: rgba(255, 255, 255, 0.06);
}
.bugs-tab.active {
border-color: var(--color-accent);
background: rgba(99, 102, 241, 0.08);
}
.bugs-tab.muted {
opacity: 0.45;
}
.bugs-tab-icon {
font-size: 14px;
line-height: 1;
}
.bugs-tab-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-text-secondary);
}
.bugs-tab-badge {
position: absolute;
top: -6px;
right: -6px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
text-align: center;
color: #fff;
}
.bugs-tab-badge.muted {
opacity: 0.4;
background: var(--color-text-secondary);
}
.bugs-tab-badge.severity-critical { background: #ff4757; }
.bugs-tab-badge.severity-high { background: #ffa502; }
.bugs-tab-badge.severity-medium { background: #f1c40f; color: #1a1a2e; }
.bugs-tab-badge.severity-low { background: #6b7189; }
/* ====== Bug Severity Colors ====== */
.severity-critical { color: #ff4757; }
.severity-high { color: #ffa502; }
.severity-medium { color: #f1c40f; }
.severity-low { color: #6b7189; }
.severity-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.severity-badge.severity-critical { background: rgba(255, 71, 87, 0.15); color: #ff4757; }
.severity-badge.severity-high { background: rgba(255, 165, 2, 0.15); color: #ffa502; }
.severity-badge.severity-medium { background: rgba(241, 196, 15, 0.15); color: #f1c40f; }
.severity-badge.severity-low { background: rgba(107, 113, 137, 0.15); color: #6b7189; }
/* ====== Bugs View ====== */
.bugs-view {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.bugs-view-header {
margin-bottom: 20px;
}
.bugs-view-title {
font-size: 20px;
font-weight: 600;
color: var(--color-text-primary);
}
.bugs-view-subtitle {
font-size: 13px;
color: var(--color-text-secondary);
margin-top: 4px;
}
.bugs-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.bugs-table th {
text-align: left;
padding: 10px 12px;
font-weight: 600;
color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-border);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.bugs-table td {
padding: 10px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
vertical-align: middle;
}
.bugs-table tr:hover td {
background: rgba(255, 255, 255, 0.02);
}
.bugs-table tr.highlighted td {
background: rgba(99, 102, 241, 0.12);
transition: background 0.3s ease;
}
.bugs-table .bug-id {
font-family: 'SF Mono', 'Fira Code', monospace;
font-weight: 600;
color: var(--color-text-primary);
}
.bugs-table .bug-status {
text-transform: capitalize;
}
.bugs-table .bug-status.fixed {
color: var(--color-done);
}
.bugs-table .bug-progress {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 12px;
}
.bugs-table .bug-description {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bugs-table .github-link {
color: var(--color-accent);
text-decoration: none;
}
.bugs-table .github-link:hover {
text-decoration: underline;
}
.bugs-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
text-align: center;
}
.bugs-empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.4;
}
.bugs-empty-title {
font-size: 18px;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 8px;
}
.bugs-empty-text {
font-size: 14px;
color: var(--color-text-secondary);
}
.orphaned-section {
margin-top: 24px;
padding: 16px;
border: 1px dashed var(--color-border);
border-radius: 8px;
opacity: 0.7;
}
.orphaned-title {
font-size: 13px;
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: 8px;
}
.orphaned-task {
font-size: 12px;
color: var(--color-text-secondary);
padding: 4px 0;
}
.bug-icon-inline {
display: inline-block;
width: 14px;
height: 14px;
margin-right: 4px;
vertical-align: middle;
opacity: 0.7;
}
/* Pipeline bugs gap & node (spec aliases) */
.pipeline-bugs-gap {
width: 20px;
flex-shrink: 0;
}
.pipeline-node-bugs.muted {
opacity: 0.5;
}
.bugs-dot {
background: var(--color-surface-elevated) !important;
border: 2px solid var(--color-border) !important;
color: var(--color-text-muted) !important;
}
.bug-count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
font-size: 11px;
font-weight: 700;
margin-left: 4px;
vertical-align: middle;
}
.bug-count-badge.muted {
background: var(--color-surface-elevated);
color: var(--color-text-muted);
}
/* Bug view additional styles */
.bugs-view-container {
padding: 24px;
max-width: 1200px;
}
.bugs-view-container h2 {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
color: var(--color-text);
}
.bug-row:hover {
background: var(--color-surface-hover);
}
.fix-progress {
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.bug-status {
text-transform: capitalize;
}
.bug-status.fixed {
color: var(--color-success, #2ecc71);
}
.bug-github-link {
color: var(--color-accent);
text-decoration: none;
}
.bug-github-link:hover {
text-decoration: underline;
}
.bug-empty-state {
text-align: center;
padding: 48px 24px;
color: var(--color-text-muted);
font-size: 14px;
}
.bug-empty-state svg {
display: block;
margin: 0 auto 12px;
opacity: 0.4;
}
.orphaned-tasks-section {
margin-top: 32px;
padding: 16px;
background: var(--color-surface-elevated);
border-radius: var(--radius-md);
border: 1px solid var(--color-warning, #f5a623);
opacity: 0.7;
}
.orphaned-tasks-section h3 {
font-size: 13px;
font-weight: 600;
color: var(--color-warning, #f5a623);
margin-bottom: 8px;
}
.orphaned-tasks-section .task-item {
font-size: 12px;
color: var(--color-text-muted);
padding: 4px 0;
}
/* ====== Content Area ====== */
.content-area {
flex: 1;
min-height: 0;
}
.placeholder-view {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
text-align: center;
}
.placeholder-view-icon {
width: 56px;
height: 56px;
background: var(--color-surface-elevated);
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
margin-bottom: 16px;
}
.placeholder-view-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 6px;
}
.placeholder-view-text {
font-size: 13px;
color: var(--color-text-muted);
max-width: 360px;
margin: 0 auto;
text-align: center;
}
/* ====== Checklist Quality Gates Tab ====== */
.checklist-view {
padding: 24px 28px;
max-width: 900px;
margin: 0 auto;
}
.gate-indicator {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-radius: var(--radius-lg);
background: var(--color-surface-elevated);
border: 1px solid var(--color-border);
margin-bottom: 28px;
}
.gate-dot {
width: 18px;
height: 18px;
border-radius: 50%;
flex-shrink: 0;
transition: background-color 0.4s ease;
}
.gate-dot.green { background-color: var(--color-green, #22c55e); }
.gate-dot.yellow { background-color: var(--color-yellow, #eab308); }
.gate-dot.red { background-color: var(--color-red, #ef4444); }
.gate-label {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.5px;
}
.gate-label.open { color: var(--color-green, #22c55e); }
.gate-label.blocked-yellow { color: var(--color-yellow, #eab308); }
.gate-label.blocked-red { color: var(--color-red, #ef4444); }
.checklist-rings {
display: flex;
flex-wrap: wrap;
gap: 24px;
justify-content: center;
margin-bottom: 24px;
}
.checklist-ring-wrapper {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
padding: 12px;
border-radius: var(--radius-lg);
transition: background-color 0.15s ease;
border: 2px solid transparent;
}
.checklist-ring-wrapper:hover {
background: var(--color-surface-elevated);
}
.checklist-ring-wrapper:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.checklist-ring-wrapper.expanded {
border-color: var(--color-primary);
background: var(--color-surface-elevated);
}
.checklist-ring-svg {
width: 100px;
height: 100px;
}
.checklist-ring-track {
fill: none;
stroke: var(--color-border);
stroke-width: 8;
}
.checklist-ring-fill {
fill: none;
stroke-width: 8;
stroke-linecap: round;
transition: stroke-dashoffset 0.8s cubic-bezier(0.4, 0, 0.2, 1), stroke 0.4s ease;
transform: rotate(-90deg);
transform-origin: 50% 50%;
}
.checklist-ring-fill.red { stroke: var(--color-red, #ef4444); }
.checklist-ring-fill.yellow { stroke: var(--color-yellow, #eab308); }
.checklist-ring-fill.green { stroke: var(--color-green, #22c55e); }
.checklist-ring-pct {
font-size: 18px;
font-weight: 700;
fill: var(--color-text);
text-anchor: middle;
dominant-baseline: central;
}
.checklist-ring-name {
margin-top: 8px;
font-size: 13px;
font-weight: 600;
color: var(--color-text);
text-align: center;
}
.checklist-ring-fraction {
font-size: 12px;
color: var(--color-text-muted);
}
.checklist-detail {
max-height: 0;
overflow: hidden;
transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.15s ease, opacity 0.15s ease;
border-radius: var(--radius-lg);
background: var(--color-surface-elevated);
border: 1px solid transparent;
margin-bottom: 0;
opacity: 0;
}
.checklist-detail.open {
max-height: 2000px;
border-color: var(--color-border);
margin-bottom: 16px;
opacity: 1;
}
.checklist-detail-inner {
padding: 16px 20px;
}
.checklist-category {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-text-muted);
margin: 16px 0 8px;
padding-bottom: 4px;
border-bottom: 1px solid var(--color-border);
}
.checklist-category:first-child {
margin-top: 0;
}
.checklist-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 0;
font-size: 13px;
color: var(--color-text);
line-height: 1.4;
}
.checklist-item-icon {
flex-shrink: 0;
width: 18px;
font-size: 14px;
}
.checklist-item-icon.checked { color: var(--color-green, #22c55e); }
.checklist-item-icon.unchecked { color: var(--color-text-muted); }
.checklist-item-id {
font-family: var(--font-mono, monospace);
font-size: 11px;
color: var(--color-text-muted);
background: var(--color-surface);
padding: 1px 5px;
border-radius: var(--radius-sm, 4px);
flex-shrink: 0;
}
.checklist-item-tag {
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 9999px;
background: var(--color-primary-muted, rgba(99, 102, 241, 0.15));
color: var(--color-primary, #6366f1);
flex-shrink: 0;
}
.checklist-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
text-align: center;
}
.checklist-empty-icon {
width: 56px;
height: 56px;
background: var(--color-surface-elevated);
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
margin-bottom: 16px;
}
.checklist-empty-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 6px;
}
.checklist-empty-text {
font-size: 13px;
color: var(--color-text-muted);
max-width: 360px;
}
/* ====== Testify Traceability Tab ====== */
.testify-view {
padding: 24px 28px;
max-width: 1200px;
margin: 0 auto;
}
.testify-seal {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 18px;
border-radius: var(--radius-lg);
background: var(--color-surface-elevated);
border: 1px solid var(--color-border);
margin-bottom: 20px;
font-size: 13px;
font-weight: 600;
}
.testify-seal-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.testify-seal-dot.valid { background: var(--color-verified); }
.testify-seal-dot.tampered { background: var(--color-tampered); animation: pulse-warning 2s ease-in-out infinite; }
.testify-seal-dot.missing { background: var(--color-text-muted); }
.testify-seal-label.valid { color: var(--color-verified); }
.testify-seal-label.tampered { color: var(--color-tampered); }
.testify-seal-label.missing { color: var(--color-text-muted); }
.testify-seal-hash {
font-family: var(--font-mono);
font-size: 11px;
color: var(--color-text-muted);
margin-left: auto;
}
.testify-sankey-container {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 20px;
overflow-x: auto;
}
.testify-sankey-svg {
display: block;
width: 100%;
}
.testify-sankey-svg .sankey-node {
cursor: pointer;
transition: opacity var(--transition-fast);
}
.testify-sankey-svg .sankey-node:focus {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.testify-sankey-svg .sankey-node-rect {
rx: 4;
ry: 4;
stroke-width: 1.5;
transition: stroke var(--transition-fast), fill var(--transition-fast);
}
.testify-sankey-svg .sankey-node-label {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
fill: var(--color-text);
pointer-events: none;
}
.testify-sankey-svg .sankey-node-desc {
font-family: var(--font-sans);
font-size: 9px;
fill: var(--color-text-secondary);
pointer-events: none;
}
.testify-sankey-svg .sankey-flow {
fill-opacity: 0.25;
stroke-opacity: 0.4;
stroke-width: 1;
transition: fill-opacity var(--transition-fast), stroke-opacity var(--transition-fast), opacity var(--transition-fast);
}
.testify-sankey-svg .sankey-flow:hover {
fill-opacity: 0.45;
stroke-opacity: 0.7;
}
.testify-sankey-svg .sankey-group-bg {
rx: 6;
ry: 6;
}
.testify-sankey-svg .sankey-group-label {
font-family: var(--font-sans);
font-size: 10px;
font-weight: 600;
fill: var(--color-text-secondary);
}
.testify-sankey-svg .sankey-col-label {
font-family: var(--font-sans);
font-size: 11px;
font-weight: 700;
fill: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.testify-sankey-svg .dimmed {
opacity: 0.12;
}
.testify-sankey-svg .highlighted .sankey-flow {
fill-opacity: 0.5;
stroke-opacity: 0.8;
}
.testify-sankey-svg .gap-node .sankey-node-rect {
stroke: var(--color-tampered);
stroke-width: 2;
stroke-dasharray: 4 2;
}
.testify-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
text-align: center;
}
.testify-empty-icon {
width: 56px;
height: 56px;
background: var(--color-surface-elevated);
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
margin-bottom: 16px;
}
.testify-empty-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 6px;
}
.testify-empty-text {
font-size: 13px;
color: var(--color-text-muted);
max-width: 400px;
}
.testify-empty-text code {
background: var(--color-surface-elevated);
padding: 2px 6px;
border-radius: 4px;
font-family: var(--font-mono);
font-size: 12px;
}
@keyframes testify-fade-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.testify-sankey-svg .sankey-col-left .sankey-node { animation: testify-fade-in 0.4s ease both; }
.testify-sankey-svg .sankey-col-mid .sankey-node { animation: testify-fade-in 0.4s ease 0.15s both; }
.testify-sankey-svg .sankey-col-right .sankey-node { animation: testify-fade-in 0.4s ease 0.3s both; }
.testify-sankey-svg .sankey-flow { animation: testify-fade-in 0.4s ease 0.45s both; }
/* ====== Spec Story Map Tab ====== */
.storymap-view {
padding: 24px 28px;
display: flex;
transition: padding-right var(--transition-normal);
}
.storymap-main {
flex: 1;
min-width: 0;
}
.storymap-section-title {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--color-text-secondary);
margin-bottom: 16px;
}
/* Story Map Swim Lanes */
.swim-lanes {
margin-bottom: 32px;
}
.swim-lane {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
min-height: 80px;
}
.swim-lane-label {
width: 40px;
flex-shrink: 0;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.5px;
padding: 8px 0;
text-align: center;
border-radius: var(--radius-sm);
color: white;
}
.swim-lane-label.p1 { background: var(--color-p1); }
.swim-lane-label.p2 { background: var(--color-p2); }
.swim-lane-label.p3 { background: var(--color-p3); }
.swim-lane-cards {
display: flex;
gap: 12px;
flex-wrap: wrap;
flex: 1;
min-height: 40px;
padding: 4px;
border-radius: var(--radius-sm);
border: 1px dashed var(--color-border-subtle);
}
.swim-lane-cards:empty::after {
content: 'No stories';
color: var(--color-text-muted);
font-size: 12px;
font-style: italic;
padding: 8px;
}
/* Story Cards */
.story-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 12px 14px;
min-width: 200px;
max-width: 280px;
cursor: pointer;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.story-card:hover {
border-color: var(--color-accent);
box-shadow: var(--shadow-card-hover);
}
.story-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.story-card-id {
font-size: 11px;
font-weight: 700;
font-family: var(--font-mono);
color: var(--color-accent);
}
.story-card-priority {
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 10px;
color: white;
}
.story-card-priority.p1 { background: var(--color-p1); }
.story-card-priority.p2 { background: var(--color-p2); }
.story-card-priority.p3 { background: var(--color-p3); }
.story-card-title {
font-size: 13px;
font-weight: 500;
color: var(--color-text);
margin-bottom: 8px;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.story-card-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.story-card-badge {
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 8px;
background: var(--color-surface-elevated);
color: var(--color-text-secondary);
font-family: var(--font-mono);
}
.story-card-badge.scenarios {
background: rgba(59, 130, 246, 0.12);
color: var(--color-accent);
}
.story-card-badge.clarifications {
background: rgba(245, 166, 35, 0.12);
color: var(--color-inprogress);
cursor: pointer;
}
.story-card-badge.clarifications:hover {
background: rgba(245, 166, 35, 0.25);
}
/* Requirements Graph */
.graph-container {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
position: relative;
overflow: hidden;
margin-bottom: 24px;
}
.graph-svg {
width: 100%;
cursor: grab;
}
.graph-svg:active { cursor: grabbing; }
.graph-edge {
stroke: var(--color-border);
stroke-width: 1.5;
fill: none;
transition: opacity var(--transition-fast), stroke var(--transition-fast);
}
.graph-edge.highlighted {
stroke: var(--color-text);
stroke-width: 2.5;
}
.graph-edge.dimmed { opacity: 0.15; }
.graph-node { cursor: pointer; }
.graph-node.dimmed { opacity: 0.2; }
.graph-node circle {
transition: stroke var(--transition-fast), r var(--transition-fast);
}
.graph-node.highlighted circle {
stroke: var(--color-text);
stroke-width: 3;
filter: drop-shadow(0 0 4px var(--color-text));
}
.graph-node-us circle { fill: var(--color-accent); }
.graph-node-fr circle { fill: var(--color-done); }
.graph-node-sc circle { fill: var(--color-inprogress); }
.graph-node text {
font-size: 10px;
font-weight: 600;
font-family: var(--font-mono);
fill: var(--color-text);
text-anchor: middle;
pointer-events: none;
}
.graph-tooltip {
position: absolute;
background: var(--color-surface-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 8px 12px;
font-size: 12px;
color: var(--color-text);
max-width: 300px;
pointer-events: none;
z-index: 50;
box-shadow: var(--shadow-card);
display: none;
}
.graph-empty {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--color-text-muted);
font-size: 13px;
}
.graph-legend {
display: flex;
gap: 16px;
padding: 8px 14px;
border-top: 1px solid var(--color-border-subtle);
font-size: 11px;
color: var(--color-text-secondary);
}
.graph-legend-item {
display: flex;
align-items: center;
gap: 5px;
}
.graph-legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.graph-legend-dot.us { background: var(--color-accent); }
.graph-legend-dot.fr { background: var(--color-done); }
.graph-legend-dot.sc { background: var(--color-inprogress); }
/* Clarify View */
.clarify-view {
padding: 24px 32px;
max-width: 800px;
margin: 0 auto;
}
.clarify-empty {
text-align: center;
padding: 60px 20px;
color: var(--color-text-muted);
max-width: 480px;
margin: 0 auto;
word-break: normal;
overflow-wrap: break-word;
}
.clarify-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--color-border);
}
.clarify-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text);
}
.clarify-count {
font-size: 12px;
color: var(--color-text-muted);
background: var(--color-surface-hover);
padding: 4px 10px;
border-radius: 12px;
}
.clarify-session {
margin-bottom: 24px;
}
.clarify-session-label {
font-size: 11px;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.clarify-entries {
display: flex;
flex-direction: column;
gap: 12px;
}
.clarify-entry {
background: var(--color-surface);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
padding: 14px 16px;
}
.clarify-question {
font-size: 13px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 8px;
line-height: 1.5;
}
.clarify-answer {
font-size: 13px;
color: var(--color-text-secondary);
line-height: 1.5;
}
.clarify-q-label, .clarify-a-label {
display: inline-block;
width: 20px;
height: 20px;
line-height: 20px;
text-align: center;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
margin-right: 8px;
flex-shrink: 0;
}
.clarify-q-label {
background: var(--color-accent);
color: white;
}
.clarify-a-label {
background: var(--color-done);
color: white;
}
.clarify-refs {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--color-border-subtle);
}
.clarify-ref {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
background: var(--color-surface-hover);
color: var(--color-accent);
letter-spacing: 0.3px;
text-decoration: none;
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
}
.clarify-ref:hover {
background: var(--color-accent);
color: white;
}
.story-card.highlighted {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
animation: cardPulse 0.6s ease-out;
}
@keyframes cardPulse {
0% { outline-color: transparent; }
50% { outline-color: var(--color-accent); }
100% { outline-color: var(--color-accent); }
}
/* ====== Cross-Link Navigation ====== */
.cross-link {
cursor: pointer;
text-decoration: underline;
text-decoration-style: dotted;
text-decoration-color: var(--color-accent);
text-underline-offset: 2px;
transition: text-decoration-color var(--transition-fast);
}
.cross-link:hover {
text-decoration-style: solid;
text-decoration-color: var(--color-accent-hover);
}
.card.highlighted {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
animation: highlight-pulse 1.5s ease-out;
}
.task-item.highlighted {
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
border-radius: var(--radius-sm);
animation: highlight-pulse 1.5s ease-out;
}
.checklist-item.highlighted {
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
border-radius: var(--radius-sm);
animation: highlight-pulse 1.5s ease-out;
}
.clarify-entry.highlighted {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
animation: highlight-pulse 1.5s ease-out;
}
.sankey-node.task-checked .sankey-node-rect {
stroke: var(--color-done);
stroke-width: 2;
}
.cross-link-tip {
font-size: 11px;
font-style: italic;
color: var(--color-text-muted);
padding: 6px 12px;
user-select: none;
}
@keyframes highlight-pulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-accent) 40%, transparent); }
70% { box-shadow: 0 0 0 8px transparent; }
100% { box-shadow: 0 0 0 0 transparent; }
}
/* Detail Panel — floating left sidebar */
.detail-panel {
position: fixed;
left: 0;
top: 57px;
width: 360px;
height: calc(100vh - 57px);
overflow-y: auto;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
padding: 20px 24px;
box-shadow: 4px 0 16px rgba(0,0,0,0.15);
z-index: 90;
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(-12px); }
to { opacity: 1; transform: translateX(0); }
}
.detail-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.detail-panel-id {
font-size: 12px;
font-weight: 700;
font-family: var(--font-mono);
padding: 3px 8px;
border-radius: 8px;
color: white;
}
.detail-panel-id.us { background: var(--color-accent); }
.detail-panel-id.fr { background: var(--color-done); }
.detail-panel-id.sc { background: var(--color-inprogress); }
.detail-panel-close {
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 18px;
padding: 4px 8px;
border-radius: var(--radius-sm);
transition: background var(--transition-fast);
}
.detail-panel-close:hover {
background: var(--color-surface-hover);
}
.detail-panel-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 12px;
}
.detail-panel-body {
font-size: 13px;
color: var(--color-text-secondary);
line-height: 1.7;
white-space: pre-wrap;
}
.detail-panel-body strong {
color: var(--color-text);
}
/* Storymap empty state */
.storymap-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
color: var(--color-text-muted);
font-size: 13px;
}
/* ====== Constitution Tab ====== */
.constitution-view {
padding: 24px 28px;
}
.premise-section {
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border);
}
.premise-title {
font-size: 1.1rem;
font-weight: 700;
margin: 0 0 12px;
color: var(--text);
}
.premise-heading {
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
margin: 14px 0 6px;
}
.premise-text {
font-size: 0.9rem;
line-height: 1.55;
color: var(--text);
margin: 0 0 6px;
}
.premise-list {
margin: 4px 0 8px 18px;
padding: 0;
font-size: 0.9rem;
line-height: 1.55;
color: var(--text);
}
.premise-list li {
margin-bottom: 3px;
}
.constitution-layout {
display: flex;
gap: 32px;
align-items: flex-start;
}
@media (max-width: 900px) {
.constitution-layout { flex-direction: column; }
}
.constitution-left {
flex: 1 1 50%;
min-width: 0;
display: flex;
align-items: flex-start;
justify-content: center;
}
.constitution-right {
flex: 1 1 40%;
min-width: 0;
}
.constitution-summary {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 20px;
}
.constitution-summary-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
font-size: 13px;
font-weight: 500;
color: var(--color-text);
border-bottom: 1px solid var(--color-border-subtle);
cursor: pointer;
border-radius: var(--radius-sm);
transition: background var(--transition-fast);
}
.constitution-summary-item:last-child { border-bottom: none; }
.constitution-summary-item:hover { background: var(--color-surface-hover); }
.constitution-summary-item.selected { background: var(--color-surface-elevated); border-left: 3px solid var(--color-accent); }
.constitution-summary-item .principle-num {
font-family: var(--font-mono);
font-size: 11px;
color: var(--color-text-muted);
flex-shrink: 0;
width: 24px;
}
.constitution-summary-item .level-badge {
font-size: 9px;
font-weight: 700;
padding: 2px 5px;
border-radius: 3px;
text-transform: uppercase;
margin-left: auto;
flex-shrink: 0;
letter-spacing: 0.3px;
}
.level-badge.must { background: rgba(59, 130, 246, 0.15); color: var(--color-accent); }
.level-badge.should { background: rgba(245, 166, 35, 0.15); color: var(--color-inprogress); }
.level-badge.may { background: rgba(107, 113, 137, 0.15); color: var(--color-text-muted); }
.constitution-body {
display: flex;
gap: 24px;
align-items: flex-start;
}
@media (max-width: 768px) {
.constitution-body { flex-direction: column; }
}
.radar-container {
width: 100%;
max-width: 520px;
}
.radar-container svg {
width: 100%;
height: auto;
overflow: visible;
}
.radar-axis {
cursor: pointer;
transition: opacity var(--transition-fast);
}
.radar-axis:hover { opacity: 0.8; }
.radar-axis:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.detail-card {
background: var(--color-surface-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 20px;
margin-top: 12px;
animation: cardEnter 0.25s ease;
}
.detail-card h3 {
font-size: 16px;
font-weight: 700;
margin-bottom: 4px;
color: var(--color-text);
}
.detail-card .detail-level {
margin-bottom: 12px;
}
.detail-card .detail-text {
font-size: 13px;
line-height: 1.6;
color: var(--color-text-secondary);
margin-bottom: 16px;
}
.detail-card .detail-rationale {
font-size: 12px;
line-height: 1.5;
color: var(--color-text-muted);
padding-top: 12px;
border-top: 1px solid var(--color-border-subtle);
}
.detail-card .detail-rationale strong {
color: var(--color-text-secondary);
}
.amendment-timeline {
margin-top: 24px;
padding: 16px 0;
border-top: 1px solid var(--color-border-subtle);
}
.amendment-timeline-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-text-muted);
margin-bottom: 8px;
}
.amendment-timeline-content {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: var(--color-text-secondary);
}
.amendment-timeline-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-accent);
flex-shrink: 0;
}
.amendment-timeline-line {
flex: 1;
height: 2px;
background: var(--color-border);
}
/* ====== Plan View ====== */
.planview-view { padding: 24px 28px; }
.planview-section {
margin-bottom: 28px; background: var(--color-surface); border: 1px solid var(--color-border);
border-radius: var(--radius-lg); padding: 20px 24px; box-shadow: var(--shadow-column);
}
.planview-section-title {
font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px;
color: var(--color-text-secondary); margin-bottom: 16px;
}
/* Badge Wall */
.badge-wall { display: flex; flex-wrap: wrap; gap: 10px; }
.tech-badge {
display: inline-flex; flex-direction: column; padding: 10px 16px;
background: var(--color-surface-elevated); border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md); transition: all var(--transition-fast);
position: relative; cursor: default; box-shadow: 0 1px 3px rgba(0,0,0,0.15);
border-left: 3px solid var(--color-accent);
}
.tech-badge:hover { background: var(--color-surface-hover); border-color: var(--color-accent); box-shadow: var(--shadow-card); transform: translateY(-1px); }
.tech-badge-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--color-text-muted); margin-bottom: 3px; }
.tech-badge-value { font-size: 13px; font-weight: 600; color: var(--color-text); }
.tech-badge-tooltip {
display: none; position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%);
background: var(--color-surface-elevated); border: 1px solid var(--color-border);
border-radius: var(--radius-sm); padding: 10px 14px; font-size: 12px; color: var(--color-text-secondary);
max-width: 300px; white-space: normal; z-index: 50; box-shadow: var(--shadow-card-hover);
pointer-events: none; line-height: 1.5;
}
.tech-badge:hover .tech-badge-tooltip { display: block; }
/* Tessl Tiles Panel */
.tessl-tiles { display: flex; flex-wrap: wrap; gap: 12px; }
.tessl-tile-card {
display: flex; flex-direction: column; padding: 14px 18px;
background: var(--color-surface-elevated); border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md); min-width: 200px;
border-left: 3px solid var(--color-done); box-shadow: 0 1px 3px rgba(0,0,0,0.15);
transition: all var(--transition-fast);
}
.tessl-tile-card:hover { box-shadow: var(--shadow-card); transform: translateY(-1px); }
.tessl-tile-name { font-size: 13px; font-weight: 600; color: var(--color-text); font-family: var(--font-mono); }
.tessl-tile-version { font-size: 11px; color: var(--color-text-muted); margin-top: 4px; }
.tessl-tile-eval { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--color-border-subtle); }
.tessl-eval-score { font-size: 28px; font-weight: 700; color: var(--color-done); }
.tessl-eval-bar { height: 4px; background: var(--color-border); border-radius: 2px; margin-top: 6px; overflow: hidden; }
.tessl-eval-bar-fill { height: 100%; background: var(--color-done); border-radius: 2px; }
.tessl-eval-multiplier {
display: inline-block; margin-top: 6px; padding: 2px 10px; border-radius: 12px;
background: rgba(39, 201, 63, 0.15); color: var(--color-done); font-size: 11px; font-weight: 600;
}
/* File Structure Tree — VS Code style */
.file-tree { font-family: var(--font-mono); font-size: 13px; }
.file-tree-entry {
display: flex; align-items: center; height: 26px; padding: 0 8px;
border-radius: var(--radius-sm); transition: background var(--transition-fast);
cursor: default; gap: 0; white-space: nowrap;
}
.file-tree-entry:hover { background: var(--color-surface-hover); }
.file-tree-indent { display: inline-flex; flex-shrink: 0; }
.file-tree-guide {
width: 18px; height: 26px; position: relative; flex-shrink: 0;
}
.file-tree-guide::before {
content: ''; position: absolute; left: 8px; top: 0; bottom: 0;
width: 1px; background: var(--color-border-subtle);
}
.file-tree-chevron {
width: 18px; height: 26px; display: inline-flex; align-items: center; justify-content: center;
flex-shrink: 0; cursor: pointer; color: var(--color-text-muted);
transition: color var(--transition-fast); font-size: 11px; user-select: none;
}
.file-tree-chevron:hover { color: var(--color-accent); }
.file-tree-chevron-spacer { width: 18px; flex-shrink: 0; }
.file-tree-file-icon {
width: 18px; height: 26px; display: inline-flex; align-items: center; justify-content: center;
flex-shrink: 0; font-size: 14px;
}
.file-tree-file-icon.dir { color: var(--color-accent); }
.file-tree-file-icon.file { color: var(--color-text-muted); }
.file-tree-file-icon.file-js { color: #f0db4f; }
.file-tree-file-icon.file-json { color: #f0db4f; }
.file-tree-file-icon.file-md { color: var(--color-accent); }
.file-tree-file-icon.file-html { color: #e44d26; }
.file-tree-file-icon.file-css { color: #264de4; }
.file-tree-label {
display: flex; align-items: center; gap: 6px; flex-shrink: 0;
}
.file-tree-name { color: var(--color-text); white-space: nowrap; flex-shrink: 0; }
.file-tree-name.planned { color: var(--color-text-muted); }
.file-tree-status {
flex-shrink: 0; font-size: 10px; padding: 1px 6px; border-radius: 3px; font-weight: 600;
}
.file-tree-status.existing { color: var(--color-done); background: rgba(39,201,63,0.1); }
.file-tree-status.planned-tag { color: var(--color-text-muted); background: var(--color-surface-elevated); }
.file-tree-comment {
color: var(--color-text-muted); margin-left: auto; padding-left: 16px;
font-size: 12px; opacity: 0.5; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; min-width: 0; flex: 1 1 0;
}
.file-tree-comment.truncated { cursor: help !important; }
.file-tree-children { overflow: hidden; }
.file-tree-children.collapsed { display: none; }
/* Architecture Diagram */
.diagram-container { position: relative; }
.diagram-svg {
width: 100%; background: var(--color-bg); border: 1px solid var(--color-border);
border-radius: var(--radius-md); padding: 8px;
}
.diagram-node { cursor: pointer; transition: all var(--transition-fast); }
.diagram-node:hover { filter: brightness(1.2); }
.diagram-node-rect { rx: 10; ry: 10; stroke-width: 2; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); }
.diagram-node-label { font-family: var(--font-sans); font-size: 13px; font-weight: 600; fill: var(--color-text); }
.diagram-edge-line { stroke: var(--color-border); stroke-width: 2; fill: none; stroke-dasharray: 6 3; }
.diagram-edge-label {
font-family: var(--font-mono); font-size: 10px; fill: var(--color-text-muted);
paint-order: stroke; stroke: var(--color-bg); stroke-width: 4px;
}
.diagram-raw { font-family: var(--font-mono); font-size: 12px; white-space: pre; overflow-x: auto; padding: 16px; background: var(--color-bg); border: 1px solid var(--color-border); border-radius: var(--radius-md); color: var(--color-text-secondary); line-height: 1.4; }
/* Diagram legend */
.diagram-legend { display: flex; gap: 16px; margin-top: 12px; justify-content: center; }
.diagram-legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--color-text-muted); }
.diagram-legend-dot { width: 10px; height: 10px; border-radius: 3px; }
/* Plan empty states */
.planview-empty { text-align: center; padding: 48px 20px; color: var(--color-text-muted); }
.planview-empty-title { font-size: 16px; font-weight: 600; margin-bottom: 8px; color: var(--color-text-secondary); }
.planview-empty-text { font-size: 14px; line-height: 1.6; }
/* ====== Scrollbar ====== */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--color-text-muted); }
/* ====== Analyze View ====== */
.analyze-view { padding: 24px; display: flex; flex-direction: column; gap: 24px; }
.analyze-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 80px 40px; text-align: center;
}
.analyze-empty-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.4; }
.analyze-empty-title { font-size: 18px; font-weight: 600; margin-bottom: 8px; }
.analyze-empty-text { color: var(--color-text-secondary); max-width: 420px; }
.analyze-empty-text code { background: var(--color-surface-elevated); padding: 2px 8px; border-radius: var(--radius-sm); font-family: var(--font-mono); font-size: 13px; }
.analyze-section {
background: var(--color-surface); border: 1px solid var(--color-border);
border-radius: var(--radius-lg); overflow: hidden;
}
.analyze-section-header {
padding: 16px 20px; font-size: 14px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-border);
}
/* Health Gauge */
.gauge-container {
display: flex; align-items: center; justify-content: center;
padding: 32px 20px; gap: 32px; flex-wrap: wrap;
}
.gauge-svg { width: 200px; height: 120px; }
.gauge-arc { fill: none; stroke-width: 18; stroke-linecap: round; }
.gauge-zone-red { stroke: #ff4757; }
.gauge-zone-yellow { stroke: #ffa502; }
.gauge-zone-green { stroke: #27c93f; }
.gauge-needle {
fill: var(--color-text); transition: transform 1s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.gauge-needle { transition: none; }
}
.gauge-score {
font-size: 36px; font-weight: 700; text-anchor: middle;
fill: var(--color-text);
}
.gauge-label {
font-size: 12px; text-anchor: middle; fill: var(--color-text-secondary);
}
.gauge-breakdown {
display: flex; flex-direction: column; gap: 8px; min-width: 200px;
}
.gauge-factor {
display: flex; justify-content: space-between; align-items: center;
font-size: 13px; padding: 6px 12px;
background: var(--color-surface-elevated); border-radius: var(--radius-sm);
}
.gauge-factor-label { color: var(--color-text-secondary); display: flex; flex-direction: column; gap: 2px; }
.gauge-factor-value { font-weight: 600; font-family: var(--font-mono); white-space: nowrap; }
.gauge-factor-context { font-size: 11px; color: var(--color-text-muted); font-weight: 400; }
.gauge-factor-green { border-left: 3px solid #27c93f; }
.gauge-factor-yellow { border-left: 3px solid #ffa502; }
.gauge-factor-red { border-left: 3px solid #ff4757; }
.gauge-trend {
display: inline-flex; align-items: center; gap: 4px;
font-size: 14px; font-weight: 600; margin-left: 8px;
}
.gauge-trend-up { color: #27c93f; }
.gauge-trend-down { color: #ff4757; }
.gauge-na { font-size: 24px; color: var(--color-text-muted); text-align: center; padding: 40px; }
/* Heatmap */
.heatmap-wrapper { overflow-x: auto; padding: 16px 20px; }
.heatmap-table {
width: 100%; border-collapse: collapse; font-size: 13px;
}
.heatmap-table th {
padding: 10px 16px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.05em; font-size: 11px; color: var(--color-text-secondary);
border-bottom: 2px solid var(--color-border); text-align: center;
}
.heatmap-table th[scope="row"] {
text-align: left; font-size: 13px; text-transform: none; letter-spacing: normal;
color: var(--color-text); max-width: 300px; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis;
}
.heatmap-table td {
padding: 8px 16px; text-align: center; border-bottom: 1px solid var(--color-border-subtle);
}
.heatmap-cell {
display: inline-block; width: 28px; height: 28px; border-radius: 6px;
cursor: pointer; transition: transform var(--transition-fast);
}
.heatmap-cell:hover { transform: scale(1.15); }
.heatmap-cell:focus { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.coverage-covered { background: #27c93f; }
.coverage-partial { background: #ffa502; }
.coverage-missing { background: #ff4757; }
.coverage-na { background: var(--color-text-muted); opacity: 0.4; }
.heatmap-refs {
display: none; font-size: 11px; color: var(--color-text-secondary);
padding: 4px 0; font-family: var(--font-mono);
}
.heatmap-refs.expanded { display: block; }
.heatmap-row { animation: fadeIn 0.3s ease forwards; opacity: 0; }
@keyframes fadeIn { to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.heatmap-row { animation: none; opacity: 1; }
}
.heatmap-empty {
padding: 40px; text-align: center; color: var(--color-text-muted); font-size: 14px;
}
.heatmap-desc {
font-size: 12px; color: var(--color-text-secondary); max-width: 400px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.heatmap-refs-inline { display: none; }
.heatmap-scroll { max-height: 500px; overflow-y: auto; }
.coverage-summary {
display: flex; gap: 16px; margin-bottom: 20px;
}
.cov-stat { flex: 1; }
.cov-stat-header {
display: flex; justify-content: space-between; align-items: baseline;
margin-bottom: 6px; font-size: 13px;
}
.cov-stat-label { font-weight: 600; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.05em; font-size: 11px; }
.cov-stat-value { font-weight: 700; font-family: var(--font-mono); font-size: 14px; }
.cov-bar {
height: 6px; background: var(--color-surface-elevated); border-radius: 3px; overflow: hidden;
}
.cov-bar-fill { height: 100%; border-radius: 3px; transition: width 0.8s ease-out; }
.cov-bar-green { background: #27c93f; }
.cov-bar-yellow { background: #ffa502; }
.cov-bar-red { background: #ff4757; }
.coverage-success {
text-align: center; padding: 24px; color: #27c93f; font-weight: 500; font-size: 14px;
}
.coverage-gaps-label {
font-size: 13px; font-weight: 600; color: var(--color-text-secondary);
margin-bottom: 12px;
}
/* Severity Table */
.severity-wrapper { padding: 0; }
.severity-controls {
display: flex; gap: 12px; padding: 12px 20px; align-items: center;
border-bottom: 1px solid var(--color-border-subtle);
}
.severity-filter {
background: var(--color-surface-elevated); color: var(--color-text);
border: 1px solid var(--color-border); border-radius: var(--radius-sm);
padding: 6px 12px; font-size: 13px; font-family: var(--font-sans);
cursor: pointer;
}
.severity-table {
width: 100%; border-collapse: collapse; font-size: 13px;
}
.severity-table th {
padding: 10px 16px; font-weight: 600; text-align: left;
border-bottom: 2px solid var(--color-border); cursor: pointer;
user-select: none; font-size: 12px; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--color-text-secondary);
}
.severity-table th:hover { color: var(--color-text); }
.severity-table th:focus { outline: 2px solid var(--color-accent); outline-offset: -2px; }
.severity-table td {
padding: 10px 16px; border-bottom: 1px solid var(--color-border-subtle);
vertical-align: top;
}
.severity-table tr[data-severity] { cursor: pointer; transition: background var(--transition-fast); }
.severity-table tr[data-severity]:hover { background: var(--color-surface-hover); }
.severity-table tr[data-severity]:focus { outline: 2px solid var(--color-accent); outline-offset: -2px; }
.severity-badge {
display: inline-block; padding: 2px 10px; border-radius: 12px;
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
}
.severity-critical { background: rgba(255,71,87,0.15); color: #ff4757; }
.severity-high { background: rgba(255,165,2,0.15); color: #ffa502; }
.severity-medium { background: rgba(255,193,7,0.15); color: #e0a800; }
.severity-low { background: rgba(52,152,219,0.15); color: #3498db; }
.severity-recommendation {
display: none; padding: 12px 16px; background: var(--color-surface-elevated);
font-size: 12px; color: var(--color-text-secondary); border-bottom: 1px solid var(--color-border-subtle);
}
.severity-recommendation.expanded { display: table-row; }
.severity-resolved td { text-decoration: line-through; opacity: 0.5; }
.severity-empty {
padding: 40px; text-align: center; color: #27c93f; font-size: 14px; font-weight: 500;
}
.severity-table-scroll { max-height: 500px; overflow-y: auto; }
/* Animations */
.analyze-section.slide-in {
animation: slideInUp 0.4s var(--transition-slow) forwards; opacity: 0;
transform: translateY(16px);
}
@keyframes slideInUp {
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.analyze-section.slide-in { animation: none; opacity: 1; transform: none; }
}
/* Toast notification */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 10px 20px;
font-size: 13px;
box-shadow: var(--shadow-card-hover);
z-index: 200;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.25s ease;
}
.toast.visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
pointer-events: auto;
}
</style>
</head>
<body>
<div id="toast" class="toast"></div>
<!-- Header -->
<header class="header" role="banner">
<div class="header-left">
<div class="logo">
<div class="logo-icon" aria-hidden="true">D</div>
<span>IIKit Dashboard</span>
</div>
<div class="project-label" id="projectLabel" title=""></div>
<div class="feature-selector" role="navigation" aria-label="Feature selector">
<select id="featureSelect" aria-label="Select feature to display" tabindex="0">
<option value="">Loading features...</option>
</select>
</div>
</div>
<div class="header-right">
<div id="integrityBadge" class="integrity-badge missing" role="status" aria-label="Test integrity status">
<span class="integrity-dot" aria-hidden="true"></span>
<span class="integrity-text">Checking...</span>
</div>
<button id="themeToggle" class="theme-toggle" aria-label="Toggle light/dark theme" title="Toggle theme" tabindex="0">
<span id="themeIcon">☾</span>
</button>
<div id="activityIndicator" role="status" aria-label="Agent activity: idle" title="Agent idle — no recent file changes" style="display:none"></div>
</div>
</header>
<!-- Pipeline Bar -->
<nav id="pipelineBar" class="pipeline-bar" role="navigation" aria-label="IIKit workflow pipeline">
</nav>
<!-- Content Area -->
<main class="content-area" role="main">
<div id="contentArea">
<div class="board-container">
<div id="board" class="board" role="region" aria-label="Dashboard board">
<div class="loading" id="loadingState">
<div class="loading-spinner" aria-label="Loading board data"></div>
</div>
</div>
</div>
</div>
</main>
<script>
(function() {
'use strict';
// Expose switchTab on window for test automation (clarify utility has no pipeline node)
const _exposeForTests = () => { window._switchTab = (id) => switchTab(id); };
setTimeout(_exposeForTests, 0);
// ====== State ======
let currentFeature = null;
let currentBoard = null;
let currentPipeline = null;
let currentChecklist = null;
let expandedChecklist = null;
let currentTestify = null;
let currentAnalyze = null;
let currentBugs = null;
let activeTab = null;
let externalSankeyHighlight = null;
// DASHBOARD_DATA mode: when data is inlined by the generator
const staticMode = typeof window.DASHBOARD_DATA === 'object' && window.DASHBOARD_DATA !== null;
let previousCardColumns = {}; // Track card positions for animations
// ====== DOM References ======
const boardEl = document.getElementById('board');
const featureSelect = document.getElementById('featureSelect');
const integrityBadge = document.getElementById('integrityBadge');
const activityIndicator = document.getElementById('activityIndicator');
const loadingState = document.getElementById('loadingState');
const pipelineBar = document.getElementById('pipelineBar');
const contentArea = document.getElementById('contentArea');
// ====== Pipeline ======
const PHASE_ICONS = {
not_started: '',
in_progress: '▶',
complete: '✓',
skipped: '—',
available: '●'
};
const BUG_ICON_SMALL = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 2l1.88 1.88M14.12 3.88L16 2M9 7.13v-1a3 3 0 0 1 6 0v1M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6zM3.5 11H2M5.06 6.5l-1.5-1M22 11h-1.5M18.94 6.5l1.5-1M12 20v2"/></svg>';
function getBugsBadgeHtml(bugsData) {
if (!bugsData || !bugsData.exists) return '';
const count = bugsData.summary.open;
const sev = bugsData.summary.highestOpenSeverity;
const colors = { critical: '#ff4757', high: '#ffa502', medium: '#f1c40f', low: '#6b7189' };
const textColors = { critical: '#fff', high: '#fff', medium: '#1a1a2e', low: '#fff' };
if (count === 0) {
return ' <span class="bug-count-badge muted">0</span>';
}
const bg = colors[sev] || '#6b7189';
const fg = textColors[sev] || '#fff';
return ` <span class="bug-count-badge" style="background:${bg};color:${fg}">${count}</span>`;
}
function renderPipeline(pipeline) {
if (!pipeline || !pipeline.phases) return;
currentPipeline = pipeline;
pipelineBar.innerHTML = '';
pipeline.phases.forEach((phase, i) => {
// Add connector before each node (except the first)
if (i > 0) {
const connector = document.createElement('div');
connector.className = 'pipeline-connector';
// Color connector green if previous phase is complete
const prevPhase = pipeline.phases[i - 1];
if (prevPhase.status === 'complete') {
connector.classList.add('complete');
}
pipelineBar.appendChild(connector);
}
const node = document.createElement('button');
node.className = 'pipeline-node' + (phase.optional ? ' optional' : '');
if (activeTab === phase.id) node.classList.add('active');
node.setAttribute('tabindex', '0');
if (activeTab === phase.id) node.setAttribute('aria-current', 'true');
node.innerHTML = `
<div class="pipeline-dot ${phase.status}">${PHASE_ICONS[phase.status] || ''}</div>
<span class="pipeline-label${phase.name.includes('\n') ? ' pipeline-label-multiline' : ''}">${phase.name.includes('\n') ? phase.name.split('\n').map(l => `<span class="pipeline-label-line">${escapeHtml(l)}</span>`).join('') : escapeHtml(phase.name)}${phase.id === 'tasks' ? ' <span class="pipeline-redirect" title="Opens Implement board">↗</span>' : ''}</span>
<span class="pipeline-progress">${phase.progress || ''}</span>
${phase.clarifications > 0 ? '<span class="pipeline-clarify-badge" title="' + phase.clarifications + ' clarification session' + (phase.clarifications > 1 ? 's' : '') + '">?' + phase.clarifications + '</span>' : ''}
`;
node.addEventListener('click', () => switchTab(phase.id));
pipelineBar.appendChild(node);
});
// Bugs tab — standalone, no connector
const bugsGap = document.createElement('div');
bugsGap.className = 'pipeline-bugs-gap';
pipelineBar.appendChild(bugsGap);
const bugsTab = document.createElement('button');
bugsTab.className = 'bugs-tab' + (activeTab === 'bugs' ? ' active' : '');
bugsTab.setAttribute('data-view', 'bugs');
bugsTab.setAttribute('tabindex', '0');
if (activeTab === 'bugs') bugsTab.setAttribute('aria-current', 'true');
const badgeHtml = currentBugs ? getBugsBadgeHtml(currentBugs) : '';
bugsTab.innerHTML = `
<div class="pipeline-dot bugs-dot">${BUG_ICON_SMALL}</div>
<span class="pipeline-label">Bugs${badgeHtml}</span>
`;
if (currentBugs && !currentBugs.exists) {
bugsTab.classList.add('muted');
}
bugsTab.addEventListener('click', () => switchTab('bugs'));
pipelineBar.appendChild(bugsTab);
}
function switchTab(phaseId) {
if (phaseId === 'tasks') {
// Tasks tab redirects to Implement (kanban board) since tasks are shown there
activeTab = 'implement';
if (currentPipeline) renderPipeline(currentPipeline);
renderBoardView();
showToast('Tasks are managed on the Implement board');
return;
}
activeTab = phaseId;
// Re-render pipeline to update active state
if (currentPipeline) renderPipeline(currentPipeline);
if (phaseId === 'implement') {
renderBoardView();
} else if (phaseId === 'constitution') {
renderConstitutionView();
} else if (phaseId === 'spec') {
renderStoryMapView();
} else if (phaseId === 'plan') {
renderPlanView();
} else if (phaseId === 'clarify') {
renderClarifyView();
} else if (phaseId === 'checklist') {
renderChecklistView();
} else if (phaseId === 'testify') {
renderTestifyView();
} else if (phaseId === 'analyze') {
renderAnalyzeView();
} else if (phaseId === 'bugs') {
renderBugsView();
} else {
renderPlaceholderView(phaseId);
}
}
// ====== Cross-Panel Navigation ======
const CROSS_LINK_MOD_KEY = /Mac|iPhone|iPad/.test(navigator.platform || '') ? '\u2318' : 'Ctrl';
async function navigateToPanel(panelId, entityId) {
switchTab(panelId);
// Sentinel elements per panel — poll until the panel has rendered
const sentinels = {
spec: '.graph-node', testify: '.sankey-node', implement: '.card',
checklist: '.checklist-item', clarify: '.clarify-entry', analyze: '.heatmap-row',
bugs: '.bug-row'
};
const sel = sentinels[panelId];
if (sel) {
for (let i = 0; i < 30; i++) {
if (contentArea.querySelector(sel)) break;
await new Promise(r => setTimeout(r, 50));
}
}
// Dispatch to panel-specific highlight
const highlighters = {
spec: highlightSpecEntity,
testify: highlightTestifyEntity,
implement: highlightBoardEntity,
checklist: highlightChecklistEntity,
clarify: highlightClarifyEntity,
bugs: highlightBugsEntity
};
const fn = highlighters[panelId];
if (fn) fn(entityId);
}
// Delegated Cmd/Ctrl+click handler for all cross-links
contentArea.addEventListener('click', (e) => {
if (!(e.metaKey || e.ctrlKey)) return;
const link = e.target.closest('[data-cross-target]');
if (link) {
e.preventDefault();
e.stopPropagation();
navigateToPanel(link.dataset.crossTarget, link.dataset.crossId);
}
});
// ====== Panel-Specific Highlight Functions ======
function highlightSpecEntity(entityId) {
// Only normalize US refs (US-2 → US2); FR/SC keep their hyphens (SC-007 stays SC-007)
const nodeId = entityId.replace(/^US-/i, 'US');
highlightGraphNode(nodeId);
// Scroll to story card (for US refs) or graph node (for FR/SC)
const card = contentArea.querySelector(`.story-card[data-story-id="${nodeId}"]`);
if (card) {
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.classList.add('highlighted');
setTimeout(() => card.classList.remove('highlighted'), 2000);
const story = currentStoryMap?.stories?.find(s => s.id === nodeId);
if (story) showDetailPanel(nodeId, 'us', story.title, story.body || '');
return;
}
const nodeEl = contentArea.querySelector(`.graph-node[data-id="${nodeId}"]`);
if (nodeEl) {
nodeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
const type = nodeId.startsWith('FR') ? 'fr' : 'sc';
showDetailPanel(nodeId, type, nodeId, nodeEl.dataset.desc || '');
}
}
function highlightTestifyEntity(entityId) {
if (externalSankeyHighlight) {
externalSankeyHighlight(entityId);
}
const nodeEl = contentArea.querySelector(`.sankey-node[data-node-id="${entityId}"]`);
if (nodeEl) {
nodeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
function highlightBoardEntity(entityId) {
// Task IDs (T prefix): find .task-id span by text, expand list, highlight
if (/^T\d+$/i.test(entityId)) {
const taskIds = contentArea.querySelectorAll('.task-id');
for (const span of taskIds) {
if (span.textContent.trim() === entityId) {
const taskItem = span.closest('.task-item');
const taskList = span.closest('.task-list');
// Expand collapsed task list
if (taskList && taskList.classList.contains('collapsed')) {
const btn = taskList.previousElementSibling;
if (btn && btn.classList.contains('task-toggle')) {
btn.click();
}
}
if (taskItem) {
taskItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
taskItem.classList.add('highlighted');
setTimeout(() => taskItem.classList.remove('highlighted'), 2000);
}
return;
}
}
}
// Story IDs (US prefix): find .card[data-card-id]
const card = contentArea.querySelector(`.card[data-card-id="${entityId}"]`);
if (card) {
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
card.classList.add('highlighted');
setTimeout(() => card.classList.remove('highlighted'), 2000);
}
}
function highlightChecklistEntity(entityId) {
// Search tags and inline cross-link spans for the entity ID
const matches = contentArea.querySelectorAll(`.checklist-item-tag, .checklist-item .cross-link[data-cross-id="${entityId}"]`);
for (const el of matches) {
if (el.dataset.crossId === entityId || el.textContent.trim() === entityId) {
const item = el.closest('.checklist-item');
// Expand parent section if collapsed
const detail = el.closest('.checklist-detail');
if (detail && !detail.classList.contains('open')) {
const filename = detail.id?.replace('checklist-detail-', '');
if (filename && currentChecklist) {
toggleChecklistExpand(filename, currentChecklist);
// Re-query after re-render
setTimeout(() => highlightChecklistEntity(entityId), 100);
return;
}
}
if (item) {
item.scrollIntoView({ behavior: 'smooth', block: 'center' });
item.classList.add('highlighted');
setTimeout(() => item.classList.remove('highlighted'), 2000);
}
return;
}
}
}
function highlightClarifyEntity(entityId) {
const ref = contentArea.querySelector(`.clarify-ref[data-ref-id="${entityId}"]`);
if (ref) {
const entry = ref.closest('.clarify-entry');
if (entry) {
entry.scrollIntoView({ behavior: 'smooth', block: 'center' });
entry.classList.add('highlighted');
setTimeout(() => entry.classList.remove('highlighted'), 2000);
}
}
}
function renderBoardView() {
contentArea.innerHTML = `
<div class="board-container">
<div class="cross-link-tip">Tip: ${CROSS_LINK_MOD_KEY}+click any identifier to navigate to its linked panel</div>
<div id="board" class="board" role="region" aria-label="Dashboard board"></div>
</div>`;
// Re-assign boardEl reference
const newBoardEl = document.getElementById('board');
if (currentBoard) {
renderBoardInto(newBoardEl, currentBoard);
}
}
// ====== Bugs View (T014, T015, T016) ======
async function renderBugsView() {
if (!currentFeature) return;
contentArea.innerHTML = '<div class="loading">Loading bug data...</div>';
try {
let data;
if (staticMode && window.DASHBOARD_DATA.featureData[currentFeature]) {
data = window.DASHBOARD_DATA.featureData[currentFeature].bugs;
} else {
const res = await fetch(`/api/bugs/${currentFeature}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
data = await res.json();
}
currentBugs = data;
if (currentPipeline) renderPipeline(currentPipeline);
renderBugsContent(data);
} catch (err) {
contentArea.innerHTML = `<div class="error-message">Error loading bugs: ${escapeHtml(err.message)}</div>`;
}
}
function renderBugsContent(data) {
if (!data || !data.exists || data.bugs.length === 0) {
// T015: Empty state
const msg = !data || !data.exists
? 'No bugs have been reported for this feature.'
: 'All clear — no bug entries found.';
contentArea.innerHTML = `
<div class="bugs-empty">
<div class="bugs-empty-icon">✓</div>
<div class="bugs-empty-title">No Bugs</div>
<div class="bugs-empty-text">${msg}</div>
</div>`;
return;
}
const { bugs, orphanedTasks, summary, repoUrl } = data;
let tableRows = '';
for (const bug of bugs) {
// Fix task progress
const progressText = bug.fixTasks.total > 0
? `${bug.fixTasks.checked}/${bug.fixTasks.total}`
: '\u2014';
// GitHub link (T016)
let githubHtml = '\u2014';
if (bug.githubIssue) {
const resolvedUrl = resolveGitHubUrl(bug.githubIssue, repoUrl);
if (resolvedUrl) {
githubHtml = `<a href="${escapeHtml(resolvedUrl)}" class="github-link bug-github-link" target="_blank" rel="noopener">${escapeHtml(bug.githubIssue)} <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:middle"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3"/></svg></a>`;
} else {
githubHtml = escapeHtml(bug.githubIssue);
}
}
// Fix task list for cross-links
let taskLinks = '';
if (bug.fixTasks.total > 0) {
taskLinks = bug.fixTasks.tasks.map(t =>
`<span class="task-id cross-link" data-cross-target="implement" data-cross-id="${t.id}" style="cursor:pointer;color:var(--color-accent);font-family:monospace;font-size:11px;">${t.id}</span>`
).join(', ');
}
tableRows += `
<tr class="bug-row" data-bug-id="${bug.id}">
<td class="bug-id">${bug.id}</td>
<td><span class="severity-badge severity-${bug.severity}">${bug.severity}</span></td>
<td class="bug-status ${bug.status === 'fixed' ? 'fixed' : ''}">${bug.status}</td>
<td class="bug-progress">${progressText}${taskLinks ? '<br>' + taskLinks : ''}</td>
<td class="bug-description" title="${escapeHtml(bug.description || '')}">${escapeHtml(bug.description || '\u2014')}</td>
<td>${githubHtml}</td>
</tr>`;
}
// Orphaned tasks section (TS-020)
let orphanedHtml = '';
if (orphanedTasks && orphanedTasks.length > 0) {
orphanedHtml = `
<div class="orphaned-section">
<div class="orphaned-title">⚠ Orphaned Fix Tasks (no matching bug in bugs.md)</div>
${orphanedTasks.map(t => `
<div class="orphaned-task">
<span class="task-id cross-link" data-cross-target="implement" data-cross-id="${t.id}" style="cursor:pointer;color:var(--color-accent);">${t.id}</span>
[${t.bugTag}] ${escapeHtml(t.description)}
</div>
`).join('')}
</div>`;
}
contentArea.innerHTML = `
<div class="bugs-view">
<div class="bugs-view-header">
<div class="bugs-view-title">Bug Tracking</div>
<div class="bugs-view-subtitle">${summary.total} total · ${summary.open} open · ${summary.fixed} fixed</div>
</div>
<div class="cross-link-tip">Tip: ${CROSS_LINK_MOD_KEY}+click task IDs to navigate to the Implement board</div>
<table class="bugs-table" role="table" aria-label="Bug status table">
<thead>
<tr>
<th>Bug ID</th>
<th aria-sort="descending">Severity</th>
<th>Status</th>
<th>Fix Tasks</th>
<th>Description</th>
<th>GitHub</th>
</tr>
</thead>
<tbody>${tableRows}</tbody>
</table>
${orphanedHtml}
</div>`;
}
// T016: Client-side GitHub URL resolution
function resolveGitHubUrl(issueRef, repoUrl) {
if (!issueRef || !repoUrl) return null;
const match = issueRef.match(/#(\d+)/);
if (!match) return null;
let base = repoUrl.replace(/\.git$/, '');
const ssh = base.match(/^git@([^:]+):(.+)$/);
if (ssh) base = 'https://' + ssh[1] + '/' + ssh[2];
return base + '/issues/' + match[1];
}
// T021: Highlight a bug row in the Bugs tab
function highlightBugsEntity(entityId) {
const row = contentArea.querySelector(`tr[data-bug-id="${entityId}"]`);
if (row) {
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
row.classList.add('highlighted');
setTimeout(() => row.classList.remove('highlighted'), 2000);
}
}
function renderPlaceholderView(phaseId) {
const phaseNames = {
constitution: 'Constitution', spec: 'Specification',
plan: 'Plan', checklist: 'Checklist', testify: 'Test Specs',
tasks: 'Tasks', analyze: 'Analysis', implement: 'Implementation'
};
const name = phaseNames[phaseId] || phaseId;
contentArea.innerHTML = `
<div class="placeholder-view">
<div class="placeholder-view-title">${name} View</div>
<div class="placeholder-view-text">This phase visualization is coming soon. It will be available in a future update.</div>
</div>`;
}
// ====== Checklist Quality Gates View ======
async function renderChecklistView() {
if (!currentFeature) return;
try {
if (staticMode && window.DASHBOARD_DATA.featureData[currentFeature]) {
currentChecklist = window.DASHBOARD_DATA.featureData[currentFeature].checklist;
} else {
const res = await fetch(`/api/checklist/${currentFeature}`);
if (!res.ok) throw new Error('Failed to load');
currentChecklist = await res.json();
}
expandedChecklist = null;
renderChecklistContent(currentChecklist);
} catch {
contentArea.innerHTML = '<div class="checklist-empty"><div class="checklist-empty-title">Failed to load checklist data.</div></div>';
}
}
function renderChecklistContent(data) {
if (!data) return;
// Empty state
if (!data.files || data.files.length === 0) {
contentArea.innerHTML = `
<div class="checklist-empty">
<div class="checklist-empty-icon">☐</div>
<div class="checklist-empty-title">No checklists generated for this feature</div>
<div class="checklist-empty-text">Run <code>/iikit-03-checklist</code> to generate domain-specific quality checklists for requirements validation.</div>
</div>`;
return;
}
const CIRCUMFERENCE = 2 * Math.PI * 42; // radius=42 for viewBox 100x100
let html = '<div class="checklist-view">';
// Gate indicator
html += renderGateIndicator(data.gate);
// Progress rings
html += '<div class="checklist-rings">';
data.files.forEach((file) => {
const isExpanded = expandedChecklist === file.filename;
const offset = CIRCUMFERENCE - (file.percentage / 100) * CIRCUMFERENCE;
html += `<div class="checklist-ring-wrapper${isExpanded ? ' expanded' : ''}"
tabindex="0" role="button"
aria-expanded="${isExpanded}"
aria-controls="checklist-detail-${escapeHtml(file.filename)}"
aria-label="${escapeHtml(file.name)}: ${file.checked} of ${file.total} items complete, ${file.percentage}%"
data-filename="${escapeHtml(file.filename)}">
<svg class="checklist-ring-svg" viewBox="0 0 100 100" role="img"
aria-label="${escapeHtml(file.name)}: ${file.checked} of ${file.total} items complete, ${file.percentage}%">
<circle class="checklist-ring-track" cx="50" cy="50" r="42" />
<circle class="checklist-ring-fill ${file.color}" cx="50" cy="50" r="42"
stroke-dasharray="${CIRCUMFERENCE}"
stroke-dashoffset="${offset}" />
<text class="checklist-ring-pct" x="50" y="50">${file.percentage}%</text>
</svg>
<div class="checklist-ring-name">${escapeHtml(file.name)}</div>
<div class="checklist-ring-fraction">${file.checked}/${file.total}</div>
</div>`;
});
html += '</div>';
html += `<div class="cross-link-tip">Tip: ${CROSS_LINK_MOD_KEY}+click any FR/SC tag to navigate to its linked panel</div>`;
// Accordion detail panels
data.files.forEach((file) => {
const isExpanded = expandedChecklist === file.filename;
html += `<div id="checklist-detail-${escapeHtml(file.filename)}" class="checklist-detail${isExpanded ? ' open' : ''}">`;
html += '<div class="checklist-detail-inner">';
html += renderChecklistDetail(file);
html += '</div></div>';
});
html += '</div>';
contentArea.innerHTML = html;
// Attach click and keyboard handlers to rings
document.querySelectorAll('.checklist-ring-wrapper').forEach(wrapper => {
const filename = wrapper.getAttribute('data-filename');
wrapper.addEventListener('click', () => toggleChecklistExpand(filename, data));
wrapper.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleChecklistExpand(filename, data);
}
});
});
}
function renderGateIndicator(gate) {
if (!gate) return '';
const levelClass = gate.level === 'yellow' ? 'blocked-yellow' : (gate.level === 'red' ? 'blocked-red' : 'open');
return `<div class="gate-indicator" role="status" aria-live="polite">
<div class="gate-dot ${gate.level}"></div>
<span class="gate-label ${levelClass}">${escapeHtml(gate.label)}</span>
</div>`;
}
function renderChecklistDetail(file) {
if (!file || !file.items || file.items.length === 0) {
return '<div style="color:var(--color-text-muted);font-size:13px;">No items detected in this checklist.</div>';
}
let html = '';
let currentCategory = null;
file.items.forEach(item => {
if (item.category !== currentCategory) {
currentCategory = item.category;
if (currentCategory) {
html += `<h4 class="checklist-category">${escapeHtml(currentCategory)}</h4>`;
}
}
const icon = item.checked
? '<span class="checklist-item-icon checked">✓</span>'
: '<span class="checklist-item-icon unchecked">☐</span>';
const chkId = item.chkId
? `<span class="checklist-item-id">${escapeHtml(item.chkId)}</span>`
: '';
const tags = (item.tags || []).map(t => {
const isCrossLink = /^(FR|SC)-?\d+$/i.test(t);
if (isCrossLink) {
return `<span class="checklist-item-tag cross-link" data-cross-target="spec" data-cross-id="${escapeHtml(t)}">${escapeHtml(t)}</span>`;
}
return `<span class="checklist-item-tag">${escapeHtml(t)}</span>`;
}).join('');
const linkedText = escapeHtml(item.text).replace(
/\b((?:FR|SC)-\d+)\b/g,
'<span class="cross-link" data-cross-target="spec" data-cross-id="$1">$1</span>'
);
html += `<div class="checklist-item">
${icon}${chkId}
<span style="flex:1">${linkedText}</span>
${tags}
</div>`;
});
return html;
}
function toggleChecklistExpand(filename, data) {
if (expandedChecklist === filename) {
expandedChecklist = null;
} else {
expandedChecklist = filename;
}
renderChecklistContent(data);
}
// ====== Testify Traceability View ======
async function renderTestifyView() {
if (!currentFeature) return;
try {
if (staticMode && window.DASHBOARD_DATA.featureData[currentFeature]) {
currentTestify = window.DASHBOARD_DATA.featureData[currentFeature].testify;
renderTestifyContent(currentTestify);
return;
}
const res = await fetch(`/api/testify/${currentFeature}`);
if (!res.ok) throw new Error('Failed to load');
currentTestify = await res.json();
renderTestifyContent(currentTestify);
} catch {
contentArea.innerHTML = '<div class="testify-empty"><div class="testify-empty-title">Failed to load testify data.</div></div>';
}
}
function renderTestifyContent(data) {
if (!data) return;
// Empty state
if (!data.exists) {
contentArea.innerHTML = `
<div class="testify-empty">
<div class="testify-empty-icon">🔍</div>
<div class="testify-empty-title">No test specifications generated for this feature</div>
<div class="testify-empty-text">Run <code>/iikit-04-testify</code> to generate test specifications with traceability links to requirements and tasks.</div>
</div>`;
return;
}
let html = '<div class="testify-view">';
// Integrity seal
html += renderIntegritySeal(data.integrity);
html += `<div class="cross-link-tip">Tip: ${CROSS_LINK_MOD_KEY}+click any identifier to navigate to its linked panel</div>`;
// Sankey diagram
html += renderSankeyDiagram(data);
html += '</div>';
contentArea.innerHTML = html;
// Attach hover and keyboard handlers after DOM insert
attachSankeyInteractions(data);
}
function renderIntegritySeal(integrity) {
const labels = { valid: 'Verified', tampered: 'Tampered', missing: 'Missing' };
const label = labels[integrity.status] || 'Unknown';
const hashDisplay = integrity.currentHash ? integrity.currentHash.substring(0, 12) + '...' : '';
return `<div class="testify-seal" role="status" aria-live="polite" aria-label="Assertion integrity: ${label}">
<div class="testify-seal-dot ${escapeHtml(integrity.status)}"></div>
<span class="testify-seal-label ${escapeHtml(integrity.status)}">${escapeHtml(label)}</span>
<span style="color: var(--color-text-muted); font-weight: 400;">Assertion Integrity</span>
${hashDisplay ? `<span class="testify-seal-hash">${escapeHtml(hashDisplay)}</span>` : ''}
</div>`;
}
function renderSankeyDiagram(data) {
const { requirements, testSpecs, tasks, edges, gaps, pyramid } = data;
// Layout constants
const PAD = 20;
const COL_LEFT = 40;
const COL_MID = 440;
const COL_RIGHT = 840;
const NODE_W = 180;
const NODE_H = 32;
const NODE_GAP = 6;
const GROUP_PAD = 8;
const GROUP_GAP = 16;
const LABEL_Y = 20;
const COL_LABEL_Y = LABEL_Y;
// Compute positions for each column
const reqNodes = requirements.map((r, i) => ({
...r, x: COL_LEFT, y: PAD + 30 + i * (NODE_H + NODE_GAP),
col: 'left', isGap: gaps.untestedRequirements.includes(r.id)
}));
// Middle column: group by pyramid type
const typeOrder = ['acceptance', 'contract', 'validation'];
const typeLabels = { acceptance: 'Acceptance', contract: 'Contract', validation: 'Validation' };
const typeColors = { acceptance: '#3B82F6', contract: '#22C55E', validation: '#8B5CF6' };
const midNodes = [];
const groupMeta = [];
let midY = PAD + 30;
for (const type of typeOrder) {
const ids = pyramid[type].ids;
const count = pyramid[type].count;
const groupStartY = midY;
const specs = ids.map(id => testSpecs.find(ts => ts.id === id)).filter(Boolean);
// Cluster width scales with count — narrower at top (acceptance), wider at bottom (validation)
const widthScale = type === 'acceptance' ? 0.7 : type === 'contract' ? 0.85 : 1.0;
const effectiveW = Math.round(NODE_W * widthScale);
const xOffset = COL_MID + Math.round((NODE_W - effectiveW) / 2);
for (let j = 0; j < specs.length; j++) {
midNodes.push({
...specs[j], x: xOffset, y: midY + GROUP_PAD + j * (NODE_H + NODE_GAP),
w: effectiveW, col: 'mid', type,
isGap: gaps.unimplementedTests.includes(specs[j].id)
});
}
const groupH = count > 0
? GROUP_PAD * 2 + count * NODE_H + (count - 1) * NODE_GAP
: GROUP_PAD * 2 + NODE_H; // min height for empty groups
groupMeta.push({
type, label: `${typeLabels[type]}: ${count}`,
x: xOffset - GROUP_PAD, y: groupStartY,
w: effectiveW + GROUP_PAD * 2, h: groupH,
color: typeColors[type]
});
midY += groupH + GROUP_GAP;
}
// Right column: tasks — mark completed tasks from board data
const checkedTaskIds = new Set();
if (currentBoard) {
for (const cards of [currentBoard.todo, currentBoard.in_progress, currentBoard.done]) {
for (const card of (cards || [])) {
for (const t of (card.tasks || [])) {
if (t.checked) checkedTaskIds.add(t.id);
}
}
}
}
const taskNodes = tasks.map((t, i) => ({
...t, x: COL_RIGHT, y: PAD + 30 + i * (NODE_H + NODE_GAP),
col: 'right', isGap: false, isChecked: checkedTaskIds.has(t.id)
}));
// SVG height
const maxLeft = reqNodes.length > 0 ? reqNodes[reqNodes.length - 1].y + NODE_H + PAD : 100;
const maxMid = midY + PAD;
const maxRight = taskNodes.length > 0 ? taskNodes[taskNodes.length - 1].y + NODE_H + PAD : 100;
const svgH = Math.max(maxLeft, maxMid, maxRight, 200);
const svgW = COL_RIGHT + NODE_W + PAD + 40;
// Build node lookup for edge drawing
const nodeMap = {};
for (const n of reqNodes) nodeMap[n.id] = n;
for (const n of midNodes) nodeMap[n.id] = n;
for (const n of taskNodes) nodeMap[n.id] = n;
// Build directed edge indices for chain computation
// reqToTest: requirement ID -> [test spec IDs]
// testToReq: test spec ID -> [requirement IDs]
// testToTask: test spec ID -> [task IDs]
// taskToTest: task ID -> [test spec IDs]
const reqToTest = {};
const testToReq = {};
const testToTask = {};
const taskToTest = {};
for (const e of edges) {
if (e.type === 'requirement-to-test') {
if (!reqToTest[e.from]) reqToTest[e.from] = [];
reqToTest[e.from].push(e.to);
if (!testToReq[e.to]) testToReq[e.to] = [];
testToReq[e.to].push(e.from);
} else if (e.type === 'test-to-task') {
if (!testToTask[e.from]) testToTask[e.from] = [];
testToTask[e.from].push(e.to);
if (!taskToTest[e.to]) taskToTest[e.to] = [];
taskToTest[e.to].push(e.from);
}
}
const reqIdSet = new Set(requirements.map(r => r.id));
const tsIdSet = new Set(testSpecs.map(t => t.id));
// Compute directed chain: follows flow direction, not transitive BFS
function getDirectedChain(startId) {
const chain = new Set([startId]);
if (reqIdSet.has(startId)) {
// Requirement: go right → test specs → tasks
const linkedTests = reqToTest[startId] || [];
for (const tsId of linkedTests) {
chain.add(tsId);
for (const taskId of (testToTask[tsId] || [])) chain.add(taskId);
}
} else if (tsIdSet.has(startId)) {
// Test spec: go left → requirements, go right → tasks
for (const reqId of (testToReq[startId] || [])) chain.add(reqId);
for (const taskId of (testToTask[startId] || [])) chain.add(taskId);
} else {
// Task: go left → test specs → requirements
const linkedTests = taskToTest[startId] || [];
for (const tsId of linkedTests) {
chain.add(tsId);
for (const reqId of (testToReq[tsId] || [])) chain.add(reqId);
}
}
return chain;
}
let svg = `<svg class="testify-sankey-svg" viewBox="0 0 ${svgW} ${svgH}" role="img"
aria-label="Traceability Sankey diagram: ${requirements.length} requirements, ${testSpecs.length} test specifications, ${tasks.length} tasks">`;
// Column labels
svg += `<text class="sankey-col-label" x="${COL_LEFT + NODE_W / 2}" y="${COL_LABEL_Y}" text-anchor="middle">Requirements</text>`;
svg += `<text class="sankey-col-label" x="${COL_MID + NODE_W / 2}" y="${COL_LABEL_Y}" text-anchor="middle">Test Specs</text>`;
svg += `<text class="sankey-col-label" x="${COL_RIGHT + NODE_W / 2}" y="${COL_LABEL_Y}" text-anchor="middle">Tasks</text>`;
// Pyramid group backgrounds
for (const g of groupMeta) {
svg += `<rect class="sankey-group-bg" x="${g.x}" y="${g.y}" width="${g.w}" height="${g.h}" fill="${g.color}" fill-opacity="0.06" stroke="${g.color}" stroke-opacity="0.15" stroke-width="1"/>`;
svg += `<text class="sankey-group-label" x="${g.x + g.w / 2}" y="${g.y - 4}" text-anchor="middle" fill="${g.color}">${escapeHtml(g.label)}</text>`;
}
// Flow bands (edges)
svg += '<g class="sankey-flows">';
for (const e of edges) {
const src = nodeMap[e.from];
const tgt = nodeMap[e.to];
if (!src || !tgt) continue;
const srcW = src.w || NODE_W;
const tgtW = tgt.w || NODE_W;
const x1 = src.x + srcW;
const y1 = src.y + NODE_H / 2;
const x2 = tgt.x;
const y2 = tgt.y + NODE_H / 2;
const cx = (x1 + x2) / 2;
// Color by test type
let color = '#3B82F6';
if (e.type === 'requirement-to-test') {
const ts = testSpecs.find(t => t.id === e.to);
if (ts) color = typeColors[ts.type] || color;
} else if (e.type === 'test-to-task') {
const ts = testSpecs.find(t => t.id === e.from);
if (ts) color = typeColors[ts.type] || color;
}
const chainIds = [e.from, e.to].join(' ');
svg += `<path class="sankey-flow" d="M${x1},${y1} C${cx},${y1} ${cx},${y2} ${x2},${y2}"
fill="none" stroke="${color}" data-chain-ids="${escapeHtml(chainIds)}" data-from="${escapeHtml(e.from)}" data-to="${escapeHtml(e.to)}"/>`;
}
svg += '</g>';
// Render nodes
function renderNodeGroup(nodes, colClass) {
let s = `<g class="${colClass}">`;
for (const n of nodes) {
const w = n.w || NODE_W;
const chain = Array.from(getDirectedChain(n.id)).join(' ');
const gapClass = n.isGap ? ' gap-node' : (n.isChecked ? ' task-checked' : '');
const isUntestedReq = gaps.untestedRequirements.includes(n.id);
const isUnimplTest = gaps.unimplementedTests.includes(n.id);
let ariaDesc = n.id;
if (n.traceability && n.traceability.length > 0) {
ariaDesc += ', linked from ' + n.traceability.join(', ');
}
// Find connections
const outgoing = edges.filter(e => e.from === n.id).map(e => e.to);
const incoming = edges.filter(e => e.to === n.id).map(e => e.from);
if (incoming.length > 0) ariaDesc += ', linked from ' + incoming.join(', ');
if (outgoing.length > 0) ariaDesc += ', linked to ' + outgoing.join(', ');
if (isUntestedReq) ariaDesc += ' (untested)';
if (isUnimplTest) ariaDesc += ' (unimplemented)';
const fillColor = n.isGap ? 'var(--color-surface-elevated)' : 'var(--color-surface-elevated)';
const strokeColor = n.isGap ? 'var(--color-tampered)' : 'var(--color-border)';
const label = n.id;
const desc = n.text || n.title || n.description || '';
const truncDesc = desc.length > 22 ? desc.substring(0, 22) + '...' : desc;
// Cross-link: requirements → spec, tasks → implement, gap nodes → checklist
let crossAttr = '';
if (n.isGap) {
crossAttr = ` data-cross-target="checklist" data-cross-id="${escapeHtml(n.id)}"`;
} else if (n.col === 'left') {
crossAttr = ` data-cross-target="spec" data-cross-id="${escapeHtml(n.id)}"`;
} else if (n.col === 'right') {
crossAttr = ` data-cross-target="implement" data-cross-id="${escapeHtml(n.id)}"`;
}
s += `<g class="sankey-node${gapClass}" tabindex="0" role="button"
data-node-id="${escapeHtml(n.id)}" data-chain="${escapeHtml(chain)}"${crossAttr}
aria-describedby="desc-${escapeHtml(n.id)}">
<title>${escapeHtml(n.id)}: ${escapeHtml(desc)}${isUntestedReq ? ' (untested)' : ''}${isUnimplTest ? ' (unimplemented)' : ''}</title>
<desc id="desc-${escapeHtml(n.id)}">${escapeHtml(ariaDesc)}</desc>
<rect class="sankey-node-rect" x="${n.x}" y="${n.y}" width="${w}" height="${NODE_H}"
fill="${fillColor}" stroke="${strokeColor}"/>
<text class="sankey-node-label" x="${n.x + 8}" y="${n.y + 13}">${escapeHtml(label)}</text>
<text class="sankey-node-desc" x="${n.x + 8}" y="${n.y + 25}">${escapeHtml(truncDesc)}</text>
</g>`;
}
s += '</g>';
return s;
}
svg += renderNodeGroup(reqNodes, 'sankey-col-left');
svg += renderNodeGroup(midNodes, 'sankey-col-mid');
svg += renderNodeGroup(taskNodes, 'sankey-col-right');
// Empty column messages
if (requirements.length === 0) {
svg += `<text x="${COL_LEFT + NODE_W / 2}" y="${PAD + 60}" text-anchor="middle" fill="var(--color-text-muted)" font-size="11">No requirements found in spec.md</text>`;
}
if (tasks.length === 0) {
svg += `<text x="${COL_RIGHT + NODE_W / 2}" y="${PAD + 60}" text-anchor="middle" fill="var(--color-text-muted)" font-size="11">No tasks generated \u2014 run /iikit-05-tasks</text>`;
}
svg += '</svg>';
return `<div class="testify-sankey-container">${svg}</div>`;
}
function attachSankeyInteractions(data) {
const svgEl = contentArea.querySelector('.testify-sankey-svg');
if (!svgEl) return;
// Clear fade-in animations after they complete so .dimmed opacity can take effect
// (CSS animation fill-mode: both overrides normal opacity rules)
setTimeout(() => {
svgEl.querySelectorAll('.sankey-node, .sankey-flow').forEach(el => {
el.style.animation = 'none';
});
}, 1000);
const allNodes = svgEl.querySelectorAll('.sankey-node');
const allFlows = svgEl.querySelectorAll('.sankey-flow');
const allElements = [...allNodes, ...allFlows];
let lockedChain = null; // Click locks the highlight
// Expose highlight to cross-panel navigation
externalSankeyHighlight = (nodeId) => { lockedChain = nodeId; highlightChain(nodeId); };
function highlightChain(nodeId) {
// Get full chain from data-chain attribute
const nodeEl = svgEl.querySelector(`[data-node-id="${nodeId}"]`);
if (!nodeEl) return;
const chainStr = nodeEl.getAttribute('data-chain') || '';
const chainIds = new Set(chainStr.split(' ').filter(Boolean));
// Dim everything, highlight chain members
for (const el of allNodes) {
const elId = el.getAttribute('data-node-id');
if (chainIds.has(elId)) {
el.classList.add('highlighted');
el.classList.remove('dimmed');
} else {
el.classList.add('dimmed');
el.classList.remove('highlighted');
}
}
for (const flow of allFlows) {
const from = flow.getAttribute('data-from');
const to = flow.getAttribute('data-to');
if (chainIds.has(from) && chainIds.has(to)) {
flow.classList.add('highlighted');
flow.classList.remove('dimmed');
} else {
flow.classList.add('dimmed');
flow.classList.remove('highlighted');
}
}
}
function clearHighlight() {
if (lockedChain) return; // Don't clear if a chain is click-locked
for (const el of allElements) {
el.classList.remove('dimmed', 'highlighted');
}
}
// Hover handlers on nodes
for (const node of allNodes) {
const nodeId = node.getAttribute('data-node-id');
node.addEventListener('mouseenter', () => {
if (!lockedChain) highlightChain(nodeId);
});
node.addEventListener('mouseleave', clearHighlight);
// Click: lock/unlock chain highlight (skip if Cmd/Ctrl for cross-navigation)
node.addEventListener('click', (e) => {
if (e.metaKey || e.ctrlKey) return;
if (lockedChain === nodeId) {
lockedChain = null;
clearHighlight();
} else {
lockedChain = nodeId;
highlightChain(nodeId);
}
});
// Keyboard: Enter/Space triggers chain highlight
node.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (lockedChain === nodeId) {
lockedChain = null;
clearHighlight();
} else {
lockedChain = nodeId;
highlightChain(nodeId);
}
}
});
}
// Hover handlers on flows
for (const flow of allFlows) {
const from = flow.getAttribute('data-from');
flow.addEventListener('mouseenter', () => {
if (!lockedChain) highlightChain(from);
});
flow.addEventListener('mouseleave', clearHighlight);
}
// Click on empty SVG area clears locked chain
svgEl.addEventListener('click', (e) => {
if (e.target === svgEl || e.target.tagName === 'svg') {
lockedChain = null;
for (const el of allElements) {
el.classList.remove('dimmed', 'highlighted');
}
}
});
}
// ====== Spec Story Map View ======
let currentStoryMap = null;
async function renderStoryMapView() {
if (!currentFeature) return;
try {
if (!currentStoryMap) {
if (staticMode && window.DASHBOARD_DATA.featureData[currentFeature]) {
currentStoryMap = window.DASHBOARD_DATA.featureData[currentFeature].storyMap;
} else {
const res = await fetch(`/api/storymap/${currentFeature}`);
if (!res.ok) throw new Error('Failed to load');
currentStoryMap = await res.json();
}
}
renderStoryMapContent(currentStoryMap);
} catch {
contentArea.innerHTML = '<div class="storymap-empty">Failed to load story map data.</div>';
}
}
function renderStoryMapContent(data) {
if (!data.stories.length && !data.requirements.length) {
contentArea.innerHTML = `
<div class="storymap-empty">
<div class="placeholder-view-title">No Specification Data</div>
<div>This feature's spec.md has no user stories or requirements yet.</div>
</div>`;
return;
}
contentArea.innerHTML = `
<div class="storymap-view" role="region" aria-label="Spec Story Map">
<div class="storymap-main">
<div class="cross-link-tip">Tip: ${CROSS_LINK_MOD_KEY}+click any identifier to navigate to its linked panel</div>
<div class="storymap-section-title">Story Map</div>
<div class="swim-lanes" role="list" aria-label="User stories by priority"></div>
<div class="storymap-section-title">Requirements Graph</div>
<div class="graph-container">
<svg class="graph-svg" aria-label="Requirements relationship graph"></svg>
<div class="graph-tooltip"></div>
<div class="graph-legend">
<div class="graph-legend-item"><span class="graph-legend-dot us"></span> User Story</div>
<div class="graph-legend-item"><span class="graph-legend-dot fr"></span> Requirement</div>
<div class="graph-legend-item"><span class="graph-legend-dot sc"></span> Success Criterion</div>
</div>
</div>
</div>
</div>
<div class="detail-panel-slot"></div>`;
renderSwimLanes(data);
renderRequirementsGraph(data);
}
function renderSwimLanes(data) {
const container = contentArea.querySelector('.swim-lanes');
if (!container) return;
const lanes = { P1: [], P2: [], P3: [] };
for (const story of data.stories) {
const p = story.priority || 'P3';
if (!lanes[p]) lanes[p] = [];
lanes[p].push(story);
}
let html = '';
for (const [priority, stories] of Object.entries(lanes)) {
html += `<div class="swim-lane" role="listitem">
<div class="swim-lane-label ${priority.toLowerCase()}">${priority}</div>
<div class="swim-lane-cards">`;
for (const story of stories) {
const refs = (story.requirementRefs || []).map(r => `<span class="story-card-badge">${escapeHtml(r)}</span>`).join('');
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)}">
<div class="story-card-header">
<span class="story-card-id">${escapeHtml(story.id)}</span>
<span class="story-card-priority ${story.priority.toLowerCase()}">${escapeHtml(story.priority)}</span>
</div>
<div class="story-card-title">${escapeHtml(story.title)}</div>
<div class="story-card-meta">
<span class="story-card-badge scenarios">${story.scenarioCount || 0} scenario${(story.scenarioCount || 0) !== 1 ? 's' : ''}</span>
${refs}
</div>
</div>`;
}
html += '</div></div>';
}
container.innerHTML = html;
// Story card click → highlight graph node + show detail (FR-016)
container.querySelectorAll('.story-card').forEach(card => {
card.addEventListener('click', (e) => {
if (e.metaKey || e.ctrlKey) return; // Let cross-link handler handle Cmd/Ctrl+click
const storyId = card.dataset.storyId;
highlightGraphNode(storyId);
const story = data.stories.find(s => s.id === storyId);
if (story) showDetailPanel(storyId, 'us', story.title, story.body || '');
const nodeEl = contentArea.querySelector(`.graph-node[data-id="${storyId}"]`);
if (nodeEl) nodeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
card.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); card.click(); }
});
});
}
function renderRequirementsGraph(data) {
const svg = contentArea.querySelector('.graph-svg');
if (!svg) return;
if (!data.requirements.length && !data.successCriteria.length) {
const container = svg.parentElement;
container.innerHTML = '<div class="graph-empty">No requirements or success criteria defined yet.</div>' +
container.querySelector('.graph-legend')?.outerHTML || '';
return;
}
// Build nodes
const nodes = [];
const nodeMap = {};
for (const s of data.stories) {
const n = { id: s.id, type: 'us', label: s.id, desc: s.title, x: 0, y: 0, vx: 0, vy: 0 };
nodes.push(n);
nodeMap[s.id] = n;
}
for (const r of data.requirements) {
const n = { id: r.id, type: 'fr', label: r.id, desc: r.text, x: 0, y: 0, vx: 0, vy: 0 };
nodes.push(n);
nodeMap[r.id] = n;
}
for (const sc of data.successCriteria) {
const n = { id: sc.id, type: 'sc', label: sc.id, desc: sc.text, x: 0, y: 0, vx: 0, vy: 0 };
nodes.push(n);
nodeMap[sc.id] = n;
}
// Size SVG based on node count
const width = svg.clientWidth || 800;
const height = Math.min(width, Math.max(300, nodes.length * 30 + 100));
svg.style.height = height + 'px';
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
// Initial positions: spread nodes randomly within bounds
const pad = 50;
for (const n of nodes) {
n.x = pad + Math.random() * (width - 2 * pad);
n.y = pad + Math.random() * (height - 2 * pad);
}
// Build edges
const edges = data.edges.filter(e => nodeMap[e.from] && nodeMap[e.to]);
// Simple force-directed layout
for (let iter = 0; iter < 120; iter++) {
// Repulsion between all pairs
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
let dx = nodes[j].x - nodes[i].x;
let dy = nodes[j].y - nodes[i].y;
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
let force = 3000 / (dist * dist);
let fx = (dx / dist) * force;
let fy = (dy / dist) * force;
nodes[i].vx -= fx;
nodes[i].vy -= fy;
nodes[j].vx += fx;
nodes[j].vy += fy;
}
}
// Attraction along edges
for (const e of edges) {
const a = nodeMap[e.from];
const b = nodeMap[e.to];
let dx = b.x - a.x;
let dy = b.y - a.y;
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
let force = (dist - 100) * 0.05;
let fx = (dx / dist) * force;
let fy = (dy / dist) * force;
a.vx += fx;
a.vy += fy;
b.vx -= fx;
b.vy -= fy;
}
// Apply velocities with damping
for (const n of nodes) {
n.vx *= 0.7;
n.vy *= 0.7;
n.x += n.vx;
n.y += n.vy;
n.x = Math.max(pad, Math.min(width - pad, n.x));
n.y = Math.max(pad, Math.min(height - pad, n.y));
}
}
// Render edges
let svgContent = '';
for (const e of edges) {
const a = nodeMap[e.from];
const b = nodeMap[e.to];
svgContent += `<line class="graph-edge" data-from="${e.from}" data-to="${e.to}" x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}"/>`;
}
// Render nodes
const radius = { us: 18, fr: 14, sc: 12 };
for (const n of nodes) {
const r = radius[n.type] || 14;
const crossTarget = n.type === 'us' ? 'implement' : 'testify';
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)}">
<circle cx="${n.x}" cy="${n.y}" r="${r}" stroke="var(--color-bg)" stroke-width="2"/>
<text x="${n.x}" y="${n.y + r + 14}">${n.label}</text>
</g>`;
}
svg.innerHTML = svgContent;
// Click-to-highlight + detail panel (FR-006)
svg.addEventListener('click', (e) => {
if (e.metaKey || e.ctrlKey) return; // Let cross-link handler handle Cmd/Ctrl+click
const nodeEl = e.target.closest('.graph-node');
if (nodeEl) {
const id = nodeEl.dataset.id;
highlightGraphNode(id);
// Show detail panel
const story = data.stories.find(s => s.id === id);
const req = data.requirements.find(r => r.id === id);
const sc = data.successCriteria.find(s => s.id === id);
if (story) showDetailPanel(id, 'us', story.title, story.body || '');
else if (req) showDetailPanel(id, 'fr', id, req.text);
else if (sc) showDetailPanel(id, 'sc', id, sc.text);
} else {
clearGraphHighlight();
closeDetailPanel();
}
});
// Tooltip on hover (FR-014)
const tooltip = contentArea.querySelector('.graph-tooltip');
svg.addEventListener('mouseover', (e) => {
const nodeEl = e.target.closest('.graph-node');
if (nodeEl && tooltip) {
tooltip.textContent = nodeEl.dataset.desc;
tooltip.style.display = 'block';
}
});
svg.addEventListener('mousemove', (e) => {
if (tooltip && tooltip.style.display === 'block') {
const rect = svg.parentElement.getBoundingClientRect();
tooltip.style.left = (e.clientX - rect.left + 12) + 'px';
tooltip.style.top = (e.clientY - rect.top - 8) + 'px';
}
});
svg.addEventListener('mouseout', (e) => {
if (!e.target.closest('.graph-node') && tooltip) {
tooltip.style.display = 'none';
}
});
// Drag nodes (FR-007)
let dragNode = null;
let dragOffset = { x: 0, y: 0 };
svg.addEventListener('mousedown', (e) => {
if (e.metaKey || e.ctrlKey) return; // Don't drag on Cmd/Ctrl+click
const nodeEl = e.target.closest('.graph-node');
if (!nodeEl) return;
e.preventDefault();
dragNode = nodeEl;
const circle = nodeEl.querySelector('circle');
const svgRect = svg.getBoundingClientRect();
const viewBox = svg.viewBox.baseVal;
const scaleX = viewBox.width / svgRect.width;
const scaleY = viewBox.height / svgRect.height;
dragOffset.x = parseFloat(circle.getAttribute('cx')) - (e.clientX - svgRect.left) * scaleX;
dragOffset.y = parseFloat(circle.getAttribute('cy')) - (e.clientY - svgRect.top) * scaleY;
svg.style.cursor = 'grabbing';
});
document.addEventListener('mousemove', (e) => {
if (!dragNode) return;
const svgRect = svg.getBoundingClientRect();
const viewBox = svg.viewBox.baseVal;
const scaleX = viewBox.width / svgRect.width;
const scaleY = viewBox.height / svgRect.height;
const nx = (e.clientX - svgRect.left) * scaleX + dragOffset.x;
const ny = (e.clientY - svgRect.top) * scaleY + dragOffset.y;
const circle = dragNode.querySelector('circle');
const text = dragNode.querySelector('text');
circle.setAttribute('cx', nx);
circle.setAttribute('cy', ny);
text.setAttribute('x', nx);
text.setAttribute('y', ny + parseFloat(circle.getAttribute('r')) + 14);
// Update connected edges
const nodeId = dragNode.dataset.id;
svg.querySelectorAll('.graph-edge').forEach(edge => {
if (edge.dataset.from === nodeId) { edge.setAttribute('x1', nx); edge.setAttribute('y1', ny); }
if (edge.dataset.to === nodeId) { edge.setAttribute('x2', nx); edge.setAttribute('y2', ny); }
});
});
document.addEventListener('mouseup', () => {
if (dragNode) {
dragNode = null;
svg.style.cursor = 'grab';
}
});
// Zoom/pan via wheel (FR-008)
svg.addEventListener('wheel', (e) => {
e.preventDefault();
const viewBox = svg.viewBox.baseVal;
const scale = e.deltaY > 0 ? 1.1 : 0.9;
const svgRect = svg.getBoundingClientRect();
const mx = ((e.clientX - svgRect.left) / svgRect.width) * viewBox.width + viewBox.x;
const my = ((e.clientY - svgRect.top) / svgRect.height) * viewBox.height + viewBox.y;
const nw = viewBox.width * scale;
const nh = viewBox.height * scale;
viewBox.x = mx - (mx - viewBox.x) * scale;
viewBox.y = my - (my - viewBox.y) * scale;
viewBox.width = nw;
viewBox.height = nh;
}, { passive: false });
}
function highlightGraphNode(nodeId) {
const svg = contentArea.querySelector('.graph-svg');
if (!svg) return;
// Build connected set
const connected = new Set([nodeId]);
svg.querySelectorAll('.graph-edge').forEach(edge => {
if (edge.dataset.from === nodeId) connected.add(edge.dataset.to);
if (edge.dataset.to === nodeId) connected.add(edge.dataset.from);
});
svg.querySelectorAll('.graph-node').forEach(n => {
const id = n.dataset.id;
n.classList.toggle('highlighted', id === nodeId);
n.classList.toggle('dimmed', !connected.has(id));
});
svg.querySelectorAll('.graph-edge').forEach(e => {
const isConnected = e.dataset.from === nodeId || e.dataset.to === nodeId;
e.classList.toggle('highlighted', isConnected);
e.classList.toggle('dimmed', !isConnected);
});
}
function clearGraphHighlight() {
const svg = contentArea.querySelector('.graph-svg');
if (!svg) return;
svg.querySelectorAll('.graph-node').forEach(n => { n.classList.remove('highlighted', 'dimmed'); });
svg.querySelectorAll('.graph-edge').forEach(e => { e.classList.remove('highlighted', 'dimmed'); });
}
function showDetailPanel(id, type, title, body) {
const slot = contentArea.querySelector('.detail-panel-slot');
if (!slot) return;
// Simple markdown-like rendering: bold, italic, numbered lists
const rendered = body
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/^(\d+)\.\s+/gm, '<br>$1. ');
// Check for related clarifications
let clarifyLink = '';
if (currentStoryMap && currentStoryMap.clarifications) {
const hasClarify = currentStoryMap.clarifications.some(c =>
c.refs && c.refs.some(r => r === id || r.replace(/^(US|FR|SC)-/i, (_, p) => p.toUpperCase()) === id)
);
if (hasClarify) {
clarifyLink = `<div style="margin-top:12px;padding-top:8px;border-top:1px solid var(--color-border-subtle)">
<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>
</div>`;
}
}
slot.innerHTML = `
<div class="detail-panel" role="region" aria-label="Detail view for ${escapeHtml(id)}">
<div class="detail-panel-header">
<span class="detail-panel-id ${type}">${escapeHtml(id)}</span>
<button class="detail-panel-close" aria-label="Close detail panel">×</button>
</div>
<div class="detail-panel-title">${escapeHtml(title)}</div>
<div class="detail-panel-body">${rendered}</div>
${clarifyLink}
</div>`;
slot.querySelector('.detail-panel-close').addEventListener('click', closeDetailPanel);
const clarifyNav = slot.querySelector('.clarify-nav-link');
if (clarifyNav) {
clarifyNav.addEventListener('click', (e) => {
e.preventDefault();
navigateToPanel('clarify', clarifyNav.dataset.entityId);
});
}
slot.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function closeDetailPanel() {
const slot = contentArea.querySelector('.detail-panel-slot');
if (slot) slot.innerHTML = '';
}
// ====== Clarify View ======
async function renderClarifyView() {
if (!currentFeature) {
contentArea.innerHTML = '<div class="placeholder-view"><div class="placeholder-view-title">Select a feature to view clarifications</div></div>';
return;
}
// Check if spec phase has been completed
const specPhase = currentPipeline?.phases?.find(p => p.id === 'spec');
if (specPhase && specPhase.status === 'not_started') {
contentArea.innerHTML = `
<div class="clarify-view" role="region" aria-label="Clarifications">
<div class="clarify-empty">
<div class="placeholder-view-title">Specification Not Yet Created</div>
<div class="placeholder-view-text">The Clarify phase refines an existing specification. Run <code>/iikit-01-specify</code> first to create the feature spec, then run <code>/iikit-clarify</code> to resolve ambiguities.</div>
</div>
</div>`;
return;
}
try {
if (!currentStoryMap) {
if (staticMode && window.DASHBOARD_DATA.featureData[currentFeature]) {
currentStoryMap = window.DASHBOARD_DATA.featureData[currentFeature].storyMap;
} else {
const res = await fetch(`/api/storymap/${currentFeature}`);
if (!res.ok) throw new Error('Failed to load');
currentStoryMap = await res.json();
}
}
renderClarifyContent(currentStoryMap.clarifications || []);
} catch {
contentArea.innerHTML = '<div class="placeholder-view"><div class="placeholder-view-title">Failed to load clarification data</div></div>';
}
}
function renderClarifyContent(clarifications) {
if (!clarifications.length) {
// TS-019/TS-020: Distinguish "clarify was run, no issues" from "never run"
const clarifyPhase = currentPipeline?.phases?.find(p => p.id === 'clarify');
const clarifyWasRun = clarifyPhase && (clarifyPhase.status === 'complete' || clarifyPhase.status === 'skipped');
const title = clarifyWasRun
? 'Clarification Complete'
: 'Clarify Not Yet Run';
const text = clarifyWasRun
? 'The clarification phase was completed with no ambiguities found. The specification is clear as written.'
: 'This is an optional phase. Run <code>/iikit-clarify</code> to identify and resolve ambiguities in any artifact.';
contentArea.innerHTML = `
<div class="clarify-view" role="region" aria-label="Clarifications">
<div class="clarify-empty">
<div class="placeholder-view-title">${title}</div>
<div class="placeholder-view-text">${text}</div>
</div>
</div>`;
return;
}
// Group by session
const sessions = {};
for (const c of clarifications) {
if (!sessions[c.session]) sessions[c.session] = [];
sessions[c.session].push(c);
}
let html = `<div class="clarify-view" role="region" aria-label="Clarifications">
<div class="clarify-header">
<span class="clarify-title">Clarification Trail</span>
<span class="clarify-count">${clarifications.length} Q&A${clarifications.length !== 1 ? 's' : ''}</span>
</div>
<div class="cross-link-tip">Tip: ${CROSS_LINK_MOD_KEY}+click any identifier to navigate to its linked panel</div>
<div class="clarify-sessions">`;
for (const [session, entries] of Object.entries(sessions)) {
html += `<div class="clarify-session">
<div class="clarify-session-label">Session ${escapeHtml(session)}</div>
<div class="clarify-entries">`;
for (const c of entries) {
const refsHtml = (c.refs && c.refs.length)
? `<div class="clarify-refs">${c.refs.map(r => `<a class="clarify-ref" href="#" data-ref-id="${escapeHtml(r)}">${escapeHtml(r)}</a>`).join('')}</div>`
: '';
html += `<div class="clarify-entry">
<div class="clarify-question"><span class="clarify-q-label">Q</span> ${escapeHtml(c.question)}</div>
<div class="clarify-answer"><span class="clarify-a-label">A</span> ${escapeHtml(c.answer)}</div>
${refsHtml}
</div>`;
}
html += '</div></div>';
}
html += '</div></div>';
contentArea.innerHTML = html;
// Wire up ref links to navigate to Spec tab and highlight the item
contentArea.querySelectorAll('.clarify-ref').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const refId = link.dataset.refId;
navigateToSpecItem(refId);
});
});
}
async function navigateToSpecItem(refId) {
// Thin wrapper — delegates to generic cross-panel navigation
navigateToPanel('spec', refId);
}
// ====== Constitution View ======
let currentConstitution = null;
let currentPremise = null;
let selectedPrinciple = null;
async function renderConstitutionView() {
try {
if (staticMode) {
if (!currentConstitution) currentConstitution = window.DASHBOARD_DATA.constitution;
if (!currentPremise) currentPremise = window.DASHBOARD_DATA.premise;
} else {
const fetches = [];
if (!currentConstitution) {
fetches.push(fetch('/api/constitution').then(r => r.json()).then(d => { currentConstitution = d; }));
}
if (!currentPremise) {
fetches.push(fetch('/api/premise').then(r => r.json()).then(d => { currentPremise = d; }));
}
await Promise.all(fetches);
}
renderConstitutionContent(currentConstitution);
} catch (err) {
console.error('Failed to load constitution:', err);
contentArea.innerHTML = '<div class="placeholder-view"><div class="placeholder-view-title">Failed to load constitution</div></div>';
}
}
function renderConstitutionContent(data) {
if (!data || !data.exists) {
contentArea.innerHTML = `
<div class="placeholder-view">
<div class="placeholder-view-title">No Constitution Found</div>
<div class="placeholder-view-text">Run /iikit-00-constitution to define your project's governance principles.</div>
</div>`;
return;
}
const principles = data.principles;
if (principles.length === 0) {
contentArea.innerHTML = `
<div class="placeholder-view">
<div class="placeholder-view-title">No Principles Found</div>
<div class="placeholder-view-text">Your CONSTITUTION.md exists but has no parseable principles.</div>
</div>`;
return;
}
// Summary list
const summaryHtml = principles.map((p, i) => {
const isSelected = selectedPrinciple && selectedPrinciple.number === p.number;
return `<div class="constitution-summary-item${isSelected ? ' selected' : ''}" id="principle-item-${i}"><span class="principle-num">${escapeHtml(p.number)}.</span> ${escapeHtml(p.name)} <span class="level-badge ${p.level.toLowerCase()}">${p.level}</span></div>`;
}).join('');
// Radar chart SVG
const radarSvg = generateRadarSVG(principles);
// Detail card — only shown when a principle is selected
const detailHtml = selectedPrinciple
? `<div class="detail-card">${renderDetailCard(selectedPrinciple)}</div>`
: '';
// Timeline
const timelineHtml = data.version ? renderTimeline(data.version) : '';
// Premise section
const premiseHtml = currentPremise && currentPremise.exists
? `<div class="premise-section">${renderPremiseContent(currentPremise.content)}</div>`
: '';
contentArea.innerHTML = `
<div class="constitution-view">
${premiseHtml}
<div class="constitution-layout">
<div class="constitution-left">
<div class="radar-container">${radarSvg}</div>
</div>
<div class="constitution-right">
<div class="constitution-summary">${summaryHtml}</div>
${detailHtml}
</div>
</div>
${timelineHtml}
</div>`;
// Attach click handlers to radar axes AND list items
function selectPrinciple(p) {
selectedPrinciple = p;
renderConstitutionContent(data);
}
principles.forEach((p, i) => {
const axisEl = document.getElementById(`radar-axis-${i}`);
if (axisEl) {
axisEl.addEventListener('click', () => selectPrinciple(p));
axisEl.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectPrinciple(p); } });
}
const itemEl = document.getElementById(`principle-item-${i}`);
if (itemEl) {
itemEl.addEventListener('click', () => selectPrinciple(p));
}
});
}
function renderPremiseContent(markdown) {
// Simple markdown to HTML: headings, paragraphs, bold, lists
const lines = markdown.split('\n');
let html = '';
let inList = false;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
if (inList) { html += '</ul>'; inList = false; }
continue;
}
if (/^# /.test(trimmed)) {
if (inList) { html += '</ul>'; inList = false; }
html += `<h3 class="premise-title">${escapeHtml(trimmed.slice(2))}</h3>`;
} else if (/^## /.test(trimmed)) {
if (inList) { html += '</ul>'; inList = false; }
html += `<h4 class="premise-heading">${escapeHtml(trimmed.slice(3))}</h4>`;
} else if (/^- /.test(trimmed)) {
if (!inList) { html += '<ul class="premise-list">'; inList = true; }
const content = trimmed.slice(2).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html += `<li>${content}</li>`;
} else {
if (inList) { html += '</ul>'; inList = false; }
const content = trimmed.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html += `<p class="premise-text">${content}</p>`;
}
}
if (inList) html += '</ul>';
return html;
}
function generateRadarSVG(principles) {
const size = 360;
const cx = size / 2;
const cy = size / 2;
const maxR = size / 2 - 60;
const levels = { 'MUST': 1, 'SHOULD': 0.66, 'MAY': 0.33 };
const n = principles.length;
const angleStep = (2 * Math.PI) / n;
const startAngle = -Math.PI / 2; // Start from top
// Ring lines (33%, 66%, 100%)
let rings = '';
[0.33, 0.66, 1].forEach(pct => {
const r = maxR * pct;
const points = [];
for (let i = 0; i < n; i++) {
const angle = startAngle + i * angleStep;
points.push(`${cx + r * Math.cos(angle)},${cy + r * Math.sin(angle)}`);
}
rings += `<polygon points="${points.join(' ')}" fill="none" stroke="var(--color-border)" stroke-width="1" opacity="0.5"/>`;
});
// Axis lines from center to outer edge
let axes = '';
for (let i = 0; i < n; i++) {
const angle = startAngle + i * angleStep;
const x2 = cx + maxR * Math.cos(angle);
const y2 = cy + maxR * Math.sin(angle);
axes += `<line x1="${cx}" y1="${cy}" x2="${x2}" y2="${y2}" stroke="var(--color-border)" stroke-width="1" opacity="0.3"/>`;
}
// Filled polygon connecting principle values
const valuePoints = principles.map((p, i) => {
const angle = startAngle + i * angleStep;
const r = maxR * (levels[p.level] || 0.66);
return `${cx + r * Math.cos(angle)},${cy + r * Math.sin(angle)}`;
});
const polygon = `<polygon points="${valuePoints.join(' ')}" fill="var(--color-accent)" fill-opacity="0.2" stroke="var(--color-accent)" stroke-width="2"/>`;
// Value dots and clickable areas
let dots = '';
principles.forEach((p, i) => {
const angle = startAngle + i * angleStep;
const r = maxR * (levels[p.level] || 0.66);
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
// Label position (pushed further out)
const labelR = maxR + 24;
const lx = cx + labelR * Math.cos(angle);
const ly = cy + labelR * Math.sin(angle);
const anchor = Math.abs(Math.cos(angle)) < 0.1 ? 'middle' : Math.cos(angle) > 0 ? 'start' : 'end';
const isSelected = selectedPrinciple && selectedPrinciple.number === p.number;
dots += `
<g id="radar-axis-${i}" class="radar-axis" tabindex="0" role="button"
aria-label="${escapeHtml(p.name)} (${p.level})">
<circle cx="${x}" cy="${y}" r="${isSelected ? 7 : 5}" fill="var(--color-accent)" stroke="${isSelected ? 'var(--color-text)' : 'none'}" stroke-width="2"/>
<circle cx="${x}" cy="${y}" r="16" fill="transparent"/>
<text x="${lx}" y="${ly}" text-anchor="${anchor}" dominant-baseline="middle"
font-size="11" font-weight="600" fill="var(--color-text-secondary)"
style="letter-spacing: 0.2px;">${escapeHtml(p.name)}</text>
</g>`;
});
const ariaLabel = `Radar chart showing ${n} constitution principles: ${principles.map(p => p.name + ' (' + p.level + ')').join(', ')}`;
return `<svg viewBox="0 0 ${size} ${size}" role="img" aria-label="${escapeHtml(ariaLabel)}">${rings}${axes}${polygon}${dots}</svg>`;
}
function renderDetailCard(principle) {
// Strip rationale from main text to avoid duplication
let mainText = principle.text;
if (principle.rationale) {
const ratIdx = mainText.indexOf('**Rationale**');
if (ratIdx > -1) mainText = mainText.substring(0, ratIdx);
}
mainText = mainText.trim();
return `
<h3>${escapeHtml(principle.number)}. ${escapeHtml(principle.name)}</h3>
<div class="detail-level"><span class="level-badge ${principle.level.toLowerCase()}">${principle.level}</span></div>
<div class="detail-text">${escapeHtml(mainText).replace(/\n\n/g, '<br><br>').replace(/\n/g, ' ')}</div>
${principle.rationale ? `<div class="detail-rationale"><strong>Rationale:</strong> ${escapeHtml(principle.rationale)}</div>` : ''}`;
}
function renderTimeline(version) {
return `
<div class="amendment-timeline">
<div class="amendment-timeline-label">Amendment History</div>
<div class="amendment-timeline-content">
<div class="amendment-timeline-dot"></div>
<span>v${escapeHtml(version.version)} — Ratified ${escapeHtml(version.ratified)}</span>
${version.ratified !== version.lastAmended ? `
<div class="amendment-timeline-line"></div>
<div class="amendment-timeline-dot"></div>
<span>Amended ${escapeHtml(version.lastAmended)}</span>
` : ''}
</div>
</div>`;
}
// ====== Analyze Consistency View ======
async function renderAnalyzeView() {
if (!currentFeature) return;
try {
if (staticMode && window.DASHBOARD_DATA.featureData[currentFeature]) {
currentAnalyze = window.DASHBOARD_DATA.featureData[currentFeature].analyze;
} else {
const res = await fetch(`/api/analyze/${currentFeature}`);
if (!res.ok) throw new Error('Failed to load');
currentAnalyze = await res.json();
}
renderAnalyzeContent(currentAnalyze);
} catch {
contentArea.innerHTML = '<div class="analyze-empty"><div class="analyze-empty-title">Failed to load analyze data.</div></div>';
}
}
function renderAnalyzeContent(data) {
if (!data) return;
if (!data.exists) {
contentArea.innerHTML = `
<div class="analyze-empty">
<div class="analyze-empty-icon">🔎</div>
<div class="analyze-empty-title">No analysis data for this feature</div>
<div class="analyze-empty-text">Run <code>/iikit-06-analyze</code> to generate a cross-artifact consistency analysis report.</div>
</div>`;
return;
}
let html = '<div class="analyze-view">';
// Health Gauge Section
html += '<div class="analyze-section">';
html += '<div class="analyze-section-header">Health Score</div>';
html += renderHealthGauge(data.healthScore, data);
html += '</div>';
// Coverage Heatmap Section
html += '<div class="analyze-section" style="animation-delay: 0.1s">';
html += `<div class="cross-link-tip">Tip: ${CROSS_LINK_MOD_KEY}+click any identifier to navigate to its linked panel</div>`;
html += '<div class="analyze-section-header">Coverage Heatmap</div>';
html += renderHeatmap(data.heatmap);
html += '</div>';
// Severity Table Section
html += '<div class="analyze-section slide-in" style="animation-delay: 0.2s">';
html += '<div class="analyze-section-header">Issues</div>';
html += renderSeverityTable(data.issues);
html += '</div>';
html += '</div>';
contentArea.innerHTML = html;
// Attach event listeners after rendering
attachHeatmapListeners();
attachSeverityListeners();
// Trigger gauge animation after a frame
requestAnimationFrame(() => {
const needle = contentArea.querySelector('.gauge-needle');
if (needle && data.healthScore) {
const angle = (data.healthScore.score / 100) * 180;
needle.style.transform = `rotate(${angle}deg)`;
}
});
}
function renderHealthGauge(healthScore, analyzeData) {
if (!healthScore) {
return '<div class="gauge-na">N/A</div>';
}
const { score, zone, factors, trend } = healthScore;
// SVG semicircular gauge
// Arc center at (100, 90), radius 70
const cx = 100, cy = 90, r = 70;
const startAngle = Math.PI;
const endAngle = 0;
function arcPath(startPct, endPct) {
const a1 = Math.PI - (startPct / 100) * Math.PI;
const a2 = Math.PI - (endPct / 100) * Math.PI;
const x1 = cx + r * Math.cos(a1);
const y1 = cy - r * Math.sin(a1);
const x2 = cx + r * Math.cos(a2);
const y2 = cy - r * Math.sin(a2);
const large = (endPct - startPct) > 50 ? 1 : 0;
return `M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2}`;
}
let trendHtml = '';
if (trend === 'improving') {
trendHtml = '<span class="gauge-trend gauge-trend-up">▲ improving</span>';
} else if (trend === 'declining') {
trendHtml = '<span class="gauge-trend gauge-trend-down">▼ declining</span>';
}
let gaugeHtml = '<div class="gauge-container">';
gaugeHtml += `<svg class="gauge-svg" viewBox="0 0 200 110" role="meter" aria-valuenow="${score}" aria-valuemin="0" aria-valuemax="100" aria-label="Health score: ${score} out of 100">`;
// Zone arcs
gaugeHtml += `<path class="gauge-arc gauge-zone-red" d="${arcPath(0, 40)}" />`;
gaugeHtml += `<path class="gauge-arc gauge-zone-yellow" d="${arcPath(40, 70)}" />`;
gaugeHtml += `<path class="gauge-arc gauge-zone-green" d="${arcPath(70, 100)}" />`;
// Needle (starts at 0, animated via CSS transform)
gaugeHtml += `<line class="gauge-needle" x1="${cx}" y1="${cy}" x2="${cx - r + 10}" y2="${cy}" stroke-width="3" stroke="var(--color-text)" style="transform-origin: ${cx}px ${cy}px; transform: rotate(0deg);" />`;
// Score text
gaugeHtml += `<text class="gauge-score" x="${cx}" y="${cy - 15}">${score}</text>`;
gaugeHtml += `<text class="gauge-label" x="${cx}" y="${cy + 5}">/ 100</text>`;
gaugeHtml += '</svg>';
// Breakdown with context
gaugeHtml += '<div class="gauge-breakdown">';
if (factors) {
const metrics = analyzeData?.metrics;
const issues = analyzeData?.issues || [];
const alignment = analyzeData?.constitutionAlignment || [];
for (const [key, factor] of Object.entries(factors)) {
const isOk = factor.value === 100;
const colorClass = factor.value >= 71 ? 'gauge-factor-green' : factor.value >= 41 ? 'gauge-factor-yellow' : 'gauge-factor-red';
let context = '';
if (key === 'requirementsCoverage' && metrics) {
context = metrics.requirementCoverage || '';
} else if (key === 'constitutionCompliance') {
const violations = alignment.filter(a => a.status === 'VIOLATION');
context = violations.length ? violations.map(v => v.principle).join(', ') : 'All aligned';
} else if (key === 'phaseSeparation') {
const resolved = issues.filter(i => i.category && /phase/i.test(i.category) && i.resolved);
const unresolved = issues.filter(i => i.category && /phase/i.test(i.category) && !i.resolved);
if (unresolved.length) context = `${unresolved.length} violation${unresolved.length > 1 ? 's' : ''} open`;
else if (resolved.length) context = `${resolved.length} resolved`;
else if (factor.value < 100) context = 'See issues below';
else context = 'No violations';
} else if (key === 'testCoverage' && metrics) {
context = metrics.totalTestSpecs ? `${metrics.totalTestSpecs} test specs` : '';
}
gaugeHtml += `<div class="gauge-factor ${colorClass}">`;
gaugeHtml += `<span class="gauge-factor-label">${escapeHtml(factor.label)}`;
if (context) gaugeHtml += `<span class="gauge-factor-context">${escapeHtml(context)}</span>`;
gaugeHtml += `</span>`;
gaugeHtml += `<span class="gauge-factor-value">${factor.value}%</span>`;
gaugeHtml += `</div>`;
}
}
if (trendHtml) gaugeHtml += `<div style="text-align: center; margin-top: 4px;">${trendHtml}</div>`;
gaugeHtml += '</div>';
gaugeHtml += '</div>';
return gaugeHtml;
}
function renderHeatmap(heatmap) {
if (!heatmap || !heatmap.rows || heatmap.rows.length === 0) {
return '<div class="heatmap-empty">No coverage data</div>';
}
// Only look at columns with real data (skip 'plan' if ALL plan cells are N/A)
const activeCols = (heatmap.columns || []).filter(c => {
if (c !== 'plan') return true;
return heatmap.rows.some(r => r.cells.plan?.status !== 'na');
});
const total = heatmap.rows.length;
// Find rows with gaps (missing or partial in any active column)
const gaps = heatmap.rows.filter(row =>
activeCols.some(col => {
const cell = row.cells[col];
return cell && (cell.status === 'missing' || cell.status === 'partial');
})
);
// Coverage stats per column
const colStats = {};
for (const col of activeCols) {
const covered = heatmap.rows.filter(r => r.cells[col]?.status === 'covered').length;
colStats[col] = { covered, total, pct: total > 0 ? Math.round((covered / total) * 100) : 0 };
}
let html = '<div style="padding: 20px;">';
// Summary bar
html += '<div class="coverage-summary">';
for (const col of activeCols) {
const s = colStats[col];
const colorClass = s.pct === 100 ? 'cov-bar-green' : s.pct >= 70 ? 'cov-bar-yellow' : 'cov-bar-red';
html += `<div class="cov-stat">`;
html += `<div class="cov-stat-header"><span class="cov-stat-label">${escapeHtml(col.charAt(0).toUpperCase() + col.slice(1))}</span><span class="cov-stat-value">${s.covered}/${s.total}</span></div>`;
html += `<div class="cov-bar"><div class="cov-bar-fill ${colorClass}" style="width: ${s.pct}%"></div></div>`;
html += `</div>`;
}
html += '</div>';
if (gaps.length === 0) {
// All covered — compact success
html += `<div class="coverage-success">All ${total} requirements are covered by tasks and tests</div>`;
} else {
// Show only the gaps
html += `<div class="coverage-gaps-label">${gaps.length} requirement${gaps.length > 1 ? 's' : ''} with gaps:</div>`;
html += '<table class="heatmap-table" role="grid">';
html += '<thead><tr><th scope="col">Requirement</th><th scope="col">Description</th>';
for (const col of activeCols) {
html += `<th scope="col">${escapeHtml(col.charAt(0).toUpperCase() + col.slice(1))}</th>`;
}
html += '</tr></thead><tbody>';
gaps.forEach((row, i) => {
html += `<tr class="heatmap-row" style="animation-delay: ${i * 0.04}s">`;
html += `<th scope="row"><code>${escapeHtml(row.id)}</code></th>`;
html += `<td class="heatmap-desc">${escapeHtml(row.text)}</td>`;
for (const col of activeCols) {
const cell = row.cells[col] || { status: 'na', refs: [] };
const statusClass = 'coverage-' + cell.status;
const statusLabel = cell.status === 'covered' ? 'Covered' : cell.status === 'partial' ? 'Partial' : cell.status === 'missing' ? 'Missing' : 'N/A';
const refs = (cell.refs && cell.refs.length > 0) ? cell.refs.join(', ') : '';
html += `<td>`;
html += `<span class="heatmap-cell ${statusClass}" tabindex="0" role="button" aria-label="${escapeHtml(row.id)} ${col}: ${statusLabel}" data-req="${escapeHtml(row.id)}" data-col="${escapeHtml(col)}"></span>`;
if (refs) {
html += `<span class="heatmap-refs-inline">${refs}</span>`;
html += `<div class="heatmap-refs" data-req="${escapeHtml(row.id)}" data-col="${escapeHtml(col)}">${refs}</div>`;
}
html += `</td>`;
}
html += '</tr>';
});
html += '</tbody></table>';
}
html += '</div>';
return html;
}
function renderSeverityTable(issues) {
if (!issues || issues.length === 0) {
return '<div class="severity-empty">No issues found — all artifacts are consistent</div>';
}
// Collect unique categories and severities
const categories = [...new Set(issues.map(i => i.category))].sort();
const severities = ['critical', 'high', 'medium', 'low'];
let html = '<div class="severity-wrapper">';
// Filters
html += '<div class="severity-controls">';
html += '<select class="severity-filter" id="analyzeCategoryFilter"><option value="">All Categories</option>';
for (const cat of categories) {
html += `<option value="${escapeHtml(cat)}">${escapeHtml(cat)}</option>`;
}
html += '</select>';
html += '<select class="severity-filter" id="analyzeSeverityFilter"><option value="">All Severities</option>';
for (const sev of severities) {
html += `<option value="${sev}">${sev.charAt(0).toUpperCase() + sev.slice(1)}</option>`;
}
html += '</select>';
html += '</div>';
// Table
html += '<div class="severity-table-scroll"><table class="severity-table">';
html += '<thead><tr>';
html += '<th tabindex="0" data-sort="id">ID</th>';
html += '<th tabindex="0" data-sort="category">Category</th>';
html += '<th tabindex="0" data-sort="severity">Severity</th>';
html += '<th tabindex="0" data-sort="location">Location</th>';
html += '<th tabindex="0" data-sort="summary">Summary</th>';
html += '</tr></thead><tbody>';
// Sort by severity by default
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
const sorted = [...issues].sort((a, b) => (severityOrder[a.severity] || 4) - (severityOrder[b.severity] || 4));
for (const issue of sorted) {
const resolvedClass = issue.resolved ? ' severity-resolved' : '';
html += `<tr data-severity="${escapeHtml(issue.severity)}" data-category="${escapeHtml(issue.category)}" tabindex="0" class="${resolvedClass}">`;
html += `<td>${escapeHtml(issue.id)}</td>`;
html += `<td>${escapeHtml(issue.category)}</td>`;
html += `<td><span class="severity-badge severity-${escapeHtml(issue.severity)}">${escapeHtml(issue.severity)}</span></td>`;
html += `<td><code>${escapeHtml(issue.location)}</code></td>`;
html += `<td>${escapeHtml(issue.summary)}</td>`;
html += '</tr>';
html += `<tr class="severity-recommendation" data-expand-for="${escapeHtml(issue.id)}"><td colspan="5">${escapeHtml(issue.recommendation)}</td></tr>`;
}
html += '</tbody></table></div></div>';
return html;
}
function attachHeatmapListeners() {
const cells = contentArea.querySelectorAll('.heatmap-cell');
cells.forEach(cell => {
const handler = () => {
const req = cell.dataset.req;
const col = cell.dataset.col;
const ref = contentArea.querySelector(`.heatmap-refs[data-req="${req}"][data-col="${col}"]`);
if (ref) ref.classList.toggle('expanded');
};
cell.addEventListener('click', handler);
cell.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handler(); }
});
});
}
function attachSeverityListeners() {
// Row expansion
const rows = contentArea.querySelectorAll('.severity-table tr[data-severity]');
rows.forEach(row => {
const handler = () => {
const nextRow = row.nextElementSibling;
if (nextRow && nextRow.classList.contains('severity-recommendation')) {
nextRow.classList.toggle('expanded');
}
};
row.addEventListener('click', handler);
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handler(); }
});
});
// Column sorting
const headers = contentArea.querySelectorAll('.severity-table th[data-sort]');
headers.forEach(th => {
const handler = () => {
const sortKey = th.dataset.sort;
const tbody = contentArea.querySelector('.severity-table tbody');
if (!tbody) return;
const dataRows = Array.from(tbody.querySelectorAll('tr[data-severity]'));
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
dataRows.sort((a, b) => {
let aVal, bVal;
if (sortKey === 'severity') {
aVal = severityOrder[a.dataset.severity] || 4;
bVal = severityOrder[b.dataset.severity] || 4;
return aVal - bVal;
}
const aIdx = Array.from(th.parentElement.children).indexOf(th);
aVal = a.children[aIdx]?.textContent || '';
bVal = b.children[aIdx]?.textContent || '';
return aVal.localeCompare(bVal);
});
// Rebuild tbody preserving recommendation rows
const fragment = document.createDocumentFragment();
for (const row of dataRows) {
fragment.appendChild(row);
const expandId = row.querySelector('td')?.textContent;
const recRow = tbody.querySelector(`tr[data-expand-for="${expandId}"]`);
if (recRow) fragment.appendChild(recRow);
}
tbody.innerHTML = '';
tbody.appendChild(fragment);
};
th.addEventListener('click', handler);
th.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handler(); }
});
});
// Filters
const catFilter = document.getElementById('analyzeCategoryFilter');
const sevFilter = document.getElementById('analyzeSeverityFilter');
function applyFilters() {
const catVal = catFilter?.value || '';
const sevVal = sevFilter?.value || '';
const allRows = contentArea.querySelectorAll('.severity-table tbody tr');
allRows.forEach(row => {
if (row.classList.contains('severity-recommendation')) {
// Hide recommendations when parent is hidden
const expandFor = row.dataset.expandFor;
const parent = contentArea.querySelector(`.severity-table tr[data-severity] td:first-child`);
row.style.display = row.classList.contains('expanded') ? '' : 'none';
return;
}
if (!row.dataset.severity) return;
const matchCat = !catVal || row.dataset.category === catVal;
const matchSev = !sevVal || row.dataset.severity === sevVal;
row.style.display = (matchCat && matchSev) ? '' : 'none';
// Also hide its recommendation row
const nextRow = row.nextElementSibling;
if (nextRow && nextRow.classList.contains('severity-recommendation')) {
nextRow.style.display = (matchCat && matchSev) ? (nextRow.classList.contains('expanded') ? '' : 'none') : 'none';
}
});
}
if (catFilter) catFilter.addEventListener('change', applyFilters);
if (sevFilter) sevFilter.addEventListener('change', applyFilters);
}
function selectDefaultTab(pipeline) {
if (!pipeline || !pipeline.phases) return 'implement';
const impl = pipeline.phases.find(p => p.id === 'implement');
if (impl && (impl.status === 'in_progress' || impl.status === 'complete')) return 'implement';
// Walk backward through all phases to find last completed
const allPhases = ['constitution', 'spec', 'clarify', 'plan', 'checklist', 'testify', 'tasks', 'analyze', 'implement'];
for (let i = allPhases.length - 1; i >= 0; i--) {
const phase = pipeline.phases.find(p => p.id === allPhases[i]);
if (phase && phase.status === 'complete') {
return allPhases[i];
}
}
return 'implement';
}
async function loadPipeline(featureId) {
try {
let pipeline;
if (staticMode && window.DASHBOARD_DATA.featureData[featureId]) {
pipeline = window.DASHBOARD_DATA.featureData[featureId].pipeline;
} else {
const res = await fetch(`/api/pipeline/${featureId}`);
if (!res.ok) return;
pipeline = await res.json();
}
currentPipeline = pipeline;
if (!activeTab) {
activeTab = selectDefaultTab(pipeline);
}
renderPipeline(pipeline);
switchTab(activeTab);
} catch (err) {
console.error('Failed to load pipeline:', err);
}
}
// ====== Feature Loading ======
async function loadFeatures() {
try {
let features;
if (staticMode) {
features = window.DASHBOARD_DATA.features;
} else {
const res = await fetch('/api/features');
features = await res.json();
}
updateFeatureSelector(features);
if (features.length > 0 && !currentFeature) {
currentFeature = features[0].id;
featureSelect.value = currentFeature;
loadPipeline(currentFeature);
loadBoard(currentFeature);
loadBugs(currentFeature);
} else if (features.length === 0) {
showEmptyState();
}
} catch (err) {
console.error('Failed to load features:', err);
}
}
function updateFeatureSelector(features) {
featureSelect.innerHTML = '';
if (features.length === 0) {
featureSelect.innerHTML = '<option value="">No features found</option>';
return;
}
for (const f of features) {
const opt = document.createElement('option');
opt.value = f.id;
opt.textContent = `${f.id} — ${f.name} (${f.progress})`;
featureSelect.appendChild(opt);
}
}
featureSelect.addEventListener('change', () => {
const val = featureSelect.value;
if (val && val !== currentFeature) {
currentFeature = val;
previousCardColumns = {};
activeTab = null; // Reset tab selection for new feature
currentStoryMap = null; // Reset story map cache
currentPlanView = null; // Reset plan view cache
currentTestify = null; // Reset testify cache
currentAnalyze = null; // Reset analyze cache
currentBugs = null; // Reset bugs cache
loadPipeline(val);
loadBoard(val);
loadBugs(val);
}
});
featureSelect.addEventListener('keydown', (e) => {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
// Default browser behavior handles this
return;
}
});
// ====== Board Loading ======
async function loadBoard(featureId) {
try {
showLoading();
let board;
if (staticMode && window.DASHBOARD_DATA.featureData[featureId]) {
board = window.DASHBOARD_DATA.featureData[featureId].board;
} else {
const res = await fetch(`/api/board/${featureId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
board = await res.json();
}
currentBoard = board;
renderBoard(board);
updateIntegrity(board.integrity);
} catch (err) {
console.error('Failed to load board:', err);
showEmptyState('Failed to load board data');
}
}
async function loadBugs(featureId) {
try {
let bugs;
if (staticMode && window.DASHBOARD_DATA.featureData[featureId]) {
bugs = window.DASHBOARD_DATA.featureData[featureId].bugs;
} else {
const res = await fetch(`/api/bugs/${featureId}`);
if (!res.ok) return;
bugs = await res.json();
}
currentBugs = bugs;
if (currentPipeline) renderPipeline(currentPipeline);
if (activeTab === 'bugs') renderBugsContent(currentBugs);
} catch {
// Silently fail — bugs are optional
}
}
function showLoading() {
boardEl.innerHTML = '<div class="loading"><div class="loading-spinner"></div></div>';
}
function showEmptyState(msg) {
boardEl.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">☰</div>
<div class="empty-state-title">${msg || 'No features found'}</div>
<div class="empty-state-text">Create a feature with spec.md and tasks.md in your specs/ directory to get started.</div>
</div>`;
}
// ====== Board Rendering ======
function renderBoardInto(targetEl, board) {
if (!board || !targetEl) return;
const columns = [
{ key: 'todo', label: 'Todo', cards: board.todo || [] },
{ key: 'in_progress', label: 'In Progress', cards: board.in_progress || [] },
{ key: 'done', label: 'Done', cards: board.done || [] }
];
// Build new card positions
const newCardColumns = {};
for (const col of columns) {
for (const card of col.cards) {
newCardColumns[card.id] = col.key;
}
}
// Detect moved cards
const movedCards = {};
for (const [cardId, newCol] of Object.entries(newCardColumns)) {
const oldCol = previousCardColumns[cardId];
if (oldCol && oldCol !== newCol) {
movedCards[cardId] = { from: oldCol, to: newCol };
}
}
targetEl.innerHTML = '';
for (const col of columns) {
const colEl = document.createElement('div');
colEl.className = `column ${col.key === 'in_progress' ? 'in-progress' : col.key}`;
colEl.setAttribute('role', 'region');
colEl.setAttribute('aria-label', `${col.label} column with ${col.cards.length} stories`);
colEl.innerHTML = `
<div class="column-header">
<div class="column-title">
<span class="column-dot" aria-hidden="true"></span>
${col.label}
</div>
<span class="column-count">${col.cards.length}</span>
</div>
<div class="column-body" id="col-${col.key}"></div>`;
targetEl.appendChild(colEl);
const bodyEl = colEl.querySelector('.column-body');
if (col.cards.length === 0) {
bodyEl.innerHTML = '<div class="column-empty">No stories</div>';
} else {
for (const card of col.cards) {
const cardEl = createCardElement(card, col.key);
// Add animation class if card just moved here
if (movedCards[card.id]) {
cardEl.classList.add('entering');
if (movedCards[card.id].to === 'done') {
cardEl.classList.add('just-completed');
}
// Remove animation class after it completes
cardEl.addEventListener('animationend', () => {
cardEl.classList.remove('entering', 'just-completed');
}, { once: true });
}
bodyEl.appendChild(cardEl);
}
}
}
previousCardColumns = newCardColumns;
}
function renderBoard(board) {
const targetEl = document.getElementById('board');
if (targetEl) renderBoardInto(targetEl, board);
}
const BUG_SVG_ICON = '<svg class="bug-icon-inline" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 2l1.88 1.88M14.12 3.88L16 2M9 7.13v-1a3.003 3.003 0 116 0v1M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 014-4h4a4 4 0 014 4v3c0 3.3-2.7 6-6 6zM2 11h3M19 11h3M2 16h3M19 16h3"/></svg>';
function createCardElement(card, columnKey) {
const el = document.createElement('div');
el.className = 'card' + (card.isBugCard ? ' bug-card' : '');
el.setAttribute('data-card-id', card.id);
el.setAttribute('role', 'article');
el.setAttribute('aria-label', `${card.title} - ${card.priority} - ${card.progress} tasks complete`);
const progressParts = card.progress.split('/');
const checked = parseInt(progressParts[0], 10);
const total = parseInt(progressParts[1], 10);
const pct = total > 0 ? Math.round((checked / total) * 100) : 0;
const priorityClass = card.priority ? card.priority.toLowerCase() : 'p3';
const cardIdHtml = card.isBugCard
? `<div class="card-id cross-link" data-cross-target="bugs" data-cross-id="${card.id}">${BUG_SVG_ICON}${card.id}</div>`
: `<div class="card-id cross-link" data-cross-target="spec" data-cross-id="${card.id}">${card.id}</div>`;
el.innerHTML = `
${cardIdHtml}
<div class="card-header">
<div class="card-title" title="${escapeHtml(card.title)}">${card.isBugCard ? BUG_SVG_ICON : ''}${escapeHtml(card.title)}</div>
<span class="priority-badge ${priorityClass}" aria-label="Priority ${card.priority}">${card.priority}</span>
</div>
<div class="progress-container">
<div class="progress-info">
<span class="progress-label">Progress</span>
<span class="progress-value">${card.progress} (${pct}%)</span>
</div>
<div class="progress-bar" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100">
<div class="progress-fill" style="width: ${pct}%"></div>
</div>
</div>
<button class="task-toggle" onclick="toggleTasks(this)" aria-expanded="false" aria-controls="tasks-${card.id}">
<span class="task-toggle-icon" aria-hidden="true">▶</span>
${(card.tasks || []).length} tasks
</button>
<ul class="task-list collapsed" id="tasks-${card.id}" aria-label="Tasks for ${card.id}">
${(card.tasks || []).map(t => {
const isBugTask = t.isBugFix || (t.id && t.id.startsWith('T-B'));
const taskIcon = isBugTask ? BUG_SVG_ICON : '';
const bugTagLink = t.bugTag
? ` <span class="cross-link" data-cross-target="bugs" data-cross-id="${t.bugTag}" style="cursor:pointer;color:var(--color-accent);font-size:11px;">[${t.bugTag}]</span>`
: '';
return `
<li class="task-item ${t.checked ? 'checked' : ''}" data-task-id="${t.id}">
<span class="task-checkbox ${t.checked ? 'checked' : ''}" aria-hidden="true"></span>
${taskIcon}<span class="task-id cross-link" data-cross-target="testify" data-cross-id="${t.id}">${t.id}</span>${bugTagLink}
<span class="task-description">${escapeHtml(t.description)}</span>
</li>`;
}).join('')}
</ul>`;
return el;
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
// ====== Toast ======
function showToast(message, duration = 2500) {
const el = document.getElementById('toast');
el.textContent = message;
el.classList.add('visible');
setTimeout(() => el.classList.remove('visible'), duration);
}
// ====== Integrity Badge ======
function updateIntegrity(integrity) {
if (!integrity) return;
const badge = integrityBadge;
const textEl = badge.querySelector('.integrity-text');
badge.className = `integrity-badge ${integrity.status}`;
switch (integrity.status) {
case 'valid':
textEl.textContent = 'Verified';
badge.setAttribute('aria-label', 'Test integrity: verified');
badge.title = 'Assertion hash matches stored hash';
break;
case 'tampered':
textEl.textContent = 'Tampered';
badge.setAttribute('aria-label', 'Test integrity: tampered - assertions may have been modified');
badge.title = 'Assertion hash does not match stored hash!';
break;
case 'missing':
textEl.textContent = 'Missing';
badge.setAttribute('aria-label', 'Test integrity: no hash data available');
badge.title = 'No test specifications or context.json found';
break;
}
}
// ====== Plan View ======
let currentPlanView = null;
async function renderPlanView() {
if (!currentFeature) return;
try {
if (!currentPlanView) {
if (staticMode && window.DASHBOARD_DATA.featureData[currentFeature]) {
currentPlanView = window.DASHBOARD_DATA.featureData[currentFeature].planView;
} else {
const res = await fetch(`/api/planview/${currentFeature}`);
currentPlanView = await res.json();
}
}
renderPlanViewContent(currentPlanView);
} catch (err) {
contentArea.innerHTML = `<div class="planview-empty"><div class="planview-empty-title">Error loading plan</div><div class="planview-empty-text">${escapeHtml(err.message)}</div></div>`;
}
}
function renderPlanViewContent(data) {
if (!data || !data.exists) {
contentArea.innerHTML = `<div class="planview-empty"><div class="planview-empty-title">No plan created yet</div><div class="planview-empty-text">Run /iikit-02-plan to create a technical implementation plan for this feature.</div></div>`;
return;
}
let html = '<div class="planview-view">';
// Badge Wall
html += '<div class="planview-section">';
html += '<div class="planview-section-title">Tech Stack</div>';
if (data.techContext && data.techContext.length > 0) {
html += '<div class="badge-wall">';
for (const entry of data.techContext) {
const tooltip = findResearchTooltip(entry.value, data.researchDecisions);
html += `<div class="tech-badge" role="listitem">`;
html += `<span class="tech-badge-label">${escapeHtml(entry.label)}</span>`;
html += `<span class="tech-badge-value">${escapeHtml(entry.value)}</span>`;
if (tooltip) {
html += `<div class="tech-badge-tooltip" role="tooltip">${escapeHtml(tooltip)}</div>`;
}
html += `</div>`;
}
html += '</div>';
} else {
html += '<div class="planview-empty"><div class="planview-empty-text">No tech stack defined in plan</div></div>';
}
html += '</div>';
// Tessl Tiles Panel
if (data.tesslTiles && data.tesslTiles.length > 0) {
html += '<div class="planview-section">';
html += '<div class="planview-section-title">Tessl Tiles</div>';
html += '<div class="tessl-tiles">';
for (const tile of data.tesslTiles) {
html += `<div class="tessl-tile-card">`;
html += `<span class="tessl-tile-name">${escapeHtml(tile.name)}</span>`;
html += `<span class="tessl-tile-version">v${escapeHtml(tile.version)}</span>`;
if (tile.eval) {
const chartTotal = tile.eval.chartData.pass + tile.eval.chartData.fail;
const barPct = chartTotal > 0 ? Math.round(tile.eval.chartData.pass / chartTotal * 100) : tile.eval.score;
html += `<div class="tessl-tile-eval">`;
html += `<span class="tessl-eval-score">${tile.eval.score}%</span>`;
html += `<div class="tessl-eval-bar" title="${tile.eval.chartData.pass} pass, ${tile.eval.chartData.fail} fail"><div class="tessl-eval-bar-fill" style="width:${barPct}%"></div></div>`;
if (tile.eval.multiplier) {
html += `<span class="tessl-eval-multiplier">\u2191 ${tile.eval.multiplier}x</span>`;
}
html += `</div>`;
}
html += `</div>`;
}
html += '</div></div>';
}
// File Structure Tree
if (data.fileStructure && data.fileStructure.entries && data.fileStructure.entries.length > 0) {
html += '<div class="planview-section">';
html += '<div class="planview-section-title">Project Structure</div>';
html += '<div class="file-tree" role="tree" aria-label="Project file structure">';
html += renderFileTree(data.fileStructure.entries, 0);
html += '</div></div>';
}
// Architecture Diagram
if (data.diagram && data.diagram.nodes && data.diagram.nodes.length > 0) {
html += '<div class="planview-section">';
html += '<div class="planview-section-title">Architecture</div>';
html += '<div class="diagram-container">';
html += renderDiagramSVG(data.diagram);
html += '</div>';
html += renderDiagramLegend(data.diagram);
html += '<div id="diagram-detail" class="detail-panel-slot"></div>';
html += '</div>';
} else if (data.diagram && data.diagram.raw) {
// Fallback: raw ASCII
html += '<div class="planview-section">';
html += '<div class="planview-section-title">Architecture</div>';
html += `<pre class="diagram-raw">${escapeHtml(data.diagram.raw)}</pre>`;
html += '</div>';
}
html += '</div>';
contentArea.innerHTML = html;
// Attach event handlers
attachTreeHandlers();
attachDiagramHandlers(data.diagram);
}
function findResearchTooltip(badgeValue, decisions) {
if (!decisions || decisions.length === 0) return null;
const valueLower = badgeValue.toLowerCase();
for (const d of decisions) {
if (d.title && valueLower.includes(d.title.toLowerCase().split(' ')[0])) {
return d.rationale || d.decision;
}
// Also check if decision title words appear in badge value
if (d.title) {
const words = d.title.toLowerCase().split(/\s+/);
for (const word of words) {
if (word.length > 3 && valueLower.includes(word)) {
return d.rationale || d.decision;
}
}
}
}
return null;
}
function getFileIconInfo(name, isDir) {
if (isDir) return { cls: 'dir', ch: '\uD83D\uDCC2' };
const ext = name.split('.').pop();
if (['js', 'mjs'].includes(ext)) return { cls: 'file-js', ch: 'JS' };
if (ext === 'json') return { cls: 'file-json', ch: '{}' };
if (ext === 'md') return { cls: 'file-md', ch: 'M\u2193' };
if (ext === 'html') return { cls: 'file-html', ch: '</>' };
return { cls: 'file', ch: '\u25CB' };
}
function renderFileTree(entries) {
let html = '';
let i = 0;
while (i < entries.length) {
const entry = entries[i];
const isDir = entry.type === 'directory';
const expanded = entry.depth < 2;
// Indent guides
let indent = '';
for (let d = 0; d < entry.depth; d++) {
indent += '<span class="file-tree-guide"></span>';
}
const { cls, ch } = getFileIconInfo(entry.name, isDir);
if (isDir) {
const children = [];
let j = i + 1;
while (j < entries.length && entries[j].depth > entry.depth) {
children.push(entries[j]);
j++;
}
const childId = `tree-${entry.depth}-${entry.name}`.replace(/[^a-zA-Z0-9-]/g, '_');
html += `<div class="file-tree-entry">`;
html += `<span class="file-tree-indent">${indent}</span>`;
html += `<span class="file-tree-chevron" data-target="${childId}">${expanded ? '\u25BE' : '\u25B8'}</span>`;
html += `<span class="file-tree-file-icon ${cls}">${ch}</span>`;
html += `<span class="file-tree-label"><span class="file-tree-name">${escapeHtml(entry.name)}</span></span>`;
if (entry.comment) html += `<span class="file-tree-comment" data-full="${escapeHtml(entry.comment)}">${escapeHtml(entry.comment)}</span>`;
html += `</div>`;
html += `<div id="${childId}" class="file-tree-children${expanded ? '' : ' collapsed'}">`;
html += renderFileTree(children);
html += `</div>`;
i = j;
} else {
const isPlanned = entry.exists === false;
const nameClass = isPlanned ? ' planned' : '';
html += `<div class="file-tree-entry">`;
html += `<span class="file-tree-indent">${indent}</span>`;
html += `<span class="file-tree-chevron-spacer"></span>`;
html += `<span class="file-tree-file-icon ${cls}">${ch}</span>`;
html += `<span class="file-tree-label">`;
html += `<span class="file-tree-name${nameClass}">${escapeHtml(entry.name)}</span>`;
if (isPlanned) html += `<span class="file-tree-status planned-tag">planned</span>`;
html += `</span>`;
if (entry.comment) html += `<span class="file-tree-comment" data-full="${escapeHtml(entry.comment)}">${escapeHtml(entry.comment)}</span>`;
html += `</div>`;
i++;
}
}
return html;
}
function renderDiagramLegend(diagram) {
if (!diagram || !diagram.nodes) return '';
const types = new Set(diagram.nodes.map(n => n.type).filter(t => t !== 'default'));
if (types.size === 0) return '';
const colors = { client: 'var(--color-accent)', server: 'var(--color-p2)', storage: 'var(--color-done)', external: 'var(--color-p1)' };
let html = '<div class="diagram-legend">';
for (const type of types) {
html += `<div class="diagram-legend-item"><span class="diagram-legend-dot" style="background:${colors[type] || 'var(--color-text-muted)'}"></span>${type}</div>`;
}
html += '</div>';
return html;
}
function attachTreeHandlers() {
document.querySelectorAll('.file-tree-chevron').forEach(chevron => {
chevron.addEventListener('click', () => {
const targetId = chevron.dataset.target;
const children = document.getElementById(targetId);
if (!children) return;
const isCollapsed = children.classList.contains('collapsed');
children.classList.toggle('collapsed', !isCollapsed);
chevron.textContent = isCollapsed ? '\u25BE' : '\u25B8';
});
});
// Add title tooltip only on comments that are actually truncated
document.querySelectorAll('.file-tree-comment').forEach(comment => {
if (comment.scrollWidth > comment.clientWidth) {
comment.classList.add('truncated');
comment.title = comment.dataset.full || comment.textContent;
}
});
}
function renderDiagramSVG(diagram) {
if (!diagram || !diagram.nodes || diagram.nodes.length === 0) return '';
// Calculate SVG dimensions from node positions
let maxX = 0, maxY = 0;
for (const n of diagram.nodes) {
const right = n.x + n.width;
const bottom = n.y + n.height;
if (right > maxX) maxX = right;
if (bottom > maxY) maxY = bottom;
}
// Scale to SVG viewport
const padding = 40;
const svgWidth = 800;
const scaleX = (svgWidth - padding * 2) / (maxX || 1);
const scaleY = scaleX; // maintain aspect ratio
const svgHeight = maxY * scaleY + padding * 2;
const nodeTypeColors = {
client: 'var(--color-accent)',
server: 'var(--color-p2)',
storage: 'var(--color-done)',
external: 'var(--color-p1)',
default: 'var(--color-text-muted)'
};
let svg = `<svg class="diagram-svg" viewBox="0 0 ${svgWidth} ${svgHeight}" role="figure" aria-label="Architecture diagram">`;
// Draw edges first (behind nodes)
for (const edge of diagram.edges) {
const fromNode = diagram.nodes.find(n => n.id === edge.from);
const toNode = diagram.nodes.find(n => n.id === edge.to);
if (!fromNode || !toNode) continue;
const x1 = (fromNode.x + fromNode.width / 2) * scaleX + padding;
const y1 = (fromNode.y + fromNode.height) * scaleY + padding;
const x2 = (toNode.x + toNode.width / 2) * scaleX + padding;
const y2 = toNode.y * scaleY + padding;
svg += `<line class="diagram-edge-line" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" aria-hidden="true"/>`;
if (edge.label) {
const midX = (x1 + x2) / 2 + 8;
const midY = (y1 + y2) / 2;
svg += `<text class="diagram-edge-label" x="${midX}" y="${midY}">${escapeHtml(edge.label)}</text>`;
}
}
// Draw nodes
for (const node of diagram.nodes) {
const x = node.x * scaleX + padding;
const y = node.y * scaleY + padding;
const w = node.width * scaleX;
const h = node.height * scaleY;
const color = nodeTypeColors[node.type] || nodeTypeColors.default;
svg += `<g class="diagram-node" data-node-id="${node.id}" tabindex="0" role="img" aria-label="${escapeHtml(node.label)} (${node.type})">`;
svg += `<rect class="diagram-node-rect" x="${x}" y="${y}" width="${w}" height="${h}" fill="var(--color-surface)" stroke="${color}"/>`;
svg += `<text class="diagram-node-label" x="${x + w/2}" y="${y + h/2 + 5}" text-anchor="middle">${escapeHtml(node.label)}</text>`;
svg += `</g>`;
}
svg += '</svg>';
return svg;
}
function attachDiagramHandlers(diagram) {
if (!diagram) return;
document.querySelectorAll('.diagram-node').forEach(nodeEl => {
const handler = () => {
const nodeId = nodeEl.dataset.nodeId;
const node = diagram.nodes.find(n => n.id === nodeId);
if (!node) return;
showPlanDetailPanel(node);
};
nodeEl.addEventListener('click', handler);
nodeEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handler(); }
});
});
}
function showPlanDetailPanel(node) {
const slot = document.getElementById('diagram-detail');
if (!slot) return;
const typeLabel = node.type !== 'default' ? ` (${node.type})` : '';
slot.innerHTML = `
<div class="detail-panel">
<div class="detail-panel-header">
<span class="detail-panel-id">${escapeHtml(node.label)}${typeLabel}</span>
<button class="detail-panel-close" aria-label="Close detail panel">\u00d7</button>
</div>
<div class="detail-panel-body"><pre style="white-space:pre-wrap;font-family:var(--font-mono);font-size:13px;color:var(--color-text-secondary)">${escapeHtml(node.content)}</pre></div>
</div>`;
slot.querySelector('.detail-panel-close').addEventListener('click', () => { slot.innerHTML = ''; });
}
// ====== WebSocket (removed — replaced by meta-refresh in static mode) ======
// ====== Theme Toggle ======
const themeToggle = document.getElementById('themeToggle');
const themeIcon = document.getElementById('themeIcon');
const html = document.documentElement;
// Three-state cycle: system -> light -> dark -> system
let themeMode = localStorage.getItem('iikit-theme') || 'system';
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
function applyTheme(mode) {
themeMode = mode;
if (mode === 'system') {
localStorage.removeItem('iikit-theme');
const resolved = getSystemTheme();
if (resolved === 'light') {
html.setAttribute('data-theme', 'light');
} else {
html.removeAttribute('data-theme');
}
themeIcon.textContent = '\uD83D\uDDA5'; // monitor
themeToggle.setAttribute('aria-label', 'Theme: System (click for Light)');
themeToggle.title = 'Theme: System';
} else if (mode === 'light') {
localStorage.setItem('iikit-theme', 'light');
html.setAttribute('data-theme', 'light');
themeIcon.textContent = '\u2600'; // sun
themeToggle.setAttribute('aria-label', 'Theme: Light (click for Dark)');
themeToggle.title = 'Theme: Light';
} else {
localStorage.setItem('iikit-theme', 'dark');
html.removeAttribute('data-theme');
themeIcon.textContent = '\u263E'; // moon
themeToggle.setAttribute('aria-label', 'Theme: Dark (click for System)');
themeToggle.title = 'Theme: Dark';
}
}
themeToggle.addEventListener('click', () => {
const next = themeMode === 'system' ? 'light' : themeMode === 'light' ? 'dark' : 'system';
applyTheme(next);
});
// Listen for OS theme changes — only react in system mode
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => {
if (themeMode === 'system') applyTheme('system');
});
// Apply on load
applyTheme(themeMode);
// ====== Init ======
if (staticMode) {
// Bootstrap from inlined DASHBOARD_DATA (generated HTML)
const D = window.DASHBOARD_DATA;
const label = document.getElementById('projectLabel');
const dirName = D.meta.projectPath.split('/').pop();
label.textContent = dirName;
label.title = D.meta.projectPath;
document.title = dirName + ' — IIKit Dashboard';
currentConstitution = D.constitution;
currentPremise = D.premise;
updateFeatureSelector(D.features);
if (D.features.length > 0) {
currentFeature = D.features[0].id;
featureSelect.value = currentFeature;
const fd = D.featureData[currentFeature];
if (fd) {
currentPipeline = fd.pipeline;
currentBoard = fd.board;
currentBugs = fd.bugs;
if (!activeTab) activeTab = selectDefaultTab(fd.pipeline);
renderPipeline(fd.pipeline);
renderBoard(fd.board);
updateIntegrity(fd.board.integrity);
if (fd.bugs && currentPipeline) renderPipeline(currentPipeline);
switchTab(activeTab);
}
}
} else {
// Fetch mode (server-based)
fetch('/api/meta').then(r => r.json()).then(meta => {
const label = document.getElementById('projectLabel');
const dirName = meta.projectPath.split('/').pop();
label.textContent = dirName;
label.title = meta.projectPath;
}).catch(() => {});
loadFeatures();
}
})();
// ====== Task List Toggle (global, called from onclick) ======
function toggleTasks(btn) {
const list = btn.nextElementSibling;
const icon = btn.querySelector('.task-toggle-icon');
const isCollapsed = list.classList.contains('collapsed');
if (isCollapsed) {
list.classList.remove('collapsed');
list.classList.add('expanded');
icon.classList.add('expanded');
btn.setAttribute('aria-expanded', 'true');
} else {
list.classList.remove('expanded');
list.classList.add('collapsed');
icon.classList.remove('expanded');
btn.setAttribute('aria-expanded', 'false');
}
}
</script>
</body>
</html>evals
scenario-1
scenario-2
scenario-3
scenario-4
scenario-5
scenario-6
scenario-7
scenario-8
scenario-9
scenario-10
scenario-11
scenario-12
scenario-13
scenario-14
rules
skills
iikit-00-constitution
scripts
dashboard
iikit-01-specify
iikit-02-plan
iikit-03-checklist
scripts
bash
dashboard
iikit-04-testify
iikit-05-tasks
iikit-06-analyze
iikit-07-implement
iikit-08-taskstoissues
iikit-bugfix
scripts
dashboard
iikit-clarify
iikit-core
references
scripts
bash
dashboard
powershell
templates