@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.
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/package.json +28 -0
- package/read-specs +210 -0
- package/skills/read-specs/SKILL.md +35 -0
- package/spec-reader +104 -0
- package/spec-viewer.html +2366 -0
- package/view-proposal +30 -0
package/spec-viewer.html
ADDED
|
@@ -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">◈</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">📄</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">⚠</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 · ${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)">•</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(/^> (.+)$/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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
init();
|
|
2364
|
+
</script>
|
|
2365
|
+
</body>
|
|
2366
|
+
</html>
|