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.
- package/README.md +77 -0
- package/bin/iikit-dashboard.js +68 -0
- package/package.json +45 -0
- package/src/board.js +93 -0
- package/src/integrity.js +63 -0
- package/src/parser.js +768 -0
- package/src/pipeline.js +130 -0
- package/src/planview.js +195 -0
- package/src/public/index.html +3322 -0
- package/src/server.js +302 -0
- package/src/storymap.js +40 -0
|
@@ -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">☾</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: '▶',
|
|
1730
|
+
complete: '✓',
|
|
1731
|
+
skipped: '—',
|
|
1732
|
+
available: '●'
|
|
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">×</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&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)} — 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">☰</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">▶</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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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>
|