@davevdveen/spec-reader 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2366 @@
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>SpecReader</title>
7
+ <style>
8
+ :root {
9
+ --sidebar-w: 280px;
10
+ }
11
+
12
+ /* ═══ Original — clean, neutral, Apple Books white ═══ */
13
+ html.original-light, :root {
14
+ --bg: #FBFBFB; --surface: #FFFFFF; --surface-hover: #F5F5F5;
15
+ --sidebar-bg: #F5F5F5; --border: #E5E5E5; --border-active: #D1D1D1;
16
+ --text: #1D1D1F; --text-secondary: #606065; --text-muted: #747477;
17
+ --accent: #0070EA; --accent-dim: rgba(0,112,234,0.08);
18
+ --code-bg: #F5F5F5; --table-stripe: rgba(0,0,0,0.02);
19
+ --when-color: #B45D00; --when-bg: rgba(180,93,0,0.1);
20
+ --then-color: #22853B; --then-bg: rgba(34,133,59,0.1);
21
+ --and-color: #0070EA; --and-bg: rgba(0,112,234,0.08);
22
+ --added-color: #248A3D; --added-bg: rgba(36,138,61,0.06);
23
+ --modified-color: #E67700; --modified-bg: rgba(230,119,0,0.06);
24
+ --removed-color: #FF3B30; --removed-bg: rgba(255,59,48,0.06);
25
+ }
26
+ html.original-dark {
27
+ --bg: #1A1A1A; --surface: #242424; --surface-hover: #2A2A2A;
28
+ --sidebar-bg: #1C1C1E; --border: #38383A; --border-active: #545458;
29
+ --text: #E0E0E0; --text-secondary: #A4A4A4; --text-muted: #818181;
30
+ --accent: #2592FF; --accent-dim: rgba(37,146,255,0.12);
31
+ --code-bg: #1C1C1E; --table-stripe: rgba(255,255,255,0.02);
32
+ --when-color: #FF9F0A; --when-bg: rgba(255,159,10,0.12);
33
+ --then-color: #30D158; --then-bg: rgba(48,209,88,0.12);
34
+ --and-color: #0A84FF; --and-bg: rgba(10,132,255,0.1);
35
+ --added-color: #30D158; --added-bg: rgba(48,209,88,0.08);
36
+ --modified-color: #FF9F0A; --modified-bg: rgba(255,159,10,0.08);
37
+ --removed-color: #FF453A; --removed-bg: rgba(255,69,58,0.08);
38
+ }
39
+
40
+ /* ═══ Paper — cool gray, newsprint, literary ═══ */
41
+ html.paper-light {
42
+ --bg: #EDEDEB; --surface: #F5F5F3; --surface-hover: #E5E5E2;
43
+ --sidebar-bg: #E3E3E0; --border: #D2D2CF; --border-active: #BBBBB8;
44
+ --text: #2C2C2A; --text-secondary: #535351; --text-muted: #6B6B69;
45
+ --accent: #0067D9; --accent-dim: rgba(0,103,217,0.08);
46
+ --code-bg: #E5E5E2; --table-stripe: rgba(0,0,0,0.02);
47
+ --when-color: #A95400; --when-bg: rgba(169,84,0,0.1);
48
+ --then-color: #207B36; --then-bg: rgba(32,123,54,0.1);
49
+ --and-color: #0067D9; --and-bg: rgba(0,103,217,0.08);
50
+ --added-color: #248A3D; --added-bg: rgba(36,138,61,0.06);
51
+ --modified-color: #C06000; --modified-bg: rgba(192,96,0,0.06);
52
+ --removed-color: #D42020; --removed-bg: rgba(212,32,32,0.06);
53
+ }
54
+ html.paper-dark {
55
+ --bg: #1C1C1E; --surface: #2C2C2E; --surface-hover: #3A3A3C;
56
+ --sidebar-bg: #1C1C1E; --border: #38383A; --border-active: #545458;
57
+ --text: #C8C8C8; --text-secondary: #A5A5A9; --text-muted: #838385;
58
+ --accent: #2B94FF; --accent-dim: rgba(43,148,255,0.12);
59
+ --code-bg: #2C2C2E; --table-stripe: rgba(255,255,255,0.02);
60
+ --when-color: #E8A030; --when-bg: rgba(232,160,48,0.1);
61
+ --then-color: #4ADE80; --then-bg: rgba(74,222,128,0.1);
62
+ --and-color: #0A84FF; --and-bg: rgba(10,132,255,0.1);
63
+ --added-color: #4ADE80; --added-bg: rgba(74,222,128,0.06);
64
+ --modified-color: #E8A030; --modified-bg: rgba(232,160,48,0.06);
65
+ --removed-color: #FF6060; --removed-bg: rgba(255,96,96,0.06);
66
+ }
67
+
68
+ /* ═══ Calm — warm sepia, Kindle-like, reduced blue light ═══ */
69
+ html.calm-light {
70
+ --bg: #F8F1E3; --surface: #FBF6EE; --surface-hover: #F0E8D8;
71
+ --sidebar-bg: #F0E8D8; --border: #DDD5C4; --border-active: #C8BFA8;
72
+ --text: #5F4B32; --text-secondary: #6C5F4E; --text-muted: #746E62;
73
+ --accent: #8F672B; --accent-dim: rgba(143,103,43,0.1);
74
+ --code-bg: #F0E8D8; --table-stripe: rgba(95,75,50,0.02);
75
+ --when-color: #A05F00; --when-bg: rgba(160,95,0,0.1);
76
+ --then-color: #2D7A3A; --then-bg: rgba(45,122,58,0.1);
77
+ --and-color: #8F672B; --and-bg: rgba(143,103,43,0.08);
78
+ --added-color: #2D7A3A; --added-bg: rgba(45,122,58,0.06);
79
+ --modified-color: #B06800; --modified-bg: rgba(176,104,0,0.06);
80
+ --removed-color: #C53030; --removed-bg: rgba(197,48,48,0.06);
81
+ }
82
+ html.calm-dark {
83
+ --bg: #1A1814; --surface: #24211C; --surface-hover: #2C2822;
84
+ --sidebar-bg: #1E1C17; --border: #383428; --border-active: #504A3C;
85
+ --text: #D6D0C4; --text-secondary: #A8A296; --text-muted: #867F74;
86
+ --accent: #C8A460; --accent-dim: rgba(200,164,96,0.1);
87
+ --code-bg: #211E18; --table-stripe: rgba(255,255,255,0.02);
88
+ --when-color: #DCA040; --when-bg: rgba(220,160,64,0.12);
89
+ --then-color: #5CB870; --then-bg: rgba(92,184,112,0.1);
90
+ --and-color: #C8A460; --and-bg: rgba(200,164,96,0.08);
91
+ --added-color: #5CB870; --added-bg: rgba(92,184,112,0.06);
92
+ --modified-color: #DCA040; --modified-bg: rgba(220,160,64,0.06);
93
+ --removed-color: #E06050; --removed-bg: rgba(224,96,80,0.06);
94
+ }
95
+
96
+ /* ═══ Mono — Original colors, monospace typography ═══ */
97
+ html.mono-light {
98
+ --bg: #FBFBFB; --surface: #FFFFFF; --surface-hover: #F5F5F5;
99
+ --sidebar-bg: #F5F5F5; --border: #E5E5E5; --border-active: #D1D1D1;
100
+ --text: #1D1D1F; --text-secondary: #606065; --text-muted: #747477;
101
+ --accent: #0070EA; --accent-dim: rgba(0,112,234,0.08);
102
+ --code-bg: #F5F5F5; --table-stripe: rgba(0,0,0,0.02);
103
+ --when-color: #B45D00; --when-bg: rgba(180,93,0,0.1);
104
+ --then-color: #22853B; --then-bg: rgba(34,133,59,0.1);
105
+ --and-color: #0070EA; --and-bg: rgba(0,112,234,0.08);
106
+ --added-color: #248A3D; --added-bg: rgba(36,138,61,0.06);
107
+ --modified-color: #E67700; --modified-bg: rgba(230,119,0,0.06);
108
+ --removed-color: #FF3B30; --removed-bg: rgba(255,59,48,0.06);
109
+ }
110
+ html.mono-dark {
111
+ --bg: #1A1A1A; --surface: #242424; --surface-hover: #2A2A2A;
112
+ --sidebar-bg: #1C1C1E; --border: #38383A; --border-active: #545458;
113
+ --text: #E0E0E0; --text-secondary: #A4A4A4; --text-muted: #818181;
114
+ --accent: #2592FF; --accent-dim: rgba(37,146,255,0.12);
115
+ --code-bg: #1C1C1E; --table-stripe: rgba(255,255,255,0.02);
116
+ --when-color: #FF9F0A; --when-bg: rgba(255,159,10,0.12);
117
+ --then-color: #30D158; --then-bg: rgba(48,209,88,0.12);
118
+ --and-color: #0A84FF; --and-bg: rgba(10,132,255,0.1);
119
+ --added-color: #30D158; --added-bg: rgba(48,209,88,0.08);
120
+ --modified-color: #FF9F0A; --modified-bg: rgba(255,159,10,0.08);
121
+ --removed-color: #FF453A; --removed-bg: rgba(255,69,58,0.08);
122
+ }
123
+ /* ── Theme typography overrides ── */
124
+ /* Original: sans-serif, clean modern */
125
+ html[class^="original-"] #content { font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif; }
126
+ html[class^="original-"] #content h1,
127
+ html[class^="original-"] #content h2,
128
+ html[class^="original-"] #content h3,
129
+ html[class^="original-"] #content h4 { font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', sans-serif; }
130
+ /* Paper: serif, literary */
131
+ html[class^="paper-"] #content { font-family: Georgia, 'Times New Roman', serif; }
132
+ /* Calm: serif, bookish reading */
133
+ html[class^="calm-"] #content { font-family: ui-serif, 'New York', Georgia, serif; }
134
+ /* Mono: all monospace */
135
+ html[class^="mono-"] #content,
136
+ html[class^="mono-"] #content h1,
137
+ html[class^="mono-"] #content h2,
138
+ html[class^="mono-"] #content h3,
139
+ html[class^="mono-"] #content h4 {
140
+ font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace;
141
+ }
142
+ html[class^="mono-"] #content { font-size: 0.9375rem; line-height: 1.5; }
143
+ html[class^="mono-"] #content h1 { font-size: 1.5rem; }
144
+ html[class^="mono-"] #content h2 { font-size: 1.125rem; }
145
+ html[class^="mono-"] #content h3 { font-size: 0.9375rem; }
146
+
147
+ /* Auto-detect: default to original-light, or original-dark if system prefers dark */
148
+ @media (prefers-color-scheme: dark) {
149
+ :root:not([class*="-light"]):not([class*="-dark"]) {
150
+ --bg: #1A1A1A; --surface: #242424; --surface-hover: #2A2A2A;
151
+ --sidebar-bg: #1C1C1E; --border: #38383A; --border-active: #545458;
152
+ --text: #E0E0E0; --text-secondary: #A4A4A4; --text-muted: #818181;
153
+ --accent: #2592FF; --accent-dim: rgba(37,146,255,0.12);
154
+ --code-bg: #1C1C1E; --table-stripe: rgba(255,255,255,0.02);
155
+ --when-color: #FF9F0A; --when-bg: rgba(255,159,10,0.12);
156
+ --then-color: #30D158; --then-bg: rgba(48,209,88,0.12);
157
+ --and-color: #0A84FF; --and-bg: rgba(10,132,255,0.1);
158
+ --added-color: #30D158; --added-bg: rgba(48,209,88,0.08);
159
+ --modified-color: #FF9F0A; --modified-bg: rgba(255,159,10,0.08);
160
+ --removed-color: #FF453A; --removed-bg: rgba(255,69,58,0.08);
161
+ }
162
+ }
163
+
164
+ * { margin: 0; padding: 0; box-sizing: border-box; transition: background-color 0.3s, color 0.15s, border-color 0.3s; }
165
+ button { -webkit-appearance: none; appearance: none; background: transparent; border: none; border-radius: 0; color: inherit; font: inherit; text-align: inherit; }
166
+
167
+ @media (prefers-contrast: more) {
168
+ :root, html[class*="-light"] {
169
+ --text: #000000;
170
+ --text-secondary: #3C3C43;
171
+ --text-muted: #6C6C70;
172
+ --border: #C6C6C8;
173
+ --border-active: #8E8E93;
174
+ }
175
+ html[class*="-dark"] {
176
+ --text: #FFFFFF;
177
+ --text-secondary: #CBCBCD;
178
+ --text-muted: #9A9A9E;
179
+ --border: #545458;
180
+ --border-active: #7C7C80;
181
+ }
182
+ }
183
+
184
+ @media (prefers-reduced-motion: reduce) {
185
+ * { transition: none !important; animation: none !important; }
186
+ }
187
+
188
+ body {
189
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif;
190
+ background: var(--bg);
191
+ color: var(--text);
192
+ height: 100vh;
193
+ display: flex;
194
+ -webkit-font-smoothing: antialiased;
195
+ }
196
+
197
+ /* ── Sidebar (floating panel, pushes content) ── */
198
+ aside {
199
+ width: var(--sidebar-w);
200
+ min-width: 180px;
201
+ max-width: 50vw;
202
+ height: calc(100vh - 16px);
203
+ margin: 8px 0 8px 8px;
204
+ background: var(--sidebar-bg);
205
+ border: 1px solid var(--border);
206
+ border-radius: 12px;
207
+ display: flex;
208
+ flex-direction: column;
209
+ overflow: hidden;
210
+ flex-shrink: 0;
211
+ box-shadow: 0 2px 16px rgba(0,0,0,0.06);
212
+ }
213
+
214
+ .resize-handle {
215
+ width: 4px;
216
+ cursor: col-resize;
217
+ background: transparent;
218
+ height: 100vh;
219
+ flex-shrink: 0;
220
+ }
221
+ .resize-handle:hover,
222
+ .resize-handle.dragging {
223
+ background: var(--accent);
224
+ opacity: 0.5;
225
+ }
226
+ body.sidebar-hidden aside,
227
+ body.sidebar-hidden .resize-handle { display: none; }
228
+
229
+ .sidebar-header {
230
+ padding: 16px 16px 12px;
231
+ border-bottom: 1px solid var(--border);
232
+ }
233
+ .sidebar-header h1 {
234
+ font-size: 0.8125rem;
235
+ font-weight: 600;
236
+ color: var(--text-muted);
237
+ letter-spacing: 0.05em;
238
+ text-transform: uppercase;
239
+ margin-bottom: 10px;
240
+ }
241
+ .theme-trigger {
242
+ background: none;
243
+ border: none;
244
+ color: var(--text-muted);
245
+ font-family: ui-serif, Georgia, serif;
246
+ font-size: 1.125rem;
247
+ font-weight: 500;
248
+ cursor: pointer;
249
+ padding: 2px 8px;
250
+ border-radius: 6px;
251
+ flex-shrink: 0;
252
+ }
253
+ .theme-trigger:hover { color: var(--text); background: var(--surface-hover); }
254
+
255
+ .theme-popover {
256
+ display: none;
257
+ position: fixed;
258
+ top: 48px;
259
+ right: 20px;
260
+ width: 280px;
261
+ background: var(--surface);
262
+ border: 1px solid var(--border);
263
+ border-radius: 14px;
264
+ padding: 16px;
265
+ z-index: 200;
266
+ box-shadow: 0 8px 32px rgba(0,0,0,0.15);
267
+ font-family: -apple-system, BlinkMacSystemFont, sans-serif;
268
+ }
269
+ .theme-popover.open { display: block; }
270
+
271
+
272
+ .mode-selector {
273
+ display: flex;
274
+ background: var(--border);
275
+ border-radius: 20px;
276
+ padding: 3px;
277
+ margin-bottom: 14px;
278
+ }
279
+ .mode-btn {
280
+ flex: 1;
281
+ color: var(--text-muted);
282
+ height: 26px;
283
+ border-radius: 14px;
284
+ cursor: pointer;
285
+ display: flex;
286
+ align-items: center;
287
+ justify-content: center;
288
+ transition: all 0.15s;
289
+ }
290
+ .mode-btn:hover { color: var(--text-secondary); }
291
+ .mode-btn.active {
292
+ background: var(--surface);
293
+ color: var(--text);
294
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
295
+ }
296
+ .mode-sep {
297
+ width: 1px;
298
+ height: 14px;
299
+ background: var(--text-muted);
300
+ opacity: 0.3;
301
+ flex-shrink: 0;
302
+ align-self: center;
303
+ transition: opacity 0.15s;
304
+ }
305
+ .mode-btn.active + .mode-sep,
306
+ .mode-sep:has(+ .mode-btn.active) { opacity: 0; }
307
+
308
+ .theme-grid {
309
+ display: grid;
310
+ grid-template-columns: 1fr 1fr;
311
+ gap: 8px;
312
+ }
313
+ .theme-card {
314
+ background: none;
315
+ border: 2px solid var(--border);
316
+ border-radius: 10px;
317
+ padding: 10px;
318
+ cursor: pointer;
319
+ text-align: center;
320
+ font-family: inherit;
321
+ }
322
+ .theme-card:hover { border-color: var(--border-active); }
323
+ .theme-card.active { border-color: var(--accent); }
324
+ .theme-card-preview {
325
+ display: block;
326
+ font-size: 1.375rem;
327
+ font-weight: 600;
328
+ border-radius: 6px;
329
+ padding: 8px 0;
330
+ margin-bottom: 6px;
331
+ }
332
+ .theme-card-label {
333
+ font-size: 0.6875rem;
334
+ color: var(--text-secondary);
335
+ }
336
+ .theme-card.active .theme-card-label { color: var(--accent); }
337
+ .sidebar-filter {
338
+ width: 100%;
339
+ font-family: inherit;
340
+ font-size: 0.8125rem;
341
+ padding: 8px 12px;
342
+ border: none;
343
+ border-radius: 18px;
344
+ background: var(--border);
345
+ color: var(--text);
346
+ outline: none;
347
+ margin-top: 8px;
348
+ }
349
+ .sidebar-filter:focus { border-color: var(--accent); }
350
+ .sidebar-filter::placeholder { color: var(--text-muted); }
351
+
352
+ .scope-bar {
353
+ display: flex;
354
+ align-items: center;
355
+ margin-top: 10px;
356
+ background: var(--border);
357
+ border-radius: 20px;
358
+ padding: 3px;
359
+ }
360
+ .scope-btn {
361
+ flex: 1;
362
+ height: 26px;
363
+ border-radius: 14px;
364
+ color: var(--text-muted);
365
+ cursor: pointer;
366
+ display: flex;
367
+ align-items: center;
368
+ justify-content: center;
369
+ transition: all 0.15s;
370
+ }
371
+ .scope-btn svg { flex-shrink: 0; }
372
+ .scope-btn:hover { color: var(--text-secondary); }
373
+ .scope-btn.active { color: #fff; background: var(--accent); box-shadow: 0 1px 4px rgba(0,0,0,0.15); }
374
+ .scope-sep {
375
+ width: 1px;
376
+ height: 14px;
377
+ background: var(--text-muted);
378
+ opacity: 0.3;
379
+ flex-shrink: 0;
380
+ transition: opacity 0.15s;
381
+ }
382
+ .scope-btn.active + .scope-sep,
383
+ .scope-sep:has(+ .scope-btn.active) { opacity: 0; }
384
+
385
+ .sidebar-list {
386
+ flex: 1;
387
+ overflow-y: auto;
388
+ padding: 8px 0 12px;
389
+ border-radius: 0 0 12px 12px;
390
+ }
391
+
392
+ .sidebar-section {
393
+ margin-bottom: 4px;
394
+ }
395
+ /* ── Sidebar indent system ──
396
+ Level 0: section headers (SPECS, CHANGES, ARCHIVE)
397
+ Level 1: groups / items directly in a section
398
+ Level 2: items inside a group
399
+ Level 3: items inside a nested group (e.g., spec domain inside a change)
400
+
401
+ All text aligns to its level's baseline regardless of arrows.
402
+ Arrows sit in a fixed gutter before the text.
403
+ */
404
+ .sidebar-section-header {
405
+ display: flex;
406
+ align-items: center;
407
+ padding: 10px 16px 6px 16px;
408
+ cursor: pointer;
409
+ user-select: none;
410
+ }
411
+ .sidebar-section-header:hover { background: var(--surface-hover); }
412
+ .sidebar-section-arrow {
413
+ font-size: 0.625rem;
414
+ color: var(--text-muted);
415
+ transition: transform 0.15s;
416
+ width: 16px;
417
+ flex-shrink: 0;
418
+ text-align: center;
419
+ }
420
+ .sidebar-section-arrow { transform: rotate(-90deg); }
421
+ .sidebar-section.collapsed .sidebar-section-arrow { transform: rotate(90deg); }
422
+ .sidebar-section.collapsed .sidebar-section-items { display: none; }
423
+ .sidebar-section-label {
424
+ font-size: 0.6875rem;
425
+ font-weight: 600;
426
+ color: var(--text-muted);
427
+ text-transform: uppercase;
428
+ letter-spacing: 0.06em;
429
+ flex: 1;
430
+ }
431
+ .sidebar-section-count {
432
+ font-size: 0.625rem;
433
+ color: var(--text-muted);
434
+ background: var(--surface);
435
+ padding: 1px 6px;
436
+ border-radius: 8px;
437
+ min-width: 18px;
438
+ text-align: center;
439
+ }
440
+
441
+ .sidebar-group {
442
+ margin-bottom: 2px;
443
+ }
444
+ /* Group headers — indent controlled by data-indent like items */
445
+ .sidebar-group-label,
446
+ .sidebar-year-label {
447
+ font-size: 0.8125rem;
448
+ font-weight: 600;
449
+ color: var(--text-muted);
450
+ letter-spacing: 0.04em;
451
+ padding-top: 8px;
452
+ padding-right: 16px;
453
+ padding-bottom: 3px;
454
+ user-select: none;
455
+ cursor: pointer;
456
+ display: flex;
457
+ align-items: center;
458
+ word-break: break-word;
459
+ }
460
+ .sidebar-group-label:hover,
461
+ .sidebar-year-label:hover { color: var(--text-secondary); }
462
+ .sidebar-year-separator {
463
+ font-size: 0.625rem;
464
+ font-weight: 600;
465
+ color: var(--text-muted);
466
+ letter-spacing: 0.06em;
467
+ padding: 12px 16px 4px;
468
+ display: flex;
469
+ align-items: center;
470
+ gap: 8px;
471
+ user-select: none;
472
+ }
473
+
474
+ /* Static labels (spec domain headers) — no arrow */
475
+ .sidebar-group-label-static { cursor: default; }
476
+ .sidebar-group-label-static:hover { color: var(--text-muted); }
477
+
478
+ /* Collapse arrow — sits in the gutter before text via negative margin */
479
+ .sidebar-group-arrow {
480
+ font-size: 0.5rem;
481
+ margin-left: -12px;
482
+ margin-right: 4px;
483
+ transition: transform 0.15s;
484
+ flex-shrink: 0;
485
+ }
486
+ .sidebar-group-arrow { transform: rotate(-90deg); }
487
+ .sidebar-group.collapsed .sidebar-group-arrow { transform: rotate(90deg); }
488
+ .sidebar-group.collapsed .sidebar-group-items { display: none; }
489
+
490
+ /* ── Indentation levels ──
491
+ L0: section items (project files) → 32px (base .sidebar-item)
492
+ L1: inside groups (change files) → 46px
493
+ L1: static domain headers in specs section → 32px (same as section items)
494
+ L2: spec.md under domain in specs section → 46px
495
+ L1: static domain headers inside groups → 46px (same as group items)
496
+ L2: spec.md under domain inside groups → 60px
497
+ */
498
+ /* ── Sidebar indent system ──
499
+ All items get their indent from a data-indent attribute set in JS.
500
+ Level 1 = 32px (aligned with section header text)
501
+ Level 2 = 46px (aligned with group header text, after arrow)
502
+ Level 3 = 60px (nested under a subheader)
503
+ */
504
+ .sidebar-item {
505
+ display: flex;
506
+ align-items: center;
507
+ gap: 6px;
508
+ width: 100%;
509
+ padding-top: 5px;
510
+ padding-right: 16px;
511
+ padding-bottom: 5px;
512
+ font-family: inherit;
513
+ font-size: 0.8125rem;
514
+ color: var(--text-secondary);
515
+ background: none;
516
+ border: none;
517
+ text-align: left;
518
+ cursor: pointer;
519
+ transition: background 0.1s, color 0.1s;
520
+ overflow: hidden;
521
+ text-overflow: ellipsis;
522
+ white-space: nowrap;
523
+ }
524
+ .sidebar-item:hover {
525
+ background: var(--surface-hover);
526
+ color: var(--text);
527
+ }
528
+ .sidebar-item.active {
529
+ background: var(--accent-dim);
530
+ color: var(--accent);
531
+ }
532
+ .sidebar-item-badge {
533
+ font-size: 0.625rem;
534
+ flex-shrink: 0;
535
+ }
536
+ .sidebar-item-badge.progress {
537
+ color: var(--when-color);
538
+ }
539
+ .sidebar-item-badge.complete {
540
+ color: var(--then-color);
541
+ }
542
+
543
+ .sidebar-empty {
544
+ padding: 24px 16px;
545
+ color: var(--text-muted);
546
+ font-size: 0.8125rem;
547
+ text-align: center;
548
+ line-height: 1.5;
549
+ }
550
+
551
+ /* ── Main content ── */
552
+ main {
553
+ flex: 1;
554
+ overflow-y: auto;
555
+ padding: 28px 48px;
556
+ scroll-behavior: smooth;
557
+ outline: none;
558
+ min-height: 0;
559
+ }
560
+
561
+ #content {
562
+ max-width: 680px;
563
+ margin: 0 auto;
564
+ line-height: 1.55;
565
+ font-size: 1.125rem;
566
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif;
567
+ text-rendering: optimizeLegibility;
568
+ }
569
+
570
+ /* ── Empty state ── */
571
+ .empty-state {
572
+ display: flex;
573
+ flex-direction: column;
574
+ align-items: center;
575
+ justify-content: center;
576
+ min-height: 60vh;
577
+ color: var(--text-muted);
578
+ text-align: center;
579
+ }
580
+ .empty-state .icon { font-size: 3rem; margin-bottom: 16px; opacity: 0.4; }
581
+ .empty-state p { font-size: 0.9375rem; max-width: 320px; line-height: 1.6; }
582
+
583
+
584
+ /* ── Page navigation (fixed bottom) ── */
585
+ .content-wrapper {
586
+ flex: 1;
587
+ display: flex;
588
+ flex-direction: column;
589
+ height: 100vh;
590
+ min-width: 0;
591
+ }
592
+
593
+ .page-nav-container {
594
+ flex-shrink: 0;
595
+ }
596
+
597
+ .page-nav {
598
+ display: flex;
599
+ justify-content: center;
600
+ border-top: 1px solid var(--border);
601
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
602
+ background: var(--bg);
603
+ }
604
+ .page-nav-inner {
605
+ display: flex;
606
+ justify-content: space-between;
607
+ align-items: center;
608
+ width: 100%;
609
+ max-width: 680px;
610
+ padding: 12px 0;
611
+ }
612
+
613
+ /* ── Page title bar (fixed top) ── */
614
+ .page-title-bar {
615
+ flex-shrink: 0;
616
+ display: flex;
617
+ align-items: center;
618
+ padding: 0 20px;
619
+ border-bottom: 1px solid var(--border);
620
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
621
+ background: var(--bg);
622
+ }
623
+ .sidebar-toggle {
624
+ background: none;
625
+ border: none;
626
+ color: var(--text-muted);
627
+ cursor: pointer;
628
+ padding: 6px;
629
+ display: flex;
630
+ align-items: center;
631
+ flex-shrink: 0;
632
+ }
633
+ .sidebar-toggle:hover { color: var(--text); }
634
+ .sidebar-toggle svg { width: 18px; height: 18px; }
635
+ .page-title-inner {
636
+ flex: 1;
637
+ max-width: 680px;
638
+ margin: 0 auto;
639
+ padding: 8px 0;
640
+ }
641
+ .page-title-name {
642
+ font-size: 0.8125rem;
643
+ font-weight: 500;
644
+ color: var(--text);
645
+ margin-bottom: 4px;
646
+ }
647
+ .page-title-path-row {
648
+ display: flex;
649
+ align-items: center;
650
+ gap: 6px;
651
+ min-width: 0;
652
+ }
653
+ .page-title-path {
654
+ font-size: 0.6875rem;
655
+ color: var(--text-muted);
656
+ font-family: 'SF Mono', 'Menlo', monospace;
657
+ word-break: break-all;
658
+ }
659
+ .copy-path-btn {
660
+ background: none;
661
+ border: none;
662
+ color: var(--text-muted);
663
+ cursor: pointer;
664
+ padding: 2px;
665
+ flex-shrink: 0;
666
+ display: flex;
667
+ align-items: center;
668
+ }
669
+ .copy-path-btn:hover { color: var(--accent); }
670
+ margin-top: 2px;
671
+ }
672
+ .page-nav-btn {
673
+ -webkit-appearance: none;
674
+ appearance: none;
675
+ background: transparent;
676
+ border: none;
677
+ border-radius: 0;
678
+ color: var(--text-secondary);
679
+ font-size: 0.8125rem;
680
+ padding: 6px 0;
681
+ cursor: pointer;
682
+ max-width: 45%;
683
+ text-align: left;
684
+ font-family: inherit;
685
+ }
686
+ .page-nav-btn:hover { color: var(--accent); }
687
+ .page-nav-btn.next { text-align: right; margin-left: auto; }
688
+ .page-nav-label {
689
+ font-size: 0.6875rem;
690
+ text-transform: uppercase;
691
+ letter-spacing: 0.04em;
692
+ color: var(--text-muted);
693
+ margin-bottom: 6px;
694
+ }
695
+ .page-nav-title {
696
+ white-space: nowrap;
697
+ overflow: hidden;
698
+ text-overflow: ellipsis;
699
+ }
700
+ .page-nav-btn:empty { visibility: hidden; }
701
+
702
+ /* ── Typography ── */
703
+ #content h1, #content h2, #content h3, #content h4 {
704
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', sans-serif;
705
+ }
706
+ #content > *:first-child { margin-top: 0 !important; }
707
+ #content > div:first-child > *:first-child { margin-top: 0 !important; }
708
+ #content h1 { font-size: 2rem; font-weight: 700; letter-spacing: -0.02em; margin-bottom: 12px; line-height: 1.2; }
709
+ #content h2 { font-size: 1.375rem; font-weight: 600; margin-top: 40px; margin-bottom: 12px; letter-spacing: -0.01em; }
710
+ #content h3 { font-size: 1.0625rem; font-weight: 600; margin-top: 28px; margin-bottom: 8px; }
711
+ #content h4 { font-size: 0.9375rem; font-weight: 600; margin-top: 20px; margin-bottom: 6px; color: var(--text-secondary); }
712
+ #content p { margin-bottom: 16px; }
713
+ #content a { color: var(--accent); text-decoration: none; }
714
+ #content a:hover { text-decoration: underline; }
715
+ #content strong { font-weight: 600; }
716
+ #content em { font-style: italic; color: var(--text-secondary); }
717
+
718
+ #content blockquote {
719
+ border-left: 3px solid var(--accent);
720
+ padding: 12px 20px;
721
+ margin: 16px 0;
722
+ background: var(--accent-dim);
723
+ border-radius: 0 8px 8px 0;
724
+ color: var(--text-secondary);
725
+ }
726
+ #content blockquote p:last-child { margin-bottom: 0; }
727
+ #content hr { border: none; border-top: 1px solid var(--border); margin: 40px 0; }
728
+
729
+ /* ── Lists ── */
730
+ #content ul, #content ol { margin: 0 0 16px 24px; }
731
+ #content li { margin-bottom: 4px; }
732
+ #content li > ul, #content li > ol { margin-top: 6px; margin-bottom: 0; }
733
+
734
+ /* ── Code ── */
735
+ #content code {
736
+ font-family: 'SF Mono', 'Menlo', 'Monaco', monospace;
737
+ font-size: 0.88em;
738
+ background: var(--code-bg);
739
+ padding: 2px 6px;
740
+ border-radius: 4px;
741
+ border: 1px solid var(--border);
742
+ }
743
+ #content pre {
744
+ background: var(--code-bg);
745
+ border: 1px solid var(--border);
746
+ border-radius: 8px;
747
+ padding: 16px 20px;
748
+ overflow-x: auto;
749
+ margin: 16px 0;
750
+ line-height: 1.5;
751
+ white-space: pre-wrap;
752
+ word-wrap: break-word;
753
+ }
754
+ #content pre code { background: none; border: none; padding: 0; font-size: 0.8125rem; }
755
+ #content pre.yaml-file { background: var(--code-bg); border: none; border-radius: 10px; padding: 24px 28px; font-size: 0.875rem; line-height: 1.7; }
756
+
757
+ /* ── Tables ── */
758
+ #content table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 0.875rem; }
759
+ #content th { text-align: left; font-weight: 600; padding: 10px 12px; border-bottom: 2px solid var(--border); color: var(--text-secondary); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; }
760
+ #content td { padding: 10px 12px; border-bottom: 1px solid var(--border); vertical-align: top; }
761
+ #content tr:nth-child(even) td { background: var(--table-stripe); }
762
+ #content tr:hover td { background: rgba(255,255,255,0.03); }
763
+
764
+ /* ── Images ── */
765
+ #content img { max-width: 100%; border-radius: 8px; margin: 16px 0; }
766
+
767
+ /* ── Checkbox lists ── */
768
+ #content li:has(> input[type="checkbox"]) { list-style: none; margin-left: -24px; }
769
+ #content input[type="checkbox"] {
770
+ -webkit-appearance: none;
771
+ appearance: none;
772
+ width: 16px;
773
+ height: 16px;
774
+ border: 2px solid var(--border-active);
775
+ border-radius: 4px;
776
+ background: transparent;
777
+ margin-right: 8px;
778
+ vertical-align: middle;
779
+ position: relative;
780
+ flex-shrink: 0;
781
+ }
782
+ #content input[type="checkbox"]:checked {
783
+ background: var(--surface);
784
+ }
785
+ #content input[type="checkbox"]:checked::after {
786
+ content: '';
787
+ position: absolute;
788
+ left: 3px;
789
+ top: -1px;
790
+ width: 5px;
791
+ height: 9px;
792
+ border: solid var(--then-color);
793
+ border-width: 0 2px 2px 0;
794
+ transform: rotate(45deg);
795
+ }
796
+ #content input[type="checkbox"]:not(:checked) {
797
+ background: var(--surface);
798
+ }
799
+
800
+ /* ── Spec-aware rendering ── */
801
+ .spec-header {
802
+ display: flex;
803
+ align-items: center;
804
+ gap: 12px;
805
+ margin-bottom: 8px;
806
+ }
807
+ .spec-stats {
808
+ font-size: 0.75rem;
809
+ color: var(--text-muted);
810
+ background: var(--surface);
811
+ padding: 3px 10px;
812
+ border-radius: 12px;
813
+ }
814
+
815
+ .spec-requirement {
816
+ border: 1px solid var(--border);
817
+ border-radius: 10px;
818
+ margin: 16px 0;
819
+ overflow: hidden;
820
+ background: var(--surface);
821
+ }
822
+ .spec-requirement summary {
823
+ padding: 14px 18px;
824
+ cursor: pointer;
825
+ font-weight: 600;
826
+ font-size: 0.9375rem;
827
+ list-style: none;
828
+ display: flex;
829
+ align-items: center;
830
+ gap: 8px;
831
+ transition: background 0.1s;
832
+ }
833
+ .spec-requirement summary:hover { background: var(--surface-hover); }
834
+ .spec-requirement summary::before {
835
+ content: '\25B6';
836
+ font-size: 0.5625rem;
837
+ color: var(--text-muted);
838
+ transition: transform 0.15s;
839
+ flex-shrink: 0;
840
+ }
841
+ .spec-requirement[open] summary::before { transform: rotate(90deg); }
842
+ .spec-requirement-body {
843
+ padding: 0 18px 16px;
844
+ }
845
+ .spec-requirement-desc {
846
+ font-size: 0.875rem;
847
+ color: var(--text-secondary);
848
+ margin-bottom: 12px;
849
+ line-height: 1.6;
850
+ }
851
+
852
+ .spec-scenario {
853
+ background: var(--bg);
854
+ border: 1px solid var(--border);
855
+ border-radius: 8px;
856
+ padding: 12px 16px;
857
+ margin: 10px 0;
858
+ }
859
+ .spec-scenario-title {
860
+ font-size: 0.8125rem;
861
+ font-weight: 600;
862
+ color: var(--text);
863
+ margin-bottom: 8px;
864
+ }
865
+
866
+ .spec-clause {
867
+ display: flex;
868
+ align-items: baseline;
869
+ gap: 8px;
870
+ margin: 4px 0;
871
+ font-size: 0.875rem;
872
+ line-height: 1.5;
873
+ }
874
+ .clause-pill {
875
+ font-size: 0.625rem;
876
+ font-weight: 700;
877
+ padding: 2px 7px;
878
+ border-radius: 4px;
879
+ letter-spacing: 0.03em;
880
+ flex-shrink: 0;
881
+ font-family: 'SF Mono', 'Menlo', monospace;
882
+ }
883
+ .clause-pill.when, .clause-pill.given { color: var(--when-color); background: var(--when-bg); }
884
+ .clause-pill.then { color: var(--then-color); background: var(--then-bg); }
885
+ .clause-pill.and { color: var(--and-color); background: var(--and-bg); }
886
+
887
+ .rfc-keyword {
888
+ font-weight: 700;
889
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
890
+ font-size: 0.85em;
891
+ letter-spacing: 0.02em;
892
+ }
893
+ .rfc-required { color: var(--removed-color); }
894
+ .rfc-recommended { color: var(--modified-color); }
895
+ .rfc-optional { color: var(--text-muted); }
896
+
897
+ /* ── Delta spec sections ── */
898
+ .delta-section {
899
+ border-left: 3px solid;
900
+ padding-left: 16px;
901
+ margin: 24px 0 16px;
902
+ }
903
+ .delta-section.added { border-color: var(--added-color); }
904
+ .delta-section.modified { border-color: var(--modified-color); }
905
+ .delta-section.removed { border-color: var(--removed-color); }
906
+
907
+ .delta-badge {
908
+ display: inline-block;
909
+ font-size: 0.6875rem;
910
+ font-weight: 700;
911
+ padding: 2px 8px;
912
+ border-radius: 4px;
913
+ letter-spacing: 0.04em;
914
+ margin-bottom: 12px;
915
+ }
916
+ .delta-badge.added { color: var(--added-color); background: var(--added-bg); }
917
+ .delta-badge.modified { color: var(--modified-color); background: var(--modified-bg); }
918
+ .delta-badge.removed { color: var(--removed-color); background: var(--removed-bg); }
919
+
920
+ /* ── Task progress ── */
921
+ .task-progress {
922
+ display: flex;
923
+ align-items: center;
924
+ gap: 12px;
925
+ margin-bottom: 24px;
926
+ padding: 12px 16px;
927
+ background: var(--surface);
928
+ border-radius: 10px;
929
+ border: 1px solid var(--border);
930
+ }
931
+ .task-progress-bar {
932
+ flex: 1;
933
+ height: 6px;
934
+ background: var(--bg);
935
+ border-radius: 3px;
936
+ overflow: hidden;
937
+ }
938
+ .task-progress-fill {
939
+ height: 100%;
940
+ border-radius: 3px;
941
+ transition: width 0.3s;
942
+ }
943
+ .task-progress-fill.complete { background: var(--then-color); }
944
+ .task-progress-fill.partial { background: var(--accent); }
945
+ .task-progress-text {
946
+ font-size: 0.8125rem;
947
+ font-weight: 600;
948
+ color: var(--text-secondary);
949
+ min-width: 60px;
950
+ text-align: right;
951
+ }
952
+
953
+ /* ── Link preview overlay ── */
954
+ .overlay-backdrop {
955
+ position: fixed;
956
+ inset: 0;
957
+ background: rgba(0,0,0,0.6);
958
+ z-index: 100;
959
+ display: flex;
960
+ align-items: center;
961
+ justify-content: center;
962
+ animation: fadeIn 0.15s ease;
963
+ }
964
+ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
965
+
966
+ .overlay-panel {
967
+ width: min(800px, 90vw);
968
+ height: 80vh;
969
+ background: var(--bg);
970
+ border: 1px solid var(--border);
971
+ border-radius: 12px;
972
+ display: flex;
973
+ flex-direction: column;
974
+ overflow: hidden;
975
+ box-shadow: 0 24px 80px rgba(0,0,0,0.5);
976
+ }
977
+ .overlay-header {
978
+ display: flex;
979
+ align-items: center;
980
+ justify-content: space-between;
981
+ padding: 12px 16px;
982
+ border-bottom: 1px solid var(--border);
983
+ background: var(--surface);
984
+ flex-shrink: 0;
985
+ }
986
+ .overlay-title { font-size: 0.8125rem; font-weight: 600; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
987
+ .overlay-actions { display: flex; gap: 8px; flex-shrink: 0; }
988
+ .overlay-btn {
989
+ font-family: inherit;
990
+ font-size: 0.75rem;
991
+ font-weight: 500;
992
+ padding: 5px 12px;
993
+ border-radius: 6px;
994
+ border: 1px solid var(--border);
995
+ cursor: pointer;
996
+ transition: background 0.1s, border-color 0.1s;
997
+ }
998
+ .overlay-btn-secondary { background: var(--surface); color: var(--text-secondary); }
999
+ .overlay-btn-secondary:hover { background: var(--surface-hover); color: var(--text); }
1000
+ .overlay-btn-primary { background: var(--accent-dim); color: var(--accent); border-color: var(--accent); }
1001
+ .overlay-btn-primary:hover { background: var(--accent); color: var(--bg); }
1002
+ .overlay-body { flex: 1; overflow-y: auto; padding: 32px 32px; }
1003
+ .overlay-body::-webkit-scrollbar { width: 6px; }
1004
+ .overlay-body::-webkit-scrollbar-track { background: transparent; }
1005
+ .overlay-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
1006
+ .overlay-content { max-width: 680px; margin: 0 auto; line-height: 1.7; font-size: 0.9375rem; }
1007
+ .overlay-content h1 { font-size: 1.75rem; font-weight: 700; letter-spacing: -0.02em; margin-bottom: 8px; line-height: 1.2; }
1008
+ .overlay-content h2 { font-size: 1.25rem; font-weight: 600; margin-top: 36px; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
1009
+ .overlay-content h3 { font-size: 1rem; font-weight: 600; margin-top: 24px; margin-bottom: 10px; }
1010
+ .overlay-content h4 { font-size: 0.875rem; font-weight: 600; margin-top: 20px; margin-bottom: 6px; color: var(--text-secondary); }
1011
+ .overlay-content p { margin-bottom: 14px; }
1012
+ .overlay-content a { color: var(--accent); text-decoration: none; }
1013
+ .overlay-content a:hover { text-decoration: underline; }
1014
+ .overlay-content strong { font-weight: 600; }
1015
+ .overlay-content em { font-style: italic; color: var(--text-secondary); }
1016
+ .overlay-content blockquote { border-left: 3px solid var(--accent); padding: 10px 16px; margin: 14px 0; background: var(--accent-dim); border-radius: 0 8px 8px 0; color: var(--text-secondary); }
1017
+ .overlay-content blockquote p:last-child { margin-bottom: 0; }
1018
+ .overlay-content hr { border: none; border-top: 1px solid var(--border); margin: 32px 0; }
1019
+ .overlay-content ul, .overlay-content ol { margin: 0 0 14px 24px; }
1020
+ .overlay-content li { margin-bottom: 5px; }
1021
+ .overlay-content code { font-family: 'SF Mono', 'Menlo', monospace; font-size: 0.88em; background: var(--code-bg); padding: 2px 6px; border-radius: 4px; border: 1px solid var(--border); }
1022
+ .overlay-content pre { background: var(--code-bg); border: 1px solid var(--border); border-radius: 8px; padding: 14px 18px; overflow-x: auto; margin: 14px 0; line-height: 1.5; }
1023
+ .overlay-content pre code { background: none; border: none; padding: 0; font-size: 0.8125rem; }
1024
+ .overlay-content table { width: 100%; border-collapse: collapse; margin: 14px 0; font-size: 0.8125rem; }
1025
+ .overlay-content th { text-align: left; font-weight: 600; padding: 8px 10px; border-bottom: 2px solid var(--border); color: var(--text-secondary); font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.04em; }
1026
+ .overlay-content td { padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
1027
+ .overlay-content input[type="checkbox"] { margin-right: 8px; accent-color: var(--accent); }
1028
+
1029
+ /* ── PDF embed ── */
1030
+ .pdf-embed { width: 100%; height: calc(100vh - 48px); border: none; border-radius: 8px; background: var(--surface); }
1031
+
1032
+ /* ── Open file button ── */
1033
+ .open-btn {
1034
+ display: inline-flex;
1035
+ align-items: center;
1036
+ gap: 8px;
1037
+ padding: 10px 20px;
1038
+ background: var(--surface);
1039
+ color: var(--accent);
1040
+ border: 1px solid var(--border);
1041
+ border-radius: 10px;
1042
+ font-family: inherit;
1043
+ font-size: 0.875rem;
1044
+ font-weight: 500;
1045
+ text-decoration: none;
1046
+ cursor: pointer;
1047
+ transition: background 0.15s, border-color 0.15s;
1048
+ }
1049
+ .open-btn:hover { background: var(--surface-hover); border-color: var(--accent); text-decoration: none; }
1050
+
1051
+ .loading { text-align: center; padding: 48px; color: var(--text-muted); }
1052
+ </style>
1053
+ </head>
1054
+ <body>
1055
+
1056
+ <!-- Sidebar -->
1057
+ <aside>
1058
+ <div class="sidebar-header">
1059
+ <h1>SpecReader</h1>
1060
+ <div class="scope-bar" id="scope-bar">
1061
+ <button class="scope-btn active" data-scope="specs" title="Specs — OpenSpec files only">
1062
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 2h8a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M6 5h4M6 8h4M6 11h2"/></svg>
1063
+ </button>
1064
+ <div class="scope-sep"></div>
1065
+ <button class="scope-btn" data-scope="docs" title="Docs — All markdown files">
1066
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 2H4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V6L9 2z"/><path d="M9 2v4h4"/></svg>
1067
+ </button>
1068
+ <div class="scope-sep"></div>
1069
+ <button class="scope-btn" data-scope="all" title="All — All readable files">
1070
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h5l2 2h5v7a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4z"/><path d="M2 4V3a1 1 0 0 1 1-1h4l2 2"/></svg>
1071
+ </button>
1072
+ <div class="scope-sep"></div>
1073
+ <button class="scope-btn" data-scope="search" title="Search files">
1074
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5L14 14"/></svg>
1075
+ </button>
1076
+ </div>
1077
+ <input type="text" class="sidebar-filter" id="filter-input" placeholder="Search..." style="display:none">
1078
+ </div>
1079
+ <div class="sidebar-list" id="sidebar-list">
1080
+ <div class="sidebar-empty">Loading specs...</div>
1081
+ </div>
1082
+ </aside>
1083
+
1084
+ <div class="resize-handle" id="resize-handle"></div>
1085
+
1086
+ <!-- Content wrapper -->
1087
+ <div class="content-wrapper">
1088
+ <div class="page-title-bar" id="page-title-bar">
1089
+ <button class="sidebar-toggle" id="sidebar-collapse" title="Toggle sidebar (Cmd+Shift+R)">
1090
+ <svg viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
1091
+ <rect x="1" y="2" width="16" height="14" rx="2"/>
1092
+ <line x1="6" y1="2" x2="6" y2="16"/>
1093
+ </svg>
1094
+ </button>
1095
+ <div class="page-title-inner">
1096
+ <div class="page-title-name" id="page-title-name"></div>
1097
+ <div class="page-title-path-row">
1098
+ <div class="page-title-path" id="page-title-path"></div>
1099
+ <button class="copy-path-btn" id="copy-path-btn" title="Copy path">
1100
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
1101
+ <rect x="5" y="5" width="9" height="9" rx="1.5"/>
1102
+ <path d="M11 5V3.5A1.5 1.5 0 0 0 9.5 2h-6A1.5 1.5 0 0 0 2 3.5v6A1.5 1.5 0 0 0 3.5 11H5"/>
1103
+ </svg>
1104
+ </button>
1105
+ </div>
1106
+ </div>
1107
+ <button class="theme-trigger" id="theme-trigger" title="Themes & Settings">Aa</button>
1108
+ <div class="theme-popover" id="theme-popover">
1109
+ <div class="mode-selector">
1110
+ <button class="mode-btn" data-mode="light" title="Light">
1111
+ <svg width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="9" cy="9" r="3.5"/><path d="M9 2v2M9 14v2M2 9h2M14 9h2M4.2 4.2l1.4 1.4M12.4 12.4l1.4 1.4M4.2 13.8l1.4-1.4M12.4 5.6l1.4-1.4"/></svg>
1112
+ </button>
1113
+ <div class="mode-sep"></div>
1114
+ <button class="mode-btn" data-mode="dark" title="Dark">
1115
+ <svg width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M15 10.4A6.5 6.5 0 0 1 7.6 3a6.5 6.5 0 1 0 7.4 7.4Z"/></svg>
1116
+ </button>
1117
+ <div class="mode-sep"></div>
1118
+ <button class="mode-btn" data-mode="auto" title="Auto">
1119
+ <svg width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="9" cy="9" r="7"/><path d="M9 2a7 7 0 0 1 0 14" fill="currentColor"/></svg>
1120
+ </button>
1121
+ </div>
1122
+ <div class="theme-grid">
1123
+ <button class="theme-card" data-theme="original">
1124
+ <span class="theme-card-preview" style="background:#FBFBFB;color:#1D1D1F;font-family:-apple-system,sans-serif;">Aa</span>
1125
+ <span class="theme-card-label">Original</span>
1126
+ </button>
1127
+ <button class="theme-card" data-theme="paper">
1128
+ <span class="theme-card-preview" style="background:#EDEDEB;color:#2C2C2A;font-family:Georgia,serif;">Aa</span>
1129
+ <span class="theme-card-label">Paper</span>
1130
+ </button>
1131
+ <button class="theme-card" data-theme="calm">
1132
+ <span class="theme-card-preview" style="background:#F8F1E3;color:#5F4B32;font-family:ui-serif,Georgia,serif;">Aa</span>
1133
+ <span class="theme-card-label">Calm</span>
1134
+ </button>
1135
+ <button class="theme-card" data-theme="mono">
1136
+ <span class="theme-card-preview" style="background:#FBFBFB;color:#1D1D1F;font-family:'SF Mono',Menlo,monospace;font-size:18px;">Aa</span>
1137
+ <span class="theme-card-label">Mono</span>
1138
+ </button>
1139
+ </div>
1140
+ </div>
1141
+ </div>
1142
+ <main tabindex="-1">
1143
+ <div id="content">
1144
+ <div class="empty-state">
1145
+ <div class="icon">&#9672;</div>
1146
+ <p>Select a file from the sidebar to start reading.</p>
1147
+ </div>
1148
+ </div>
1149
+ </main>
1150
+ <div class="page-nav-container" id="page-nav-container"></div>
1151
+ </div>
1152
+
1153
+ <script>
1154
+ // ── Theme (personality) + Mode (light/dark) ──
1155
+ function getSystemMode() {
1156
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
1157
+ }
1158
+
1159
+ const THEME_PREVIEWS = {
1160
+ original: { light: { bg: '#FBFBFB', text: '#1D1D1F' }, dark: { bg: '#1A1A1A', text: '#E0E0E0' } },
1161
+ paper: { light: { bg: '#EDEDEB', text: '#2C2C2A' }, dark: { bg: '#1C1C1E', text: '#C8C8C8' } },
1162
+ calm: { light: { bg: '#F8F1E3', text: '#5F4B32' }, dark: { bg: '#1A1814', text: '#D6D0C4' } },
1163
+ mono: { light: { bg: '#FBFBFB', text: '#1D1D1F' }, dark: { bg: '#1A1A1A', text: '#E0E0E0' } },
1164
+ };
1165
+
1166
+ const THEME_FONTS = {
1167
+ original: '-apple-system, sans-serif',
1168
+ paper: 'Georgia, serif',
1169
+ calm: 'ui-serif, Georgia, serif',
1170
+ mono: "'SF Mono', Menlo, monospace",
1171
+ };
1172
+
1173
+ function applyThemeAndMode() {
1174
+ const theme = localStorage.getItem('spec-viewer-theme') || 'original';
1175
+ const savedMode = localStorage.getItem('spec-viewer-mode');
1176
+ const mode = savedMode || getSystemMode();
1177
+ const activeMode = savedMode || 'auto';
1178
+ document.documentElement.className = theme + '-' + mode;
1179
+
1180
+ // Update theme card previews to reflect current mode
1181
+ document.querySelectorAll('.theme-card').forEach(c => {
1182
+ c.classList.toggle('active', c.dataset.theme === theme);
1183
+ const preview = c.querySelector('.theme-card-preview');
1184
+ const t = c.dataset.theme;
1185
+ if (preview && THEME_PREVIEWS[t]) {
1186
+ const colors = THEME_PREVIEWS[t][mode];
1187
+ preview.style.background = colors.bg;
1188
+ preview.style.color = colors.text;
1189
+ preview.style.fontFamily = THEME_FONTS[t];
1190
+ }
1191
+ });
1192
+
1193
+ // Update Aa trigger to match selected theme font
1194
+ const trigger = document.getElementById('theme-trigger');
1195
+ if (trigger) trigger.style.fontFamily = THEME_FONTS[theme];
1196
+
1197
+ document.querySelectorAll('.mode-btn').forEach(b => {
1198
+ b.classList.toggle('active', b.dataset.mode === activeMode);
1199
+ });
1200
+ }
1201
+
1202
+ (function initTheme() {
1203
+ applyThemeAndMode();
1204
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
1205
+ if (!localStorage.getItem('spec-viewer-mode')) applyThemeAndMode();
1206
+ });
1207
+ })();
1208
+
1209
+ let currentPath = null;
1210
+ let manifest = null;
1211
+ let lastContent = null;
1212
+ let currentScope = localStorage.getItem('spec-viewer-scope') || 'specs';
1213
+ let internalHashChange = false;
1214
+
1215
+ async function refreshManifest() {
1216
+ try {
1217
+ const resp = await fetch('manifest.json');
1218
+ manifest = await resp.json();
1219
+ sidebarData = groupFiles(manifest.files);
1220
+ renderSidebar();
1221
+ } catch {}
1222
+ }
1223
+
1224
+ function updateBarPositions() {
1225
+ // No-op: bars are now in the layout flow, not fixed
1226
+ }
1227
+
1228
+ function toggleSidebar() {
1229
+ document.body.classList.toggle('sidebar-hidden');
1230
+ localStorage.setItem('spec-viewer-sidebar-hidden', document.body.classList.contains('sidebar-hidden'));
1231
+ updateBarPositions();
1232
+ }
1233
+
1234
+ // ── Init ──
1235
+ async function init() {
1236
+ // Load manifest (or embedded data)
1237
+ if (window.__SPEC_DATA) {
1238
+ manifest = window.__SPEC_DATA;
1239
+ } else {
1240
+ try {
1241
+ const resp = await fetch('manifest.json');
1242
+ manifest = await resp.json();
1243
+ } catch {
1244
+ document.getElementById('sidebar-list').innerHTML =
1245
+ '<div class="sidebar-empty">No manifest.json found.<br>Run <code>./read-specs</code> first.</div>';
1246
+ return;
1247
+ }
1248
+ }
1249
+
1250
+ renderSidebar();
1251
+
1252
+ // Filter
1253
+ let filterTimeout;
1254
+ document.getElementById('filter-input').addEventListener('input', e => {
1255
+ clearTimeout(filterTimeout);
1256
+ filterTimeout = setTimeout(() => renderSidebar(e.target.value.toLowerCase()), 150);
1257
+ });
1258
+
1259
+ // Scope bar
1260
+ const scopeLastFile = {};
1261
+
1262
+ function applyScope(scope) {
1263
+ // Remember current file for the old scope
1264
+ if (currentPath) scopeLastFile[currentScope] = currentPath;
1265
+
1266
+ currentScope = scope;
1267
+ localStorage.setItem('spec-viewer-scope', scope);
1268
+ document.querySelectorAll('.scope-btn').forEach(b => {
1269
+ b.classList.toggle('active', b.dataset.scope === scope);
1270
+ });
1271
+ const filterInput = document.getElementById('filter-input');
1272
+ if (scope === 'search') {
1273
+ filterInput.style.display = '';
1274
+ filterInput.focus();
1275
+ renderSidebar(filterInput.value.toLowerCase());
1276
+ } else {
1277
+ filterInput.style.display = 'none';
1278
+ filterInput.value = '';
1279
+ renderSidebar();
1280
+ }
1281
+
1282
+ // 1. Stored selection takes precedence (if file still exists)
1283
+ if (scopeLastFile[scope]) {
1284
+ const btn = document.querySelector(`.sidebar-item[data-path="${CSS.escape(scopeLastFile[scope])}"]`);
1285
+ if (btn) { loadFile(scopeLastFile[scope]); return; }
1286
+ delete scopeLastFile[scope];
1287
+ }
1288
+ // 2. Specs scope: first active proposal
1289
+ if (scope === 'specs') {
1290
+ const proposal = manifest.files.find(f => f.name === 'proposal.md' && f.path.match(/(?:^|openspec\/)changes\/[^/]+\/proposal\.md$/) && !f.path.includes('archive'));
1291
+ if (proposal) { loadFile(proposal.path); return; }
1292
+ }
1293
+ // 3. Fallback: first file in sidebar
1294
+ const firstItem = document.querySelector('.sidebar-item[data-path]');
1295
+ if (firstItem) loadFile(firstItem.dataset.path);
1296
+ }
1297
+
1298
+ // Default to 'docs' if no openspec folder exists
1299
+ const hasOpenSpec = manifest.files.some(f => f.path.startsWith('openspec/') || f.path.startsWith('specs/'));
1300
+ if (!hasOpenSpec && currentScope === 'specs') {
1301
+ currentScope = 'docs';
1302
+ }
1303
+ document.querySelectorAll('.scope-btn').forEach(b => {
1304
+ b.classList.toggle('active', b.dataset.scope === currentScope);
1305
+ b.onclick = () => applyScope(b.dataset.scope);
1306
+ });
1307
+ // Show search input if search scope is active on load
1308
+ if (currentScope === 'search') {
1309
+ document.getElementById('filter-input').style.display = '';
1310
+ }
1311
+
1312
+ // Theme popover
1313
+ const popover = document.getElementById('theme-popover');
1314
+ document.getElementById('theme-trigger').onclick = () => popover.classList.toggle('open');
1315
+ document.addEventListener('click', e => {
1316
+ if (!e.target.closest('.theme-popover') && !e.target.closest('.theme-trigger')) {
1317
+ popover.classList.remove('open');
1318
+ }
1319
+ });
1320
+
1321
+ // Mode selector (light / dark / auto)
1322
+ document.querySelectorAll('.mode-btn').forEach(btn => {
1323
+ btn.onclick = () => {
1324
+ if (btn.dataset.mode === 'auto') {
1325
+ localStorage.removeItem('spec-viewer-mode');
1326
+ } else {
1327
+ localStorage.setItem('spec-viewer-mode', btn.dataset.mode);
1328
+ }
1329
+ applyThemeAndMode();
1330
+ };
1331
+ });
1332
+
1333
+ // Theme cards
1334
+ document.querySelectorAll('.theme-card').forEach(btn => {
1335
+ btn.onclick = () => {
1336
+ localStorage.setItem('spec-viewer-theme', btn.dataset.theme);
1337
+ applyThemeAndMode();
1338
+ };
1339
+ });
1340
+
1341
+
1342
+ // Sidebar collapse
1343
+ const collapseBtn = document.getElementById('sidebar-collapse');
1344
+ collapseBtn.onclick = toggleSidebar;
1345
+ if (localStorage.getItem('spec-viewer-sidebar-hidden') === 'true') {
1346
+ document.body.classList.add('sidebar-hidden');
1347
+ }
1348
+ requestAnimationFrame(updateBarPositions);
1349
+
1350
+ // Keyboard shortcuts
1351
+ document.addEventListener('keydown', e => {
1352
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
1353
+ // Cmd+Shift+R — toggle sidebar
1354
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'r') {
1355
+ e.preventDefault();
1356
+ toggleSidebar();
1357
+ return;
1358
+ }
1359
+ if (e.key === '-' && !e.metaKey && !e.ctrlKey) {
1360
+ const details = document.querySelectorAll('#content details');
1361
+ if (details.length === 0) return;
1362
+ const allOpen = [...details].every(d => d.open);
1363
+ details.forEach(d => d.open = !allOpen);
1364
+ }
1365
+ // Left/right arrow for page navigation
1366
+ if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && currentPath && !e.metaKey && !e.ctrlKey && !e.altKey) {
1367
+ const { prev, next } = getPageNav(currentPath);
1368
+ if (e.key === 'ArrowLeft' && prev) { e.preventDefault(); loadFile(prev); }
1369
+ if (e.key === 'ArrowRight' && next) { e.preventDefault(); loadFile(next); }
1370
+ }
1371
+ });
1372
+
1373
+ // Auto-select from URL hash or open first file
1374
+ if (location.hash) {
1375
+ let path = location.hash.slice(1);
1376
+ try { path = decodeURIComponent(path); } catch {}
1377
+ loadFile(path);
1378
+ } else if (manifest && manifest.files) {
1379
+ // Priority: README > active change proposal > first file
1380
+ // Specs scope: first active proposal, else first file
1381
+ if (currentScope === 'specs') {
1382
+ const proposal = manifest.files.find(f => f.name === 'proposal.md' && f.path.match(/(?:^|openspec\/)changes\/[^/]+\/proposal\.md$/) && !f.path.includes('archive'));
1383
+ if (proposal) { loadFile(proposal.path); }
1384
+ else { const first = document.querySelector('.sidebar-item[data-path]'); if (first) loadFile(first.dataset.path); }
1385
+ } else {
1386
+ const first = document.querySelector('.sidebar-item[data-path]');
1387
+ if (first) loadFile(first.dataset.path);
1388
+ }
1389
+ }
1390
+
1391
+ // Re-load file when hash changes externally (e.g., view-proposal opens a new URL)
1392
+ window.addEventListener('hashchange', async () => {
1393
+ if (internalHashChange) {
1394
+ internalHashChange = false;
1395
+ return;
1396
+ }
1397
+ if (location.hash) {
1398
+ await refreshManifest();
1399
+ let path = location.hash.slice(1);
1400
+ try { path = decodeURIComponent(path); } catch {}
1401
+ loadFile(path);
1402
+ }
1403
+ });
1404
+
1405
+ // Poll for file changes every 3 seconds (auto-refresh for propose→review loop)
1406
+ setInterval(async () => {
1407
+ if (!currentPath || document.hidden) return;
1408
+ try {
1409
+ const resp = await fetch(currentPath);
1410
+ if (!resp.ok) return;
1411
+ const newContent = await resp.text();
1412
+ if (newContent !== lastContent) {
1413
+ lastContent = newContent;
1414
+ const content = document.getElementById('content');
1415
+ content.innerHTML = renderContent(newContent, currentPath);
1416
+ interceptLinks(content, currentPath);
1417
+ }
1418
+ } catch {}
1419
+ }, 3000);
1420
+
1421
+ // Sidebar resize handle with localStorage persistence
1422
+ const handle = document.getElementById('resize-handle');
1423
+ const sidebar = document.querySelector('aside');
1424
+ let dragging = false;
1425
+
1426
+ const savedWidth = localStorage.getItem('spec-viewer-sidebar-width');
1427
+ if (savedWidth) sidebar.style.width = savedWidth + 'px';
1428
+
1429
+ handle.addEventListener('mousedown', e => {
1430
+ dragging = true;
1431
+ handle.classList.add('dragging');
1432
+ document.body.style.cursor = 'col-resize';
1433
+ document.body.style.userSelect = 'none';
1434
+ e.preventDefault();
1435
+ });
1436
+
1437
+ document.addEventListener('mousemove', e => {
1438
+ if (!dragging) return;
1439
+ const newWidth = Math.max(180, Math.min(e.clientX, window.innerWidth * 0.5));
1440
+ sidebar.style.width = newWidth + 'px';
1441
+ updateBarPositions();
1442
+ });
1443
+
1444
+ document.addEventListener('mouseup', () => {
1445
+ if (!dragging) return;
1446
+ dragging = false;
1447
+ handle.classList.remove('dragging');
1448
+ document.body.style.cursor = '';
1449
+ document.body.style.userSelect = '';
1450
+ localStorage.setItem('spec-viewer-sidebar-width', parseInt(sidebar.style.width));
1451
+ });
1452
+ }
1453
+
1454
+ // ── Build tree from flat file list ──
1455
+ // Single source of truth for sidebar indentation
1456
+ function sidebarIndent(depth) { return (28 + depth * 14) + 'px'; }
1457
+
1458
+ function buildTree(files) {
1459
+ const root = { children: {} };
1460
+ for (const f of files) {
1461
+ const parts = f.path.split('/');
1462
+ let node = root;
1463
+ for (let i = 0; i < parts.length - 1; i++) {
1464
+ if (!node.children[parts[i]]) node.children[parts[i]] = { children: {} };
1465
+ node = node.children[parts[i]];
1466
+ }
1467
+ node.children[parts[parts.length - 1]] = { file: f };
1468
+ }
1469
+ return root;
1470
+ }
1471
+
1472
+ // ── Detect if a tree node is an openspec root ──
1473
+ function isOpenSpecNode(node) {
1474
+ return node.children && (node.children['specs'] || node.children['changes'] || node.children['config.yaml']);
1475
+ }
1476
+
1477
+ // ── Render sidebar ──
1478
+ function renderSidebar(filter) {
1479
+ const sidebar = document.getElementById('sidebar-list');
1480
+ sidebar.innerHTML = '';
1481
+
1482
+ // Apply scope filter
1483
+ let scopedFiles = manifest.files;
1484
+ const openspecPrefix = manifest.files.some(f => f.path.startsWith('openspec/')) ? 'openspec/' : '';
1485
+ if (currentScope === 'specs') {
1486
+ // OpenSpec framework .md files only (+ config.yaml, project.md)
1487
+ scopedFiles = manifest.files.filter(f => {
1488
+ const p = f.path;
1489
+ if (openspecPrefix) {
1490
+ if (!p.startsWith(openspecPrefix)) return false;
1491
+ if (f.name === 'AGENTS.md' || f.name === '.openspec.yaml') return false;
1492
+ return f.name.endsWith('.md');
1493
+ }
1494
+ // No openspec/ folder — check for root-level specs/changes structure
1495
+ if (p.startsWith('specs/') || p.startsWith('changes/')) return f.name.endsWith('.md');
1496
+ return p === 'project.md';
1497
+ });
1498
+ } else if (currentScope === 'docs') {
1499
+ // All .md files
1500
+ scopedFiles = manifest.files.filter(f => f.name.endsWith('.md'));
1501
+ } else if (currentScope === 'search') {
1502
+ // Search across all files
1503
+ scopedFiles = manifest.files;
1504
+ }
1505
+ // 'all' = everything (.md + .yml + .yaml)
1506
+
1507
+ const files = filter
1508
+ ? scopedFiles.filter(f => matchesFilter(f, filter))
1509
+ : scopedFiles;
1510
+
1511
+ if (files.length === 0) {
1512
+ sidebar.innerHTML = '<div class="sidebar-empty">No matching files.</div>';
1513
+ return;
1514
+ }
1515
+
1516
+ const tree = buildTree(files);
1517
+
1518
+ // ── Reusable primitives ──
1519
+
1520
+ // Uses global sidebarIndent()
1521
+
1522
+ function addItem(container, file, depth, label) {
1523
+ const item = createSidebarItem(file, label || null, depth);
1524
+ item.title = file.path;
1525
+ container.appendChild(item);
1526
+ }
1527
+
1528
+ function addLabel(container, text, depth) {
1529
+ const el = document.createElement('div');
1530
+ el.className = 'sidebar-group-label sidebar-group-label-static';
1531
+ el.style.paddingLeft = sidebarIndent(depth);
1532
+ el.textContent = text;
1533
+ container.appendChild(el);
1534
+ }
1535
+
1536
+ function addGroup(container, text, depth, opts, renderChildren) {
1537
+ const { collapsed, count, uppercase } = opts || {};
1538
+ const groupEl = document.createElement('div');
1539
+ groupEl.className = 'sidebar-group';
1540
+ if (collapsed) groupEl.classList.add('collapsed');
1541
+
1542
+ const label = document.createElement('div');
1543
+ label.className = 'sidebar-group-label';
1544
+ label.style.paddingLeft = sidebarIndent(depth);
1545
+ if (uppercase) { label.style.textTransform = 'uppercase'; label.style.letterSpacing = '0.06em'; }
1546
+
1547
+ const arrow = document.createElement('span');
1548
+ arrow.className = 'sidebar-group-arrow';
1549
+ arrow.textContent = '\u25B6';
1550
+ const textSpan = document.createElement('span');
1551
+ textSpan.textContent = text;
1552
+ textSpan.style.flex = '1';
1553
+ label.appendChild(arrow);
1554
+ label.appendChild(textSpan);
1555
+
1556
+ if (count !== undefined) {
1557
+ const badge = document.createElement('span');
1558
+ badge.className = 'sidebar-section-count';
1559
+ badge.textContent = count;
1560
+ label.appendChild(badge);
1561
+ }
1562
+
1563
+ label.onclick = () => groupEl.classList.toggle('collapsed');
1564
+ groupEl.appendChild(label);
1565
+
1566
+ const items = document.createElement('div');
1567
+ items.className = 'sidebar-group-items';
1568
+ renderChildren(items, depth + 1);
1569
+ groupEl.appendChild(items);
1570
+
1571
+ container.appendChild(groupEl);
1572
+ }
1573
+
1574
+ function addYearSeparator(container, year, depth) {
1575
+ const el = document.createElement('div');
1576
+ el.className = 'sidebar-year-separator';
1577
+ el.style.paddingLeft = sidebarIndent(depth);
1578
+ el.textContent = year;
1579
+ container.appendChild(el);
1580
+ }
1581
+
1582
+ // ── OpenSpec-aware renderers ──
1583
+
1584
+ function renderOpenSpecSpecs(container, specsNode, depth) {
1585
+ for (const domain of Object.keys(specsNode.children).sort()) {
1586
+ const domainNode = specsNode.children[domain];
1587
+ if (!domainNode.children) continue;
1588
+ addLabel(container, domain.replace(/-/g, ' '), depth);
1589
+ for (const fname of Object.keys(domainNode.children).sort()) {
1590
+ const child = domainNode.children[fname];
1591
+ if (child.file) addItem(container, child.file, depth + 1, fname);
1592
+ }
1593
+ }
1594
+ }
1595
+
1596
+ function renderOpenSpecChangeGroup(container, groupName, groupNode, depth, opts) {
1597
+ const allFiles = [];
1598
+ function collectFiles(node) {
1599
+ for (const child of Object.values(node.children || {})) {
1600
+ if (child.file) allFiles.push(child.file);
1601
+ else collectFiles(child);
1602
+ }
1603
+ }
1604
+ collectFiles(groupNode);
1605
+
1606
+ const groupSection = opts?.section || 'changes';
1607
+ addGroup(container, formatGroupLabel(groupName, groupSection), depth, opts, (items, childDepth) => {
1608
+ const nonSpecFiles = allFiles.filter(f => !f.path.includes('/specs/'));
1609
+ const specFiles = allFiles.filter(f => f.path.includes('/specs/'));
1610
+
1611
+ for (const f of sortChangeFiles(nonSpecFiles)) {
1612
+ addItem(items, f, childDepth);
1613
+ }
1614
+
1615
+ if (specFiles.length > 0) {
1616
+ addLabel(items, 'specs', childDepth);
1617
+ for (const f of specFiles) {
1618
+ const domain = specDomain(f);
1619
+ if (domain) addLabel(items, domain.replace(/-/g, ' '), childDepth + 1);
1620
+ addItem(items, f, childDepth + (domain ? 2 : 1), 'spec.md');
1621
+ }
1622
+ }
1623
+ });
1624
+ }
1625
+
1626
+ function renderOpenSpecChanges(container, changesNode, depth) {
1627
+ const active = Object.keys(changesNode.children).filter(k => k !== 'archive').sort();
1628
+ for (const name of active) {
1629
+ renderOpenSpecChangeGroup(container, name, changesNode.children[name], depth, { section: 'changes' });
1630
+ }
1631
+
1632
+ const archiveNode = changesNode.children['archive'];
1633
+ if (archiveNode && archiveNode.children) {
1634
+ const archiveGroups = Object.keys(archiveNode.children).sort().reverse();
1635
+
1636
+ addGroup(container, 'Archive', depth, { collapsed: true, count: archiveGroups.length, uppercase: true }, (ac, ad) => {
1637
+ const byYear = {};
1638
+ for (const name of archiveGroups) {
1639
+ const ym = name.match(/^(\d{4})-/);
1640
+ const year = ym ? ym[1] : 'Other';
1641
+ if (!byYear[year]) byYear[year] = [];
1642
+ byYear[year].push(name);
1643
+ }
1644
+ for (const year of Object.keys(byYear).sort().reverse()) {
1645
+ addYearSeparator(ac, year, ad);
1646
+ for (const name of byYear[year]) {
1647
+ renderOpenSpecChangeGroup(ac, name, archiveNode.children[name], ad, { collapsed: !filter, section: 'archive' });
1648
+ }
1649
+ }
1650
+ });
1651
+ }
1652
+ }
1653
+
1654
+ function renderOpenSpecFolder(container, node, depth) {
1655
+ // Root files first
1656
+ const rootFiles = [];
1657
+ for (const [name, child] of Object.entries(node.children)) {
1658
+ if (child.file) rootFiles.push(child.file);
1659
+ }
1660
+ for (const f of rootFiles.sort((a, b) => a.name.localeCompare(b.name))) {
1661
+ addItem(container, f, depth);
1662
+ }
1663
+
1664
+ if (node.children['changes']) {
1665
+ addGroup(container, 'changes', depth, {}, (c, d) => {
1666
+ renderOpenSpecChanges(c, node.children['changes'], d);
1667
+ });
1668
+ }
1669
+
1670
+ if (node.children['specs']) {
1671
+ addGroup(container, 'specs', depth, { collapsed: true }, (c, d) => {
1672
+ renderOpenSpecSpecs(c, node.children['specs'], d);
1673
+ });
1674
+ }
1675
+ }
1676
+
1677
+ // ── Generic recursive tree ──
1678
+
1679
+ function countFiles(n) {
1680
+ let c = 0;
1681
+ for (const child of Object.values(n.children || {})) {
1682
+ c += child.file ? 1 : countFiles(child);
1683
+ }
1684
+ return c;
1685
+ }
1686
+
1687
+ function renderGenericTree(container, node, depth) {
1688
+ const folders = {};
1689
+ const loose = [];
1690
+
1691
+ for (const [name, child] of Object.entries(node.children)) {
1692
+ if (child.file) loose.push(child.file);
1693
+ else folders[name] = child;
1694
+ }
1695
+
1696
+ for (const f of loose.sort((a, b) => a.name.localeCompare(b.name))) {
1697
+ addItem(container, f, depth);
1698
+ }
1699
+
1700
+ for (const name of Object.keys(folders).sort()) {
1701
+ const folderNode = folders[name];
1702
+
1703
+ if (isOpenSpecNode(folderNode)) {
1704
+ addGroup(container, name, depth, {}, (c, d) => {
1705
+ renderOpenSpecFolder(c, folderNode, d);
1706
+ });
1707
+ } else {
1708
+ const fc = countFiles(folderNode);
1709
+ addGroup(container, name, depth, { collapsed: !filter, count: fc }, (c, d) => {
1710
+ renderGenericTree(c, folderNode, d);
1711
+ });
1712
+ }
1713
+ }
1714
+ }
1715
+
1716
+ // ── Render ──
1717
+ if (isOpenSpecNode(tree)) {
1718
+ // Root IS an openspec project
1719
+ renderOpenSpecFolder(sidebar, tree, 0);
1720
+ } else if (currentScope === 'specs' && tree.children['openspec'] && isOpenSpecNode(tree.children['openspec'])) {
1721
+ // Specs scope with openspec subfolder — render contents directly, skip the wrapper
1722
+ renderOpenSpecFolder(sidebar, tree.children['openspec'], 0);
1723
+ } else {
1724
+ renderGenericTree(sidebar, tree, 0);
1725
+ }
1726
+
1727
+ if (sidebar.children.length === 0) {
1728
+ sidebar.innerHTML = '<div class="sidebar-empty">No matching files.</div>';
1729
+ return;
1730
+ }
1731
+
1732
+ }
1733
+
1734
+ function matchesFilter(f, filter) {
1735
+ return f.path.toLowerCase().includes(filter) ||
1736
+ f.name.toLowerCase().includes(filter) ||
1737
+ (f.group && f.group.toLowerCase().includes(filter));
1738
+ }
1739
+
1740
+ function sortChangeFiles(files) {
1741
+ const order = { 'proposal.md': 0, 'spec.md': 1, 'design.md': 2, 'tasks.md': 3 };
1742
+ return [...files].sort((a, b) => {
1743
+ const oa = order[a.name] ?? 99;
1744
+ const ob = order[b.name] ?? 99;
1745
+ return oa - ob;
1746
+ });
1747
+ }
1748
+
1749
+ const MONTH_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
1750
+
1751
+ function formatGroupLabel(groupName, sectionKey) {
1752
+ // Archive groups follow YYYY-MM-DD-name convention
1753
+ if (sectionKey === 'archive') {
1754
+ const m = groupName.match(/^(\d{4})-(\d{2})-(\d{2})-(.+)$/);
1755
+ if (m) {
1756
+ const month = MONTH_SHORT[parseInt(m[2], 10) - 1] || m[2];
1757
+ const day = parseInt(m[3], 10);
1758
+ const name = m[4].replace(/-/g, ' ');
1759
+ return `${month} ${day} · ${name.charAt(0).toUpperCase() + name.slice(1)}`;
1760
+ }
1761
+ }
1762
+ // Default: replace hyphens with spaces, title case first word
1763
+ const name = groupName.replace(/-/g, ' ');
1764
+ return name.charAt(0).toUpperCase() + name.slice(1);
1765
+ }
1766
+
1767
+ function specDomain(f) {
1768
+ // Extract domain from paths like changes/archive/.../specs/domain-name/spec.md
1769
+ const m = f.path.match(/specs\/([^/]+)\/spec\.md$/);
1770
+ return m ? m[1] : null;
1771
+ }
1772
+
1773
+ function createSidebarItem(f, labelOverride, depth) {
1774
+ const btn = document.createElement('button');
1775
+ btn.className = 'sidebar-item';
1776
+ btn.dataset.path = f.path;
1777
+ btn.style.paddingLeft = sidebarIndent(depth != null ? depth : 1);
1778
+ btn.onclick = () => loadFile(f.path);
1779
+
1780
+ let label = labelOverride || f.name;
1781
+ btn.textContent = label;
1782
+
1783
+ // Task progress badge
1784
+
1785
+ return btn;
1786
+ }
1787
+
1788
+ function getAllFilePaths() {
1789
+ // Read order from the rendered sidebar DOM — matches visual order exactly
1790
+ return [...document.querySelectorAll('.sidebar-item[data-path]')]
1791
+ .map(el => el.dataset.path);
1792
+ }
1793
+
1794
+ function getPageNav(path) {
1795
+ const paths = getAllFilePaths();
1796
+ const idx = paths.indexOf(path);
1797
+ if (idx === -1) return { prev: null, next: null };
1798
+ return {
1799
+ prev: idx > 0 ? paths[idx - 1] : null,
1800
+ next: idx < paths.length - 1 ? paths[idx + 1] : null,
1801
+ };
1802
+ }
1803
+
1804
+ function formatBreadcrumb(path) {
1805
+ return path.replace(/\//g, ' / ');
1806
+ }
1807
+
1808
+ function truncateMiddle(str, maxLen) {
1809
+ if (str.length <= maxLen) return str;
1810
+ const parts = str.split('/');
1811
+ if (parts.length <= 2) return str;
1812
+ // Keep first folder + last two segments, replace middle with …
1813
+ const first = parts[0];
1814
+ const last = parts.slice(-2).join('/');
1815
+ const result = first + '/\u2026/' + last;
1816
+ if (result.length <= maxLen) return result;
1817
+ return parts[0] + '/\u2026/' + parts[parts.length - 1];
1818
+ }
1819
+
1820
+ function fileDisplayName(path) {
1821
+ const parts = path.split('/');
1822
+ const name = parts[parts.length - 1];
1823
+ const file = manifest?.files?.find(f => f.path === path);
1824
+
1825
+ // OpenSpec change files: detect by path containing changes/
1826
+ const changeMatch = path.match(/(?:^|openspec\/)changes\/(?:archive\/)?([^/]+)\//);
1827
+ if (changeMatch) {
1828
+ const groupName = changeMatch[1];
1829
+ const isArchive = path.includes('/archive/');
1830
+ const groupLabel = formatGroupLabel(groupName, isArchive ? 'archive' : 'changes');
1831
+ if (name === 'spec.md') {
1832
+ const domain = parts[parts.length - 2];
1833
+ return groupLabel + ' \u2014 ' + domain.replace(/-/g, ' ');
1834
+ }
1835
+ const types = { 'proposal.md': 'Proposal', 'design.md': 'Design', 'tasks.md': 'Tasks' };
1836
+ return groupLabel + ' \u2014 ' + (types[name] || name);
1837
+ }
1838
+
1839
+ // Base spec files
1840
+ if (name === 'spec.md') {
1841
+ const domain = parts[parts.length - 2];
1842
+ return domain.replace(/-/g, ' ') + ' \u2014 spec';
1843
+ }
1844
+
1845
+ // Generic: root files → "Project — name", subfolder files → "folder — name"
1846
+ const cleanName = name.replace(/\.[^.]+$/, '').replace(/-/g, ' ');
1847
+ if (parts.length === 1) return 'Project \u2014 ' + cleanName;
1848
+ return parts[parts.length - 2] + ' \u2014 ' + cleanName;
1849
+ }
1850
+
1851
+ function renderPageNav(path) {
1852
+ const { prev, next } = getPageNav(path);
1853
+ const nav = document.createElement('div');
1854
+ nav.className = 'page-nav';
1855
+
1856
+ const prevBtn = document.createElement('button');
1857
+ prevBtn.className = 'page-nav-btn prev';
1858
+ if (prev) {
1859
+ const label = document.createElement('div');
1860
+ label.className = 'page-nav-label';
1861
+ label.textContent = '\u2190 Previous';
1862
+ const title = document.createElement('div');
1863
+ title.className = 'page-nav-title';
1864
+ title.textContent = fileDisplayName(prev);
1865
+ prevBtn.appendChild(label);
1866
+ prevBtn.appendChild(title);
1867
+ prevBtn.onclick = () => loadFile(prev);
1868
+ }
1869
+
1870
+ const nextBtn = document.createElement('button');
1871
+ nextBtn.className = 'page-nav-btn next';
1872
+ if (next) {
1873
+ const label = document.createElement('div');
1874
+ label.className = 'page-nav-label';
1875
+ label.textContent = 'Next \u2192';
1876
+ const title = document.createElement('div');
1877
+ title.className = 'page-nav-title';
1878
+ title.textContent = fileDisplayName(next);
1879
+ nextBtn.appendChild(label);
1880
+ nextBtn.appendChild(title);
1881
+ nextBtn.onclick = () => loadFile(next);
1882
+ }
1883
+
1884
+ const inner = document.createElement('div');
1885
+ inner.className = 'page-nav-inner';
1886
+ inner.appendChild(prevBtn);
1887
+ inner.appendChild(nextBtn);
1888
+ nav.appendChild(inner);
1889
+ return nav;
1890
+ }
1891
+
1892
+ function renderContent(md, path) {
1893
+ if (path.endsWith('.yaml') || path.endsWith('.yml')) {
1894
+ return '<pre class="yaml-file"><code class="lang-yaml">' + escapeHtml(md) + '</code></pre>';
1895
+ } else if (path.endsWith('.txt') || !path.includes('.') || path.split('/').pop().indexOf('.') === -1) {
1896
+ // Plain text files and extensionless files (Brewfile, Gemfile, etc.)
1897
+ return '<pre class="yaml-file"><code>' + escapeHtml(md) + '</code></pre>';
1898
+ } else if (isOpenSpec(md, path)) {
1899
+ return renderSpec(md, path);
1900
+ } else if (isTaskFile(md, path)) {
1901
+ return renderTaskFile(md);
1902
+ }
1903
+ return renderMarkdown(md);
1904
+ }
1905
+
1906
+
1907
+ // ── Load file ──
1908
+ async function loadFile(path) {
1909
+ document.querySelectorAll('.sidebar-item.active').forEach(el => el.classList.remove('active'));
1910
+ const btn = document.querySelector(`.sidebar-item[data-path="${CSS.escape(path)}"]`);
1911
+ if (btn) {
1912
+ btn.classList.add('active');
1913
+ // Expand all parent groups so the active item is visible
1914
+ let parent = btn.parentElement;
1915
+ while (parent) {
1916
+ if (parent.classList.contains('collapsed')) parent.classList.remove('collapsed');
1917
+ parent = parent.parentElement;
1918
+ }
1919
+ btn.scrollIntoView({ block: 'nearest' });
1920
+ }
1921
+ currentPath = path;
1922
+ internalHashChange = true;
1923
+ location.hash = encodeURIComponent(path);
1924
+
1925
+ const content = document.getElementById('content');
1926
+
1927
+ // PDF
1928
+ if (path.endsWith('.pdf')) {
1929
+ content.innerHTML = `<iframe src="${escapeHtml(path)}" class="pdf-embed"></iframe>`;
1930
+ return;
1931
+ }
1932
+
1933
+ // Non-renderable (only block files we truly can't display)
1934
+ const renderableExts = ['.md', '.yaml', '.yml', '.txt'];
1935
+ const fileName = path.split('/').pop();
1936
+ const isRenderable = renderableExts.some(ext => path.endsWith(ext)) || !fileName.includes('.');
1937
+ if (!isRenderable) {
1938
+ const ext = fileName.split('.').pop().toUpperCase();
1939
+ content.innerHTML = `
1940
+ <div class="empty-state">
1941
+ <div class="icon">&#128196;</div>
1942
+ <p style="margin-bottom: 20px">This ${ext} file cannot be rendered in the browser.</p>
1943
+ <a href="${escapeHtml(path)}" download class="open-btn">${escapeHtml(fileName)}</a>
1944
+ </div>`;
1945
+ return;
1946
+ }
1947
+
1948
+ // Markdown / YAML
1949
+ content.innerHTML = '<div class="loading">Loading...</div>';
1950
+ try {
1951
+ let md;
1952
+ if (window.__SPEC_CONTENT && window.__SPEC_CONTENT[path]) {
1953
+ md = window.__SPEC_CONTENT[path];
1954
+ } else {
1955
+ const resp = await fetch(path);
1956
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
1957
+ md = await resp.text();
1958
+ }
1959
+
1960
+ lastContent = md;
1961
+
1962
+ content.innerHTML = renderContent(md, path);
1963
+
1964
+
1965
+ // Intercept links
1966
+ // Update title bar with name + breadcrumb
1967
+ document.getElementById('page-title-name').textContent = fileDisplayName(path);
1968
+ const pathEl = document.getElementById('page-title-path');
1969
+ pathEl.textContent = formatBreadcrumb(path);
1970
+ pathEl.title = path;
1971
+ document.getElementById('copy-path-btn').onclick = () => {
1972
+ const fullPath = manifest?.root ? manifest.root + '/' + path : path;
1973
+ navigator.clipboard.writeText(fullPath).then(() => {
1974
+ const btn = document.getElementById('copy-path-btn');
1975
+ btn.style.color = 'var(--then-color)';
1976
+ setTimeout(() => btn.style.color = '', 1000);
1977
+ });
1978
+ };
1979
+
1980
+ // Page nav appended to container below main
1981
+ const navContainer = document.getElementById('page-nav-container');
1982
+ navContainer.innerHTML = '';
1983
+ navContainer.appendChild(renderPageNav(path));
1984
+ interceptLinks(content, path);
1985
+ const mainEl = document.querySelector('main');
1986
+ mainEl.scrollTop = 0;
1987
+ mainEl.focus();
1988
+ } catch (e) {
1989
+ content.innerHTML = `
1990
+ <div class="empty-state">
1991
+ <div class="icon">&#9888;</div>
1992
+ <p>Failed to load file.<br><code>${escapeHtml(e.message)}</code></p>
1993
+ </div>`;
1994
+ }
1995
+ }
1996
+
1997
+ // ── Content type detection ──
1998
+ function isOpenSpec(md, path) {
1999
+ // Regular spec: has Purpose + Requirements
2000
+ if (/^## Purpose/m.test(md) && /^### Requirement:/m.test(md)) return true;
2001
+ // Delta spec: has ADDED/MODIFIED/REMOVED Requirements sections
2002
+ if (isDeltaSpec(md, path) && /^### Requirement:/m.test(md)) return true;
2003
+ // Base spec with delta headers (merged but not converted): treat as regular spec
2004
+ if (path && /^specs\//.test(path) && /^### Requirement:/m.test(md)) return true;
2005
+ return false;
2006
+ }
2007
+
2008
+ function isDeltaSpec(md, path) {
2009
+ if (!(/^## (ADDED|MODIFIED|REMOVED) Requirements/m.test(md))) return false;
2010
+ // Only treat as delta if under changes/*/specs/, not base specs/
2011
+ if (path && /^specs\//.test(path) && !/^changes\//.test(path)) return false;
2012
+ return true;
2013
+ }
2014
+
2015
+ function isTaskFile(md, path) {
2016
+ if (path && path.endsWith('tasks.md')) return true;
2017
+ // Detect files that are mostly checkboxes
2018
+ const lines = md.split('\n');
2019
+ const checkboxLines = lines.filter(l => /^- \[[x ]\]/i.test(l.trim())).length;
2020
+ return checkboxLines > 3 && checkboxLines / lines.filter(l => l.trim()).length > 0.3;
2021
+ }
2022
+
2023
+ // ── Spec-aware renderer ──
2024
+ function renderSpec(md, path) {
2025
+ const isDelta = isDeltaSpec(md, path);
2026
+ let html = '';
2027
+
2028
+ // Extract purpose
2029
+ const purposeMatch = md.match(/^## Purpose\n([\s\S]*?)(?=\n## )/m);
2030
+ if (purposeMatch) {
2031
+ html += `<div style="margin-bottom:16px">`;
2032
+ html += `<h2>Purpose</h2>`;
2033
+ html += `<p>${escapeHtml(purposeMatch[1].trim())}</p>`;
2034
+ html += `</div>`;
2035
+ }
2036
+
2037
+ // Count requirements and scenarios
2038
+ const reqCount = (md.match(/^### Requirement:/gm) || []).length;
2039
+ const scenCount = (md.match(/^#### Scenario:/gm) || []).length;
2040
+
2041
+ // Stats bar
2042
+ html += `<div class="spec-header">`;
2043
+ html += `<h2 style="margin-top:0;flex:1;border:none;padding:0;margin-bottom:0">Requirements</h2>`;
2044
+ html += `<span class="spec-stats">${reqCount} requirements &middot; ${scenCount} scenarios</span>`;
2045
+ html += `</div>`;
2046
+
2047
+ // Parse sections (delta or regular)
2048
+ if (isDelta) {
2049
+ html += renderDeltaSections(md);
2050
+ } else {
2051
+ html += renderRequirements(md);
2052
+ }
2053
+
2054
+ return html;
2055
+ }
2056
+
2057
+ function renderDeltaSections(md) {
2058
+ let html = '';
2059
+ const deltaSections = md.split(/^(?=## (?:ADDED|MODIFIED|REMOVED) Requirements)/m).filter(s => s.trim());
2060
+
2061
+ for (const section of deltaSections) {
2062
+ const headerMatch = section.match(/^## (ADDED|MODIFIED|REMOVED) Requirements/m);
2063
+ if (!headerMatch) continue;
2064
+
2065
+ const type = headerMatch[1].toLowerCase();
2066
+ html += `<div class="delta-section ${type}">`;
2067
+ html += `<span class="delta-badge ${type}">${headerMatch[1]}</span>`;
2068
+ html += renderRequirements(section);
2069
+ html += `</div>`;
2070
+ }
2071
+
2072
+ return html;
2073
+ }
2074
+
2075
+ function renderRequirements(md) {
2076
+ let html = '';
2077
+ const reqBlocks = md.split(/^(?=### Requirement:)/m).filter(s => /^### Requirement:/.test(s));
2078
+
2079
+ for (const block of reqBlocks) {
2080
+ const titleMatch = block.match(/^### Requirement:\s*(.+)$/m);
2081
+ if (!titleMatch) continue;
2082
+
2083
+ const title = titleMatch[1].trim();
2084
+ const rest = block.slice(block.indexOf('\n', block.indexOf(titleMatch[0])) + 1);
2085
+
2086
+ html += `<details class="spec-requirement" open>`;
2087
+ html += `<summary>${escapeHtml(title)}</summary>`;
2088
+ html += `<div class="spec-requirement-body">`;
2089
+
2090
+ // Extract description (text before first scenario)
2091
+ const firstScenario = rest.indexOf('#### Scenario:');
2092
+ if (firstScenario > 0) {
2093
+ const desc = rest.slice(0, firstScenario).trim();
2094
+ if (desc) {
2095
+ html += `<div class="spec-requirement-desc">${highlightRfcKeywords(escapeHtml(desc))}</div>`;
2096
+ }
2097
+ } else if (rest.trim()) {
2098
+ html += `<div class="spec-requirement-desc">${highlightRfcKeywords(escapeHtml(rest.trim()))}</div>`;
2099
+ }
2100
+
2101
+ // Parse scenarios
2102
+ const scenarioBlocks = rest.split(/^(?=#### Scenario:)/m).filter(s => /^#### Scenario:/.test(s));
2103
+ for (const scenBlock of scenarioBlocks) {
2104
+ const scenTitle = scenBlock.match(/^#### Scenario:\s*(.+)$/m);
2105
+ if (!scenTitle) continue;
2106
+
2107
+ html += `<div class="spec-scenario">`;
2108
+ html += `<div class="spec-scenario-title">${escapeHtml(scenTitle[1].trim())}</div>`;
2109
+
2110
+ const lines = scenBlock.split('\n').slice(1);
2111
+ for (const line of lines) {
2112
+ const trimmed = line.trim();
2113
+ if (!trimmed || trimmed.startsWith('####')) continue;
2114
+
2115
+ // Match clause lines: - **WHEN/THEN/AND** or - **Given/When/Then**
2116
+ const clauseMatch = trimmed.match(/^-\s+\*\*(WHEN|THEN|AND|Given|When|Then)\*\*\s*(.*)$/i);
2117
+ if (clauseMatch) {
2118
+ const keyword = clauseMatch[1];
2119
+ const text = clauseMatch[2];
2120
+ const pillClass = getPillClass(keyword);
2121
+ html += `<div class="spec-clause">`;
2122
+ html += `<span class="clause-pill ${pillClass}">${keyword.toUpperCase()}</span>`;
2123
+ html += `<span>${highlightRfcKeywords(renderInline(escapeHtml(text)))}</span>`;
2124
+ html += `</div>`;
2125
+ } else if (trimmed.startsWith('- ')) {
2126
+ // Regular list item in scenario
2127
+ html += `<div class="spec-clause" style="padding-left:4px">`;
2128
+ html += `<span style="color:var(--text-muted)">&bull;</span>`;
2129
+ html += `<span>${highlightRfcKeywords(renderInline(escapeHtml(trimmed.slice(2))))}</span>`;
2130
+ html += `</div>`;
2131
+ }
2132
+ }
2133
+
2134
+ html += `</div>`;
2135
+ }
2136
+
2137
+ html += `</div></details>`;
2138
+ }
2139
+
2140
+ return html;
2141
+ }
2142
+
2143
+ function getPillClass(keyword) {
2144
+ const k = keyword.toUpperCase();
2145
+ if (k === 'WHEN' || k === 'GIVEN') return 'when';
2146
+ if (k === 'THEN') return 'then';
2147
+ if (k === 'AND') return 'and';
2148
+ return 'when';
2149
+ }
2150
+
2151
+ function highlightRfcKeywords(text) {
2152
+ // RFC 2119 keywords with severity levels
2153
+ text = text.replace(/\b(MUST NOT|SHALL NOT)\b/g, '<span class="rfc-keyword rfc-required">$1</span>');
2154
+ text = text.replace(/\b(SHOULD NOT)\b/g, '<span class="rfc-keyword rfc-recommended">$1</span>');
2155
+ text = text.replace(/\b(MUST|SHALL|REQUIRED)\b/g, '<span class="rfc-keyword rfc-required">$1</span>');
2156
+ text = text.replace(/\b(SHOULD|RECOMMENDED)\b/g, '<span class="rfc-keyword rfc-recommended">$1</span>');
2157
+ text = text.replace(/\b(MAY|OPTIONAL)\b/g, '<span class="rfc-keyword rfc-optional">$1</span>');
2158
+ return text;
2159
+ }
2160
+
2161
+ function renderInline(text) {
2162
+ // Bold
2163
+ text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
2164
+ // Italic
2165
+ text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
2166
+ // Inline code
2167
+ text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
2168
+ // Links
2169
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
2170
+ return text;
2171
+ }
2172
+
2173
+ // ── Task file renderer ──
2174
+ function renderTaskFile(md) {
2175
+ return renderMarkdown(md);
2176
+ }
2177
+
2178
+ function countCheckboxes(md) {
2179
+ const checked = (md.match(/^- \[x\]/gim) || []).length;
2180
+ const unchecked = (md.match(/^- \[ \]/gm) || []).length;
2181
+ return { checked, total: checked + unchecked };
2182
+ }
2183
+
2184
+ // ── Minimal Markdown renderer ──
2185
+ function renderMarkdown(md) {
2186
+ let html = escapeHtml(md);
2187
+
2188
+ // Code blocks (fenced)
2189
+ html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) =>
2190
+ `<pre><code class="lang-${lang}">${code.trim()}</code></pre>`);
2191
+
2192
+ // Inline code
2193
+ html = html.replace(/`([^`\n]+)`/g, '<code>$1</code>');
2194
+
2195
+ // Headers
2196
+ function headingId(text) {
2197
+ return text.toLowerCase().replace(/<[^>]+>/g, '').replace(/[^\w -]/g, '').replace(/\s+/g, '-').replace(/^-+|-+$/g, '');
2198
+ }
2199
+ html = html.replace(/^#### (.+)$/gm, (_, t) => `<h4 id="${headingId(t)}">${t}</h4>`);
2200
+ html = html.replace(/^### (.+)$/gm, (_, t) => `<h3 id="${headingId(t)}">${t}</h3>`);
2201
+ html = html.replace(/^## (.+)$/gm, (_, t) => `<h2 id="${headingId(t)}">${t}</h2>`);
2202
+ html = html.replace(/^# (.+)$/gm, (_, t) => `<h1 id="${headingId(t)}">${t}</h1>`);
2203
+
2204
+ // Horizontal rules
2205
+ html = html.replace(/^---+$/gm, '<hr>');
2206
+
2207
+ // Blockquotes
2208
+ html = html.replace(/^&gt; (.+)$/gm, '<blockquote><p>$1</p></blockquote>');
2209
+ html = html.replace(/<\/blockquote>\n<blockquote>/g, '\n');
2210
+
2211
+ // Bold and italic
2212
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
2213
+ html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
2214
+
2215
+ // Links
2216
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
2217
+
2218
+ // Checkboxes
2219
+ html = html.replace(/^- \[x\] (.+)$/gim, '<li class="task-done"><input type="checkbox" checked disabled> $1</li>');
2220
+ html = html.replace(/^- \[ \] (.+)$/gm, '<li><input type="checkbox" disabled> $1</li>');
2221
+
2222
+ // Tables
2223
+ html = html.replace(/^(\|.+\|)\n(\|[\s\-:|]+\|)\n((?:\|.+\|\n?)+)/gm, (_, headerRow, sepRow, bodyRows) => {
2224
+ const headers = headerRow.split('|').filter(c => c.trim()).map(c => `<th>${c.trim()}</th>`).join('');
2225
+ const rows = bodyRows.trim().split('\n').map(row => {
2226
+ const cells = row.split('|').filter(c => c.trim()).map(c => `<td>${c.trim()}</td>`).join('');
2227
+ return `<tr>${cells}</tr>`;
2228
+ }).join('');
2229
+ return `<table><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`;
2230
+ });
2231
+
2232
+ // Unordered lists
2233
+ html = html.replace(/^(\s*)- (.+)$/gm, (_, indent, text) => {
2234
+ const depth = indent.length >= 4 ? 'sub' : 'top';
2235
+ return `<li class="${depth}">${text}</li>`;
2236
+ });
2237
+
2238
+ // Ordered lists
2239
+ html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
2240
+
2241
+ // Wrap consecutive <li> in <ul>
2242
+ html = html.replace(/((?:<li[^>]*>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
2243
+
2244
+ // Paragraphs
2245
+ html = html.replace(/^(?!<[hulotbp]|<\/)(.+)$/gm, (_, line) => {
2246
+ if (line.trim() === '') return '';
2247
+ return `<p>${line}</p>`;
2248
+ });
2249
+
2250
+ html = html.replace(/<p>\s*<\/p>/g, '');
2251
+ html = html.replace(/\n{3,}/g, '\n\n');
2252
+
2253
+ return html;
2254
+ }
2255
+
2256
+ // ── Link interception ──
2257
+ function interceptLinks(container, basePath) {
2258
+ container.querySelectorAll('a').forEach(a => {
2259
+ const href = a.getAttribute('href');
2260
+ if (!href) return;
2261
+ a.addEventListener('click', e => {
2262
+ e.preventDefault();
2263
+ if (href.startsWith('#')) {
2264
+ const target = href.slice(1).replace(/-+/g, '-');
2265
+ const headings = container.querySelectorAll('h1[id],h2[id],h3[id],h4[id]');
2266
+ const normalize = s => s.replace(/-+/g, '-');
2267
+ let el = document.getElementById(target);
2268
+ if (!el) el = [...headings].find(h => normalize(h.id) === normalize(target));
2269
+ if (!el) el = [...headings].find(h => target.includes(normalize(h.id)) || normalize(h.id).includes(target));
2270
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
2271
+ return;
2272
+ }
2273
+ if (href.startsWith('http://') || href.startsWith('https://')) {
2274
+ window.open(href, '_blank', 'noopener');
2275
+ } else {
2276
+ const dir = basePath.substring(0, basePath.lastIndexOf('/') + 1);
2277
+ const resolved = new URL(href, location.origin + '/' + dir).pathname.replace(/^\//, '');
2278
+ openPreview(resolved);
2279
+ }
2280
+ });
2281
+ });
2282
+ }
2283
+
2284
+ // ── Preview overlay ──
2285
+ async function openPreview(filePath) {
2286
+ const fileName = filePath.split('/').pop();
2287
+
2288
+ if (!filePath.endsWith('.md') && !filePath.endsWith('.pdf') && !filePath.endsWith('.yaml') && !filePath.endsWith('.yml')) {
2289
+ const a = document.createElement('a');
2290
+ a.href = filePath;
2291
+ a.download = fileName;
2292
+ a.click();
2293
+ return;
2294
+ }
2295
+
2296
+ const backdrop = document.createElement('div');
2297
+ backdrop.className = 'overlay-backdrop';
2298
+
2299
+ const panel = document.createElement('div');
2300
+ panel.className = 'overlay-panel';
2301
+
2302
+ const header = document.createElement('div');
2303
+ header.className = 'overlay-header';
2304
+
2305
+ const title = document.createElement('span');
2306
+ title.className = 'overlay-title';
2307
+ title.textContent = fileName;
2308
+
2309
+ const actions = document.createElement('div');
2310
+ actions.className = 'overlay-actions';
2311
+
2312
+ const closeBtn = document.createElement('button');
2313
+ closeBtn.className = 'overlay-btn overlay-btn-secondary';
2314
+ closeBtn.textContent = 'Close';
2315
+ closeBtn.onclick = () => backdrop.remove();
2316
+
2317
+ const goBtn = document.createElement('button');
2318
+ goBtn.className = 'overlay-btn overlay-btn-primary';
2319
+ goBtn.textContent = 'Open';
2320
+ goBtn.onclick = () => { backdrop.remove(); loadFile(filePath); };
2321
+
2322
+ actions.appendChild(closeBtn);
2323
+ actions.appendChild(goBtn);
2324
+ header.appendChild(title);
2325
+ header.appendChild(actions);
2326
+ panel.appendChild(header);
2327
+
2328
+ const body = document.createElement('div');
2329
+ body.className = 'overlay-body';
2330
+
2331
+ if (filePath.endsWith('.pdf')) {
2332
+ body.innerHTML = `<iframe src="${escapeHtml(filePath)}" style="width:100%;height:100%;border:none;border-radius:4px"></iframe>`;
2333
+ } else {
2334
+ body.innerHTML = '<div class="loading">Loading...</div>';
2335
+ try {
2336
+ const resp = await fetch(filePath);
2337
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
2338
+ const md = await resp.text();
2339
+ const inner = document.createElement('div');
2340
+ inner.className = 'overlay-content';
2341
+ inner.innerHTML = renderMarkdown(md);
2342
+ body.innerHTML = '';
2343
+ body.appendChild(inner);
2344
+ } catch (e) {
2345
+ body.innerHTML = `<div class="loading">Failed to load: ${escapeHtml(e.message)}</div>`;
2346
+ }
2347
+ }
2348
+
2349
+ panel.appendChild(body);
2350
+ backdrop.appendChild(panel);
2351
+ document.body.appendChild(backdrop);
2352
+
2353
+ backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); });
2354
+ const esc = e => { if (e.key === 'Escape') { backdrop.remove(); document.removeEventListener('keydown', esc); } };
2355
+ document.addEventListener('keydown', esc);
2356
+ }
2357
+
2358
+ // ── Utilities ──
2359
+ function escapeHtml(str) {
2360
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
2361
+ }
2362
+
2363
+ init();
2364
+ </script>
2365
+ </body>
2366
+ </html>