@skitterbyte/skitterspec-linear 11.0.0 → 13.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/MIGRATION.md +260 -10
  2. package/README.md +32 -2
  3. package/assets/claude-md-section.md +48 -2
  4. package/assets/commands/spec-connect.md +2 -2
  5. package/assets/commands/spec-live.md +2 -2
  6. package/assets/core/SETUP.md +21 -3
  7. package/assets/core/env.config.json.example +9 -3
  8. package/assets/core/env.config.md +102 -30
  9. package/assets/core/gating.config.json.example +4 -0
  10. package/assets/core/gating.config.md +81 -0
  11. package/assets/core/linear.config.md +67 -8
  12. package/assets/review/page.html +1501 -0
  13. package/assets/rules/spec-planning.md +224 -15
  14. package/assets/rules/spec-reports.md +269 -0
  15. package/assets/skills/spec/SKILL.md +64 -13
  16. package/assets/skills/spec-bug/SKILL.md +193 -27
  17. package/assets/skills/spec-cancel/SKILL.md +99 -8
  18. package/assets/skills/spec-claim/SKILL.md +114 -0
  19. package/assets/skills/spec-complete/SKILL.md +123 -22
  20. package/assets/skills/spec-diff/SKILL.md +564 -0
  21. package/assets/skills/spec-hotfix/SKILL.md +202 -22
  22. package/assets/skills/spec-init/SKILL.md +49 -9
  23. package/assets/skills/spec-linear-setup/SKILL.md +86 -7
  24. package/assets/skills/spec-list/SKILL.md +218 -0
  25. package/assets/skills/spec-next/SKILL.md +300 -7
  26. package/assets/skills/spec-push/SKILL.md +45 -22
  27. package/assets/skills/spec-review/SKILL.md +59 -11
  28. package/assets/skills/spec-reviewed/SKILL.md +241 -0
  29. package/assets/skills/spec-start/SKILL.md +426 -66
  30. package/assets/skills/spec-status/SKILL.md +24 -2
  31. package/assets/skills/spec-sync/SKILL.md +47 -11
  32. package/assets/skills/spec-to-main/SKILL.md +42 -20
  33. package/package.json +11 -7
  34. package/src/cli.js +1710 -80
  35. package/src/env/building.js +143 -0
  36. package/src/env/classify.js +91 -0
  37. package/src/env/config.js +57 -9
  38. package/src/env/provision.js +192 -19
  39. package/src/env/proxy.js +34 -1
  40. package/src/env/render.js +3 -12
  41. package/src/env/resolve.js +296 -9
  42. package/src/env/review.js +1329 -0
  43. package/src/env/serve.js +549 -0
  44. package/src/env/teardown.js +13 -6
  45. package/src/gating.js +155 -0
  46. package/src/init.js +124 -2
  47. package/src/prompts.js +10 -1
  48. package/src/vendor/linear/api.js +104 -1
  49. package/src/vendor/linear/cli-sync.js +874 -17
  50. package/src/vendor/linear/config.js +8 -0
  51. package/src/vendor/linear/credentials.js +94 -0
  52. package/src/vendor/linear/doctor.js +35 -0
  53. package/src/vendor/linear/identity.js +105 -0
  54. package/src/vendor/linear/mcp.js +26 -0
  55. package/src/vendor/sync-core/index.js +6 -2
  56. package/src/vendor/sync-core/src/compare.js +74 -5
  57. package/src/vendor/sync-core/src/normalize.js +30 -0
  58. package/src/vendor/sync-core/src/push.js +11 -1
  59. package/src/vendor/sync-core/src/write.js +38 -0
  60. package/LICENSE +0 -21
@@ -0,0 +1,1501 @@
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">
6
+ <title>__REVIEW_TITLE__</title>
7
+ <style>
8
+ /*
9
+ * THE COMPLETE LIGHT PALETTE LIVES HERE, on bare :root — every token this page
10
+ * uses is declared in this block and nowhere else gains a first definition. The
11
+ * two blocks below only REDEFINE. A colour whose sole definition sits inside a
12
+ * media query or a [data-theme] block is the classic unreadable-in-one-theme
13
+ * bug, and a test asserts against it.
14
+ */
15
+ :root {
16
+ --bg: #ffffff;
17
+ --fg: #1b1b1f;
18
+ --muted: #5c6070;
19
+ --line: #e1e3e8;
20
+ --panel: #f6f7f9;
21
+ --panel-2: #eceef2;
22
+ --accent: #0b5fd0;
23
+ --focus: #0b5fd0;
24
+ --add-bg: #e8fbee;
25
+ --add-fg: #04441c;
26
+ --add-mark: #1a7f37;
27
+ --del-bg: #fdecea;
28
+ --del-fg: #4c0a12;
29
+ --del-mark: #b8262f;
30
+ --band-bg: #f0f3f8;
31
+ --band-fg: #46506a;
32
+ --ln-fg: #9aa0ae;
33
+ --chip-bg: #eceef2;
34
+ --chip-fg: #46506a;
35
+ --flag-fg: #a3400b;
36
+ --flag-bg: #fdf0e6;
37
+ --confirm-fg: #6a4c05;
38
+ --confirm-bg: #fdf7e3;
39
+ --good-fg: #1a7f37;
40
+ --good-bg: #eaf8ee;
41
+ --note-bg: #eef4ff;
42
+ --note-fg: #1b2b4d;
43
+ --note-line: #b9cdf2;
44
+ --ln-w: 4.5ch;
45
+ }
46
+ :root:not([data-theme="light"]) {
47
+ @media (prefers-color-scheme: dark) {
48
+ --bg: #121316;
49
+ --fg: #e4e6ea;
50
+ --muted: #969bab;
51
+ --line: #2a2c34;
52
+ --panel: #1a1c21;
53
+ --panel-2: #23262d;
54
+ --accent: #7fb0ff;
55
+ --focus: #7fb0ff;
56
+ --add-bg: #0e2a18;
57
+ --add-fg: #b7f0c4;
58
+ --add-mark: #4ac26b;
59
+ --del-bg: #2e1215;
60
+ --del-fg: #ffc9c4;
61
+ --del-mark: #f2685f;
62
+ --band-bg: #1e2128;
63
+ --band-fg: #a3aabd;
64
+ --ln-fg: #6b7183;
65
+ --chip-bg: #23262d;
66
+ --chip-fg: #a3aabd;
67
+ --flag-fg: #ffb083;
68
+ --flag-bg: #2e1a0d;
69
+ --confirm-fg: #f2d88a;
70
+ --confirm-bg: #2a2412;
71
+ --good-fg: #7ee08a;
72
+ --good-bg: #12261a;
73
+ --note-bg: #16202e;
74
+ --note-fg: #cfe0fa;
75
+ --note-line: #35486a;
76
+ }
77
+ }
78
+ :root[data-theme="dark"] {
79
+ --bg: #121316;
80
+ --fg: #e4e6ea;
81
+ --muted: #969bab;
82
+ --line: #2a2c34;
83
+ --panel: #1a1c21;
84
+ --panel-2: #23262d;
85
+ --accent: #7fb0ff;
86
+ --focus: #7fb0ff;
87
+ --add-bg: #0e2a18;
88
+ --add-fg: #b7f0c4;
89
+ --add-mark: #4ac26b;
90
+ --del-bg: #2e1215;
91
+ --del-fg: #ffc9c4;
92
+ --del-mark: #f2685f;
93
+ --band-bg: #1e2128;
94
+ --band-fg: #a3aabd;
95
+ --ln-fg: #6b7183;
96
+ --chip-bg: #23262d;
97
+ --chip-fg: #a3aabd;
98
+ --flag-fg: #ffb083;
99
+ --flag-bg: #2e1a0d;
100
+ --confirm-fg: #f2d88a;
101
+ --confirm-bg: #2a2412;
102
+ --good-fg: #7ee08a;
103
+ --good-bg: #12261a;
104
+ --note-bg: #16202e;
105
+ --note-fg: #cfe0fa;
106
+ --note-line: #35486a;
107
+ }
108
+
109
+ * { box-sizing: border-box; }
110
+
111
+ body {
112
+ margin: 0;
113
+ /* Explicit, from a token: a transparent body borrows the host's ground. */
114
+ background: var(--bg);
115
+ color: var(--fg);
116
+ font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
117
+ }
118
+
119
+ .wrap { max-width: 1400px; margin: 0 auto; padding: 1.25rem 1rem 5rem; }
120
+
121
+ header { margin-bottom: 1rem; }
122
+ h1 { font-size: 1.3rem; margin: 0 0 .3rem; overflow-wrap: anywhere; }
123
+ .sub { color: var(--muted); font-size: .85rem; margin: 0; overflow-wrap: anywhere; }
124
+ .sub .sep { opacity: .5; padding: 0 .35rem; }
125
+ .a { color: var(--add-mark); }
126
+ .d { color: var(--del-mark); }
127
+
128
+ .controls { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin: .9rem 0 0; }
129
+
130
+ button, .toggle {
131
+ font: inherit;
132
+ font-size: .8rem;
133
+ color: var(--chip-fg);
134
+ background: var(--chip-bg);
135
+ border: 1px solid var(--line);
136
+ border-radius: 999px;
137
+ padding: .28rem .7rem;
138
+ cursor: pointer;
139
+ }
140
+ button:hover, .toggle:hover { border-color: var(--accent); color: var(--accent); }
141
+ /* Every control gets a VISIBLE focus ring — keyboard review is review too. */
142
+ button:focus-visible,
143
+ summary:focus-visible,
144
+ input:focus-visible,
145
+ .toggle:focus-visible {
146
+ outline: 2px solid var(--focus);
147
+ outline-offset: 2px;
148
+ }
149
+ .toggle { display: inline-flex; align-items: center; gap: .4rem; user-select: none; }
150
+ .toggle input { accent-color: var(--accent); margin: 0; }
151
+
152
+ .layout { display: grid; grid-template-columns: 1fr; gap: 1rem; align-items: start; }
153
+ @media (min-width: 60rem) {
154
+ .layout { grid-template-columns: 17rem minmax(0, 1fr); }
155
+ .tree-wrap { position: sticky; top: 1rem; max-height: calc(100vh - 2rem); overflow: auto; }
156
+ }
157
+ /* min-width:0 is what stops a wide table forcing the PAGE to scroll sideways. */
158
+ .main { min-width: 0; }
159
+
160
+ .tree-wrap {
161
+ border: 1px solid var(--line);
162
+ border-radius: 10px;
163
+ background: var(--panel);
164
+ }
165
+ .tree-wrap > summary {
166
+ cursor: pointer;
167
+ padding: .55rem .75rem;
168
+ font-size: .85rem;
169
+ font-weight: 600;
170
+ }
171
+ .tree { padding: 0 .6rem .7rem; font-size: .82rem; }
172
+ .tree ul { list-style: none; margin: 0; padding-left: .8rem; }
173
+ .tree > ul { padding-left: 0; }
174
+ .tree .dir { color: var(--muted); display: block; padding: .1rem 0; }
175
+ .tree-file {
176
+ display: flex;
177
+ gap: .4rem;
178
+ width: 100%;
179
+ text-align: left;
180
+ background: none;
181
+ border: 0;
182
+ border-radius: 6px;
183
+ padding: .15rem .3rem;
184
+ color: inherit;
185
+ font-size: .82rem;
186
+ }
187
+ .tree-file:hover { background: var(--panel-2); color: inherit; }
188
+ .tree-file .nm { overflow-wrap: anywhere; }
189
+ .tree-file .ct { margin-left: auto; white-space: nowrap; font-family: ui-monospace, monospace; font-size: .75rem; }
190
+ .tree-file.is-noise .nm { color: var(--muted); }
191
+
192
+ .file {
193
+ border: 1px solid var(--line);
194
+ border-radius: 10px;
195
+ margin: 0 0 .9rem;
196
+ overflow: hidden;
197
+ background: var(--bg);
198
+ }
199
+ .file > summary {
200
+ cursor: pointer;
201
+ padding: .55rem .7rem;
202
+ background: var(--panel);
203
+ display: flex;
204
+ gap: .55rem;
205
+ align-items: baseline;
206
+ flex-wrap: wrap;
207
+ }
208
+ .file[hidden] { display: none; }
209
+ .path { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .82rem; overflow-wrap: anywhere; }
210
+ .chip {
211
+ font-size: .68rem;
212
+ text-transform: uppercase;
213
+ letter-spacing: .04em;
214
+ color: var(--chip-fg);
215
+ background: var(--chip-bg);
216
+ border-radius: 999px;
217
+ padding: .1rem .45rem;
218
+ }
219
+ .counts { margin-left: auto; font-size: .78rem; font-family: ui-monospace, monospace; white-space: nowrap; }
220
+
221
+ /* The ONLY horizontally scrolling box on the page. */
222
+ .code { overflow-x: auto; border-top: 1px solid var(--line); }
223
+ table.diff { border-collapse: collapse; width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .78rem; }
224
+ table.diff td { padding: 0; vertical-align: top; }
225
+
226
+ /* One sticky gutter cell rather than three: no left-offset arithmetic to get
227
+ wrong, and user-select:none means copying the code does not drag line
228
+ numbers along with it. */
229
+ td.gutter {
230
+ position: sticky;
231
+ left: 0;
232
+ z-index: 1;
233
+ background: var(--panel);
234
+ border-right: 1px solid var(--line);
235
+ user-select: none;
236
+ white-space: pre;
237
+ color: var(--ln-fg);
238
+ padding: 0 .4rem;
239
+ text-align: right;
240
+ }
241
+ td.gutter .n { display: inline-block; min-width: var(--ln-w); }
242
+ td.gutter .s { display: inline-block; min-width: 1.2ch; text-align: center; font-weight: 700; }
243
+ td.src { white-space: pre; padding: 0 .75rem; }
244
+
245
+ tr.add td.src { background: var(--add-bg); color: var(--add-fg); }
246
+ tr.add td.gutter .s { color: var(--add-mark); }
247
+ tr.del td.src { background: var(--del-bg); color: var(--del-fg); }
248
+ tr.del td.gutter .s { color: var(--del-mark); }
249
+ tr.meta td.src { color: var(--muted); font-style: italic; }
250
+
251
+ tr.band td { background: var(--band-bg); color: var(--band-fg); }
252
+ tr.band td.src { padding: .2rem .75rem; white-space: normal; }
253
+ tr.band .n-hidden { margin-right: .6rem; }
254
+ tr.band button { font-size: .72rem; padding: .1rem .5rem; margin-right: .3rem; }
255
+
256
+ .note { color: var(--muted); padding: .6rem .75rem; font-size: .85rem; }
257
+ .empty { color: var(--muted); }
258
+ #review-block:empty { display: none; }
259
+ .review {
260
+ border: 1px solid var(--line);
261
+ border-left: 3px solid var(--accent);
262
+ border-radius: 10px;
263
+ background: var(--panel);
264
+ padding: .8rem 1rem;
265
+ margin: 0 0 1rem;
266
+ }
267
+ .review-summary { margin: 0; }
268
+ .checks { list-style: none; margin: .8rem 0 0; padding: 0; display: grid; gap: .4rem; }
269
+ .check {
270
+ display: grid;
271
+ grid-template-columns: auto 1fr;
272
+ gap: .2rem .6rem;
273
+ align-items: baseline;
274
+ padding: .4rem .6rem;
275
+ border-radius: 8px;
276
+ font-size: .88rem;
277
+ }
278
+ /* The level is a WORD, not a colour — the same reason the diff keeps its +/-
279
+ gutter. Colour is the second signal here, never the only one. */
280
+ .check-level {
281
+ font-size: .68rem;
282
+ text-transform: uppercase;
283
+ letter-spacing: .05em;
284
+ font-weight: 700;
285
+ grid-row: 1 / span 2;
286
+ }
287
+ .check-file {
288
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
289
+ font-size: .78rem;
290
+ color: var(--muted);
291
+ overflow-wrap: anywhere;
292
+ }
293
+ .check-note { grid-column: 2; }
294
+ .check.flag { background: var(--flag-bg); }
295
+ .check.flag .check-level { color: var(--flag-fg); }
296
+ .check.confirm { background: var(--confirm-bg); }
297
+ .check.confirm .check-level { color: var(--confirm-fg); }
298
+ .check.good { background: var(--good-bg); }
299
+ .check.good .check-level { color: var(--good-fg); }
300
+
301
+ /* --- review marks: accepts, comments, replies ---------------------------- */
302
+ /* Every colour below comes from a token defined on bare :root above; nothing
303
+ here gains its first definition inside a theme block. */
304
+ .accept[aria-pressed="true"] {
305
+ background: var(--good-bg);
306
+ color: var(--good-fg);
307
+ border-color: var(--good-fg);
308
+ }
309
+ .chip.accepted { background: var(--good-bg); color: var(--good-fg); }
310
+ .chip.lapsed {
311
+ background: var(--confirm-bg);
312
+ color: var(--confirm-fg);
313
+ text-transform: none;
314
+ letter-spacing: 0;
315
+ }
316
+ .chip.commented { background: var(--note-bg); color: var(--note-fg); }
317
+
318
+ /* The gutter is already user-select:none, so clicking it can never fight with
319
+ selecting the code beside it — which is why the affordance lives there. */
320
+ td.gutter.can-note { cursor: pointer; }
321
+ td.gutter.can-note:hover { color: var(--accent); }
322
+ tr.has-note td.gutter { box-shadow: inset 2px 0 0 var(--note-line); }
323
+
324
+ tr.note-row td {
325
+ background: var(--note-bg);
326
+ color: var(--note-fg);
327
+ white-space: normal;
328
+ padding: .45rem .75rem;
329
+ border-top: 1px solid var(--note-line);
330
+ border-bottom: 1px solid var(--note-line);
331
+ }
332
+ .note-meta { font-size: .72rem; color: var(--muted); display: block; margin-bottom: .15rem; }
333
+ .note-text { overflow-wrap: anywhere; }
334
+ .note-row.is-resolved .note-text { text-decoration: line-through; opacity: .75; }
335
+ .note-resolution { display: block; font-size: .82rem; margin-top: .2rem; }
336
+ .note-input {
337
+ width: 100%;
338
+ font: inherit;
339
+ font-size: .85rem;
340
+ color: var(--fg);
341
+ background: var(--bg);
342
+ border: 1px solid var(--note-line);
343
+ border-radius: 8px;
344
+ padding: .4rem .5rem;
345
+ resize: vertical;
346
+ }
347
+ .note-input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
348
+ .note-actions { display: flex; gap: .4rem; margin-top: .4rem; flex-wrap: wrap; }
349
+
350
+ .strip {
351
+ border-top: 1px solid var(--line);
352
+ background: var(--note-bg);
353
+ color: var(--note-fg);
354
+ padding: .5rem .75rem;
355
+ font-size: .85rem;
356
+ display: grid;
357
+ gap: .4rem;
358
+ }
359
+ .strip .note-input { font-size: .85rem; }
360
+
361
+ .reply { grid-column: 2; margin-top: .35rem; }
362
+ .reply .note-input { font-size: .85rem; }
363
+ .reply-given {
364
+ grid-column: 2;
365
+ margin-top: .35rem;
366
+ font-size: .85rem;
367
+ border-left: 2px solid var(--note-line);
368
+ padding-left: .5rem;
369
+ }
370
+
371
+ /* --- the verdict bar ----------------------------------------------------- */
372
+ /* Every colour here is an existing token: approve borrows the `good` pair and
373
+ changes the `flag` pair, so the bar needs no palette of its own. */
374
+ .verdict {
375
+ display: flex;
376
+ flex-wrap: wrap;
377
+ gap: .5rem;
378
+ align-items: center;
379
+ margin: .9rem 0 0;
380
+ padding: .6rem .7rem;
381
+ border: 1px solid var(--line);
382
+ border-radius: 10px;
383
+ background: var(--panel);
384
+ }
385
+ .verdict-end { margin: 1.2rem 0 0; }
386
+ .verdict-title { font-size: .8rem; color: var(--muted); }
387
+ .verdict-count { font-size: .8rem; color: var(--muted); }
388
+ .v-commit { color: var(--good-fg); background: var(--good-bg); }
389
+ .v-changes { color: var(--flag-fg); background: var(--flag-bg); }
390
+ /* A blocked control is dashed as well as dimmed, so the block survives a
391
+ greyscale screen — and the reason rides in the label, never in a tooltip
392
+ nobody hovers on a phone. */
393
+ button:disabled { color: var(--muted); background: var(--panel-2); border-style: dashed; cursor: not-allowed; }
394
+ button:disabled:hover { color: var(--muted); border-color: var(--line); }
395
+ .verdict-log { font-size: .8rem; color: var(--muted); margin: .45rem 0 0; }
396
+ .verdict-log[hidden] { display: none; }
397
+ /* Which engine drew this page. A render is always current and a stale RENDERER
398
+ is invisible without it — the counts move, the date moves, the diff is right. */
399
+ .drawn-by { font-size: .75rem; color: var(--muted); margin: .9rem 0 0; }
400
+
401
+ .copy-out {
402
+ width: 100%;
403
+ min-height: 8rem;
404
+ margin-top: .6rem;
405
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
406
+ font-size: .75rem;
407
+ color: var(--fg);
408
+ background: var(--panel);
409
+ border: 1px solid var(--line);
410
+ border-radius: 8px;
411
+ padding: .5rem;
412
+ }
413
+ .copy-out[hidden] { display: none; }
414
+ .context { margin: 0 0 1rem; padding: .75rem .9rem; border: 1px solid var(--line); border-radius: 6px; background: var(--panel, transparent); }
415
+ .context h3 { font-size: .78rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: .9rem 0 .35rem; }
416
+ .context h3:first-child { margin-top: 0; }
417
+ .context p { margin: 0 0 .5rem; line-height: 1.5; }
418
+ .context p:last-child { margin-bottom: 0; }
419
+ .context table { border-collapse: collapse; width: 100%; font-size: .85rem; }
420
+ .context th, .context td { text-align: left; padding: .2rem .5rem .2rem 0; vertical-align: top; }
421
+ .context th { color: var(--muted); font-weight: 600; }
422
+ .context ul { margin: 0; padding-left: 1.1rem; }
423
+ .context li { margin: .15rem 0; line-height: 1.45; }
424
+ .context li.done { color: var(--muted); }
425
+ .context-more > summary { cursor: pointer; color: var(--muted); font-size: .85rem; margin-top: .6rem; }
426
+ .copy-hint { font-size: .8rem; color: var(--muted); margin: .4rem 0 0; }
427
+ .sent-cmd { display: flex; gap: .4rem; align-items: stretch; margin: .4rem 0 0; }
428
+ .sent-cmd-text {
429
+ flex: 1; min-width: 0; font-family: var(--mono, ui-monospace, monospace);
430
+ font-size: .85rem; padding: .35rem .5rem; color: var(--fg);
431
+ background: var(--bg); border: 1px solid var(--line); border-radius: 4px;
432
+ }
433
+ .sent-cmd-copy { white-space: nowrap; }
434
+ /* The claim code is read aloud or copied, so it is set apart from the sentence
435
+ around it rather than buried in it. */
436
+ .copy-hint { user-select: text; }
437
+ </style>
438
+ </head>
439
+ <body>
440
+ <div class="wrap">
441
+ <header>
442
+ <h1 id="title"></h1>
443
+ <p class="sub" id="sub"></p>
444
+ <div class="controls">
445
+ <button id="expand-all" type="button">Show all context</button>
446
+ <button id="collapse-all" type="button">Collapse context</button>
447
+ <label class="toggle"><input type="checkbox" id="show-noise"> <span id="noise-label">Show bookkeeping</span></label>
448
+ <button id="theme" type="button" aria-live="polite">Theme</button>
449
+ </div>
450
+ </header>
451
+
452
+ <div class="layout">
453
+ <details class="tree-wrap" id="tree-wrap" open>
454
+ <summary id="tree-summary">Files</summary>
455
+ <nav class="tree" id="tree"></nav>
456
+ </details>
457
+ <main class="main">
458
+ <!-- WHAT THE CHANGE IS FOR, above the first filename. Composed by the
459
+ engine from the spec's own files — nothing here passed through the
460
+ model, so it is free however large the diff. Absent entirely for a spec
461
+ whose overview cannot be read: the page worked without it before. -->
462
+ <section class="context" id="context" hidden>
463
+ <div class="context-why" id="context-why"></div>
464
+ <details class="context-more" id="context-more" hidden>
465
+ <summary id="context-more-summary">More</summary>
466
+ <div id="context-rest"></div>
467
+ </details>
468
+ </section>
469
+ <div id="review-block">__REVIEW_BLOCK__</div>
470
+ <div id="files"></div>
471
+ <!-- THE VERDICT SITS AT THE END, where reading finishes. It began in the
472
+ header and the first person to use it could not find it: you read a
473
+ 350-line diff downward, and the control asking for your conclusion was
474
+ off-screen above you. A conclusion belongs after the thing it is about.
475
+ Not sticky, deliberately — a permanent bar costs vertical space on the
476
+ phone this page is read on, on a page whose whole job is showing code. -->
477
+ <footer class="verdict-end">
478
+ <div class="verdict" id="verdict">
479
+ <span class="verdict-title">Your verdict</span>
480
+ <button id="verdict-commit" class="v-commit" type="button" aria-live="polite">✓ Commit</button>
481
+ <button id="verdict-commit-continue" class="v-commit" type="button" aria-live="polite">✓ Commit &amp; Continue</button>
482
+ <button id="verdict-changes" class="v-changes" type="button">↺ Request changes</button>
483
+ <button id="verdict-discuss" type="button">… Discuss first</button>
484
+ <span class="verdict-count" id="verdict-count" aria-live="polite"></span>
485
+ </div>
486
+ <p class="verdict-log" id="verdict-log" hidden></p>
487
+ <p class="copy-hint" id="copy-hint" hidden></p>
488
+ <p class="sent-cmd" id="sent-cmd" hidden>
489
+ <input class="sent-cmd-text" id="sent-cmd-text" readonly aria-label="Command to run">
490
+ <button type="button" class="sent-cmd-copy" id="sent-cmd-copy" hidden>Copy</button>
491
+ </p>
492
+ <textarea class="copy-out" id="copy-out" readonly hidden aria-label="Review blob to copy"></textarea>
493
+ <p class="drawn-by" id="drawn-by"></p>
494
+ </footer>
495
+ </main>
496
+ </div>
497
+ </div>
498
+
499
+ <script type="application/json" id="review-data">__REVIEW_DATA__</script>
500
+ <script>
501
+ (function () {
502
+ 'use strict'
503
+
504
+ var data = JSON.parse(document.getElementById('review-data').textContent)
505
+
506
+ // Lines kept either side of a change, and how much one "expand" step reveals.
507
+ var CONTEXT = 4
508
+ var STEP = 20
509
+
510
+ // --- patch parsing -------------------------------------------------------
511
+ // Rows carry their own line numbers so the gutter needs no second pass. Hunk
512
+ // headers are emitted ONLY when there is more than one hunk: with whole-file
513
+ // context there is exactly one, and a "@@ -1,400 +1,402 @@" banner above a
514
+ // whole file says nothing a reader wants.
515
+ function parsePatch(patch) {
516
+ var raw = patch.split('\n')
517
+ var hunks = 0
518
+ var i
519
+ for (i = 0; i < raw.length; i++) if (raw[i].slice(0, 2) === '@@') hunks++
520
+
521
+ var rows = []
522
+ var oldNo = 0
523
+ var newNo = 0
524
+ var started = false
525
+ for (i = 0; i < raw.length; i++) {
526
+ var line = raw[i]
527
+ if (line.slice(0, 2) === '@@') {
528
+ var m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
529
+ if (m) { oldNo = +m[1]; newNo = +m[2] }
530
+ started = true
531
+ if (hunks > 1) rows.push({ t: 'hunk', text: line })
532
+ continue
533
+ }
534
+ if (!started) continue
535
+ var c = line.charAt(0)
536
+ if (c === '+') rows.push({ t: 'add', text: line.slice(1), n: newNo++ })
537
+ else if (c === '-') rows.push({ t: 'del', text: line.slice(1), o: oldNo++ })
538
+ else if (c === '\\') rows.push({ t: 'meta', text: line.slice(2) })
539
+ else rows.push({ t: 'ctx', text: line.slice(1), o: oldNo++, n: newNo++ })
540
+ }
541
+ return rows
542
+ }
543
+
544
+ // Which stretches of unchanged context start out hidden. A run touching the
545
+ // top or bottom of the file collapses all the way to the edge — there is no
546
+ // change on that side to need context for.
547
+ function initialBands(rows) {
548
+ var bands = []
549
+ var i = 0
550
+ while (i < rows.length) {
551
+ if (rows[i].t !== 'ctx') { i++; continue }
552
+ var j = i
553
+ while (j < rows.length && rows[j].t === 'ctx') j++
554
+ var atStart = i === 0
555
+ var atEnd = j === rows.length
556
+ var from = atStart ? i : i + CONTEXT
557
+ var to = atEnd ? j : j - CONTEXT
558
+ // A band hiding one or two lines is worse than just showing them.
559
+ if (to - from >= 3) bands.push({ from: from, to: to })
560
+ i = j
561
+ }
562
+ return bands
563
+ }
564
+
565
+ // --- per-file page state -------------------------------------------------
566
+ // Band state lives HERE, never in the emitted data: the page is a view of an
567
+ // immutable review, and expanding context is a property of this reading of it.
568
+ var state = data.files.map(function (f) {
569
+ var rows = parsePatch(f.patch)
570
+ var st = {
571
+ file: f,
572
+ rows: rows,
573
+ bands: initialBands(rows),
574
+ rendered: false,
575
+ el: null,
576
+ // What the page holds about THIS reading: whether the accept box is
577
+ // ticked now, and any notes written but not yet copied out.
578
+ accept: f.accepted === true,
579
+ drafts: [],
580
+ }
581
+ revealCommented(st)
582
+ return st
583
+ })
584
+
585
+ // --- review marks --------------------------------------------------------
586
+ // A comment is useless if the line it points at is hidden, so any band that
587
+ // swallows a commented line is dropped before the first render. Dropping the
588
+ // whole band shows a little more context than strictly needed, which is the
589
+ // harmless direction.
590
+ function revealCommented(st) {
591
+ var lines = (st.file.comments || [])
592
+ .map(function (c) { return c.line })
593
+ .filter(function (n) { return n != null })
594
+ if (!lines.length) return
595
+ var idx = []
596
+ st.rows.forEach(function (row, i) {
597
+ if (row.n != null && lines.indexOf(row.n) !== -1) idx.push(i)
598
+ })
599
+ st.bands = st.bands.filter(function (b) {
600
+ return !idx.some(function (i) { return i >= b.from && i < b.to })
601
+ })
602
+ }
603
+
604
+ // Ids are minted from the render timestamp plus a counter, so they are stable
605
+ // for this page and unique across renders. That is what makes re-pasting the
606
+ // same blob merge instead of doubling every note.
607
+ var seq = 0
608
+ function mintId() {
609
+ seq++
610
+ return data.generatedAt + '-' + seq
611
+ }
612
+
613
+ // Best effort, both ways. Safari refuses storage on file:// by THROWING, and
614
+ // a review page that fails to render because it could not autosave would be a
615
+ // far worse bug than a lost pass.
616
+ var STORE_KEY = 'skitterspec-review:' + data.spec + ':' + data.generatedAt
617
+ function save() {
618
+ try {
619
+ var accepts = {}
620
+ var drafts = []
621
+ state.forEach(function (st) {
622
+ accepts[st.file.path] = st.accept
623
+ st.drafts.forEach(function (d) { drafts.push(d) })
624
+ })
625
+ window.localStorage.setItem(STORE_KEY, JSON.stringify({ seq: seq, accepts: accepts, drafts: drafts, replies: replies }))
626
+ } catch (e) { /* no storage, or a quota — the pass still works, it just is not saved */ }
627
+ }
628
+ function restore() {
629
+ var raw = null
630
+ try { raw = window.localStorage.getItem(STORE_KEY) } catch (e) { return }
631
+ if (!raw) return
632
+ var saved
633
+ try { saved = JSON.parse(raw) } catch (e) { return }
634
+ seq = saved.seq || 0
635
+ replies = saved.replies || {}
636
+ var byPath = {}
637
+ state.forEach(function (st) { byPath[st.file.path] = st })
638
+ Object.keys(saved.accepts || {}).forEach(function (path) {
639
+ if (byPath[path]) byPath[path].accept = Boolean(saved.accepts[path])
640
+ })
641
+ ;(saved.drafts || []).forEach(function (d) {
642
+ if (byPath[d.file]) byPath[d.file].drafts.push(d)
643
+ else orphanDrafts.push(d)
644
+ })
645
+ }
646
+
647
+ // Drafts whose file left the diff between saving and reloading, and replies to
648
+ // the written review keyed by check id. Both still belong in the blob.
649
+ var orphanDrafts = []
650
+ var replies = {}
651
+
652
+ function el(tag, cls, text) {
653
+ var n = document.createElement(tag)
654
+ if (cls) n.className = cls
655
+ if (text != null) n.textContent = text
656
+ return n
657
+ }
658
+
659
+ function gutter(row, st, index) {
660
+ var td = el('td', 'gutter')
661
+ var o = el('span', 'n', row.o == null ? '' : String(row.o))
662
+ var n = el('span', 'n', row.n == null ? '' : String(row.n))
663
+ var s = el('span', 's', row.t === 'add' ? '+' : row.t === 'del' ? '-' : ' ')
664
+ td.appendChild(o); td.appendChild(n); td.appendChild(s)
665
+ if (st) {
666
+ td.className = 'gutter can-note'
667
+ td.title = 'Comment on this line'
668
+ td.addEventListener('click', function () { openDraft(st, index, row) })
669
+ }
670
+ return td
671
+ }
672
+
673
+ function bandRow(st, band, idx) {
674
+ var tr = el('tr', 'band')
675
+ tr.appendChild(el('td', 'gutter', '⋯'))
676
+ var td = el('td', 'src')
677
+ var hidden = band.to - band.from
678
+ td.appendChild(el('span', 'n-hidden', hidden + ' unchanged line' + (hidden === 1 ? '' : 's')))
679
+ var step = el('button', null, '±' + STEP)
680
+ step.type = 'button'
681
+ step.setAttribute('aria-label', 'Expand ' + STEP + ' lines either side')
682
+ step.addEventListener('click', function () {
683
+ band.from += STEP
684
+ band.to -= STEP
685
+ if (band.to - band.from < 3) st.bands.splice(st.bands.indexOf(band), 1)
686
+ renderFile(st)
687
+ })
688
+ var all = el('button', null, 'Show all')
689
+ all.type = 'button'
690
+ all.addEventListener('click', function () {
691
+ st.bands.splice(st.bands.indexOf(band), 1)
692
+ renderFile(st)
693
+ })
694
+ td.appendChild(step)
695
+ td.appendChild(all)
696
+ tr.appendChild(td)
697
+ return tr
698
+ }
699
+
700
+ // Re-render the whole file on every expand. The alternative — pre-rendering
701
+ // thousands of hidden rows and unhiding them — is what makes a whole-file
702
+ // diff page take seconds to open.
703
+ function renderFile(st) {
704
+ var host = st.el
705
+ if (!host) return
706
+ host.textContent = ''
707
+ if (!st.rows.length) {
708
+ host.appendChild(el('p', 'note', st.file.binary ? 'Binary file — not shown.' : 'No textual change.'))
709
+ return
710
+ }
711
+ var box = el('div', 'code')
712
+ var table = el('table', 'diff')
713
+ var body = document.createElement('tbody')
714
+ var bands = st.bands.slice().sort(function (a, b) { return a.from - b.from })
715
+ var b = 0
716
+ var i = 0
717
+ while (i < st.rows.length) {
718
+ if (b < bands.length && i === bands[b].from) {
719
+ body.appendChild(bandRow(st, bands[b], b))
720
+ i = bands[b].to
721
+ b++
722
+ continue
723
+ }
724
+ var row = st.rows[i]
725
+ var tr = el('tr', row.t)
726
+ if (row.t === 'hunk') {
727
+ tr.className = 'band'
728
+ tr.appendChild(el('td', 'gutter', '⋯'))
729
+ tr.appendChild(el('td', 'src', row.text))
730
+ } else {
731
+ tr.appendChild(gutter(row, st, i))
732
+ tr.appendChild(el('td', 'src', row.text === '' ? ' ' : row.text))
733
+ }
734
+ body.appendChild(tr)
735
+ // Anything said about this line sits directly beneath it — a note you
736
+ // have to go and look for is a note nobody reads.
737
+ var here = notesAt(st, row.n)
738
+ if (here.length) tr.className += ' has-note'
739
+ here.forEach(function (c) { body.appendChild(noteRow(st, c)) })
740
+ if (st.draftAt === i) body.appendChild(draftRow(st, row))
741
+ i++
742
+ }
743
+ table.appendChild(body)
744
+ box.appendChild(table)
745
+ host.appendChild(box)
746
+ }
747
+
748
+ // Every note about one line: what the engine stored, plus anything written on
749
+ // this page and not yet copied out.
750
+ function notesAt(st, line) {
751
+ if (line == null) return []
752
+ var out = []
753
+ ;(st.file.comments || []).forEach(function (c) { if (c.line === line) out.push(c) })
754
+ st.drafts.forEach(function (d) { if (d.line === line) out.push(d) })
755
+ return out
756
+ }
757
+
758
+ function noteRow(st, c) {
759
+ var tr = el('tr', 'note-row' + (c.resolved ? ' is-resolved' : ''))
760
+ tr.appendChild(el('td', 'gutter', '💬'))
761
+ var td = el('td', 'src')
762
+ var meta = c.line == null ? 'on this file' : 'line ' + c.line
763
+ if (c.check) meta += ' · answering the review'
764
+ if (!c.raisedAt) meta += ' · not sent yet'
765
+ td.appendChild(el('span', 'note-meta', meta))
766
+ td.appendChild(el('span', 'note-text', c.note))
767
+ if (c.resolved) {
768
+ td.appendChild(el('span', 'note-resolution', '↳ ' + (c.resolved.note || 'resolved')))
769
+ }
770
+ if (!c.raisedAt) {
771
+ var actions = el('div', 'note-actions')
772
+ var del = el('button', null, 'Remove')
773
+ del.type = 'button'
774
+ del.addEventListener('click', function () {
775
+ st.drafts.splice(st.drafts.indexOf(c), 1)
776
+ renderFile(st)
777
+ refresh()
778
+ })
779
+ actions.appendChild(del)
780
+ td.appendChild(actions)
781
+ }
782
+ tr.appendChild(td)
783
+ return tr
784
+ }
785
+
786
+ // The draft box. `row` is null for a note about the file as a whole.
787
+ function draftRow(st, row) {
788
+ var tr = el('tr', 'note-row')
789
+ tr.appendChild(el('td', 'gutter', '💬'))
790
+ var td = el('td', 'src')
791
+ td.appendChild(el('span', 'note-meta', row ? 'line ' + (row.n == null ? '—' : row.n) : 'on this file'))
792
+ td.appendChild(noteEditor(function (text) {
793
+ addDraft(st, text, row ? row.n : null, row ? row.text : null, null)
794
+ st.draftAt = null
795
+ renderFile(st)
796
+ }, function () {
797
+ st.draftAt = null
798
+ renderFile(st)
799
+ }))
800
+ tr.appendChild(td)
801
+ return tr
802
+ }
803
+
804
+ // One editor, used by the gutter, the file strip and the review replies.
805
+ function noteEditor(onSave, onCancel) {
806
+ var wrap = el('div')
807
+ var input = el('textarea', 'note-input')
808
+ input.rows = 2
809
+ input.placeholder = 'What should change here?'
810
+ var actions = el('div', 'note-actions')
811
+ var ok = el('button', null, 'Add note')
812
+ ok.type = 'button'
813
+ ok.addEventListener('click', function () {
814
+ var text = input.value.trim()
815
+ // An empty note would be refused by the engine, so it is refused here —
816
+ // where the person who wrote it is still looking at it.
817
+ if (!text) { input.focus(); return }
818
+ onSave(text)
819
+ })
820
+ actions.appendChild(ok)
821
+ if (onCancel) {
822
+ var no = el('button', null, 'Cancel')
823
+ no.type = 'button'
824
+ no.addEventListener('click', onCancel)
825
+ actions.appendChild(no)
826
+ }
827
+ wrap.appendChild(input)
828
+ wrap.appendChild(actions)
829
+ window.setTimeout(function () { input.focus() }, 0)
830
+ return wrap
831
+ }
832
+
833
+ function addDraft(st, note, line, lineText, check) {
834
+ st.drafts.push({
835
+ id: mintId(),
836
+ file: st.file.path,
837
+ line: line == null ? null : line,
838
+ lineText: lineText == null ? null : lineText,
839
+ check: check || null,
840
+ note: note,
841
+ })
842
+ refresh()
843
+ }
844
+
845
+ function openDraft(st, index, row) {
846
+ st.draftAt = st.draftAt === index ? null : index
847
+ renderFile(st)
848
+ }
849
+
850
+ // Notes about the file rather than a line, and the way in to writing one.
851
+ function fileStrip(st) {
852
+ var strip = el('div', 'strip')
853
+ var loose = (st.file.comments || []).concat(st.drafts).filter(function (c) { return c.line == null })
854
+ loose.forEach(function (c) {
855
+ var row = el('div', c.resolved ? 'is-resolved' : null)
856
+ row.appendChild(el('span', 'note-meta', (c.check ? 'answering the review' : 'on this file') + (c.raisedAt ? '' : ' · not sent yet')))
857
+ row.appendChild(el('span', 'note-text', c.note))
858
+ if (c.resolved) row.appendChild(el('span', 'note-resolution', '↳ ' + (c.resolved.note || 'resolved')))
859
+ strip.appendChild(row)
860
+ })
861
+ var add = el('button', null, 'Note on this file')
862
+ add.type = 'button'
863
+ add.addEventListener('click', function () {
864
+ add.hidden = true
865
+ strip.appendChild(noteEditor(function (text) {
866
+ addDraft(st, text, null, null, null)
867
+ redrawStrip(st)
868
+ }, function () { redrawStrip(st) }))
869
+ })
870
+ strip.appendChild(add)
871
+ return strip
872
+ }
873
+
874
+ function redrawStrip(st) {
875
+ if (!st.stripEl) return
876
+ var next = fileStrip(st)
877
+ st.stripEl.parentNode.replaceChild(next, st.stripEl)
878
+ st.stripEl = next
879
+ }
880
+
881
+ // --- file list -----------------------------------------------------------
882
+ var filesHost = document.getElementById('files')
883
+
884
+ state.forEach(function (st) {
885
+ var f = st.file
886
+ var d = el('details', 'file')
887
+ d.id = 'f-' + encodeURIComponent(f.path)
888
+ var s = el('summary')
889
+ s.appendChild(el('span', 'path', f.path))
890
+ s.appendChild(el('span', 'chip', f.status))
891
+ if (!f.whole) s.appendChild(el('span', 'chip', 'partial context'))
892
+ if (f.noise) s.appendChild(el('span', 'chip', 'bookkeeping'))
893
+ // A lapse is SAID. An accept that quietly vanished because the file moved on
894
+ // would leave you believing you had read code you have not.
895
+ if (f.accepted === 'lapsed') {
896
+ s.appendChild(el('span', 'chip lapsed', 'accepted earlier — changed since'))
897
+ }
898
+ var openCount = (f.comments || []).filter(function (x) { return !x.resolved }).length
899
+ if (openCount) s.appendChild(el('span', 'chip commented', openCount + ' note' + (openCount === 1 ? '' : 's')))
900
+
901
+ var accept = el('button', 'accept', '✓ accept')
902
+ accept.type = 'button'
903
+ accept.setAttribute('aria-pressed', String(st.accept))
904
+ // A summary is a toggle for the <details>; a click on a control inside it
905
+ // must not also open and close the file.
906
+ accept.addEventListener('click', function (ev) {
907
+ ev.preventDefault()
908
+ ev.stopPropagation()
909
+ st.accept = !st.accept
910
+ accept.setAttribute('aria-pressed', String(st.accept))
911
+ refresh()
912
+ })
913
+ s.appendChild(accept)
914
+
915
+ var c = el('span', 'counts')
916
+ c.appendChild(el('span', 'a', '+' + f.additions))
917
+ c.appendChild(document.createTextNode(' '))
918
+ c.appendChild(el('span', 'd', '-' + f.deletions))
919
+ s.appendChild(c)
920
+ d.appendChild(s)
921
+ st.stripEl = fileStrip(st)
922
+ d.appendChild(st.stripEl)
923
+ var host = el('div')
924
+ d.appendChild(host)
925
+ st.el = host
926
+
927
+ // Render on first open, so a review of 200 files costs one file's work.
928
+ d.addEventListener('toggle', function () {
929
+ if (d.open && !st.rendered) { st.rendered = true; renderFile(st) }
930
+ })
931
+ if (!f.noise) { d.open = true; st.rendered = true; renderFile(st) }
932
+ filesHost.appendChild(d)
933
+ st.details = d
934
+ })
935
+
936
+ if (!state.length) {
937
+ filesHost.appendChild(el('p', 'empty', 'Nothing to review — no changes found.'))
938
+ }
939
+
940
+ // --- noise filter --------------------------------------------------------
941
+ var noiseBox = document.getElementById('show-noise')
942
+ var noiseCount = state.filter(function (s) { return s.file.noise }).length
943
+ document.getElementById('noise-label').textContent = 'Show bookkeeping (' + noiseCount + ')'
944
+ if (!noiseCount) noiseBox.closest('.toggle').hidden = true
945
+
946
+ function applyNoise() {
947
+ state.forEach(function (st) {
948
+ if (st.file.noise) st.details.hidden = !noiseBox.checked
949
+ })
950
+ }
951
+ noiseBox.addEventListener('change', applyNoise)
952
+ applyNoise()
953
+
954
+ // --- file tree -----------------------------------------------------------
955
+ // Built from the paths, and it lists EVERY file including the ones the noise
956
+ // filter is hiding — a tree with an unreachable entry is worse than no tree.
957
+ function buildTree(files) {
958
+ var root = { dirs: {}, files: [] }
959
+ files.forEach(function (f) {
960
+ var parts = f.path.split('/')
961
+ var node = root
962
+ for (var i = 0; i < parts.length - 1; i++) {
963
+ if (!node.dirs[parts[i]]) node.dirs[parts[i]] = { dirs: {}, files: [] }
964
+ node = node.dirs[parts[i]]
965
+ }
966
+ node.files.push({ name: parts[parts.length - 1], file: f })
967
+ })
968
+ return root
969
+ }
970
+
971
+ function reveal(f) {
972
+ if (f.noise && !noiseBox.checked) { noiseBox.checked = true; applyNoise() }
973
+ var d = document.getElementById('f-' + encodeURIComponent(f.path))
974
+ if (!d) return
975
+ d.open = true
976
+ d.scrollIntoView({ block: 'start' })
977
+ }
978
+
979
+ function renderTree(node) {
980
+ var ul = el('ul')
981
+ Object.keys(node.dirs).sort().forEach(function (name) {
982
+ var li = el('li')
983
+ li.appendChild(el('span', 'dir', name + '/'))
984
+ li.appendChild(renderTree(node.dirs[name]))
985
+ ul.appendChild(li)
986
+ })
987
+ node.files.sort(function (a, b) { return a.name.localeCompare(b.name) }).forEach(function (entry) {
988
+ var li = el('li')
989
+ var b = el('button', 'tree-file' + (entry.file.noise ? ' is-noise' : ''))
990
+ b.type = 'button'
991
+ b.appendChild(el('span', 'nm', entry.name))
992
+ var ct = el('span', 'ct')
993
+ ct.appendChild(el('span', 'a', '+' + entry.file.additions))
994
+ ct.appendChild(document.createTextNode(' '))
995
+ ct.appendChild(el('span', 'd', '-' + entry.file.deletions))
996
+ b.appendChild(ct)
997
+ b.addEventListener('click', function () { reveal(entry.file) })
998
+ li.appendChild(b)
999
+ ul.appendChild(li)
1000
+ })
1001
+ return ul
1002
+ }
1003
+
1004
+ var tree = document.getElementById('tree')
1005
+ tree.appendChild(renderTree(buildTree(data.files)))
1006
+ document.getElementById('tree-summary').textContent =
1007
+ 'Files (' + data.files.length + ')'
1008
+ if (window.matchMedia && !window.matchMedia('(min-width: 60rem)').matches) {
1009
+ document.getElementById('tree-wrap').open = false
1010
+ }
1011
+
1012
+ // --- page-level controls -------------------------------------------------
1013
+ document.getElementById('expand-all').addEventListener('click', function () {
1014
+ state.forEach(function (st) {
1015
+ st.bands = []
1016
+ if (st.rendered) renderFile(st)
1017
+ })
1018
+ })
1019
+ document.getElementById('collapse-all').addEventListener('click', function () {
1020
+ state.forEach(function (st) {
1021
+ st.bands = initialBands(st.rows)
1022
+ if (st.rendered) renderFile(st)
1023
+ })
1024
+ })
1025
+
1026
+ // --- answering the written review ---------------------------------------
1027
+ // A `confirm` exists to ask the author a question, and until now the only way
1028
+ // to answer was to retype it in chat. The engine stamps each check with an id
1029
+ // and the file it is about; a reply becomes an ordinary comment carrying that
1030
+ // id, so it travels in the same blob as everything else.
1031
+ function wireChecks() {
1032
+ var checks = document.querySelectorAll('.check[data-check]')
1033
+ Array.prototype.forEach.call(checks, function (li) {
1034
+ var id = li.getAttribute('data-check')
1035
+ // A check with no file is about the review rather than a line of code.
1036
+ // The sentinel keeps the blob valid and lands it under "not in this diff",
1037
+ // which is exactly where a review-wide remark belongs.
1038
+ var file = li.getAttribute('data-file') || '(review)'
1039
+ var given = el('div', 'reply-given')
1040
+ var box = el('div', 'reply')
1041
+ function draw() {
1042
+ given.textContent = replies[id] ? '↳ ' + replies[id] : ''
1043
+ given.hidden = !replies[id]
1044
+ box.textContent = ''
1045
+ var add = el('button', null, replies[id] ? 'Change answer' : 'Answer')
1046
+ add.type = 'button'
1047
+ add.addEventListener('click', function () {
1048
+ box.textContent = ''
1049
+ box.appendChild(noteEditor(function (text) {
1050
+ replies[id] = text
1051
+ draw()
1052
+ refresh()
1053
+ }, draw))
1054
+ })
1055
+ box.appendChild(add)
1056
+ }
1057
+ draw()
1058
+ li.appendChild(given)
1059
+ li.appendChild(box)
1060
+ li.setAttribute('data-reply-file', file)
1061
+ })
1062
+ }
1063
+
1064
+ // --- the blob -------------------------------------------------------------
1065
+ /**
1066
+ * A DELTA, not a snapshot. The engine merges rather than replaces, so this
1067
+ * says only what changed: files newly ticked, accepts withdrawn, notes
1068
+ * written. A file already accepted with this exact content has nothing to
1069
+ * say, and a file that was never accepted has nothing to withdraw.
1070
+ *
1071
+ * Withdrawal is explicit for the same reason — under a merge, absence means
1072
+ * "not mentioned", never "remove it".
1073
+ */
1074
+ function buildBlob(verdict) {
1075
+ var accepted = []
1076
+ var unaccepted = []
1077
+ var comments = []
1078
+ state.forEach(function (st) {
1079
+ var stored = st.file.accepted
1080
+ if (st.accept) {
1081
+ // No hash means the engine could not read the file; accepting it would
1082
+ // record a vouch for content nobody can identify.
1083
+ if (stored !== true && st.file.hash) accepted.push({ path: st.file.path, hash: st.file.hash })
1084
+ } else if (stored === true || stored === 'lapsed') {
1085
+ unaccepted.push(st.file.path)
1086
+ }
1087
+ st.drafts.forEach(function (d) { comments.push(d) })
1088
+ })
1089
+ orphanDrafts.forEach(function (d) { comments.push(d) })
1090
+ Object.keys(replies).forEach(function (id) {
1091
+ var li = document.querySelector('.check[data-check="' + id + '"]')
1092
+ comments.push({
1093
+ id: data.generatedAt + '-reply-' + id,
1094
+ file: (li && li.getAttribute('data-reply-file')) || '(review)',
1095
+ line: null,
1096
+ lineText: null,
1097
+ check: id,
1098
+ note: replies[id],
1099
+ })
1100
+ })
1101
+ var blob = {
1102
+ version: 1,
1103
+ spec: data.spec,
1104
+ generatedAt: data.generatedAt,
1105
+ accepted: accepted,
1106
+ unaccepted: unaccepted,
1107
+ comments: comments,
1108
+ }
1109
+ // Absent unless a verdict was actually chosen. The engine reads an absent
1110
+ // one as `discuss`, and a blob that never claimed one should keep saying so.
1111
+ if (verdict) blob.verdict = verdict
1112
+ return blob
1113
+ }
1114
+
1115
+ var verdictBtns = {
1116
+ commit: document.getElementById('verdict-commit'),
1117
+ 'commit-continue': document.getElementById('verdict-commit-continue'),
1118
+ changes: document.getElementById('verdict-changes'),
1119
+ discuss: document.getElementById('verdict-discuss'),
1120
+ }
1121
+ // The committing pair, named once. A fifth verdict that commits is added
1122
+ // here, and the shared block below follows for free — the same reason the
1123
+ // engine keeps a `COMMITTING` list rather than a second condition.
1124
+ var COMMITTERS = ['commit', 'commit-continue']
1125
+ var LABELS = { commit: '✓ Commit', 'commit-continue': '✓ Commit & Continue' }
1126
+ var TITLES = {
1127
+ commit: 'Commit what you just read, and stop there',
1128
+ // Says what it will NOT do: a reader must not press this expecting the
1129
+ // spec to be finished and landed.
1130
+ 'commit-continue': 'Commit what you just read, then build the next phase — nothing is landed',
1131
+ }
1132
+
1133
+ var verdictCount = document.getElementById('verdict-count')
1134
+ var verdictLog = document.getElementById('verdict-log')
1135
+ var copyOut = document.getElementById('copy-out')
1136
+ var copyHint = document.getElementById('copy-hint')
1137
+ var sentCmd = document.getElementById('sent-cmd')
1138
+ var sentCmdText = document.getElementById('sent-cmd-text')
1139
+ var sentCmdCopy = document.getElementById('sent-cmd-copy')
1140
+
1141
+ function pending() {
1142
+ var b = buildBlob()
1143
+ return b.accepted.length + b.unaccepted.length + b.comments.length
1144
+ }
1145
+
1146
+ /**
1147
+ * How many notes would still be open once this pass lands — the exact number
1148
+ * the engine's `judgeVerdict` will count, which is what makes the disabled
1149
+ * button a preview of the refusal rather than a second opinion about it.
1150
+ *
1151
+ * Stored comments the agent has not answered, PLUS every comment this pass is
1152
+ * about to send (a note you just wrote is open by definition).
1153
+ *
1154
+ * UNACCEPTED FILES ARE NOT COUNTED AND MUST NEVER BE. A comment is a request
1155
+ * you made; an unticked file is merely something you said nothing about, and
1156
+ * counting those would be the tally this whole feature is designed not to be.
1157
+ */
1158
+ function openNotes() {
1159
+ var stored = (data.notes && data.notes.totals && data.notes.totals.unresolved) || 0
1160
+ return stored + buildBlob().comments.length
1161
+ }
1162
+
1163
+ // Redraw the bar and autosave. Called after every mark, so the block lifts
1164
+ // the moment the last note goes and a phone tab switch mid-review costs
1165
+ // nothing.
1166
+ function refresh() {
1167
+ var n = pending()
1168
+ var open = openNotes()
1169
+ verdictCount.textContent = n
1170
+ ? n + (n === 1 ? ' mark to send' : ' marks to send')
1171
+ : 'Nothing marked — a clean read still commits'
1172
+ // ONE COUNT, BOTH BUTTONS. Two committing controls blocked by two separate
1173
+ // reads is how a page ends up with one disabled and the other not — which
1174
+ // would be a way around the single refusal this page makes.
1175
+ var blocked = open > 0
1176
+ var why = open + (open === 1 ? ' open note' : ' open notes')
1177
+ // Is there a phase left for `/spec-next` to build? The engine answers, and
1178
+ // its ABSENCE is a third state, not a no: a spec whose phases it could not
1179
+ // read — a legacy layout, an inline-phase overview — leaves the button
1180
+ // exactly as it was. Removing an offer that might work is worse than
1181
+ // leaving one that might not.
1182
+ var noPhaseLeft = Boolean(data.phases) && data.phases.hasNextPhase === false
1183
+ COMMITTERS.forEach(function (key) {
1184
+ var btn = verdictBtns[key]
1185
+ // AN OPEN NOTE WINS. Two reasons can be true at once and the label has
1186
+ // room for one — the open note is the one the reader can act on, so a
1187
+ // "no phase left" in its place would send them to fix the wrong thing.
1188
+ var stop = blocked ? why : key === 'commit-continue' && noPhaseLeft ? 'no phase left' : null
1189
+ // The reason goes ON the control. A button that is merely dimmed reads as
1190
+ // a broken page, and a title attribute is unreachable on a touch screen.
1191
+ btn.disabled = Boolean(stop)
1192
+ btn.textContent = stop ? LABELS[key] + ' — ' + stop : LABELS[key]
1193
+ btn.title = blocked
1194
+ ? 'Resolve or remove your notes first — you asked for something, so it cannot also be fine'
1195
+ : key === 'commit-continue' && noPhaseLeft
1196
+ ? 'Every phase of this spec is done — there is nothing left to build'
1197
+ : TITLES[key]
1198
+ })
1199
+ // Said rather than left unset: the committing pair is the ONLY thing this
1200
+ // page ever blocks, and the other two are always available on purpose — a
1201
+ // note you want acted on, and a question, are never the wrong thing to send.
1202
+ verdictBtns.changes.disabled = false
1203
+ verdictBtns.discuss.disabled = false
1204
+ save()
1205
+ }
1206
+
1207
+ // The last honoured verdict, as history. It is why the page can look
1208
+ // untouched after an approval: the verdict was consumed, not stored.
1209
+ function drawLog() {
1210
+ var last = data.notes && data.notes.lastDecision
1211
+ if (!last) return
1212
+ var said = last.verdict === 'approve' ? 'approved' : last.verdict === 'changes' ? 'changes requested' : 'discussed'
1213
+ var when = String(last.at || '').slice(0, 10)
1214
+ verdictLog.hidden = false
1215
+ verdictLog.textContent = said + ' earlier' + (when ? ' · ' + when : '') + (last.note ? ' · ' + last.note : '')
1216
+ }
1217
+
1218
+ /**
1219
+ * Hand over the command itself, where the reader's eye already is.
1220
+ *
1221
+ * Two ways out and the page picks by CAPABILITY, not by trying and failing:
1222
+ * `navigator.clipboard` is secure-context-only, and a page served at
1223
+ * `http://<lan-ip>:7777` is not a secure context — which is the ordinary case
1224
+ * here, not an edge one. A Copy button that does nothing when tapped is worse
1225
+ * than text that was always text, so the button only exists where it can work.
1226
+ */
1227
+ function showCommand(cmd) {
1228
+ sentCmd.hidden = false
1229
+ sentCmdText.value = cmd
1230
+ var canCopy = Boolean(navigator.clipboard && navigator.clipboard.writeText)
1231
+ sentCmdCopy.hidden = !canCopy
1232
+ if (canCopy) return
1233
+ // Selected, and SAID to be selected: a reader who sees nothing happen has
1234
+ // no way to tell a page that did nothing from one that already did the work.
1235
+ sentCmdText.focus()
1236
+ sentCmdText.select()
1237
+ copyHint.textContent = copyHint.textContent + ' (selected — copy it)'
1238
+ }
1239
+
1240
+ if (sentCmdCopy) {
1241
+ sentCmdCopy.addEventListener('click', function () {
1242
+ navigator.clipboard.writeText(sentCmdText.value).then(function () {
1243
+ sentCmdCopy.textContent = 'Copied'
1244
+ }, function () {
1245
+ // The capability was there and the write still failed. Fall back to the
1246
+ // same selection the no-clipboard path uses rather than saying nothing.
1247
+ sentCmdText.focus()
1248
+ sentCmdText.select()
1249
+ sentCmdCopy.textContent = 'Copy failed — selected'
1250
+ })
1251
+ })
1252
+ }
1253
+
1254
+ /**
1255
+ * The header: why this change exists, what it touches, what this phase set
1256
+ * out to do. Every part is optional and each is drawn only if present — a
1257
+ * spec with a Problem and no Impact table is ordinary, not broken.
1258
+ *
1259
+ * BUILT WITH createElement, never innerHTML. The text is markdown from a spec
1260
+ * file, which means it can contain anything someone typed — and this page is
1261
+ * served over the network, so treating it as markup would be the one place a
1262
+ * spec's prose could reach the DOM.
1263
+ */
1264
+ function drawContext() {
1265
+ var ctx = data.context
1266
+ if (!ctx) return
1267
+ var why = document.getElementById('context-why')
1268
+ var rest = document.getElementById('context-rest')
1269
+ var more = document.getElementById('context-more')
1270
+ var drewRest = false
1271
+
1272
+ function head(into, label) {
1273
+ var h = document.createElement('h3')
1274
+ h.textContent = label
1275
+ into.appendChild(h)
1276
+ }
1277
+
1278
+ if (ctx.problem) {
1279
+ // FIRST PARAGRAPH OPEN, the remainder folded. A phone shows perhaps six
1280
+ // lines before the file list is pushed off-screen, and pushing it off is
1281
+ // the thing this header was built to avoid.
1282
+ var paras = String(ctx.problem).split(/\n\s*\n/)
1283
+ head(why, 'Why')
1284
+ var lead = document.createElement('p')
1285
+ lead.textContent = paras[0].replace(/\s+/g, ' ').trim()
1286
+ why.appendChild(lead)
1287
+ for (var i = 1; i < paras.length; i++) {
1288
+ var p = document.createElement('p')
1289
+ p.textContent = paras[i].replace(/\s+/g, ' ').trim()
1290
+ rest.appendChild(p)
1291
+ drewRest = true
1292
+ }
1293
+ }
1294
+
1295
+ if (ctx.impact) {
1296
+ head(rest, 'Touches')
1297
+ drewRest = true
1298
+ if (ctx.impact.rows) {
1299
+ var table = document.createElement('table')
1300
+ var hr = document.createElement('tr')
1301
+ ;['Surface', 'Change', 'Detail'].forEach(function (label) {
1302
+ var th = document.createElement('th')
1303
+ th.textContent = label
1304
+ hr.appendChild(th)
1305
+ })
1306
+ table.appendChild(hr)
1307
+ ctx.impact.rows.forEach(function (row) {
1308
+ var tr = document.createElement('tr')
1309
+ ;[row.surface, row.change, row.detail].forEach(function (cell) {
1310
+ var td = document.createElement('td')
1311
+ td.textContent = cell
1312
+ tr.appendChild(td)
1313
+ })
1314
+ table.appendChild(tr)
1315
+ })
1316
+ rest.appendChild(table)
1317
+ } else {
1318
+ var prose = document.createElement('p')
1319
+ prose.textContent = ctx.impact.prose
1320
+ rest.appendChild(prose)
1321
+ }
1322
+ }
1323
+
1324
+ if (ctx.phase) {
1325
+ head(rest, 'Phase ' + ctx.phase.n + (ctx.phase.title ? ' — ' + ctx.phase.title : ''))
1326
+ drewRest = true
1327
+ if (ctx.phase.goal) {
1328
+ var g = document.createElement('p')
1329
+ g.textContent = ctx.phase.goal
1330
+ rest.appendChild(g)
1331
+ }
1332
+ if (ctx.phase.tasks && ctx.phase.tasks.length) {
1333
+ var ul = document.createElement('ul')
1334
+ ctx.phase.tasks.forEach(function (t) {
1335
+ var li = document.createElement('li')
1336
+ // Done is dimmed rather than struck through: every task is ticked by
1337
+ // the time a phase renders, and a whole list struck through reads as
1338
+ // cancelled work.
1339
+ li.className = t.done ? 'done' : ''
1340
+ li.textContent = (t.done ? '✅ ' : '⬜ ') + t.text
1341
+ ul.appendChild(li)
1342
+ })
1343
+ rest.appendChild(ul)
1344
+ }
1345
+ }
1346
+
1347
+ document.getElementById('context').hidden = false
1348
+ more.hidden = !drewRest
1349
+ }
1350
+
1351
+ function showFallback(json, why) {
1352
+ copyOut.hidden = false
1353
+ copyOut.value = json
1354
+ copyOut.focus()
1355
+ copyOut.select()
1356
+ copyHint.hidden = false
1357
+ copyHint.textContent = why
1358
+ }
1359
+
1360
+ // One action per decision: the verdict button IS the copy button, so the blob
1361
+ // can never carry a verdict other than the one just pressed.
1362
+ /**
1363
+ * Send the pass back over the wire, to the page's own URL.
1364
+ *
1365
+ * The blob never passes through the model this way: the engine holds it and
1366
+ * you read six digits out, where a paste costs context in proportion to how
1367
+ * much you wrote.
1368
+ */
1369
+ function post(json) {
1370
+ copyHint.hidden = false
1371
+ copyHint.textContent = 'Sending…'
1372
+ fetch(location.pathname, {
1373
+ method: 'POST',
1374
+ headers: { 'content-type': 'application/json' },
1375
+ body: json,
1376
+ }).then(function (res) {
1377
+ return res.text().then(function (text) {
1378
+ if (!res.ok) {
1379
+ // A pass the server REFUSED must never read as sent. Its message
1380
+ // names the entry that was wrong, so it is shown rather than summarised.
1381
+ copyHint.textContent = 'Not sent — ' + (text || res.status)
1382
+ return
1383
+ }
1384
+ var code = ''
1385
+ try { code = JSON.parse(text).code } catch (e) {}
1386
+ // NAME THE COMMAND. Not "claim it with 418207", which instructs a
1387
+ // transcription nobody needs to make, and not "tell Claude it is
1388
+ // waiting", which describes the arrangement `/spec-reviewed` replaced —
1389
+ // narrating that a pass exists and hoping the agent looks. The code
1390
+ // stays, as the thing to CHECK: it is the one part of a waiting pass
1391
+ // that tells yours apart from anyone else's.
1392
+ if (code) {
1393
+ copyHint.textContent = 'Sent. Run this where Claude is:'
1394
+ showCommand('/spec-reviewed ' + code)
1395
+ } else {
1396
+ // NO CODE, NO COMMAND. A `/spec-reviewed` with nothing after it still
1397
+ // works — it picks up the single waiting pass — but the page cannot
1398
+ // claim the server named THIS one, so it does not put words in its
1399
+ // mouth. Bare is what the reader is told, because bare is what is true.
1400
+ copyHint.textContent = 'Sent — run /spec-reviewed to pick it up.'
1401
+ }
1402
+ })
1403
+ }, function () {
1404
+ // The server was there when the page was served and is not now. Falling
1405
+ // back to the clipboard keeps the pass recoverable rather than lost.
1406
+ showFallback(json, 'Could not reach the server. Copy this, then paste it to Claude.')
1407
+ })
1408
+ }
1409
+
1410
+ function send(verdict) {
1411
+ var json = JSON.stringify(buildBlob(verdict), null, 2)
1412
+ // DECIDED FROM WHAT THE PAGE IS, not from what it can guess. A page opened
1413
+ // over http was served by the engine and can hand the pass straight back; a
1414
+ // `file://` page has no server to talk to and never will.
1415
+ //
1416
+ // NOT "try the POST and fall back on failure": a failed POST and an absent
1417
+ // one look identical to the reader, so the fallback would hide a server
1418
+ // that is refusing passes behind a message about copying.
1419
+ if (location.protocol !== 'file:') {
1420
+ post(json)
1421
+ return
1422
+ }
1423
+ // `file://` is not a secure context everywhere, and the clipboard API is
1424
+ // absent or rejects there — so the textarea is a shipping requirement, not
1425
+ // a nicety. It is the path a local page usually takes.
1426
+ if (!navigator.clipboard || !navigator.clipboard.writeText) {
1427
+ showFallback(json, 'Copy this, then paste it to Claude.')
1428
+ return
1429
+ }
1430
+ navigator.clipboard.writeText(json).then(function () {
1431
+ copyOut.hidden = true
1432
+ copyHint.hidden = false
1433
+ copyHint.textContent = 'Copied — paste it to Claude to pick up this review.'
1434
+ }, function () {
1435
+ showFallback(json, 'Could not reach the clipboard. Copy this, then paste it to Claude.')
1436
+ })
1437
+ }
1438
+
1439
+ Object.keys(verdictBtns).forEach(function (verdict) {
1440
+ verdictBtns[verdict].addEventListener('click', function () {
1441
+ // A disabled button does not fire in a browser; the guard is for every
1442
+ // other caller, and it keeps the block a fact rather than a style.
1443
+ if (verdictBtns[verdict].disabled) return
1444
+ send(verdict)
1445
+ })
1446
+ })
1447
+
1448
+ // Said only when the engine reported one. A page from a render that could not
1449
+ // read its own version says nothing rather than claiming "unknown" — an
1450
+ // absence here is about the lookup, not about the page.
1451
+ if (data.engine) {
1452
+ document.getElementById('drawn-by').textContent = 'rendered by skitterspec ' + data.engine
1453
+ }
1454
+
1455
+ restore()
1456
+ drawContext()
1457
+ drawLog()
1458
+ wireChecks()
1459
+ state.forEach(function (st) {
1460
+ var btn = st.details && st.details.querySelector('.accept')
1461
+ if (btn) btn.setAttribute('aria-pressed', String(st.accept))
1462
+ if (st.rendered) renderFile(st)
1463
+ redrawStrip(st)
1464
+ })
1465
+ refresh()
1466
+
1467
+ // The [data-theme] blocks are unreachable in a local file without this — a
1468
+ // browser only ever supplies prefers-color-scheme.
1469
+ var themeBtn = document.getElementById('theme')
1470
+ themeBtn.addEventListener('click', function () {
1471
+ var root = document.documentElement
1472
+ var now = root.getAttribute('data-theme')
1473
+ var dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
1474
+ var next = now === 'dark' ? 'light' : now === 'light' ? 'dark' : dark ? 'light' : 'dark'
1475
+ root.setAttribute('data-theme', next)
1476
+ themeBtn.textContent = next === 'dark' ? 'Theme: dark' : 'Theme: light'
1477
+ })
1478
+
1479
+ // --- header --------------------------------------------------------------
1480
+ document.getElementById('title').textContent = data.title
1481
+ var t = data.totals
1482
+ var sub = document.getElementById('sub')
1483
+ function part(text, cls) { sub.appendChild(el('span', cls, text)) }
1484
+ function sep() { sub.appendChild(el('span', 'sep', '·')) }
1485
+ part(data.branch)
1486
+ sep()
1487
+ part(data.mode === 'branch'
1488
+ ? 'everything since ' + data.base + (data.fellBack ? ' (working tree clean)' : '')
1489
+ : 'uncommitted work')
1490
+ sep()
1491
+ part(t.files + ' file' + (t.files === 1 ? '' : 's'))
1492
+ sep()
1493
+ part('+' + t.additions, 'a')
1494
+ part(' ')
1495
+ part('-' + t.deletions, 'd')
1496
+ sep()
1497
+ part(data.generatedAt)
1498
+ })()
1499
+ </script>
1500
+ </body>
1501
+ </html>