iikit-dashboard 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3322 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>IIKit Kanban Board</title>
7
+ <style>
8
+ /* ====== CSS Custom Properties ====== */
9
+ :root {
10
+ --color-bg: #0f1117;
11
+ --color-surface: #1a1d27;
12
+ --color-surface-elevated: #222536;
13
+ --color-surface-hover: #2a2d40;
14
+ --color-border: #2e3148;
15
+ --color-border-subtle: #252839;
16
+ --color-text: #e8eaed;
17
+ --color-text-secondary: #9aa0b4;
18
+ --color-text-muted: #6b7189;
19
+ --color-accent: #3B82F6;
20
+ --color-accent-hover: #60A5FA;
21
+ --color-todo: #4a90d9;
22
+ --color-inprogress: #f5a623;
23
+ --color-done: #27c93f;
24
+ --color-p1: #ff4757;
25
+ --color-p2: #ffa502;
26
+ --color-p3: #3498db;
27
+ --color-verified: #27c93f;
28
+ --color-tampered: #ff4757;
29
+ --color-missing: #6b7189;
30
+ --radius-sm: 6px;
31
+ --radius-md: 10px;
32
+ --radius-lg: 14px;
33
+ --shadow-card: 0 2px 8px rgba(0,0,0,0.3), 0 1px 3px rgba(0,0,0,0.2);
34
+ --shadow-card-hover: 0 8px 24px rgba(0,0,0,0.4), 0 2px 8px rgba(0,0,0,0.3);
35
+ --shadow-column: 0 1px 4px rgba(0,0,0,0.2);
36
+ --transition-fast: 0.15s ease;
37
+ --transition-normal: 0.25s ease;
38
+ --transition-slow: 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
39
+ --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto, Oxygen, sans-serif;
40
+ --font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
41
+ }
42
+
43
+ /* ====== Light Theme ====== */
44
+ [data-theme="light"] {
45
+ --color-bg: #f5f6f8;
46
+ --color-surface: #ffffff;
47
+ --color-surface-elevated: #f0f1f4;
48
+ --color-surface-hover: #e8e9ee;
49
+ --color-border: #d8dae0;
50
+ --color-border-subtle: #e4e6eb;
51
+ --color-text: #1a1d27;
52
+ --color-text-secondary: #5a5f72;
53
+ --color-text-muted: #8b90a0;
54
+ --color-accent: #2563EB;
55
+ --color-accent-hover: #3B82F6;
56
+ --shadow-card: 0 1px 4px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
57
+ --shadow-card-hover: 0 4px 12px rgba(0,0,0,0.12), 0 2px 4px rgba(0,0,0,0.06);
58
+ --shadow-column: 0 1px 3px rgba(0,0,0,0.06);
59
+ }
60
+
61
+ /* ====== Reset & Base ====== */
62
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
63
+
64
+ body {
65
+ font-family: var(--font-sans);
66
+ background: var(--color-bg);
67
+ color: var(--color-text);
68
+ min-height: 100vh;
69
+ -webkit-font-smoothing: antialiased;
70
+ -moz-osx-font-smoothing: grayscale;
71
+ }
72
+
73
+ /* ====== Header ====== */
74
+ .header {
75
+ display: flex;
76
+ align-items: center;
77
+ justify-content: space-between;
78
+ padding: 16px 28px;
79
+ background: var(--color-surface);
80
+ border-bottom: 1px solid var(--color-border);
81
+ position: sticky;
82
+ top: 0;
83
+ z-index: 100;
84
+ backdrop-filter: blur(12px);
85
+ gap: 16px;
86
+ }
87
+
88
+ .header-left {
89
+ display: flex;
90
+ align-items: center;
91
+ gap: 16px;
92
+ min-width: 0;
93
+ flex: 1 1 auto;
94
+ }
95
+
96
+ .feature-selector {
97
+ position: relative;
98
+ min-width: 100px;
99
+ flex: 0 1 300px;
100
+ }
101
+
102
+ .logo {
103
+ display: flex;
104
+ align-items: center;
105
+ gap: 10px;
106
+ font-weight: 700;
107
+ font-size: 16px;
108
+ letter-spacing: -0.3px;
109
+ color: var(--color-text);
110
+ }
111
+
112
+ .logo-icon {
113
+ width: 28px;
114
+ height: 28px;
115
+ background: linear-gradient(135deg, var(--color-accent), #1D4ED8);
116
+ border-radius: var(--radius-sm);
117
+ display: flex;
118
+ align-items: center;
119
+ justify-content: center;
120
+ font-size: 14px;
121
+ color: white;
122
+ font-weight: 800;
123
+ }
124
+
125
+ .header-right {
126
+ display: flex;
127
+ align-items: center;
128
+ gap: 14px;
129
+ flex-shrink: 0;
130
+ }
131
+
132
+ /* ====== Feature Selector ====== */
133
+
134
+ .feature-selector select {
135
+ appearance: none;
136
+ background: var(--color-surface-elevated);
137
+ color: var(--color-text);
138
+ border: 1px solid var(--color-border);
139
+ border-radius: var(--radius-sm);
140
+ padding: 8px 36px 8px 12px;
141
+ font-size: 13px;
142
+ font-family: var(--font-sans);
143
+ font-weight: 500;
144
+ cursor: pointer;
145
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
146
+ width: 100%;
147
+ }
148
+
149
+ .feature-selector select:hover {
150
+ border-color: var(--color-accent);
151
+ }
152
+
153
+ .feature-selector select:focus {
154
+ outline: none;
155
+ border-color: var(--color-accent);
156
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.25);
157
+ }
158
+
159
+ .feature-selector::after {
160
+ content: '';
161
+ position: absolute;
162
+ right: 12px;
163
+ top: 50%;
164
+ transform: translateY(-50%);
165
+ width: 0;
166
+ height: 0;
167
+ border-left: 4px solid transparent;
168
+ border-right: 4px solid transparent;
169
+ border-top: 5px solid var(--color-text-secondary);
170
+ pointer-events: none;
171
+ }
172
+
173
+ /* ====== Integrity Badge ====== */
174
+ .integrity-badge {
175
+ display: inline-flex;
176
+ align-items: center;
177
+ gap: 6px;
178
+ padding: 6px 12px;
179
+ border-radius: 20px;
180
+ font-size: 12px;
181
+ font-weight: 600;
182
+ letter-spacing: 0.3px;
183
+ text-transform: uppercase;
184
+ transition: all var(--transition-fast);
185
+ }
186
+
187
+ .integrity-badge.valid {
188
+ background: rgba(39, 201, 63, 0.12);
189
+ color: var(--color-verified);
190
+ border: 1px solid rgba(39, 201, 63, 0.25);
191
+ }
192
+
193
+ .integrity-badge.tampered {
194
+ background: rgba(255, 71, 87, 0.12);
195
+ color: var(--color-tampered);
196
+ border: 1px solid rgba(255, 71, 87, 0.25);
197
+ animation: pulse-warning 2s ease-in-out infinite;
198
+ }
199
+
200
+ .integrity-badge.missing {
201
+ background: rgba(107, 113, 137, 0.12);
202
+ color: var(--color-missing);
203
+ border: 1px solid rgba(107, 113, 137, 0.25);
204
+ }
205
+
206
+ @keyframes pulse-warning {
207
+ 0%, 100% { opacity: 1; }
208
+ 50% { opacity: 0.7; }
209
+ }
210
+
211
+ .integrity-dot {
212
+ width: 7px;
213
+ height: 7px;
214
+ border-radius: 50%;
215
+ display: inline-block;
216
+ }
217
+
218
+ .integrity-badge.valid .integrity-dot { background: var(--color-verified); }
219
+ .integrity-badge.tampered .integrity-dot { background: var(--color-tampered); }
220
+ .integrity-badge.missing .integrity-dot { background: var(--color-missing); }
221
+
222
+ /* ====== Connection Status ====== */
223
+ .activity-indicator {
224
+ width: 10px;
225
+ height: 10px;
226
+ border-radius: 50%;
227
+ cursor: help;
228
+ transition: background-color var(--transition-normal), box-shadow var(--transition-normal);
229
+ background-color: var(--color-text-muted);
230
+ }
231
+
232
+ .activity-indicator.active {
233
+ background-color: var(--color-verified);
234
+ box-shadow: 0 0 8px rgba(39, 201, 63, 0.6);
235
+ animation: pulse-glow 1.5s ease-in-out infinite;
236
+ }
237
+
238
+ .activity-indicator.idle {
239
+ background-color: var(--color-text-muted);
240
+ box-shadow: none;
241
+ animation: none;
242
+ }
243
+
244
+ @keyframes pulse-glow {
245
+ 0%, 100% { box-shadow: 0 0 4px rgba(39, 201, 63, 0.4); }
246
+ 50% { box-shadow: 0 0 12px rgba(39, 201, 63, 0.8); }
247
+ }
248
+
249
+ /* ====== Board Layout ====== */
250
+ .board-container {
251
+ padding: 24px 28px;
252
+ overflow-x: auto;
253
+ }
254
+
255
+ .board {
256
+ display: grid;
257
+ grid-template-columns: repeat(3, 1fr);
258
+ gap: 20px;
259
+ min-width: 900px;
260
+ }
261
+
262
+ /* ====== Columns ====== */
263
+ .column {
264
+ background: var(--color-surface);
265
+ border-radius: var(--radius-lg);
266
+ border: 1px solid var(--color-border-subtle);
267
+ box-shadow: var(--shadow-column);
268
+ display: flex;
269
+ flex-direction: column;
270
+ min-height: 200px;
271
+ }
272
+
273
+ .column-header {
274
+ display: flex;
275
+ align-items: center;
276
+ justify-content: space-between;
277
+ padding: 16px 18px 12px;
278
+ border-bottom: 1px solid var(--color-border-subtle);
279
+ }
280
+
281
+ .column-title {
282
+ display: flex;
283
+ align-items: center;
284
+ gap: 10px;
285
+ font-size: 13px;
286
+ font-weight: 600;
287
+ text-transform: uppercase;
288
+ letter-spacing: 0.8px;
289
+ color: var(--color-text-secondary);
290
+ }
291
+
292
+ .column-dot {
293
+ width: 9px;
294
+ height: 9px;
295
+ border-radius: 50%;
296
+ }
297
+
298
+ .column.todo .column-dot { background: var(--color-todo); }
299
+ .column.in-progress .column-dot { background: var(--color-inprogress); }
300
+ .column.done .column-dot { background: var(--color-done); }
301
+
302
+ .column-count {
303
+ background: var(--color-surface-elevated);
304
+ color: var(--color-text-muted);
305
+ font-size: 11px;
306
+ font-weight: 700;
307
+ padding: 2px 8px;
308
+ border-radius: 10px;
309
+ min-width: 22px;
310
+ text-align: center;
311
+ }
312
+
313
+ .column-body {
314
+ padding: 12px;
315
+ display: flex;
316
+ flex-direction: column;
317
+ gap: 10px;
318
+ flex: 1;
319
+ }
320
+
321
+ /* ====== Cards ====== */
322
+ .card {
323
+ background: var(--color-surface-elevated);
324
+ border: 1px solid var(--color-border);
325
+ border-radius: var(--radius-md);
326
+ padding: 16px;
327
+ box-shadow: var(--shadow-card);
328
+ transition: transform var(--transition-normal), box-shadow var(--transition-normal), opacity var(--transition-slow);
329
+ cursor: default;
330
+ }
331
+
332
+ .card:hover {
333
+ transform: translateY(-2px);
334
+ box-shadow: var(--shadow-card-hover);
335
+ border-color: var(--color-accent);
336
+ }
337
+
338
+ /* Card slide animation classes */
339
+ .card.entering {
340
+ animation: cardEnter 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
341
+ }
342
+
343
+ .card.exiting {
344
+ animation: cardExit 0.3s ease-in forwards;
345
+ }
346
+
347
+ @keyframes cardEnter {
348
+ from { opacity: 0; transform: translateY(-10px) scale(0.97); }
349
+ to { opacity: 1; transform: translateY(0) scale(1); }
350
+ }
351
+
352
+ @keyframes cardExit {
353
+ from { opacity: 1; transform: translateY(0) scale(1); }
354
+ to { opacity: 0; transform: translateY(10px) scale(0.97); }
355
+ }
356
+
357
+ .card-header {
358
+ display: flex;
359
+ align-items: flex-start;
360
+ justify-content: space-between;
361
+ gap: 10px;
362
+ margin-bottom: 12px;
363
+ }
364
+
365
+ .card-title {
366
+ font-size: 14px;
367
+ font-weight: 600;
368
+ line-height: 1.4;
369
+ color: var(--color-text);
370
+ overflow: hidden;
371
+ text-overflow: ellipsis;
372
+ display: -webkit-box;
373
+ -webkit-line-clamp: 2;
374
+ -webkit-box-orient: vertical;
375
+ }
376
+
377
+ .card-title:hover {
378
+ -webkit-line-clamp: unset;
379
+ }
380
+
381
+ .card-id {
382
+ font-size: 11px;
383
+ font-family: var(--font-mono);
384
+ color: var(--color-text-muted);
385
+ margin-bottom: 4px;
386
+ }
387
+
388
+ /* ====== Priority Badges ====== */
389
+ .priority-badge {
390
+ display: inline-flex;
391
+ align-items: center;
392
+ padding: 3px 8px;
393
+ border-radius: 4px;
394
+ font-size: 11px;
395
+ font-weight: 700;
396
+ letter-spacing: 0.5px;
397
+ flex-shrink: 0;
398
+ }
399
+
400
+ .priority-badge.p1 {
401
+ background: rgba(255, 71, 87, 0.15);
402
+ color: var(--color-p1);
403
+ }
404
+
405
+ .priority-badge.p2 {
406
+ background: rgba(255, 165, 2, 0.15);
407
+ color: var(--color-p2);
408
+ }
409
+
410
+ .priority-badge.p3 {
411
+ background: rgba(52, 152, 219, 0.15);
412
+ color: var(--color-p3);
413
+ }
414
+
415
+ /* ====== Progress Bar ====== */
416
+ .progress-container {
417
+ margin: 10px 0;
418
+ }
419
+
420
+ .progress-info {
421
+ display: flex;
422
+ align-items: center;
423
+ justify-content: space-between;
424
+ margin-bottom: 6px;
425
+ }
426
+
427
+ .progress-label {
428
+ font-size: 11px;
429
+ color: var(--color-text-muted);
430
+ font-weight: 500;
431
+ }
432
+
433
+ .progress-value {
434
+ font-size: 11px;
435
+ font-family: var(--font-mono);
436
+ color: var(--color-text-secondary);
437
+ font-weight: 600;
438
+ }
439
+
440
+ .progress-bar {
441
+ width: 100%;
442
+ height: 4px;
443
+ background: var(--color-border);
444
+ border-radius: 2px;
445
+ overflow: hidden;
446
+ }
447
+
448
+ .progress-fill {
449
+ height: 100%;
450
+ border-radius: 2px;
451
+ transition: width var(--transition-slow);
452
+ min-width: 0;
453
+ }
454
+
455
+ .column.todo .progress-fill { background: var(--color-todo); }
456
+ .column.in-progress .progress-fill { background: var(--color-inprogress); }
457
+ .column.done .progress-fill { background: var(--color-done); }
458
+
459
+ /* ====== Task List ====== */
460
+ .task-list {
461
+ list-style: none;
462
+ display: flex;
463
+ flex-direction: column;
464
+ gap: 4px;
465
+ margin-top: 8px;
466
+ }
467
+
468
+ .task-item {
469
+ display: flex;
470
+ align-items: flex-start;
471
+ gap: 8px;
472
+ padding: 5px 6px;
473
+ border-radius: var(--radius-sm);
474
+ font-size: 12px;
475
+ line-height: 1.5;
476
+ color: var(--color-text-secondary);
477
+ transition: background var(--transition-fast);
478
+ }
479
+
480
+ .task-item:hover {
481
+ background: var(--color-surface-hover);
482
+ }
483
+
484
+ .task-checkbox {
485
+ flex-shrink: 0;
486
+ width: 15px;
487
+ height: 15px;
488
+ border-radius: 3px;
489
+ border: 1.5px solid var(--color-border);
490
+ margin-top: 2px;
491
+ display: flex;
492
+ align-items: center;
493
+ justify-content: center;
494
+ transition: all var(--transition-fast);
495
+ }
496
+
497
+ .task-checkbox.checked {
498
+ background: var(--color-done);
499
+ border-color: var(--color-done);
500
+ }
501
+
502
+ .task-checkbox.checked::after {
503
+ content: '';
504
+ display: block;
505
+ width: 4px;
506
+ height: 7px;
507
+ border: solid white;
508
+ border-width: 0 1.5px 1.5px 0;
509
+ transform: rotate(45deg) translate(-0.5px, -0.5px);
510
+ }
511
+
512
+ .task-item.checked .task-description {
513
+ text-decoration: line-through;
514
+ color: var(--color-text-muted);
515
+ }
516
+
517
+ .task-id {
518
+ font-family: var(--font-mono);
519
+ font-size: 10px;
520
+ color: var(--color-text-muted);
521
+ flex-shrink: 0;
522
+ margin-top: 1px;
523
+ }
524
+
525
+ .task-description {
526
+ flex: 1;
527
+ transition: color var(--transition-fast);
528
+ }
529
+
530
+ /* ====== Empty State ====== */
531
+ .empty-state {
532
+ display: flex;
533
+ flex-direction: column;
534
+ align-items: center;
535
+ justify-content: center;
536
+ padding: 60px 20px;
537
+ text-align: center;
538
+ grid-column: 1 / -1;
539
+ }
540
+
541
+ .empty-state-icon {
542
+ width: 64px;
543
+ height: 64px;
544
+ background: var(--color-surface-elevated);
545
+ border-radius: var(--radius-lg);
546
+ display: flex;
547
+ align-items: center;
548
+ justify-content: center;
549
+ font-size: 28px;
550
+ margin-bottom: 16px;
551
+ }
552
+
553
+ .empty-state-title {
554
+ font-size: 16px;
555
+ font-weight: 600;
556
+ color: var(--color-text);
557
+ margin-bottom: 6px;
558
+ }
559
+
560
+ .empty-state-text {
561
+ font-size: 13px;
562
+ color: var(--color-text-muted);
563
+ max-width: 300px;
564
+ }
565
+
566
+ .column-empty {
567
+ display: flex;
568
+ align-items: center;
569
+ justify-content: center;
570
+ padding: 24px 16px;
571
+ color: var(--color-text-muted);
572
+ font-size: 12px;
573
+ font-style: italic;
574
+ flex: 1;
575
+ }
576
+
577
+ /* ====== Completion Celebration ====== */
578
+ .card.just-completed {
579
+ animation: celebrateComplete 0.6s ease-out;
580
+ }
581
+
582
+ @keyframes celebrateComplete {
583
+ 0% { transform: scale(1); }
584
+ 30% { transform: scale(1.03); box-shadow: 0 0 20px rgba(39, 201, 63, 0.3); }
585
+ 100% { transform: scale(1); }
586
+ }
587
+
588
+ /* ====== Loading ====== */
589
+ .loading {
590
+ display: flex;
591
+ align-items: center;
592
+ justify-content: center;
593
+ padding: 60px;
594
+ grid-column: 1 / -1;
595
+ }
596
+
597
+ .loading-spinner {
598
+ width: 32px;
599
+ height: 32px;
600
+ border: 3px solid var(--color-border);
601
+ border-top-color: var(--color-accent);
602
+ border-radius: 50%;
603
+ animation: spin 0.8s linear infinite;
604
+ }
605
+
606
+ @keyframes spin {
607
+ to { transform: rotate(360deg); }
608
+ }
609
+
610
+ /* ====== Responsive ====== */
611
+ @media (max-width: 1024px) {
612
+ .board {
613
+ min-width: unset;
614
+ grid-template-columns: 1fr;
615
+ }
616
+ .column { min-height: 100px; }
617
+ }
618
+
619
+ /* ====== Theme Toggle ====== */
620
+ .theme-toggle {
621
+ background: var(--color-surface-elevated);
622
+ border: 1px solid var(--color-border);
623
+ border-radius: var(--radius-sm);
624
+ padding: 6px 10px;
625
+ cursor: pointer;
626
+ font-size: 16px;
627
+ line-height: 1;
628
+ transition: border-color var(--transition-fast), background var(--transition-fast);
629
+ display: flex;
630
+ align-items: center;
631
+ justify-content: center;
632
+ }
633
+
634
+ .theme-toggle:hover {
635
+ border-color: var(--color-accent);
636
+ background: var(--color-surface-hover);
637
+ }
638
+
639
+ .theme-toggle:focus-visible {
640
+ outline: 2px solid var(--color-accent);
641
+ outline-offset: 2px;
642
+ }
643
+
644
+ /* ====== Collapsible Task List ====== */
645
+ .task-toggle {
646
+ display: flex;
647
+ align-items: center;
648
+ gap: 6px;
649
+ margin-top: 10px;
650
+ padding: 4px 6px;
651
+ border: none;
652
+ background: none;
653
+ color: var(--color-text-muted);
654
+ font-size: 11px;
655
+ font-family: var(--font-sans);
656
+ font-weight: 500;
657
+ cursor: pointer;
658
+ border-radius: var(--radius-sm);
659
+ transition: color var(--transition-fast), background var(--transition-fast);
660
+ width: 100%;
661
+ text-align: left;
662
+ }
663
+
664
+ .task-toggle:hover {
665
+ color: var(--color-text-secondary);
666
+ background: var(--color-surface-hover);
667
+ }
668
+
669
+ .task-toggle-icon {
670
+ transition: transform var(--transition-fast);
671
+ font-size: 9px;
672
+ }
673
+
674
+ .task-toggle-icon.expanded {
675
+ transform: rotate(90deg);
676
+ }
677
+
678
+ .task-list {
679
+ overflow: hidden;
680
+ transition: max-height var(--transition-normal), opacity var(--transition-fast);
681
+ }
682
+
683
+ .task-list.collapsed {
684
+ max-height: 0;
685
+ opacity: 0;
686
+ margin-top: 0;
687
+ }
688
+
689
+ .task-list.expanded {
690
+ max-height: 2000px;
691
+ opacity: 1;
692
+ }
693
+
694
+ /* ====== Pipeline Bar ====== */
695
+ .pipeline-bar {
696
+ display: flex;
697
+ align-items: center;
698
+ gap: 0;
699
+ padding: 14px 28px;
700
+ background: var(--color-surface);
701
+ border-bottom: 1px solid var(--color-border);
702
+ position: sticky;
703
+ top: 58px;
704
+ z-index: 90;
705
+ }
706
+
707
+ .pipeline-node {
708
+ display: flex;
709
+ flex-direction: column;
710
+ align-items: center;
711
+ gap: 6px;
712
+ padding: 8px 12px;
713
+ border-radius: var(--radius-md);
714
+ cursor: pointer;
715
+ transition: background var(--transition-fast), transform var(--transition-fast);
716
+ flex: 0 1 96px;
717
+ width: 96px;
718
+ min-width: 40px;
719
+ position: relative;
720
+ border: 1px solid var(--color-border-subtle);
721
+ background: transparent;
722
+ overflow: hidden;
723
+ }
724
+
725
+ .pipeline-node:hover {
726
+ background: var(--color-surface-hover);
727
+ transform: translateY(-1px);
728
+ }
729
+
730
+ .pipeline-node:focus-visible {
731
+ outline: 2px solid var(--color-accent);
732
+ outline-offset: 2px;
733
+ }
734
+
735
+ .pipeline-node.active {
736
+ border: 2px solid var(--color-accent);
737
+ background: var(--color-surface-elevated);
738
+ }
739
+
740
+ .pipeline-dot {
741
+ width: 28px;
742
+ height: 28px;
743
+ border-radius: 50%;
744
+ display: flex;
745
+ align-items: center;
746
+ justify-content: center;
747
+ font-size: 12px;
748
+ font-weight: 700;
749
+ transition: all var(--transition-normal);
750
+ }
751
+
752
+ .pipeline-dot.not_started {
753
+ background: var(--color-surface-elevated);
754
+ border: 2px solid var(--color-border);
755
+ color: var(--color-text-muted);
756
+ }
757
+
758
+ .pipeline-dot.in_progress {
759
+ background: rgba(245, 166, 35, 0.15);
760
+ border: 2px solid var(--color-inprogress);
761
+ color: var(--color-inprogress);
762
+ }
763
+
764
+ .pipeline-dot.complete {
765
+ background: rgba(39, 201, 63, 0.15);
766
+ border: 2px solid var(--color-done);
767
+ color: var(--color-done);
768
+ }
769
+
770
+ .pipeline-dot.skipped {
771
+ background: var(--color-surface-elevated);
772
+ border: 2px dashed var(--color-text-muted);
773
+ color: var(--color-text-muted);
774
+ }
775
+
776
+ .pipeline-dot.available {
777
+ background: rgba(59, 130, 246, 0.12);
778
+ border: 2px solid var(--color-accent);
779
+ color: var(--color-accent);
780
+ }
781
+
782
+ .pipeline-label {
783
+ font-size: 10px;
784
+ font-weight: 600;
785
+ color: var(--color-text-secondary);
786
+ text-transform: uppercase;
787
+ letter-spacing: 0.5px;
788
+ white-space: nowrap;
789
+ overflow: hidden;
790
+ text-overflow: ellipsis;
791
+ max-width: 100%;
792
+ }
793
+
794
+ .pipeline-progress {
795
+ font-size: 9px;
796
+ font-family: var(--font-mono);
797
+ color: var(--color-text-muted);
798
+ min-height: 13px;
799
+ }
800
+
801
+ .pipeline-connector {
802
+ flex: 1 1 24px;
803
+ min-width: 4px;
804
+ height: 2px;
805
+ background: var(--color-border);
806
+ margin-top: -16px;
807
+ }
808
+
809
+ .pipeline-connector.complete {
810
+ background: var(--color-done);
811
+ }
812
+
813
+ .pipeline-node.optional .pipeline-label {
814
+ opacity: 0.8;
815
+ }
816
+
817
+ /* ====== Content Area ====== */
818
+ .content-area {
819
+ flex: 1;
820
+ min-height: 0;
821
+ }
822
+
823
+ .placeholder-view {
824
+ display: flex;
825
+ flex-direction: column;
826
+ align-items: center;
827
+ justify-content: center;
828
+ padding: 80px 20px;
829
+ text-align: center;
830
+ }
831
+
832
+ .placeholder-view-icon {
833
+ width: 56px;
834
+ height: 56px;
835
+ background: var(--color-surface-elevated);
836
+ border-radius: var(--radius-lg);
837
+ display: flex;
838
+ align-items: center;
839
+ justify-content: center;
840
+ font-size: 24px;
841
+ margin-bottom: 16px;
842
+ }
843
+
844
+ .placeholder-view-title {
845
+ font-size: 16px;
846
+ font-weight: 600;
847
+ color: var(--color-text);
848
+ margin-bottom: 6px;
849
+ }
850
+
851
+ .placeholder-view-text {
852
+ font-size: 13px;
853
+ color: var(--color-text-muted);
854
+ max-width: 360px;
855
+ }
856
+
857
+ /* ====== Spec Story Map Tab ====== */
858
+ .storymap-view {
859
+ padding: 24px 28px;
860
+ display: flex;
861
+ transition: padding-right var(--transition-normal);
862
+ }
863
+
864
+ .storymap-main {
865
+ flex: 1;
866
+ min-width: 0;
867
+ }
868
+
869
+ .storymap-section-title {
870
+ font-size: 13px;
871
+ font-weight: 600;
872
+ text-transform: uppercase;
873
+ letter-spacing: 0.8px;
874
+ color: var(--color-text-secondary);
875
+ margin-bottom: 16px;
876
+ }
877
+
878
+ /* Story Map Swim Lanes */
879
+ .swim-lanes {
880
+ margin-bottom: 32px;
881
+ }
882
+
883
+ .swim-lane {
884
+ display: flex;
885
+ align-items: flex-start;
886
+ gap: 12px;
887
+ margin-bottom: 12px;
888
+ min-height: 80px;
889
+ }
890
+
891
+ .swim-lane-label {
892
+ width: 40px;
893
+ flex-shrink: 0;
894
+ font-size: 12px;
895
+ font-weight: 700;
896
+ letter-spacing: 0.5px;
897
+ padding: 8px 0;
898
+ text-align: center;
899
+ border-radius: var(--radius-sm);
900
+ color: white;
901
+ }
902
+
903
+ .swim-lane-label.p1 { background: var(--color-p1); }
904
+ .swim-lane-label.p2 { background: var(--color-p2); }
905
+ .swim-lane-label.p3 { background: var(--color-p3); }
906
+
907
+ .swim-lane-cards {
908
+ display: flex;
909
+ gap: 12px;
910
+ flex-wrap: wrap;
911
+ flex: 1;
912
+ min-height: 40px;
913
+ padding: 4px;
914
+ border-radius: var(--radius-sm);
915
+ border: 1px dashed var(--color-border-subtle);
916
+ }
917
+
918
+ .swim-lane-cards:empty::after {
919
+ content: 'No stories';
920
+ color: var(--color-text-muted);
921
+ font-size: 12px;
922
+ font-style: italic;
923
+ padding: 8px;
924
+ }
925
+
926
+ /* Story Cards */
927
+ .story-card {
928
+ background: var(--color-surface);
929
+ border: 1px solid var(--color-border);
930
+ border-radius: var(--radius-md);
931
+ padding: 12px 14px;
932
+ min-width: 200px;
933
+ max-width: 280px;
934
+ cursor: pointer;
935
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
936
+ }
937
+
938
+ .story-card:hover {
939
+ border-color: var(--color-accent);
940
+ box-shadow: var(--shadow-card-hover);
941
+ }
942
+
943
+ .story-card-header {
944
+ display: flex;
945
+ align-items: center;
946
+ justify-content: space-between;
947
+ margin-bottom: 6px;
948
+ }
949
+
950
+ .story-card-id {
951
+ font-size: 11px;
952
+ font-weight: 700;
953
+ font-family: var(--font-mono);
954
+ color: var(--color-accent);
955
+ }
956
+
957
+ .story-card-priority {
958
+ font-size: 10px;
959
+ font-weight: 700;
960
+ padding: 2px 6px;
961
+ border-radius: 10px;
962
+ color: white;
963
+ }
964
+
965
+ .story-card-priority.p1 { background: var(--color-p1); }
966
+ .story-card-priority.p2 { background: var(--color-p2); }
967
+ .story-card-priority.p3 { background: var(--color-p3); }
968
+
969
+ .story-card-title {
970
+ font-size: 13px;
971
+ font-weight: 500;
972
+ color: var(--color-text);
973
+ margin-bottom: 8px;
974
+ line-height: 1.3;
975
+ overflow: hidden;
976
+ text-overflow: ellipsis;
977
+ display: -webkit-box;
978
+ -webkit-line-clamp: 2;
979
+ -webkit-box-orient: vertical;
980
+ }
981
+
982
+ .story-card-meta {
983
+ display: flex;
984
+ flex-wrap: wrap;
985
+ gap: 6px;
986
+ align-items: center;
987
+ }
988
+
989
+ .story-card-badge {
990
+ font-size: 10px;
991
+ font-weight: 600;
992
+ padding: 2px 6px;
993
+ border-radius: 8px;
994
+ background: var(--color-surface-elevated);
995
+ color: var(--color-text-secondary);
996
+ font-family: var(--font-mono);
997
+ }
998
+
999
+ .story-card-badge.scenarios {
1000
+ background: rgba(59, 130, 246, 0.12);
1001
+ color: var(--color-accent);
1002
+ }
1003
+
1004
+ .story-card-badge.clarifications {
1005
+ background: rgba(245, 166, 35, 0.12);
1006
+ color: var(--color-inprogress);
1007
+ cursor: pointer;
1008
+ }
1009
+
1010
+ .story-card-badge.clarifications:hover {
1011
+ background: rgba(245, 166, 35, 0.25);
1012
+ }
1013
+
1014
+ /* Requirements Graph */
1015
+ .graph-container {
1016
+ background: var(--color-surface);
1017
+ border: 1px solid var(--color-border);
1018
+ border-radius: var(--radius-lg);
1019
+ position: relative;
1020
+ overflow: hidden;
1021
+ margin-bottom: 24px;
1022
+ }
1023
+
1024
+ .graph-svg {
1025
+ width: 100%;
1026
+ cursor: grab;
1027
+ }
1028
+
1029
+ .graph-svg:active { cursor: grabbing; }
1030
+
1031
+ .graph-edge {
1032
+ stroke: var(--color-border);
1033
+ stroke-width: 1.5;
1034
+ fill: none;
1035
+ transition: opacity var(--transition-fast), stroke var(--transition-fast);
1036
+ }
1037
+
1038
+ .graph-edge.highlighted {
1039
+ stroke: var(--color-text);
1040
+ stroke-width: 2.5;
1041
+ }
1042
+
1043
+ .graph-edge.dimmed { opacity: 0.15; }
1044
+
1045
+ .graph-node { cursor: pointer; }
1046
+ .graph-node.dimmed { opacity: 0.2; }
1047
+
1048
+ .graph-node circle {
1049
+ transition: stroke var(--transition-fast), r var(--transition-fast);
1050
+ }
1051
+
1052
+ .graph-node.highlighted circle {
1053
+ stroke: var(--color-text);
1054
+ stroke-width: 3;
1055
+ filter: drop-shadow(0 0 4px var(--color-text));
1056
+ }
1057
+
1058
+ .graph-node-us circle { fill: var(--color-accent); }
1059
+ .graph-node-fr circle { fill: var(--color-done); }
1060
+ .graph-node-sc circle { fill: var(--color-inprogress); }
1061
+
1062
+ .graph-node text {
1063
+ font-size: 10px;
1064
+ font-weight: 600;
1065
+ font-family: var(--font-mono);
1066
+ fill: var(--color-text);
1067
+ text-anchor: middle;
1068
+ pointer-events: none;
1069
+ }
1070
+
1071
+ .graph-tooltip {
1072
+ position: absolute;
1073
+ background: var(--color-surface-elevated);
1074
+ border: 1px solid var(--color-border);
1075
+ border-radius: var(--radius-sm);
1076
+ padding: 8px 12px;
1077
+ font-size: 12px;
1078
+ color: var(--color-text);
1079
+ max-width: 300px;
1080
+ pointer-events: none;
1081
+ z-index: 50;
1082
+ box-shadow: var(--shadow-card);
1083
+ display: none;
1084
+ }
1085
+
1086
+ .graph-empty {
1087
+ display: flex;
1088
+ align-items: center;
1089
+ justify-content: center;
1090
+ height: 200px;
1091
+ color: var(--color-text-muted);
1092
+ font-size: 13px;
1093
+ }
1094
+
1095
+ .graph-legend {
1096
+ display: flex;
1097
+ gap: 16px;
1098
+ padding: 8px 14px;
1099
+ border-top: 1px solid var(--color-border-subtle);
1100
+ font-size: 11px;
1101
+ color: var(--color-text-secondary);
1102
+ }
1103
+
1104
+ .graph-legend-item {
1105
+ display: flex;
1106
+ align-items: center;
1107
+ gap: 5px;
1108
+ }
1109
+
1110
+ .graph-legend-dot {
1111
+ width: 8px;
1112
+ height: 8px;
1113
+ border-radius: 50%;
1114
+ }
1115
+
1116
+ .graph-legend-dot.us { background: var(--color-accent); }
1117
+ .graph-legend-dot.fr { background: var(--color-done); }
1118
+ .graph-legend-dot.sc { background: var(--color-inprogress); }
1119
+
1120
+ /* Clarify View */
1121
+ .clarify-view {
1122
+ padding: 24px 32px;
1123
+ max-width: 800px;
1124
+ margin: 0 auto;
1125
+ }
1126
+
1127
+ .clarify-empty {
1128
+ text-align: center;
1129
+ padding: 60px 20px;
1130
+ color: var(--color-text-muted);
1131
+ }
1132
+
1133
+ .clarify-header {
1134
+ display: flex;
1135
+ align-items: center;
1136
+ justify-content: space-between;
1137
+ margin-bottom: 24px;
1138
+ padding-bottom: 16px;
1139
+ border-bottom: 1px solid var(--color-border);
1140
+ }
1141
+
1142
+ .clarify-title {
1143
+ font-size: 16px;
1144
+ font-weight: 600;
1145
+ color: var(--color-text);
1146
+ }
1147
+
1148
+ .clarify-count {
1149
+ font-size: 12px;
1150
+ color: var(--color-text-muted);
1151
+ background: var(--color-surface-hover);
1152
+ padding: 4px 10px;
1153
+ border-radius: 12px;
1154
+ }
1155
+
1156
+ .clarify-session {
1157
+ margin-bottom: 24px;
1158
+ }
1159
+
1160
+ .clarify-session-label {
1161
+ font-size: 11px;
1162
+ font-weight: 600;
1163
+ color: var(--color-text-muted);
1164
+ text-transform: uppercase;
1165
+ letter-spacing: 0.5px;
1166
+ margin-bottom: 12px;
1167
+ }
1168
+
1169
+ .clarify-entries {
1170
+ display: flex;
1171
+ flex-direction: column;
1172
+ gap: 12px;
1173
+ }
1174
+
1175
+ .clarify-entry {
1176
+ background: var(--color-surface);
1177
+ border: 1px solid var(--color-border-subtle);
1178
+ border-radius: var(--radius-md);
1179
+ padding: 14px 16px;
1180
+ }
1181
+
1182
+ .clarify-question {
1183
+ font-size: 13px;
1184
+ font-weight: 600;
1185
+ color: var(--color-text);
1186
+ margin-bottom: 8px;
1187
+ line-height: 1.5;
1188
+ }
1189
+
1190
+ .clarify-answer {
1191
+ font-size: 13px;
1192
+ color: var(--color-text-secondary);
1193
+ line-height: 1.5;
1194
+ }
1195
+
1196
+ .clarify-q-label, .clarify-a-label {
1197
+ display: inline-block;
1198
+ width: 20px;
1199
+ height: 20px;
1200
+ line-height: 20px;
1201
+ text-align: center;
1202
+ border-radius: 4px;
1203
+ font-size: 11px;
1204
+ font-weight: 700;
1205
+ margin-right: 8px;
1206
+ flex-shrink: 0;
1207
+ }
1208
+
1209
+ .clarify-q-label {
1210
+ background: var(--color-accent);
1211
+ color: white;
1212
+ }
1213
+
1214
+ .clarify-a-label {
1215
+ background: var(--color-done);
1216
+ color: white;
1217
+ }
1218
+
1219
+ .clarify-refs {
1220
+ display: flex;
1221
+ flex-wrap: wrap;
1222
+ gap: 6px;
1223
+ margin-top: 10px;
1224
+ padding-top: 8px;
1225
+ border-top: 1px solid var(--color-border-subtle);
1226
+ }
1227
+
1228
+ .clarify-ref {
1229
+ font-size: 11px;
1230
+ font-weight: 600;
1231
+ padding: 2px 8px;
1232
+ border-radius: 10px;
1233
+ background: var(--color-surface-hover);
1234
+ color: var(--color-accent);
1235
+ letter-spacing: 0.3px;
1236
+ text-decoration: none;
1237
+ cursor: pointer;
1238
+ transition: background var(--transition-fast), color var(--transition-fast);
1239
+ }
1240
+
1241
+ .clarify-ref:hover {
1242
+ background: var(--color-accent);
1243
+ color: white;
1244
+ }
1245
+
1246
+ .story-card.highlighted {
1247
+ outline: 2px solid var(--color-accent);
1248
+ outline-offset: 2px;
1249
+ animation: cardPulse 0.6s ease-out;
1250
+ }
1251
+
1252
+ @keyframes cardPulse {
1253
+ 0% { outline-color: transparent; }
1254
+ 50% { outline-color: var(--color-accent); }
1255
+ 100% { outline-color: var(--color-accent); }
1256
+ }
1257
+
1258
+ /* Detail Panel — floating left sidebar */
1259
+ .detail-panel {
1260
+ position: fixed;
1261
+ left: 0;
1262
+ top: 57px;
1263
+ width: 360px;
1264
+ height: calc(100vh - 57px);
1265
+ overflow-y: auto;
1266
+ background: var(--color-surface);
1267
+ border-right: 1px solid var(--color-border);
1268
+ padding: 20px 24px;
1269
+ box-shadow: 4px 0 16px rgba(0,0,0,0.15);
1270
+ z-index: 90;
1271
+ animation: slideIn 0.2s ease-out;
1272
+ }
1273
+
1274
+ @keyframes slideIn {
1275
+ from { opacity: 0; transform: translateX(-12px); }
1276
+ to { opacity: 1; transform: translateX(0); }
1277
+ }
1278
+
1279
+ .detail-panel-header {
1280
+ display: flex;
1281
+ align-items: center;
1282
+ justify-content: space-between;
1283
+ margin-bottom: 12px;
1284
+ }
1285
+
1286
+ .detail-panel-id {
1287
+ font-size: 12px;
1288
+ font-weight: 700;
1289
+ font-family: var(--font-mono);
1290
+ padding: 3px 8px;
1291
+ border-radius: 8px;
1292
+ color: white;
1293
+ }
1294
+
1295
+ .detail-panel-id.us { background: var(--color-accent); }
1296
+ .detail-panel-id.fr { background: var(--color-done); }
1297
+ .detail-panel-id.sc { background: var(--color-inprogress); }
1298
+
1299
+ .detail-panel-close {
1300
+ background: none;
1301
+ border: none;
1302
+ color: var(--color-text-secondary);
1303
+ cursor: pointer;
1304
+ font-size: 18px;
1305
+ padding: 4px 8px;
1306
+ border-radius: var(--radius-sm);
1307
+ transition: background var(--transition-fast);
1308
+ }
1309
+
1310
+ .detail-panel-close:hover {
1311
+ background: var(--color-surface-hover);
1312
+ }
1313
+
1314
+ .detail-panel-title {
1315
+ font-size: 16px;
1316
+ font-weight: 600;
1317
+ color: var(--color-text);
1318
+ margin-bottom: 12px;
1319
+ }
1320
+
1321
+ .detail-panel-body {
1322
+ font-size: 13px;
1323
+ color: var(--color-text-secondary);
1324
+ line-height: 1.7;
1325
+ white-space: pre-wrap;
1326
+ }
1327
+
1328
+ .detail-panel-body strong {
1329
+ color: var(--color-text);
1330
+ }
1331
+
1332
+ /* Storymap empty state */
1333
+ .storymap-empty {
1334
+ display: flex;
1335
+ flex-direction: column;
1336
+ align-items: center;
1337
+ justify-content: center;
1338
+ padding: 60px 20px;
1339
+ text-align: center;
1340
+ color: var(--color-text-muted);
1341
+ font-size: 13px;
1342
+ }
1343
+
1344
+ /* ====== Constitution Tab ====== */
1345
+ .constitution-view {
1346
+ padding: 24px 28px;
1347
+ }
1348
+
1349
+ .constitution-layout {
1350
+ display: flex;
1351
+ gap: 32px;
1352
+ align-items: flex-start;
1353
+ }
1354
+
1355
+ @media (max-width: 900px) {
1356
+ .constitution-layout { flex-direction: column; }
1357
+ }
1358
+
1359
+ .constitution-left {
1360
+ flex: 1 1 50%;
1361
+ min-width: 0;
1362
+ display: flex;
1363
+ align-items: flex-start;
1364
+ justify-content: center;
1365
+ }
1366
+
1367
+ .constitution-right {
1368
+ flex: 1 1 40%;
1369
+ min-width: 0;
1370
+ }
1371
+
1372
+ .constitution-summary {
1373
+ display: flex;
1374
+ flex-direction: column;
1375
+ gap: 2px;
1376
+ margin-bottom: 20px;
1377
+ }
1378
+
1379
+ .constitution-summary-item {
1380
+ display: flex;
1381
+ align-items: center;
1382
+ gap: 10px;
1383
+ padding: 6px 8px;
1384
+ font-size: 13px;
1385
+ font-weight: 500;
1386
+ color: var(--color-text);
1387
+ border-bottom: 1px solid var(--color-border-subtle);
1388
+ cursor: pointer;
1389
+ border-radius: var(--radius-sm);
1390
+ transition: background var(--transition-fast);
1391
+ }
1392
+
1393
+ .constitution-summary-item:last-child { border-bottom: none; }
1394
+ .constitution-summary-item:hover { background: var(--color-surface-hover); }
1395
+ .constitution-summary-item.selected { background: var(--color-surface-elevated); border-left: 3px solid var(--color-accent); }
1396
+
1397
+ .constitution-summary-item .principle-num {
1398
+ font-family: var(--font-mono);
1399
+ font-size: 11px;
1400
+ color: var(--color-text-muted);
1401
+ flex-shrink: 0;
1402
+ width: 24px;
1403
+ }
1404
+
1405
+ .constitution-summary-item .level-badge {
1406
+ font-size: 9px;
1407
+ font-weight: 700;
1408
+ padding: 2px 5px;
1409
+ border-radius: 3px;
1410
+ text-transform: uppercase;
1411
+ margin-left: auto;
1412
+ flex-shrink: 0;
1413
+ letter-spacing: 0.3px;
1414
+ }
1415
+
1416
+ .level-badge.must { background: rgba(59, 130, 246, 0.15); color: var(--color-accent); }
1417
+ .level-badge.should { background: rgba(245, 166, 35, 0.15); color: var(--color-inprogress); }
1418
+ .level-badge.may { background: rgba(107, 113, 137, 0.15); color: var(--color-text-muted); }
1419
+
1420
+ .constitution-body {
1421
+ display: flex;
1422
+ gap: 24px;
1423
+ align-items: flex-start;
1424
+ }
1425
+
1426
+ @media (max-width: 768px) {
1427
+ .constitution-body { flex-direction: column; }
1428
+ }
1429
+
1430
+ .radar-container {
1431
+ width: 100%;
1432
+ max-width: 520px;
1433
+ }
1434
+
1435
+ .radar-container svg {
1436
+ width: 100%;
1437
+ height: auto;
1438
+ overflow: visible;
1439
+ }
1440
+
1441
+ .radar-axis {
1442
+ cursor: pointer;
1443
+ transition: opacity var(--transition-fast);
1444
+ }
1445
+
1446
+ .radar-axis:hover { opacity: 0.8; }
1447
+ .radar-axis:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
1448
+
1449
+ .detail-card {
1450
+ background: var(--color-surface-elevated);
1451
+ border: 1px solid var(--color-border);
1452
+ border-radius: var(--radius-lg);
1453
+ padding: 20px;
1454
+ margin-top: 12px;
1455
+ animation: cardEnter 0.25s ease;
1456
+ }
1457
+
1458
+ .detail-card h3 {
1459
+ font-size: 16px;
1460
+ font-weight: 700;
1461
+ margin-bottom: 4px;
1462
+ color: var(--color-text);
1463
+ }
1464
+
1465
+ .detail-card .detail-level {
1466
+ margin-bottom: 12px;
1467
+ }
1468
+
1469
+ .detail-card .detail-text {
1470
+ font-size: 13px;
1471
+ line-height: 1.6;
1472
+ color: var(--color-text-secondary);
1473
+ margin-bottom: 16px;
1474
+ }
1475
+
1476
+ .detail-card .detail-rationale {
1477
+ font-size: 12px;
1478
+ line-height: 1.5;
1479
+ color: var(--color-text-muted);
1480
+ padding-top: 12px;
1481
+ border-top: 1px solid var(--color-border-subtle);
1482
+ }
1483
+
1484
+ .detail-card .detail-rationale strong {
1485
+ color: var(--color-text-secondary);
1486
+ }
1487
+
1488
+ .amendment-timeline {
1489
+ margin-top: 24px;
1490
+ padding: 16px 0;
1491
+ border-top: 1px solid var(--color-border-subtle);
1492
+ }
1493
+
1494
+ .amendment-timeline-label {
1495
+ font-size: 11px;
1496
+ font-weight: 600;
1497
+ text-transform: uppercase;
1498
+ letter-spacing: 0.5px;
1499
+ color: var(--color-text-muted);
1500
+ margin-bottom: 8px;
1501
+ }
1502
+
1503
+ .amendment-timeline-content {
1504
+ display: flex;
1505
+ align-items: center;
1506
+ gap: 12px;
1507
+ font-size: 12px;
1508
+ color: var(--color-text-secondary);
1509
+ }
1510
+
1511
+ .amendment-timeline-dot {
1512
+ width: 8px;
1513
+ height: 8px;
1514
+ border-radius: 50%;
1515
+ background: var(--color-accent);
1516
+ flex-shrink: 0;
1517
+ }
1518
+
1519
+ .amendment-timeline-line {
1520
+ flex: 1;
1521
+ height: 2px;
1522
+ background: var(--color-border);
1523
+ }
1524
+
1525
+ /* ====== Plan View ====== */
1526
+ .planview-view { padding: 24px 28px; }
1527
+ .planview-section {
1528
+ margin-bottom: 28px; background: var(--color-surface); border: 1px solid var(--color-border);
1529
+ border-radius: var(--radius-lg); padding: 20px 24px; box-shadow: var(--shadow-column);
1530
+ }
1531
+ .planview-section-title {
1532
+ font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px;
1533
+ color: var(--color-text-secondary); margin-bottom: 16px;
1534
+ }
1535
+ /* Badge Wall */
1536
+ .badge-wall { display: flex; flex-wrap: wrap; gap: 10px; }
1537
+ .tech-badge {
1538
+ display: inline-flex; flex-direction: column; padding: 10px 16px;
1539
+ background: var(--color-surface-elevated); border: 1px solid var(--color-border-subtle);
1540
+ border-radius: var(--radius-md); transition: all var(--transition-fast);
1541
+ position: relative; cursor: default; box-shadow: 0 1px 3px rgba(0,0,0,0.15);
1542
+ border-left: 3px solid var(--color-accent);
1543
+ }
1544
+ .tech-badge:hover { background: var(--color-surface-hover); border-color: var(--color-accent); box-shadow: var(--shadow-card); transform: translateY(-1px); }
1545
+ .tech-badge-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--color-text-muted); margin-bottom: 3px; }
1546
+ .tech-badge-value { font-size: 13px; font-weight: 600; color: var(--color-text); }
1547
+ .tech-badge-tooltip {
1548
+ display: none; position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%);
1549
+ background: var(--color-surface-elevated); border: 1px solid var(--color-border);
1550
+ border-radius: var(--radius-sm); padding: 10px 14px; font-size: 12px; color: var(--color-text-secondary);
1551
+ max-width: 300px; white-space: normal; z-index: 50; box-shadow: var(--shadow-card-hover);
1552
+ pointer-events: none; line-height: 1.5;
1553
+ }
1554
+ .tech-badge:hover .tech-badge-tooltip { display: block; }
1555
+ /* Tessl Tiles Panel */
1556
+ .tessl-tiles { display: flex; flex-wrap: wrap; gap: 12px; }
1557
+ .tessl-tile-card {
1558
+ display: flex; flex-direction: column; padding: 14px 18px;
1559
+ background: var(--color-surface-elevated); border: 1px solid var(--color-border-subtle);
1560
+ border-radius: var(--radius-md); min-width: 200px;
1561
+ border-left: 3px solid var(--color-done); box-shadow: 0 1px 3px rgba(0,0,0,0.15);
1562
+ transition: all var(--transition-fast);
1563
+ }
1564
+ .tessl-tile-card:hover { box-shadow: var(--shadow-card); transform: translateY(-1px); }
1565
+ .tessl-tile-name { font-size: 13px; font-weight: 600; color: var(--color-text); font-family: var(--font-mono); }
1566
+ .tessl-tile-version { font-size: 11px; color: var(--color-text-muted); margin-top: 4px; }
1567
+ .tessl-tile-eval { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--color-border-subtle); }
1568
+ .tessl-eval-score { font-size: 28px; font-weight: 700; color: var(--color-done); }
1569
+ .tessl-eval-bar { height: 4px; background: var(--color-border); border-radius: 2px; margin-top: 6px; overflow: hidden; }
1570
+ .tessl-eval-bar-fill { height: 100%; background: var(--color-done); border-radius: 2px; }
1571
+ .tessl-eval-multiplier {
1572
+ display: inline-block; margin-top: 6px; padding: 2px 10px; border-radius: 12px;
1573
+ background: rgba(39, 201, 63, 0.15); color: var(--color-done); font-size: 11px; font-weight: 600;
1574
+ }
1575
+ /* File Structure Tree — VS Code style */
1576
+ .file-tree { font-family: var(--font-mono); font-size: 13px; }
1577
+ .file-tree-entry {
1578
+ display: flex; align-items: center; height: 26px; padding: 0 8px;
1579
+ border-radius: var(--radius-sm); transition: background var(--transition-fast);
1580
+ cursor: default; gap: 0; white-space: nowrap;
1581
+ }
1582
+ .file-tree-entry:hover { background: var(--color-surface-hover); }
1583
+ .file-tree-indent { display: inline-flex; flex-shrink: 0; }
1584
+ .file-tree-guide {
1585
+ width: 18px; height: 26px; position: relative; flex-shrink: 0;
1586
+ }
1587
+ .file-tree-guide::before {
1588
+ content: ''; position: absolute; left: 8px; top: 0; bottom: 0;
1589
+ width: 1px; background: var(--color-border-subtle);
1590
+ }
1591
+ .file-tree-chevron {
1592
+ width: 18px; height: 26px; display: inline-flex; align-items: center; justify-content: center;
1593
+ flex-shrink: 0; cursor: pointer; color: var(--color-text-muted);
1594
+ transition: color var(--transition-fast); font-size: 11px; user-select: none;
1595
+ }
1596
+ .file-tree-chevron:hover { color: var(--color-accent); }
1597
+ .file-tree-chevron-spacer { width: 18px; flex-shrink: 0; }
1598
+ .file-tree-file-icon {
1599
+ width: 18px; height: 26px; display: inline-flex; align-items: center; justify-content: center;
1600
+ flex-shrink: 0; font-size: 14px;
1601
+ }
1602
+ .file-tree-file-icon.dir { color: var(--color-accent); }
1603
+ .file-tree-file-icon.file { color: var(--color-text-muted); }
1604
+ .file-tree-file-icon.file-js { color: #f0db4f; }
1605
+ .file-tree-file-icon.file-json { color: #f0db4f; }
1606
+ .file-tree-file-icon.file-md { color: var(--color-accent); }
1607
+ .file-tree-file-icon.file-html { color: #e44d26; }
1608
+ .file-tree-file-icon.file-css { color: #264de4; }
1609
+ .file-tree-label {
1610
+ display: flex; align-items: center; gap: 6px; flex-shrink: 0;
1611
+ }
1612
+ .file-tree-name { color: var(--color-text); white-space: nowrap; flex-shrink: 0; }
1613
+ .file-tree-name.planned { color: var(--color-text-muted); }
1614
+ .file-tree-status {
1615
+ flex-shrink: 0; font-size: 10px; padding: 1px 6px; border-radius: 3px; font-weight: 600;
1616
+ }
1617
+ .file-tree-status.existing { color: var(--color-done); background: rgba(39,201,63,0.1); }
1618
+ .file-tree-status.planned-tag { color: var(--color-text-muted); background: var(--color-surface-elevated); }
1619
+ .file-tree-comment {
1620
+ color: var(--color-text-muted); margin-left: auto; padding-left: 16px;
1621
+ font-size: 12px; opacity: 0.5; overflow: hidden; text-overflow: ellipsis;
1622
+ white-space: nowrap; min-width: 0; flex: 1 1 0;
1623
+ }
1624
+ .file-tree-comment.truncated { cursor: help !important; }
1625
+ .file-tree-children { overflow: hidden; }
1626
+ .file-tree-children.collapsed { display: none; }
1627
+ /* Architecture Diagram */
1628
+ .diagram-container { position: relative; }
1629
+ .diagram-svg {
1630
+ width: 100%; background: var(--color-bg); border: 1px solid var(--color-border);
1631
+ border-radius: var(--radius-md); padding: 8px;
1632
+ }
1633
+ .diagram-node { cursor: pointer; transition: all var(--transition-fast); }
1634
+ .diagram-node:hover { filter: brightness(1.2); }
1635
+ .diagram-node-rect { rx: 10; ry: 10; stroke-width: 2; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); }
1636
+ .diagram-node-label { font-family: var(--font-sans); font-size: 13px; font-weight: 600; fill: var(--color-text); }
1637
+ .diagram-edge-line { stroke: var(--color-border); stroke-width: 2; fill: none; stroke-dasharray: 6 3; }
1638
+ .diagram-edge-label {
1639
+ font-family: var(--font-mono); font-size: 10px; fill: var(--color-text-muted);
1640
+ paint-order: stroke; stroke: var(--color-bg); stroke-width: 4px;
1641
+ }
1642
+ .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; }
1643
+ /* Diagram legend */
1644
+ .diagram-legend { display: flex; gap: 16px; margin-top: 12px; justify-content: center; }
1645
+ .diagram-legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--color-text-muted); }
1646
+ .diagram-legend-dot { width: 10px; height: 10px; border-radius: 3px; }
1647
+ /* Plan empty states */
1648
+ .planview-empty { text-align: center; padding: 48px 20px; color: var(--color-text-muted); }
1649
+ .planview-empty-title { font-size: 16px; font-weight: 600; margin-bottom: 8px; color: var(--color-text-secondary); }
1650
+ .planview-empty-text { font-size: 14px; line-height: 1.6; }
1651
+
1652
+ /* ====== Scrollbar ====== */
1653
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
1654
+ ::-webkit-scrollbar-track { background: transparent; }
1655
+ ::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 3px; }
1656
+ ::-webkit-scrollbar-thumb:hover { background: var(--color-text-muted); }
1657
+ </style>
1658
+ </head>
1659
+ <body>
1660
+ <!-- Header -->
1661
+ <header class="header" role="banner">
1662
+ <div class="header-left">
1663
+ <div class="logo">
1664
+ <div class="logo-icon" aria-hidden="true">D</div>
1665
+ <span>IIKit Dashboard</span>
1666
+ </div>
1667
+ <div class="feature-selector" role="navigation" aria-label="Feature selector">
1668
+ <select id="featureSelect" aria-label="Select feature to display" tabindex="0">
1669
+ <option value="">Loading features...</option>
1670
+ </select>
1671
+ </div>
1672
+ </div>
1673
+ <div class="header-right">
1674
+ <div id="integrityBadge" class="integrity-badge missing" role="status" aria-label="Test integrity status">
1675
+ <span class="integrity-dot" aria-hidden="true"></span>
1676
+ <span class="integrity-text">Checking...</span>
1677
+ </div>
1678
+ <button id="themeToggle" class="theme-toggle" aria-label="Toggle light/dark theme" title="Toggle theme" tabindex="0">
1679
+ <span id="themeIcon">&#9790;</span>
1680
+ </button>
1681
+ <div id="activityIndicator" class="activity-indicator idle" role="status" aria-label="Agent activity: idle" title="Agent idle — no recent file changes"></div>
1682
+ </div>
1683
+ </header>
1684
+
1685
+ <!-- Pipeline Bar -->
1686
+ <nav id="pipelineBar" class="pipeline-bar" role="navigation" aria-label="IIKit workflow pipeline">
1687
+ </nav>
1688
+
1689
+ <!-- Content Area -->
1690
+ <main class="content-area" role="main">
1691
+ <div id="contentArea">
1692
+ <div class="board-container">
1693
+ <div id="board" class="board" role="region" aria-label="Kanban board">
1694
+ <div class="loading" id="loadingState">
1695
+ <div class="loading-spinner" aria-label="Loading board data"></div>
1696
+ </div>
1697
+ </div>
1698
+ </div>
1699
+ </div>
1700
+ </main>
1701
+
1702
+ <script>
1703
+ (function() {
1704
+ 'use strict';
1705
+
1706
+ // ====== State ======
1707
+ let currentFeature = null;
1708
+ let currentBoard = null;
1709
+ let currentPipeline = null;
1710
+ let activeTab = null;
1711
+ let ws = null;
1712
+ let reconnectTimer = null;
1713
+ let previousCardColumns = {}; // Track card positions for animations
1714
+
1715
+ // ====== DOM References ======
1716
+ const boardEl = document.getElementById('board');
1717
+ const featureSelect = document.getElementById('featureSelect');
1718
+ const integrityBadge = document.getElementById('integrityBadge');
1719
+ const activityIndicator = document.getElementById('activityIndicator');
1720
+ let lastActivityTime = 0;
1721
+ const ACTIVITY_TIMEOUT = 10000; // 10 seconds
1722
+ const loadingState = document.getElementById('loadingState');
1723
+ const pipelineBar = document.getElementById('pipelineBar');
1724
+ const contentArea = document.getElementById('contentArea');
1725
+
1726
+ // ====== Pipeline ======
1727
+ const PHASE_ICONS = {
1728
+ not_started: '',
1729
+ in_progress: '&#9654;',
1730
+ complete: '&#10003;',
1731
+ skipped: '&#8212;',
1732
+ available: '&#9679;'
1733
+ };
1734
+
1735
+ function renderPipeline(pipeline) {
1736
+ if (!pipeline || !pipeline.phases) return;
1737
+ currentPipeline = pipeline;
1738
+
1739
+ pipelineBar.innerHTML = '';
1740
+
1741
+ pipeline.phases.forEach((phase, i) => {
1742
+ // Add connector before each node (except the first)
1743
+ if (i > 0) {
1744
+ const connector = document.createElement('div');
1745
+ connector.className = 'pipeline-connector';
1746
+ // Color connector green if previous phase is complete
1747
+ const prevPhase = pipeline.phases[i - 1];
1748
+ if (prevPhase.status === 'complete') {
1749
+ connector.classList.add('complete');
1750
+ }
1751
+ pipelineBar.appendChild(connector);
1752
+ }
1753
+
1754
+ const node = document.createElement('button');
1755
+ node.className = 'pipeline-node' + (phase.optional ? ' optional' : '');
1756
+ if (activeTab === phase.id) node.classList.add('active');
1757
+ node.setAttribute('tabindex', '0');
1758
+ if (activeTab === phase.id) node.setAttribute('aria-current', 'true');
1759
+
1760
+ node.innerHTML = `
1761
+ <div class="pipeline-dot ${phase.status}">${PHASE_ICONS[phase.status] || ''}</div>
1762
+ <span class="pipeline-label">${escapeHtml(phase.name)}</span>
1763
+ <span class="pipeline-progress">${phase.progress || ''}</span>
1764
+ `;
1765
+
1766
+ node.addEventListener('click', () => switchTab(phase.id));
1767
+ pipelineBar.appendChild(node);
1768
+ });
1769
+ }
1770
+
1771
+ function switchTab(phaseId) {
1772
+ activeTab = phaseId;
1773
+ // Re-render pipeline to update active state
1774
+ if (currentPipeline) renderPipeline(currentPipeline);
1775
+
1776
+ if (phaseId === 'implement') {
1777
+ renderBoardView();
1778
+ } else if (phaseId === 'constitution') {
1779
+ renderConstitutionView();
1780
+ } else if (phaseId === 'spec') {
1781
+ renderStoryMapView();
1782
+ } else if (phaseId === 'plan') {
1783
+ renderPlanView();
1784
+ } else if (phaseId === 'clarify') {
1785
+ renderClarifyView();
1786
+ } else {
1787
+ renderPlaceholderView(phaseId);
1788
+ }
1789
+ }
1790
+
1791
+ function renderBoardView() {
1792
+ contentArea.innerHTML = `
1793
+ <div class="board-container">
1794
+ <div id="board" class="board" role="region" aria-label="Kanban board"></div>
1795
+ </div>`;
1796
+ // Re-assign boardEl reference
1797
+ const newBoardEl = document.getElementById('board');
1798
+ if (currentBoard) {
1799
+ renderBoardInto(newBoardEl, currentBoard);
1800
+ }
1801
+ }
1802
+
1803
+ function renderPlaceholderView(phaseId) {
1804
+ const phaseNames = {
1805
+ constitution: 'Constitution', spec: 'Specification', clarify: 'Clarification',
1806
+ plan: 'Plan', checklist: 'Checklist', testify: 'Test Specs',
1807
+ tasks: 'Tasks', analyze: 'Analysis', implement: 'Implementation'
1808
+ };
1809
+ const name = phaseNames[phaseId] || phaseId;
1810
+ contentArea.innerHTML = `
1811
+ <div class="placeholder-view">
1812
+ <div class="placeholder-view-title">${name} View</div>
1813
+ <div class="placeholder-view-text">This phase visualization is coming soon. It will be available in a future update.</div>
1814
+ </div>`;
1815
+ }
1816
+
1817
+ // ====== Spec Story Map View ======
1818
+ let currentStoryMap = null;
1819
+
1820
+ async function renderStoryMapView() {
1821
+ if (!currentFeature) return;
1822
+
1823
+ try {
1824
+ if (!currentStoryMap) {
1825
+ const res = await fetch(`/api/storymap/${currentFeature}`);
1826
+ if (!res.ok) throw new Error('Failed to load');
1827
+ currentStoryMap = await res.json();
1828
+ }
1829
+ renderStoryMapContent(currentStoryMap);
1830
+ } catch {
1831
+ contentArea.innerHTML = '<div class="storymap-empty">Failed to load story map data.</div>';
1832
+ }
1833
+ }
1834
+
1835
+ function renderStoryMapContent(data) {
1836
+ if (!data.stories.length && !data.requirements.length) {
1837
+ contentArea.innerHTML = `
1838
+ <div class="storymap-empty">
1839
+ <div class="placeholder-view-title">No Specification Data</div>
1840
+ <div>This feature's spec.md has no user stories or requirements yet.</div>
1841
+ </div>`;
1842
+ return;
1843
+ }
1844
+
1845
+ contentArea.innerHTML = `
1846
+ <div class="storymap-view" role="region" aria-label="Spec Story Map">
1847
+ <div class="storymap-main">
1848
+ <div class="storymap-section-title">Story Map</div>
1849
+ <div class="swim-lanes" role="list" aria-label="User stories by priority"></div>
1850
+ <div class="storymap-section-title">Requirements Graph</div>
1851
+ <div class="graph-container">
1852
+ <svg class="graph-svg" aria-label="Requirements relationship graph"></svg>
1853
+ <div class="graph-tooltip"></div>
1854
+ <div class="graph-legend">
1855
+ <div class="graph-legend-item"><span class="graph-legend-dot us"></span> User Story</div>
1856
+ <div class="graph-legend-item"><span class="graph-legend-dot fr"></span> Requirement</div>
1857
+ <div class="graph-legend-item"><span class="graph-legend-dot sc"></span> Success Criterion</div>
1858
+ </div>
1859
+ </div>
1860
+ </div>
1861
+ </div>
1862
+ <div class="detail-panel-slot"></div>`;
1863
+
1864
+ renderSwimLanes(data);
1865
+ renderRequirementsGraph(data);
1866
+ }
1867
+
1868
+ function renderSwimLanes(data) {
1869
+ const container = contentArea.querySelector('.swim-lanes');
1870
+ if (!container) return;
1871
+
1872
+ const lanes = { P1: [], P2: [], P3: [] };
1873
+ for (const story of data.stories) {
1874
+ const p = story.priority || 'P3';
1875
+ if (!lanes[p]) lanes[p] = [];
1876
+ lanes[p].push(story);
1877
+ }
1878
+
1879
+ let html = '';
1880
+ for (const [priority, stories] of Object.entries(lanes)) {
1881
+ html += `<div class="swim-lane" role="listitem">
1882
+ <div class="swim-lane-label ${priority.toLowerCase()}">${priority}</div>
1883
+ <div class="swim-lane-cards">`;
1884
+ for (const story of stories) {
1885
+ const refs = (story.requirementRefs || []).map(r => `<span class="story-card-badge">${escapeHtml(r)}</span>`).join('');
1886
+ html += `<div class="story-card" data-story-id="${escapeHtml(story.id)}" tabindex="0" role="button" aria-label="${escapeHtml(story.title)}">
1887
+ <div class="story-card-header">
1888
+ <span class="story-card-id">${escapeHtml(story.id)}</span>
1889
+ <span class="story-card-priority ${story.priority.toLowerCase()}">${escapeHtml(story.priority)}</span>
1890
+ </div>
1891
+ <div class="story-card-title">${escapeHtml(story.title)}</div>
1892
+ <div class="story-card-meta">
1893
+ <span class="story-card-badge scenarios">${story.scenarioCount || 0} scenario${(story.scenarioCount || 0) !== 1 ? 's' : ''}</span>
1894
+ ${refs}
1895
+ </div>
1896
+ </div>`;
1897
+ }
1898
+ html += '</div></div>';
1899
+ }
1900
+ container.innerHTML = html;
1901
+
1902
+ // Story card click → highlight graph node + show detail (FR-016)
1903
+ container.querySelectorAll('.story-card').forEach(card => {
1904
+ card.addEventListener('click', (e) => {
1905
+ const storyId = card.dataset.storyId;
1906
+ highlightGraphNode(storyId);
1907
+ const story = data.stories.find(s => s.id === storyId);
1908
+ if (story) showDetailPanel(storyId, 'us', story.title, story.body || '');
1909
+ const nodeEl = contentArea.querySelector(`.graph-node[data-id="${storyId}"]`);
1910
+ if (nodeEl) nodeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
1911
+ });
1912
+ card.addEventListener('keydown', (e) => {
1913
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); card.click(); }
1914
+ });
1915
+ });
1916
+ }
1917
+
1918
+ function renderRequirementsGraph(data) {
1919
+ const svg = contentArea.querySelector('.graph-svg');
1920
+ if (!svg) return;
1921
+
1922
+ if (!data.requirements.length && !data.successCriteria.length) {
1923
+ const container = svg.parentElement;
1924
+ container.innerHTML = '<div class="graph-empty">No requirements or success criteria defined yet.</div>' +
1925
+ container.querySelector('.graph-legend')?.outerHTML || '';
1926
+ return;
1927
+ }
1928
+
1929
+ // Build nodes
1930
+ const nodes = [];
1931
+ const nodeMap = {};
1932
+
1933
+ for (const s of data.stories) {
1934
+ const n = { id: s.id, type: 'us', label: s.id, desc: s.title, x: 0, y: 0, vx: 0, vy: 0 };
1935
+ nodes.push(n);
1936
+ nodeMap[s.id] = n;
1937
+ }
1938
+ for (const r of data.requirements) {
1939
+ const n = { id: r.id, type: 'fr', label: r.id, desc: r.text, x: 0, y: 0, vx: 0, vy: 0 };
1940
+ nodes.push(n);
1941
+ nodeMap[r.id] = n;
1942
+ }
1943
+ for (const sc of data.successCriteria) {
1944
+ const n = { id: sc.id, type: 'sc', label: sc.id, desc: sc.text, x: 0, y: 0, vx: 0, vy: 0 };
1945
+ nodes.push(n);
1946
+ nodeMap[sc.id] = n;
1947
+ }
1948
+
1949
+ // Size SVG based on node count
1950
+ const width = svg.clientWidth || 800;
1951
+ const height = Math.min(width, Math.max(300, nodes.length * 30 + 100));
1952
+ svg.style.height = height + 'px';
1953
+ svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
1954
+
1955
+ // Initial positions: spread nodes randomly within bounds
1956
+ const pad = 50;
1957
+ for (const n of nodes) {
1958
+ n.x = pad + Math.random() * (width - 2 * pad);
1959
+ n.y = pad + Math.random() * (height - 2 * pad);
1960
+ }
1961
+
1962
+ // Build edges
1963
+ const edges = data.edges.filter(e => nodeMap[e.from] && nodeMap[e.to]);
1964
+
1965
+ // Simple force-directed layout
1966
+ for (let iter = 0; iter < 120; iter++) {
1967
+ // Repulsion between all pairs
1968
+ for (let i = 0; i < nodes.length; i++) {
1969
+ for (let j = i + 1; j < nodes.length; j++) {
1970
+ let dx = nodes[j].x - nodes[i].x;
1971
+ let dy = nodes[j].y - nodes[i].y;
1972
+ let dist = Math.sqrt(dx * dx + dy * dy) || 1;
1973
+ let force = 3000 / (dist * dist);
1974
+ let fx = (dx / dist) * force;
1975
+ let fy = (dy / dist) * force;
1976
+ nodes[i].vx -= fx;
1977
+ nodes[i].vy -= fy;
1978
+ nodes[j].vx += fx;
1979
+ nodes[j].vy += fy;
1980
+ }
1981
+ }
1982
+ // Attraction along edges
1983
+ for (const e of edges) {
1984
+ const a = nodeMap[e.from];
1985
+ const b = nodeMap[e.to];
1986
+ let dx = b.x - a.x;
1987
+ let dy = b.y - a.y;
1988
+ let dist = Math.sqrt(dx * dx + dy * dy) || 1;
1989
+ let force = (dist - 100) * 0.05;
1990
+ let fx = (dx / dist) * force;
1991
+ let fy = (dy / dist) * force;
1992
+ a.vx += fx;
1993
+ a.vy += fy;
1994
+ b.vx -= fx;
1995
+ b.vy -= fy;
1996
+ }
1997
+ // Apply velocities with damping
1998
+ for (const n of nodes) {
1999
+ n.vx *= 0.7;
2000
+ n.vy *= 0.7;
2001
+ n.x += n.vx;
2002
+ n.y += n.vy;
2003
+ n.x = Math.max(pad, Math.min(width - pad, n.x));
2004
+ n.y = Math.max(pad, Math.min(height - pad, n.y));
2005
+ }
2006
+ }
2007
+
2008
+ // Render edges
2009
+ let svgContent = '';
2010
+ for (const e of edges) {
2011
+ const a = nodeMap[e.from];
2012
+ const b = nodeMap[e.to];
2013
+ 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}"/>`;
2014
+ }
2015
+
2016
+ // Render nodes
2017
+ const radius = { us: 18, fr: 14, sc: 12 };
2018
+ for (const n of nodes) {
2019
+ const r = radius[n.type] || 14;
2020
+ svgContent += `<g class="graph-node graph-node-${n.type}" data-id="${n.id}" data-desc="${escapeHtml(n.desc)}" tabindex="0" role="button" aria-label="${n.label}: ${escapeHtml(n.desc)}">
2021
+ <circle cx="${n.x}" cy="${n.y}" r="${r}" stroke="var(--color-bg)" stroke-width="2"/>
2022
+ <text x="${n.x}" y="${n.y + r + 14}">${n.label}</text>
2023
+ </g>`;
2024
+ }
2025
+
2026
+ svg.innerHTML = svgContent;
2027
+
2028
+ // Click-to-highlight + detail panel (FR-006)
2029
+ svg.addEventListener('click', (e) => {
2030
+ const nodeEl = e.target.closest('.graph-node');
2031
+ if (nodeEl) {
2032
+ const id = nodeEl.dataset.id;
2033
+ highlightGraphNode(id);
2034
+ // Show detail panel
2035
+ const story = data.stories.find(s => s.id === id);
2036
+ const req = data.requirements.find(r => r.id === id);
2037
+ const sc = data.successCriteria.find(s => s.id === id);
2038
+ if (story) showDetailPanel(id, 'us', story.title, story.body || '');
2039
+ else if (req) showDetailPanel(id, 'fr', id, req.text);
2040
+ else if (sc) showDetailPanel(id, 'sc', id, sc.text);
2041
+ } else {
2042
+ clearGraphHighlight();
2043
+ closeDetailPanel();
2044
+ }
2045
+ });
2046
+
2047
+ // Tooltip on hover (FR-014)
2048
+ const tooltip = contentArea.querySelector('.graph-tooltip');
2049
+ svg.addEventListener('mouseover', (e) => {
2050
+ const nodeEl = e.target.closest('.graph-node');
2051
+ if (nodeEl && tooltip) {
2052
+ tooltip.textContent = nodeEl.dataset.desc;
2053
+ tooltip.style.display = 'block';
2054
+ }
2055
+ });
2056
+ svg.addEventListener('mousemove', (e) => {
2057
+ if (tooltip && tooltip.style.display === 'block') {
2058
+ const rect = svg.parentElement.getBoundingClientRect();
2059
+ tooltip.style.left = (e.clientX - rect.left + 12) + 'px';
2060
+ tooltip.style.top = (e.clientY - rect.top - 8) + 'px';
2061
+ }
2062
+ });
2063
+ svg.addEventListener('mouseout', (e) => {
2064
+ if (!e.target.closest('.graph-node') && tooltip) {
2065
+ tooltip.style.display = 'none';
2066
+ }
2067
+ });
2068
+
2069
+ // Drag nodes (FR-007)
2070
+ let dragNode = null;
2071
+ let dragOffset = { x: 0, y: 0 };
2072
+
2073
+ svg.addEventListener('mousedown', (e) => {
2074
+ const nodeEl = e.target.closest('.graph-node');
2075
+ if (!nodeEl) return;
2076
+ e.preventDefault();
2077
+ dragNode = nodeEl;
2078
+ const circle = nodeEl.querySelector('circle');
2079
+ const svgRect = svg.getBoundingClientRect();
2080
+ const viewBox = svg.viewBox.baseVal;
2081
+ const scaleX = viewBox.width / svgRect.width;
2082
+ const scaleY = viewBox.height / svgRect.height;
2083
+ dragOffset.x = parseFloat(circle.getAttribute('cx')) - (e.clientX - svgRect.left) * scaleX;
2084
+ dragOffset.y = parseFloat(circle.getAttribute('cy')) - (e.clientY - svgRect.top) * scaleY;
2085
+ svg.style.cursor = 'grabbing';
2086
+ });
2087
+
2088
+ document.addEventListener('mousemove', (e) => {
2089
+ if (!dragNode) return;
2090
+ const svgRect = svg.getBoundingClientRect();
2091
+ const viewBox = svg.viewBox.baseVal;
2092
+ const scaleX = viewBox.width / svgRect.width;
2093
+ const scaleY = viewBox.height / svgRect.height;
2094
+ const nx = (e.clientX - svgRect.left) * scaleX + dragOffset.x;
2095
+ const ny = (e.clientY - svgRect.top) * scaleY + dragOffset.y;
2096
+ const circle = dragNode.querySelector('circle');
2097
+ const text = dragNode.querySelector('text');
2098
+ circle.setAttribute('cx', nx);
2099
+ circle.setAttribute('cy', ny);
2100
+ text.setAttribute('x', nx);
2101
+ text.setAttribute('y', ny + parseFloat(circle.getAttribute('r')) + 14);
2102
+ // Update connected edges
2103
+ const nodeId = dragNode.dataset.id;
2104
+ svg.querySelectorAll('.graph-edge').forEach(edge => {
2105
+ if (edge.dataset.from === nodeId) { edge.setAttribute('x1', nx); edge.setAttribute('y1', ny); }
2106
+ if (edge.dataset.to === nodeId) { edge.setAttribute('x2', nx); edge.setAttribute('y2', ny); }
2107
+ });
2108
+ });
2109
+
2110
+ document.addEventListener('mouseup', () => {
2111
+ if (dragNode) {
2112
+ dragNode = null;
2113
+ svg.style.cursor = 'grab';
2114
+ }
2115
+ });
2116
+
2117
+ // Zoom/pan via wheel (FR-008)
2118
+ svg.addEventListener('wheel', (e) => {
2119
+ e.preventDefault();
2120
+ const viewBox = svg.viewBox.baseVal;
2121
+ const scale = e.deltaY > 0 ? 1.1 : 0.9;
2122
+ const svgRect = svg.getBoundingClientRect();
2123
+ const mx = ((e.clientX - svgRect.left) / svgRect.width) * viewBox.width + viewBox.x;
2124
+ const my = ((e.clientY - svgRect.top) / svgRect.height) * viewBox.height + viewBox.y;
2125
+ const nw = viewBox.width * scale;
2126
+ const nh = viewBox.height * scale;
2127
+ viewBox.x = mx - (mx - viewBox.x) * scale;
2128
+ viewBox.y = my - (my - viewBox.y) * scale;
2129
+ viewBox.width = nw;
2130
+ viewBox.height = nh;
2131
+ }, { passive: false });
2132
+ }
2133
+
2134
+ function highlightGraphNode(nodeId) {
2135
+ const svg = contentArea.querySelector('.graph-svg');
2136
+ if (!svg) return;
2137
+
2138
+ // Build connected set
2139
+ const connected = new Set([nodeId]);
2140
+ svg.querySelectorAll('.graph-edge').forEach(edge => {
2141
+ if (edge.dataset.from === nodeId) connected.add(edge.dataset.to);
2142
+ if (edge.dataset.to === nodeId) connected.add(edge.dataset.from);
2143
+ });
2144
+
2145
+ svg.querySelectorAll('.graph-node').forEach(n => {
2146
+ const id = n.dataset.id;
2147
+ n.classList.toggle('highlighted', id === nodeId);
2148
+ n.classList.toggle('dimmed', !connected.has(id));
2149
+ });
2150
+ svg.querySelectorAll('.graph-edge').forEach(e => {
2151
+ const isConnected = e.dataset.from === nodeId || e.dataset.to === nodeId;
2152
+ e.classList.toggle('highlighted', isConnected);
2153
+ e.classList.toggle('dimmed', !isConnected);
2154
+ });
2155
+ }
2156
+
2157
+ function clearGraphHighlight() {
2158
+ const svg = contentArea.querySelector('.graph-svg');
2159
+ if (!svg) return;
2160
+ svg.querySelectorAll('.graph-node').forEach(n => { n.classList.remove('highlighted', 'dimmed'); });
2161
+ svg.querySelectorAll('.graph-edge').forEach(e => { e.classList.remove('highlighted', 'dimmed'); });
2162
+ }
2163
+
2164
+ function showDetailPanel(id, type, title, body) {
2165
+ const slot = contentArea.querySelector('.detail-panel-slot');
2166
+ if (!slot) return;
2167
+
2168
+ // Simple markdown-like rendering: bold, italic, numbered lists
2169
+ const rendered = body
2170
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
2171
+ .replace(/\*(.+?)\*/g, '<em>$1</em>')
2172
+ .replace(/^(\d+)\.\s+/gm, '<br>$1. ');
2173
+
2174
+ slot.innerHTML = `
2175
+ <div class="detail-panel" role="region" aria-label="Detail view for ${escapeHtml(id)}">
2176
+ <div class="detail-panel-header">
2177
+ <span class="detail-panel-id ${type}">${escapeHtml(id)}</span>
2178
+ <button class="detail-panel-close" aria-label="Close detail panel">&times;</button>
2179
+ </div>
2180
+ <div class="detail-panel-title">${escapeHtml(title)}</div>
2181
+ <div class="detail-panel-body">${rendered}</div>
2182
+ </div>`;
2183
+
2184
+ slot.querySelector('.detail-panel-close').addEventListener('click', closeDetailPanel);
2185
+ slot.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
2186
+ }
2187
+
2188
+ function closeDetailPanel() {
2189
+ const slot = contentArea.querySelector('.detail-panel-slot');
2190
+ if (slot) slot.innerHTML = '';
2191
+ }
2192
+
2193
+ // ====== Clarify View ======
2194
+ async function renderClarifyView() {
2195
+ if (!currentFeature) {
2196
+ contentArea.innerHTML = '<div class="placeholder-view"><div class="placeholder-view-title">Select a feature to view clarifications</div></div>';
2197
+ return;
2198
+ }
2199
+
2200
+ // Check if spec phase has been completed
2201
+ const specPhase = currentPipeline?.phases?.find(p => p.id === 'spec');
2202
+ if (specPhase && specPhase.status === 'not_started') {
2203
+ contentArea.innerHTML = `
2204
+ <div class="clarify-view" role="region" aria-label="Clarifications">
2205
+ <div class="clarify-empty">
2206
+ <div class="placeholder-view-title">Specification Not Yet Created</div>
2207
+ <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-02-clarify</code> to resolve ambiguities.</div>
2208
+ </div>
2209
+ </div>`;
2210
+ return;
2211
+ }
2212
+
2213
+ try {
2214
+ if (!currentStoryMap) {
2215
+ const res = await fetch(`/api/storymap/${currentFeature}`);
2216
+ if (!res.ok) throw new Error('Failed to load');
2217
+ currentStoryMap = await res.json();
2218
+ }
2219
+ renderClarifyContent(currentStoryMap.clarifications || []);
2220
+ } catch {
2221
+ contentArea.innerHTML = '<div class="placeholder-view"><div class="placeholder-view-title">Failed to load clarification data</div></div>';
2222
+ }
2223
+ }
2224
+
2225
+ function renderClarifyContent(clarifications) {
2226
+ if (!clarifications.length) {
2227
+ contentArea.innerHTML = `
2228
+ <div class="clarify-view" role="region" aria-label="Clarifications">
2229
+ <div class="clarify-empty">
2230
+ <div class="placeholder-view-title">No Clarifications Recorded</div>
2231
+ <div class="placeholder-view-text">This is an optional phase. The specification exists but no clarification sessions have been recorded. Run <code>/iikit-02-clarify</code> to identify and resolve ambiguities in the spec.</div>
2232
+ </div>
2233
+ </div>`;
2234
+ return;
2235
+ }
2236
+
2237
+ // Group by session
2238
+ const sessions = {};
2239
+ for (const c of clarifications) {
2240
+ if (!sessions[c.session]) sessions[c.session] = [];
2241
+ sessions[c.session].push(c);
2242
+ }
2243
+
2244
+ let html = `<div class="clarify-view" role="region" aria-label="Clarifications">
2245
+ <div class="clarify-header">
2246
+ <span class="clarify-title">Clarification Trail</span>
2247
+ <span class="clarify-count">${clarifications.length} Q&amp;A${clarifications.length !== 1 ? 's' : ''}</span>
2248
+ </div>
2249
+ <div class="clarify-sessions">`;
2250
+
2251
+ for (const [session, entries] of Object.entries(sessions)) {
2252
+ html += `<div class="clarify-session">
2253
+ <div class="clarify-session-label">Session ${escapeHtml(session)}</div>
2254
+ <div class="clarify-entries">`;
2255
+ for (const c of entries) {
2256
+ const refsHtml = (c.refs && c.refs.length)
2257
+ ? `<div class="clarify-refs">${c.refs.map(r => `<a class="clarify-ref" href="#" data-ref-id="${escapeHtml(r)}">${escapeHtml(r)}</a>`).join('')}</div>`
2258
+ : '';
2259
+ html += `<div class="clarify-entry">
2260
+ <div class="clarify-question"><span class="clarify-q-label">Q</span> ${escapeHtml(c.question)}</div>
2261
+ <div class="clarify-answer"><span class="clarify-a-label">A</span> ${escapeHtml(c.answer)}</div>
2262
+ ${refsHtml}
2263
+ </div>`;
2264
+ }
2265
+ html += '</div></div>';
2266
+ }
2267
+
2268
+ html += '</div></div>';
2269
+ contentArea.innerHTML = html;
2270
+
2271
+ // Wire up ref links to navigate to Spec tab and highlight the item
2272
+ contentArea.querySelectorAll('.clarify-ref').forEach(link => {
2273
+ link.addEventListener('click', (e) => {
2274
+ e.preventDefault();
2275
+ const refId = link.dataset.refId;
2276
+ navigateToSpecItem(refId);
2277
+ });
2278
+ });
2279
+ }
2280
+
2281
+ async function navigateToSpecItem(refId) {
2282
+ // Normalize: clarification refs use US-2 but parser creates US2
2283
+ const nodeId = refId.replace(/^US-/, 'US');
2284
+
2285
+ // Switch to Spec tab and wait for async render to complete
2286
+ switchTab('spec');
2287
+ // Poll until the graph SVG has rendered (async fetch may take time)
2288
+ for (let i = 0; i < 20; i++) {
2289
+ if (contentArea.querySelector('.graph-node')) break;
2290
+ await new Promise(r => setTimeout(r, 50));
2291
+ }
2292
+
2293
+ // Highlight the node in the graph
2294
+ highlightGraphNode(nodeId);
2295
+
2296
+ // For US refs, scroll to and highlight the story card
2297
+ const card = contentArea.querySelector(`.story-card[data-story-id="${nodeId}"]`);
2298
+ if (card) {
2299
+ card.scrollIntoView({ behavior: 'smooth', block: 'center' });
2300
+ card.classList.add('highlighted');
2301
+ setTimeout(() => card.classList.remove('highlighted'), 2000);
2302
+ // Show detail for the story
2303
+ const story = currentStoryMap?.stories?.find(s => s.id === nodeId);
2304
+ if (story) showDetailPanel(nodeId, 'us', story.title, story.body || '');
2305
+ return;
2306
+ }
2307
+
2308
+ // For FR/SC refs, scroll to the graph node
2309
+ const nodeEl = contentArea.querySelector(`.graph-node[data-id="${nodeId}"]`);
2310
+ if (nodeEl) {
2311
+ nodeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
2312
+ const type = nodeId.startsWith('FR') ? 'fr' : 'sc';
2313
+ const desc = nodeEl.dataset.desc || '';
2314
+ showDetailPanel(nodeId, type, nodeId, desc);
2315
+ }
2316
+ }
2317
+
2318
+ // ====== Constitution View ======
2319
+ let currentConstitution = null;
2320
+ let selectedPrinciple = null;
2321
+
2322
+ async function renderConstitutionView() {
2323
+ try {
2324
+ if (!currentConstitution) {
2325
+ const res = await fetch('/api/constitution');
2326
+ currentConstitution = await res.json();
2327
+ }
2328
+ renderConstitutionContent(currentConstitution);
2329
+ } catch (err) {
2330
+ console.error('Failed to load constitution:', err);
2331
+ contentArea.innerHTML = '<div class="placeholder-view"><div class="placeholder-view-title">Failed to load constitution</div></div>';
2332
+ }
2333
+ }
2334
+
2335
+ function renderConstitutionContent(data) {
2336
+ if (!data || !data.exists) {
2337
+ contentArea.innerHTML = `
2338
+ <div class="placeholder-view">
2339
+ <div class="placeholder-view-title">No Constitution Found</div>
2340
+ <div class="placeholder-view-text">Run /iikit-00-constitution to define your project's governance principles.</div>
2341
+ </div>`;
2342
+ return;
2343
+ }
2344
+
2345
+ const principles = data.principles;
2346
+ if (principles.length === 0) {
2347
+ contentArea.innerHTML = `
2348
+ <div class="placeholder-view">
2349
+ <div class="placeholder-view-title">No Principles Found</div>
2350
+ <div class="placeholder-view-text">Your CONSTITUTION.md exists but has no parseable principles.</div>
2351
+ </div>`;
2352
+ return;
2353
+ }
2354
+
2355
+ // Summary list
2356
+ const summaryHtml = principles.map((p, i) => {
2357
+ const isSelected = selectedPrinciple && selectedPrinciple.number === p.number;
2358
+ 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>`;
2359
+ }).join('');
2360
+
2361
+ // Radar chart SVG
2362
+ const radarSvg = generateRadarSVG(principles);
2363
+
2364
+ // Detail card — only shown when a principle is selected
2365
+ const detailHtml = selectedPrinciple
2366
+ ? `<div class="detail-card">${renderDetailCard(selectedPrinciple)}</div>`
2367
+ : '';
2368
+
2369
+ // Timeline
2370
+ const timelineHtml = data.version ? renderTimeline(data.version) : '';
2371
+
2372
+ contentArea.innerHTML = `
2373
+ <div class="constitution-view">
2374
+ <div class="constitution-layout">
2375
+ <div class="constitution-left">
2376
+ <div class="radar-container">${radarSvg}</div>
2377
+ </div>
2378
+ <div class="constitution-right">
2379
+ <div class="constitution-summary">${summaryHtml}</div>
2380
+ ${detailHtml}
2381
+ </div>
2382
+ </div>
2383
+ ${timelineHtml}
2384
+ </div>`;
2385
+
2386
+ // Attach click handlers to radar axes AND list items
2387
+ function selectPrinciple(p) {
2388
+ selectedPrinciple = p;
2389
+ renderConstitutionContent(data);
2390
+ }
2391
+
2392
+ principles.forEach((p, i) => {
2393
+ const axisEl = document.getElementById(`radar-axis-${i}`);
2394
+ if (axisEl) {
2395
+ axisEl.addEventListener('click', () => selectPrinciple(p));
2396
+ axisEl.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectPrinciple(p); } });
2397
+ }
2398
+ const itemEl = document.getElementById(`principle-item-${i}`);
2399
+ if (itemEl) {
2400
+ itemEl.addEventListener('click', () => selectPrinciple(p));
2401
+ }
2402
+ });
2403
+ }
2404
+
2405
+ function generateRadarSVG(principles) {
2406
+ const size = 360;
2407
+ const cx = size / 2;
2408
+ const cy = size / 2;
2409
+ const maxR = size / 2 - 60;
2410
+ const levels = { 'MUST': 1, 'SHOULD': 0.66, 'MAY': 0.33 };
2411
+ const n = principles.length;
2412
+ const angleStep = (2 * Math.PI) / n;
2413
+ const startAngle = -Math.PI / 2; // Start from top
2414
+
2415
+ // Ring lines (33%, 66%, 100%)
2416
+ let rings = '';
2417
+ [0.33, 0.66, 1].forEach(pct => {
2418
+ const r = maxR * pct;
2419
+ const points = [];
2420
+ for (let i = 0; i < n; i++) {
2421
+ const angle = startAngle + i * angleStep;
2422
+ points.push(`${cx + r * Math.cos(angle)},${cy + r * Math.sin(angle)}`);
2423
+ }
2424
+ rings += `<polygon points="${points.join(' ')}" fill="none" stroke="var(--color-border)" stroke-width="1" opacity="0.5"/>`;
2425
+ });
2426
+
2427
+ // Axis lines from center to outer edge
2428
+ let axes = '';
2429
+ for (let i = 0; i < n; i++) {
2430
+ const angle = startAngle + i * angleStep;
2431
+ const x2 = cx + maxR * Math.cos(angle);
2432
+ const y2 = cy + maxR * Math.sin(angle);
2433
+ axes += `<line x1="${cx}" y1="${cy}" x2="${x2}" y2="${y2}" stroke="var(--color-border)" stroke-width="1" opacity="0.3"/>`;
2434
+ }
2435
+
2436
+ // Filled polygon connecting principle values
2437
+ const valuePoints = principles.map((p, i) => {
2438
+ const angle = startAngle + i * angleStep;
2439
+ const r = maxR * (levels[p.level] || 0.66);
2440
+ return `${cx + r * Math.cos(angle)},${cy + r * Math.sin(angle)}`;
2441
+ });
2442
+ const polygon = `<polygon points="${valuePoints.join(' ')}" fill="var(--color-accent)" fill-opacity="0.2" stroke="var(--color-accent)" stroke-width="2"/>`;
2443
+
2444
+ // Value dots and clickable areas
2445
+ let dots = '';
2446
+ principles.forEach((p, i) => {
2447
+ const angle = startAngle + i * angleStep;
2448
+ const r = maxR * (levels[p.level] || 0.66);
2449
+ const x = cx + r * Math.cos(angle);
2450
+ const y = cy + r * Math.sin(angle);
2451
+
2452
+ // Label position (pushed further out)
2453
+ const labelR = maxR + 24;
2454
+ const lx = cx + labelR * Math.cos(angle);
2455
+ const ly = cy + labelR * Math.sin(angle);
2456
+ const anchor = Math.abs(Math.cos(angle)) < 0.1 ? 'middle' : Math.cos(angle) > 0 ? 'start' : 'end';
2457
+
2458
+ const isSelected = selectedPrinciple && selectedPrinciple.number === p.number;
2459
+
2460
+ dots += `
2461
+ <g id="radar-axis-${i}" class="radar-axis" tabindex="0" role="button"
2462
+ aria-label="${escapeHtml(p.name)} (${p.level})">
2463
+ <circle cx="${x}" cy="${y}" r="${isSelected ? 7 : 5}" fill="var(--color-accent)" stroke="${isSelected ? 'var(--color-text)' : 'none'}" stroke-width="2"/>
2464
+ <circle cx="${x}" cy="${y}" r="16" fill="transparent"/>
2465
+ <text x="${lx}" y="${ly}" text-anchor="${anchor}" dominant-baseline="middle"
2466
+ font-size="11" font-weight="600" fill="var(--color-text-secondary)"
2467
+ style="letter-spacing: 0.2px;">${escapeHtml(p.name)}</text>
2468
+ </g>`;
2469
+ });
2470
+
2471
+ const ariaLabel = `Radar chart showing ${n} constitution principles: ${principles.map(p => p.name + ' (' + p.level + ')').join(', ')}`;
2472
+
2473
+ return `<svg viewBox="0 0 ${size} ${size}" role="img" aria-label="${escapeHtml(ariaLabel)}">${rings}${axes}${polygon}${dots}</svg>`;
2474
+ }
2475
+
2476
+ function renderDetailCard(principle) {
2477
+ // Strip rationale from main text to avoid duplication
2478
+ let mainText = principle.text;
2479
+ if (principle.rationale) {
2480
+ const ratIdx = mainText.indexOf('**Rationale**');
2481
+ if (ratIdx > -1) mainText = mainText.substring(0, ratIdx);
2482
+ }
2483
+ mainText = mainText.trim();
2484
+
2485
+ return `
2486
+ <h3>${escapeHtml(principle.number)}. ${escapeHtml(principle.name)}</h3>
2487
+ <div class="detail-level"><span class="level-badge ${principle.level.toLowerCase()}">${principle.level}</span></div>
2488
+ <div class="detail-text">${escapeHtml(mainText).replace(/\n\n/g, '<br><br>').replace(/\n/g, ' ')}</div>
2489
+ ${principle.rationale ? `<div class="detail-rationale"><strong>Rationale:</strong> ${escapeHtml(principle.rationale)}</div>` : ''}`;
2490
+ }
2491
+
2492
+ function renderTimeline(version) {
2493
+ return `
2494
+ <div class="amendment-timeline">
2495
+ <div class="amendment-timeline-label">Amendment History</div>
2496
+ <div class="amendment-timeline-content">
2497
+ <div class="amendment-timeline-dot"></div>
2498
+ <span>v${escapeHtml(version.version)} &mdash; Ratified ${escapeHtml(version.ratified)}</span>
2499
+ ${version.ratified !== version.lastAmended ? `
2500
+ <div class="amendment-timeline-line"></div>
2501
+ <div class="amendment-timeline-dot"></div>
2502
+ <span>Amended ${escapeHtml(version.lastAmended)}</span>
2503
+ ` : ''}
2504
+ </div>
2505
+ </div>`;
2506
+ }
2507
+
2508
+ function selectDefaultTab(pipeline) {
2509
+ if (!pipeline || !pipeline.phases) return 'implement';
2510
+
2511
+ const impl = pipeline.phases.find(p => p.id === 'implement');
2512
+ if (impl && (impl.status === 'in_progress' || impl.status === 'complete')) return 'implement';
2513
+
2514
+ // Walk backward through all phases to find last completed
2515
+ const allPhases = ['constitution', 'spec', 'clarify', 'plan', 'checklist', 'testify', 'tasks', 'analyze', 'implement'];
2516
+ for (let i = allPhases.length - 1; i >= 0; i--) {
2517
+ const phase = pipeline.phases.find(p => p.id === allPhases[i]);
2518
+ if (phase && phase.status === 'complete') {
2519
+ return allPhases[i];
2520
+ }
2521
+ }
2522
+
2523
+ return 'implement';
2524
+ }
2525
+
2526
+ async function loadPipeline(featureId) {
2527
+ try {
2528
+ const res = await fetch(`/api/pipeline/${featureId}`);
2529
+ if (!res.ok) return;
2530
+ const pipeline = await res.json();
2531
+ currentPipeline = pipeline;
2532
+
2533
+ if (!activeTab) {
2534
+ activeTab = selectDefaultTab(pipeline);
2535
+ }
2536
+
2537
+ renderPipeline(pipeline);
2538
+ switchTab(activeTab);
2539
+ } catch (err) {
2540
+ console.error('Failed to load pipeline:', err);
2541
+ }
2542
+ }
2543
+
2544
+ // ====== Feature Loading ======
2545
+ async function loadFeatures() {
2546
+ try {
2547
+ const res = await fetch('/api/features');
2548
+ const features = await res.json();
2549
+ updateFeatureSelector(features);
2550
+
2551
+ if (features.length > 0 && !currentFeature) {
2552
+ currentFeature = features[0].id;
2553
+ featureSelect.value = currentFeature;
2554
+ loadPipeline(currentFeature);
2555
+ loadBoard(currentFeature);
2556
+ } else if (features.length === 0) {
2557
+ showEmptyState();
2558
+ }
2559
+ } catch (err) {
2560
+ console.error('Failed to load features:', err);
2561
+ }
2562
+ }
2563
+
2564
+ function updateFeatureSelector(features) {
2565
+ featureSelect.innerHTML = '';
2566
+ if (features.length === 0) {
2567
+ featureSelect.innerHTML = '<option value="">No features found</option>';
2568
+ return;
2569
+ }
2570
+ for (const f of features) {
2571
+ const opt = document.createElement('option');
2572
+ opt.value = f.id;
2573
+ opt.textContent = `${f.id} — ${f.name} (${f.progress})`;
2574
+ featureSelect.appendChild(opt);
2575
+ }
2576
+ }
2577
+
2578
+ featureSelect.addEventListener('change', () => {
2579
+ const val = featureSelect.value;
2580
+ if (val && val !== currentFeature) {
2581
+ currentFeature = val;
2582
+ previousCardColumns = {};
2583
+ activeTab = null; // Reset tab selection for new feature
2584
+ currentStoryMap = null; // Reset story map cache
2585
+ currentPlanView = null; // Reset plan view cache
2586
+ loadPipeline(val);
2587
+ loadBoard(val);
2588
+ // Resubscribe WebSocket
2589
+ if (ws && ws.readyState === WebSocket.OPEN) {
2590
+ ws.send(JSON.stringify({ type: 'subscribe', feature: val }));
2591
+ }
2592
+ }
2593
+ });
2594
+
2595
+ featureSelect.addEventListener('keydown', (e) => {
2596
+ if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
2597
+ // Default browser behavior handles this
2598
+ return;
2599
+ }
2600
+ });
2601
+
2602
+ // ====== Board Loading ======
2603
+ async function loadBoard(featureId) {
2604
+ try {
2605
+ showLoading();
2606
+ const res = await fetch(`/api/board/${featureId}`);
2607
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
2608
+ const board = await res.json();
2609
+ currentBoard = board;
2610
+ renderBoard(board);
2611
+ updateIntegrity(board.integrity);
2612
+ } catch (err) {
2613
+ console.error('Failed to load board:', err);
2614
+ showEmptyState('Failed to load board data');
2615
+ }
2616
+ }
2617
+
2618
+ function showLoading() {
2619
+ boardEl.innerHTML = '<div class="loading"><div class="loading-spinner"></div></div>';
2620
+ }
2621
+
2622
+ function showEmptyState(msg) {
2623
+ boardEl.innerHTML = `
2624
+ <div class="empty-state">
2625
+ <div class="empty-state-icon">&#9776;</div>
2626
+ <div class="empty-state-title">${msg || 'No features found'}</div>
2627
+ <div class="empty-state-text">Create a feature with spec.md and tasks.md in your specs/ directory to get started.</div>
2628
+ </div>`;
2629
+ }
2630
+
2631
+ // ====== Board Rendering ======
2632
+ function renderBoardInto(targetEl, board) {
2633
+ if (!board || !targetEl) return;
2634
+
2635
+ const columns = [
2636
+ { key: 'todo', label: 'Todo', cards: board.todo || [] },
2637
+ { key: 'in_progress', label: 'In Progress', cards: board.in_progress || [] },
2638
+ { key: 'done', label: 'Done', cards: board.done || [] }
2639
+ ];
2640
+
2641
+ // Build new card positions
2642
+ const newCardColumns = {};
2643
+ for (const col of columns) {
2644
+ for (const card of col.cards) {
2645
+ newCardColumns[card.id] = col.key;
2646
+ }
2647
+ }
2648
+
2649
+ // Detect moved cards
2650
+ const movedCards = {};
2651
+ for (const [cardId, newCol] of Object.entries(newCardColumns)) {
2652
+ const oldCol = previousCardColumns[cardId];
2653
+ if (oldCol && oldCol !== newCol) {
2654
+ movedCards[cardId] = { from: oldCol, to: newCol };
2655
+ }
2656
+ }
2657
+
2658
+ targetEl.innerHTML = '';
2659
+
2660
+ for (const col of columns) {
2661
+ const colEl = document.createElement('div');
2662
+ colEl.className = `column ${col.key === 'in_progress' ? 'in-progress' : col.key}`;
2663
+ colEl.setAttribute('role', 'region');
2664
+ colEl.setAttribute('aria-label', `${col.label} column with ${col.cards.length} stories`);
2665
+
2666
+ colEl.innerHTML = `
2667
+ <div class="column-header">
2668
+ <div class="column-title">
2669
+ <span class="column-dot" aria-hidden="true"></span>
2670
+ ${col.label}
2671
+ </div>
2672
+ <span class="column-count">${col.cards.length}</span>
2673
+ </div>
2674
+ <div class="column-body" id="col-${col.key}"></div>`;
2675
+
2676
+ targetEl.appendChild(colEl);
2677
+
2678
+ const bodyEl = colEl.querySelector('.column-body');
2679
+
2680
+ if (col.cards.length === 0) {
2681
+ bodyEl.innerHTML = '<div class="column-empty">No stories</div>';
2682
+ } else {
2683
+ for (const card of col.cards) {
2684
+ const cardEl = createCardElement(card, col.key);
2685
+
2686
+ // Add animation class if card just moved here
2687
+ if (movedCards[card.id]) {
2688
+ cardEl.classList.add('entering');
2689
+ if (movedCards[card.id].to === 'done') {
2690
+ cardEl.classList.add('just-completed');
2691
+ }
2692
+ // Remove animation class after it completes
2693
+ cardEl.addEventListener('animationend', () => {
2694
+ cardEl.classList.remove('entering', 'just-completed');
2695
+ }, { once: true });
2696
+ }
2697
+
2698
+ bodyEl.appendChild(cardEl);
2699
+ }
2700
+ }
2701
+ }
2702
+
2703
+ previousCardColumns = newCardColumns;
2704
+ }
2705
+
2706
+ function renderBoard(board) {
2707
+ const targetEl = document.getElementById('board');
2708
+ if (targetEl) renderBoardInto(targetEl, board);
2709
+ }
2710
+
2711
+ function createCardElement(card, columnKey) {
2712
+ const el = document.createElement('div');
2713
+ el.className = 'card';
2714
+ el.setAttribute('data-card-id', card.id);
2715
+ el.setAttribute('role', 'article');
2716
+ el.setAttribute('aria-label', `${card.title} - ${card.priority} - ${card.progress} tasks complete`);
2717
+
2718
+ const progressParts = card.progress.split('/');
2719
+ const checked = parseInt(progressParts[0], 10);
2720
+ const total = parseInt(progressParts[1], 10);
2721
+ const pct = total > 0 ? Math.round((checked / total) * 100) : 0;
2722
+
2723
+ const priorityClass = card.priority ? card.priority.toLowerCase() : 'p3';
2724
+
2725
+ el.innerHTML = `
2726
+ <div class="card-id">${card.id}</div>
2727
+ <div class="card-header">
2728
+ <div class="card-title" title="${escapeHtml(card.title)}">${escapeHtml(card.title)}</div>
2729
+ <span class="priority-badge ${priorityClass}" aria-label="Priority ${card.priority}">${card.priority}</span>
2730
+ </div>
2731
+ <div class="progress-container">
2732
+ <div class="progress-info">
2733
+ <span class="progress-label">Progress</span>
2734
+ <span class="progress-value">${card.progress} (${pct}%)</span>
2735
+ </div>
2736
+ <div class="progress-bar" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100">
2737
+ <div class="progress-fill" style="width: ${pct}%"></div>
2738
+ </div>
2739
+ </div>
2740
+ <button class="task-toggle" onclick="toggleTasks(this)" aria-expanded="false" aria-controls="tasks-${card.id}">
2741
+ <span class="task-toggle-icon" aria-hidden="true">&#9654;</span>
2742
+ ${(card.tasks || []).length} tasks
2743
+ </button>
2744
+ <ul class="task-list collapsed" id="tasks-${card.id}" aria-label="Tasks for ${card.id}">
2745
+ ${(card.tasks || []).map(t => `
2746
+ <li class="task-item ${t.checked ? 'checked' : ''}">
2747
+ <span class="task-checkbox ${t.checked ? 'checked' : ''}" aria-hidden="true"></span>
2748
+ <span class="task-id">${t.id}</span>
2749
+ <span class="task-description">${escapeHtml(t.description)}</span>
2750
+ </li>
2751
+ `).join('')}
2752
+ </ul>`;
2753
+
2754
+ return el;
2755
+ }
2756
+
2757
+ function escapeHtml(str) {
2758
+ if (!str) return '';
2759
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
2760
+ }
2761
+
2762
+ // ====== Integrity Badge ======
2763
+ function updateIntegrity(integrity) {
2764
+ if (!integrity) return;
2765
+ const badge = integrityBadge;
2766
+ const textEl = badge.querySelector('.integrity-text');
2767
+
2768
+ badge.className = `integrity-badge ${integrity.status}`;
2769
+
2770
+ switch (integrity.status) {
2771
+ case 'valid':
2772
+ textEl.textContent = 'Verified';
2773
+ badge.setAttribute('aria-label', 'Test integrity: verified');
2774
+ badge.title = 'Assertion hash matches stored hash';
2775
+ break;
2776
+ case 'tampered':
2777
+ textEl.textContent = 'Tampered';
2778
+ badge.setAttribute('aria-label', 'Test integrity: tampered - assertions may have been modified');
2779
+ badge.title = 'Assertion hash does not match stored hash!';
2780
+ break;
2781
+ case 'missing':
2782
+ textEl.textContent = 'Missing';
2783
+ badge.setAttribute('aria-label', 'Test integrity: no hash data available');
2784
+ badge.title = 'No test-specs.md or context.json found';
2785
+ break;
2786
+ }
2787
+ }
2788
+
2789
+ // ====== Plan View ======
2790
+ let currentPlanView = null;
2791
+
2792
+ async function renderPlanView() {
2793
+ if (!currentFeature) return;
2794
+ try {
2795
+ if (!currentPlanView) {
2796
+ const res = await fetch(`/api/planview/${currentFeature}`);
2797
+ currentPlanView = await res.json();
2798
+ }
2799
+ renderPlanViewContent(currentPlanView);
2800
+ } catch (err) {
2801
+ 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>`;
2802
+ }
2803
+ }
2804
+
2805
+ function renderPlanViewContent(data) {
2806
+ if (!data || !data.exists) {
2807
+ contentArea.innerHTML = `<div class="planview-empty"><div class="planview-empty-title">No plan created yet</div><div class="planview-empty-text">Run /iikit-03-plan to create a technical implementation plan for this feature.</div></div>`;
2808
+ return;
2809
+ }
2810
+
2811
+ let html = '<div class="planview-view">';
2812
+
2813
+ // Badge Wall
2814
+ html += '<div class="planview-section">';
2815
+ html += '<div class="planview-section-title">Tech Stack</div>';
2816
+ if (data.techContext && data.techContext.length > 0) {
2817
+ html += '<div class="badge-wall">';
2818
+ for (const entry of data.techContext) {
2819
+ const tooltip = findResearchTooltip(entry.value, data.researchDecisions);
2820
+ html += `<div class="tech-badge" role="listitem">`;
2821
+ html += `<span class="tech-badge-label">${escapeHtml(entry.label)}</span>`;
2822
+ html += `<span class="tech-badge-value">${escapeHtml(entry.value)}</span>`;
2823
+ if (tooltip) {
2824
+ html += `<div class="tech-badge-tooltip" role="tooltip">${escapeHtml(tooltip)}</div>`;
2825
+ }
2826
+ html += `</div>`;
2827
+ }
2828
+ html += '</div>';
2829
+ } else {
2830
+ html += '<div class="planview-empty"><div class="planview-empty-text">No tech stack defined in plan</div></div>';
2831
+ }
2832
+ html += '</div>';
2833
+
2834
+ // Tessl Tiles Panel
2835
+ if (data.tesslTiles && data.tesslTiles.length > 0) {
2836
+ html += '<div class="planview-section">';
2837
+ html += '<div class="planview-section-title">Tessl Tiles</div>';
2838
+ html += '<div class="tessl-tiles">';
2839
+ for (const tile of data.tesslTiles) {
2840
+ html += `<div class="tessl-tile-card">`;
2841
+ html += `<span class="tessl-tile-name">${escapeHtml(tile.name)}</span>`;
2842
+ html += `<span class="tessl-tile-version">v${escapeHtml(tile.version)}</span>`;
2843
+ if (tile.eval) {
2844
+ html += `<div class="tessl-tile-eval">`;
2845
+ html += `<span class="tessl-eval-score">${tile.eval.score}%</span>`;
2846
+ html += `<div class="tessl-eval-bar"><div class="tessl-eval-bar-fill" style="width:${tile.eval.score}%"></div></div>`;
2847
+ if (tile.eval.multiplier) {
2848
+ html += `<span class="tessl-eval-multiplier">\u2191 ${tile.eval.multiplier}x</span>`;
2849
+ }
2850
+ html += `</div>`;
2851
+ }
2852
+ html += `</div>`;
2853
+ }
2854
+ html += '</div></div>';
2855
+ }
2856
+
2857
+ // File Structure Tree
2858
+ if (data.fileStructure && data.fileStructure.entries && data.fileStructure.entries.length > 0) {
2859
+ html += '<div class="planview-section">';
2860
+ html += '<div class="planview-section-title">Project Structure</div>';
2861
+ html += '<div class="file-tree" role="tree" aria-label="Project file structure">';
2862
+ html += renderFileTree(data.fileStructure.entries, 0);
2863
+ html += '</div></div>';
2864
+ }
2865
+
2866
+ // Architecture Diagram
2867
+ if (data.diagram && data.diagram.nodes && data.diagram.nodes.length > 0) {
2868
+ html += '<div class="planview-section">';
2869
+ html += '<div class="planview-section-title">Architecture</div>';
2870
+ html += '<div class="diagram-container">';
2871
+ html += renderDiagramSVG(data.diagram);
2872
+ html += '</div>';
2873
+ html += renderDiagramLegend(data.diagram);
2874
+ html += '<div id="diagram-detail" class="detail-panel-slot"></div>';
2875
+ html += '</div>';
2876
+ } else if (data.diagram && data.diagram.raw) {
2877
+ // Fallback: raw ASCII
2878
+ html += '<div class="planview-section">';
2879
+ html += '<div class="planview-section-title">Architecture</div>';
2880
+ html += `<pre class="diagram-raw">${escapeHtml(data.diagram.raw)}</pre>`;
2881
+ html += '</div>';
2882
+ }
2883
+
2884
+ html += '</div>';
2885
+ contentArea.innerHTML = html;
2886
+
2887
+ // Attach event handlers
2888
+ attachTreeHandlers();
2889
+ attachDiagramHandlers(data.diagram);
2890
+ }
2891
+
2892
+ function findResearchTooltip(badgeValue, decisions) {
2893
+ if (!decisions || decisions.length === 0) return null;
2894
+ const valueLower = badgeValue.toLowerCase();
2895
+ for (const d of decisions) {
2896
+ if (d.title && valueLower.includes(d.title.toLowerCase().split(' ')[0])) {
2897
+ return d.rationale || d.decision;
2898
+ }
2899
+ // Also check if decision title words appear in badge value
2900
+ if (d.title) {
2901
+ const words = d.title.toLowerCase().split(/\s+/);
2902
+ for (const word of words) {
2903
+ if (word.length > 3 && valueLower.includes(word)) {
2904
+ return d.rationale || d.decision;
2905
+ }
2906
+ }
2907
+ }
2908
+ }
2909
+ return null;
2910
+ }
2911
+
2912
+ function getFileIconInfo(name, isDir) {
2913
+ if (isDir) return { cls: 'dir', ch: '\uD83D\uDCC2' };
2914
+ const ext = name.split('.').pop();
2915
+ if (['js', 'mjs'].includes(ext)) return { cls: 'file-js', ch: 'JS' };
2916
+ if (ext === 'json') return { cls: 'file-json', ch: '{}' };
2917
+ if (ext === 'md') return { cls: 'file-md', ch: 'M\u2193' };
2918
+ if (ext === 'html') return { cls: 'file-html', ch: '</>' };
2919
+ return { cls: 'file', ch: '\u25CB' };
2920
+ }
2921
+
2922
+ function renderFileTree(entries) {
2923
+ let html = '';
2924
+ let i = 0;
2925
+ while (i < entries.length) {
2926
+ const entry = entries[i];
2927
+ const isDir = entry.type === 'directory';
2928
+ const expanded = entry.depth < 2;
2929
+
2930
+ // Indent guides
2931
+ let indent = '';
2932
+ for (let d = 0; d < entry.depth; d++) {
2933
+ indent += '<span class="file-tree-guide"></span>';
2934
+ }
2935
+
2936
+ const { cls, ch } = getFileIconInfo(entry.name, isDir);
2937
+
2938
+ if (isDir) {
2939
+ const children = [];
2940
+ let j = i + 1;
2941
+ while (j < entries.length && entries[j].depth > entry.depth) {
2942
+ children.push(entries[j]);
2943
+ j++;
2944
+ }
2945
+ const childId = `tree-${entry.depth}-${entry.name}`.replace(/[^a-zA-Z0-9-]/g, '_');
2946
+
2947
+ html += `<div class="file-tree-entry">`;
2948
+ html += `<span class="file-tree-indent">${indent}</span>`;
2949
+ html += `<span class="file-tree-chevron" data-target="${childId}">${expanded ? '\u25BE' : '\u25B8'}</span>`;
2950
+ html += `<span class="file-tree-file-icon ${cls}">${ch}</span>`;
2951
+ html += `<span class="file-tree-label"><span class="file-tree-name">${escapeHtml(entry.name)}</span></span>`;
2952
+ if (entry.comment) html += `<span class="file-tree-comment" data-full="${escapeHtml(entry.comment)}">${escapeHtml(entry.comment)}</span>`;
2953
+ html += `</div>`;
2954
+ html += `<div id="${childId}" class="file-tree-children${expanded ? '' : ' collapsed'}">`;
2955
+ html += renderFileTree(children);
2956
+ html += `</div>`;
2957
+ i = j;
2958
+ } else {
2959
+ const isPlanned = entry.exists === false;
2960
+ const nameClass = isPlanned ? ' planned' : '';
2961
+
2962
+ html += `<div class="file-tree-entry">`;
2963
+ html += `<span class="file-tree-indent">${indent}</span>`;
2964
+ html += `<span class="file-tree-chevron-spacer"></span>`;
2965
+ html += `<span class="file-tree-file-icon ${cls}">${ch}</span>`;
2966
+ html += `<span class="file-tree-label">`;
2967
+ html += `<span class="file-tree-name${nameClass}">${escapeHtml(entry.name)}</span>`;
2968
+ if (isPlanned) html += `<span class="file-tree-status planned-tag">planned</span>`;
2969
+ html += `</span>`;
2970
+ if (entry.comment) html += `<span class="file-tree-comment" data-full="${escapeHtml(entry.comment)}">${escapeHtml(entry.comment)}</span>`;
2971
+ html += `</div>`;
2972
+ i++;
2973
+ }
2974
+ }
2975
+ return html;
2976
+ }
2977
+
2978
+ function renderDiagramLegend(diagram) {
2979
+ if (!diagram || !diagram.nodes) return '';
2980
+ const types = new Set(diagram.nodes.map(n => n.type).filter(t => t !== 'default'));
2981
+ if (types.size === 0) return '';
2982
+ const colors = { client: 'var(--color-accent)', server: 'var(--color-p2)', storage: 'var(--color-done)', external: 'var(--color-p1)' };
2983
+ let html = '<div class="diagram-legend">';
2984
+ for (const type of types) {
2985
+ html += `<div class="diagram-legend-item"><span class="diagram-legend-dot" style="background:${colors[type] || 'var(--color-text-muted)'}"></span>${type}</div>`;
2986
+ }
2987
+ html += '</div>';
2988
+ return html;
2989
+ }
2990
+
2991
+ function attachTreeHandlers() {
2992
+ document.querySelectorAll('.file-tree-chevron').forEach(chevron => {
2993
+ chevron.addEventListener('click', () => {
2994
+ const targetId = chevron.dataset.target;
2995
+ const children = document.getElementById(targetId);
2996
+ if (!children) return;
2997
+ const isCollapsed = children.classList.contains('collapsed');
2998
+ children.classList.toggle('collapsed', !isCollapsed);
2999
+ chevron.textContent = isCollapsed ? '\u25BE' : '\u25B8';
3000
+ });
3001
+ });
3002
+ // Add title tooltip only on comments that are actually truncated
3003
+ document.querySelectorAll('.file-tree-comment').forEach(comment => {
3004
+ if (comment.scrollWidth > comment.clientWidth) {
3005
+ comment.classList.add('truncated');
3006
+ comment.title = comment.dataset.full || comment.textContent;
3007
+ }
3008
+ });
3009
+ }
3010
+
3011
+ function renderDiagramSVG(diagram) {
3012
+ if (!diagram || !diagram.nodes || diagram.nodes.length === 0) return '';
3013
+
3014
+ // Calculate SVG dimensions from node positions
3015
+ let maxX = 0, maxY = 0;
3016
+ for (const n of diagram.nodes) {
3017
+ const right = n.x + n.width;
3018
+ const bottom = n.y + n.height;
3019
+ if (right > maxX) maxX = right;
3020
+ if (bottom > maxY) maxY = bottom;
3021
+ }
3022
+
3023
+ // Scale to SVG viewport
3024
+ const padding = 40;
3025
+ const svgWidth = 800;
3026
+ const scaleX = (svgWidth - padding * 2) / (maxX || 1);
3027
+ const scaleY = scaleX; // maintain aspect ratio
3028
+ const svgHeight = maxY * scaleY + padding * 2;
3029
+
3030
+ const nodeTypeColors = {
3031
+ client: 'var(--color-accent)',
3032
+ server: 'var(--color-p2)',
3033
+ storage: 'var(--color-done)',
3034
+ external: 'var(--color-p1)',
3035
+ default: 'var(--color-text-muted)'
3036
+ };
3037
+
3038
+ let svg = `<svg class="diagram-svg" viewBox="0 0 ${svgWidth} ${svgHeight}" role="figure" aria-label="Architecture diagram">`;
3039
+
3040
+ // Draw edges first (behind nodes)
3041
+ for (const edge of diagram.edges) {
3042
+ const fromNode = diagram.nodes.find(n => n.id === edge.from);
3043
+ const toNode = diagram.nodes.find(n => n.id === edge.to);
3044
+ if (!fromNode || !toNode) continue;
3045
+
3046
+ const x1 = (fromNode.x + fromNode.width / 2) * scaleX + padding;
3047
+ const y1 = (fromNode.y + fromNode.height) * scaleY + padding;
3048
+ const x2 = (toNode.x + toNode.width / 2) * scaleX + padding;
3049
+ const y2 = toNode.y * scaleY + padding;
3050
+
3051
+ svg += `<line class="diagram-edge-line" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" aria-hidden="true"/>`;
3052
+ if (edge.label) {
3053
+ const midX = (x1 + x2) / 2 + 8;
3054
+ const midY = (y1 + y2) / 2;
3055
+ svg += `<text class="diagram-edge-label" x="${midX}" y="${midY}">${escapeHtml(edge.label)}</text>`;
3056
+ }
3057
+ }
3058
+
3059
+ // Draw nodes
3060
+ for (const node of diagram.nodes) {
3061
+ const x = node.x * scaleX + padding;
3062
+ const y = node.y * scaleY + padding;
3063
+ const w = node.width * scaleX;
3064
+ const h = node.height * scaleY;
3065
+ const color = nodeTypeColors[node.type] || nodeTypeColors.default;
3066
+
3067
+ svg += `<g class="diagram-node" data-node-id="${node.id}" tabindex="0" role="img" aria-label="${escapeHtml(node.label)} (${node.type})">`;
3068
+ svg += `<rect class="diagram-node-rect" x="${x}" y="${y}" width="${w}" height="${h}" fill="var(--color-surface)" stroke="${color}"/>`;
3069
+ svg += `<text class="diagram-node-label" x="${x + w/2}" y="${y + h/2 + 5}" text-anchor="middle">${escapeHtml(node.label)}</text>`;
3070
+ svg += `</g>`;
3071
+ }
3072
+
3073
+ svg += '</svg>';
3074
+ return svg;
3075
+ }
3076
+
3077
+ function attachDiagramHandlers(diagram) {
3078
+ if (!diagram) return;
3079
+ document.querySelectorAll('.diagram-node').forEach(nodeEl => {
3080
+ const handler = () => {
3081
+ const nodeId = nodeEl.dataset.nodeId;
3082
+ const node = diagram.nodes.find(n => n.id === nodeId);
3083
+ if (!node) return;
3084
+ showPlanDetailPanel(node);
3085
+ };
3086
+ nodeEl.addEventListener('click', handler);
3087
+ nodeEl.addEventListener('keydown', (e) => {
3088
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handler(); }
3089
+ });
3090
+ });
3091
+ }
3092
+
3093
+ function showPlanDetailPanel(node) {
3094
+ const slot = document.getElementById('diagram-detail');
3095
+ if (!slot) return;
3096
+ const typeLabel = node.type !== 'default' ? ` (${node.type})` : '';
3097
+ slot.innerHTML = `
3098
+ <div class="detail-panel">
3099
+ <div class="detail-panel-header">
3100
+ <span class="detail-panel-id">${escapeHtml(node.label)}${typeLabel}</span>
3101
+ <button class="detail-panel-close" aria-label="Close detail panel">\u00d7</button>
3102
+ </div>
3103
+ <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>
3104
+ </div>`;
3105
+ slot.querySelector('.detail-panel-close').addEventListener('click', () => { slot.innerHTML = ''; });
3106
+ }
3107
+
3108
+ // ====== WebSocket ======
3109
+ function connectWebSocket() {
3110
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
3111
+ const url = `${protocol}//${location.host}`;
3112
+
3113
+ // connecting
3114
+
3115
+ try {
3116
+ ws = new WebSocket(url);
3117
+ } catch (err) {
3118
+ // disconnected
3119
+ scheduleReconnect();
3120
+ return;
3121
+ }
3122
+
3123
+ ws.onopen = () => {
3124
+ // connected
3125
+ if (reconnectTimer) {
3126
+ clearTimeout(reconnectTimer);
3127
+ reconnectTimer = null;
3128
+ }
3129
+ // Subscribe to current feature
3130
+ if (currentFeature) {
3131
+ ws.send(JSON.stringify({ type: 'subscribe', feature: currentFeature }));
3132
+ }
3133
+ };
3134
+
3135
+ ws.onmessage = (event) => {
3136
+ try {
3137
+ const msg = JSON.parse(event.data);
3138
+ handleMessage(msg);
3139
+ } catch (err) {
3140
+ console.error('Failed to parse WebSocket message:', err);
3141
+ }
3142
+ };
3143
+
3144
+ ws.onclose = () => {
3145
+ // disconnected
3146
+ ws = null;
3147
+ scheduleReconnect();
3148
+ };
3149
+
3150
+ ws.onerror = () => {
3151
+ // onclose will fire after onerror
3152
+ };
3153
+ }
3154
+
3155
+ function markActivity() {
3156
+ lastActivityTime = Date.now();
3157
+ if (!activityIndicator.classList.contains('active')) {
3158
+ activityIndicator.classList.remove('idle');
3159
+ activityIndicator.classList.add('active');
3160
+ activityIndicator.title = 'Agent active \u2014 files changing';
3161
+ activityIndicator.setAttribute('aria-label', 'Agent activity: active');
3162
+ }
3163
+ }
3164
+
3165
+ // Check activity every 2 seconds
3166
+ setInterval(() => {
3167
+ if (lastActivityTime > 0 && Date.now() - lastActivityTime > ACTIVITY_TIMEOUT) {
3168
+ if (activityIndicator.classList.contains('active')) {
3169
+ activityIndicator.classList.remove('active');
3170
+ activityIndicator.classList.add('idle');
3171
+ activityIndicator.title = 'Agent idle \u2014 no recent file changes';
3172
+ activityIndicator.setAttribute('aria-label', 'Agent activity: idle');
3173
+ }
3174
+ }
3175
+ }, 2000);
3176
+
3177
+ function handleMessage(msg) {
3178
+ markActivity();
3179
+ switch (msg.type) {
3180
+ case 'board_update':
3181
+ if (msg.feature === currentFeature && msg.board) {
3182
+ currentBoard = msg.board;
3183
+ if (activeTab === 'implement') {
3184
+ renderBoard(msg.board);
3185
+ }
3186
+ updateIntegrity(msg.board.integrity);
3187
+ }
3188
+ break;
3189
+
3190
+ case 'pipeline_update':
3191
+ if (msg.feature === currentFeature && msg.pipeline) {
3192
+ currentPipeline = msg.pipeline;
3193
+ renderPipeline(msg.pipeline);
3194
+ }
3195
+ break;
3196
+
3197
+ case 'storymap_update':
3198
+ if (msg.feature === currentFeature && msg.storymap) {
3199
+ currentStoryMap = msg.storymap;
3200
+ if (activeTab === 'spec') {
3201
+ renderStoryMapContent(msg.storymap);
3202
+ }
3203
+ }
3204
+ break;
3205
+
3206
+ case 'planview_update':
3207
+ if (msg.feature === currentFeature && msg.planview) {
3208
+ currentPlanView = msg.planview;
3209
+ if (activeTab === 'plan') {
3210
+ renderPlanViewContent(msg.planview);
3211
+ }
3212
+ }
3213
+ break;
3214
+
3215
+ case 'constitution_update':
3216
+ if (msg.constitution) {
3217
+ currentConstitution = msg.constitution;
3218
+ if (activeTab === 'constitution') {
3219
+ renderConstitutionContent(msg.constitution);
3220
+ }
3221
+ }
3222
+ break;
3223
+
3224
+ case 'features_update':
3225
+ if (msg.features) {
3226
+ updateFeatureSelector(msg.features);
3227
+ if (currentFeature) {
3228
+ featureSelect.value = currentFeature;
3229
+ }
3230
+ }
3231
+ break;
3232
+ }
3233
+ }
3234
+
3235
+ function scheduleReconnect() {
3236
+ if (reconnectTimer) return;
3237
+ reconnectTimer = setTimeout(() => {
3238
+ reconnectTimer = null;
3239
+ connectWebSocket();
3240
+ }, 3000);
3241
+ }
3242
+
3243
+
3244
+ // ====== Theme Toggle ======
3245
+ const themeToggle = document.getElementById('themeToggle');
3246
+ const themeIcon = document.getElementById('themeIcon');
3247
+ const html = document.documentElement;
3248
+
3249
+ // Three-state cycle: system -> light -> dark -> system
3250
+ let themeMode = localStorage.getItem('iikit-theme') || 'system';
3251
+
3252
+ function getSystemTheme() {
3253
+ return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
3254
+ }
3255
+
3256
+ function applyTheme(mode) {
3257
+ themeMode = mode;
3258
+ if (mode === 'system') {
3259
+ localStorage.removeItem('iikit-theme');
3260
+ const resolved = getSystemTheme();
3261
+ if (resolved === 'light') {
3262
+ html.setAttribute('data-theme', 'light');
3263
+ } else {
3264
+ html.removeAttribute('data-theme');
3265
+ }
3266
+ themeIcon.textContent = '\uD83D\uDDA5'; // monitor
3267
+ themeToggle.setAttribute('aria-label', 'Theme: System (click for Light)');
3268
+ themeToggle.title = 'Theme: System';
3269
+ } else if (mode === 'light') {
3270
+ localStorage.setItem('iikit-theme', 'light');
3271
+ html.setAttribute('data-theme', 'light');
3272
+ themeIcon.textContent = '\u2600'; // sun
3273
+ themeToggle.setAttribute('aria-label', 'Theme: Light (click for Dark)');
3274
+ themeToggle.title = 'Theme: Light';
3275
+ } else {
3276
+ localStorage.setItem('iikit-theme', 'dark');
3277
+ html.removeAttribute('data-theme');
3278
+ themeIcon.textContent = '\u263E'; // moon
3279
+ themeToggle.setAttribute('aria-label', 'Theme: Dark (click for System)');
3280
+ themeToggle.title = 'Theme: Dark';
3281
+ }
3282
+ }
3283
+
3284
+ themeToggle.addEventListener('click', () => {
3285
+ const next = themeMode === 'system' ? 'light' : themeMode === 'light' ? 'dark' : 'system';
3286
+ applyTheme(next);
3287
+ });
3288
+
3289
+ // Listen for OS theme changes — only react in system mode
3290
+ window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => {
3291
+ if (themeMode === 'system') applyTheme('system');
3292
+ });
3293
+
3294
+ // Apply on load
3295
+ applyTheme(themeMode);
3296
+
3297
+ // ====== Init ======
3298
+ loadFeatures();
3299
+ connectWebSocket();
3300
+ })();
3301
+
3302
+ // ====== Task List Toggle (global, called from onclick) ======
3303
+ function toggleTasks(btn) {
3304
+ const list = btn.nextElementSibling;
3305
+ const icon = btn.querySelector('.task-toggle-icon');
3306
+ const isCollapsed = list.classList.contains('collapsed');
3307
+
3308
+ if (isCollapsed) {
3309
+ list.classList.remove('collapsed');
3310
+ list.classList.add('expanded');
3311
+ icon.classList.add('expanded');
3312
+ btn.setAttribute('aria-expanded', 'true');
3313
+ } else {
3314
+ list.classList.remove('expanded');
3315
+ list.classList.add('collapsed');
3316
+ icon.classList.remove('expanded');
3317
+ btn.setAttribute('aria-expanded', 'false');
3318
+ }
3319
+ }
3320
+ </script>
3321
+ </body>
3322
+ </html>