@skitterbyte/skitterspec-linear 12.0.0 → 14.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 (58) hide show
  1. package/MIGRATION.md +296 -10
  2. package/README.md +32 -2
  3. package/assets/claude-md-section.md +38 -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 +7 -3
  8. package/assets/core/env.config.md +90 -30
  9. package/assets/core/linear.config.md +58 -0
  10. package/assets/hooks/review-gate.js +141 -0
  11. package/assets/review/page.html +1787 -0
  12. package/assets/rules/spec-planning.md +250 -10
  13. package/assets/rules/spec-reports.md +321 -0
  14. package/assets/skills/spec/SKILL.md +33 -5
  15. package/assets/skills/spec-bug/SKILL.md +172 -9
  16. package/assets/skills/spec-cancel/SKILL.md +98 -21
  17. package/assets/skills/spec-claim/SKILL.md +114 -0
  18. package/assets/skills/spec-complete/SKILL.md +94 -25
  19. package/assets/skills/spec-diff/SKILL.md +678 -0
  20. package/assets/skills/spec-hotfix/SKILL.md +172 -11
  21. package/assets/skills/spec-init/SKILL.md +56 -7
  22. package/assets/skills/spec-linear-setup/SKILL.md +55 -1
  23. package/assets/skills/spec-list/SKILL.md +218 -0
  24. package/assets/skills/spec-next/SKILL.md +419 -7
  25. package/assets/skills/spec-push/SKILL.md +32 -8
  26. package/assets/skills/spec-review/SKILL.md +40 -5
  27. package/assets/skills/spec-reviewed/SKILL.md +258 -0
  28. package/assets/skills/spec-start/SKILL.md +386 -106
  29. package/assets/skills/spec-status/SKILL.md +24 -2
  30. package/assets/skills/spec-sync/SKILL.md +40 -4
  31. package/assets/skills/spec-to-main/SKILL.md +28 -6
  32. package/package.json +11 -7
  33. package/src/cli.js +1808 -89
  34. package/src/env/building.js +143 -0
  35. package/src/env/commitcmd.js +108 -0
  36. package/src/env/config.js +58 -9
  37. package/src/env/hooks.js +117 -0
  38. package/src/env/provision.js +54 -15
  39. package/src/env/proxy.js +34 -1
  40. package/src/env/render.js +3 -12
  41. package/src/env/resolve.js +295 -9
  42. package/src/env/review.js +1536 -0
  43. package/src/env/serve.js +573 -0
  44. package/src/env/teardown.js +13 -6
  45. package/src/init.js +150 -1
  46. package/src/vendor/linear/api.js +104 -1
  47. package/src/vendor/linear/cli-sync.js +854 -17
  48. package/src/vendor/linear/config.js +8 -0
  49. package/src/vendor/linear/credentials.js +94 -0
  50. package/src/vendor/linear/doctor.js +35 -0
  51. package/src/vendor/linear/identity.js +105 -0
  52. package/src/vendor/linear/mcp.js +26 -0
  53. package/src/vendor/sync-core/index.js +6 -2
  54. package/src/vendor/sync-core/src/compare.js +49 -3
  55. package/src/vendor/sync-core/src/normalize.js +30 -0
  56. package/src/vendor/sync-core/src/push.js +11 -1
  57. package/src/vendor/sync-core/src/write.js +38 -0
  58. package/LICENSE +0 -21
@@ -0,0 +1,1787 @@
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
+ /* THE REVIEW, ONCE IT IS OVER. A verdict that has actually been handed over
415
+ ends the page: the diff fades out and what you decided takes its place. The
416
+ fade is a transition on a class rather than a timer, so nothing depends on
417
+ JavaScript finishing, and `Show the diff anyway` brings it back — read-only,
418
+ with the buttons dead. */
419
+ .reviewable { transition: opacity .35s ease; }
420
+ .is-decided .reviewable { display: none; }
421
+ .is-decided.show-diff .reviewable { display: block; opacity: .75; }
422
+ .is-decided.show-diff .reviewable:hover { opacity: 1; }
423
+ /* Bringing the diff back must not resurrect a section that was hidden for its
424
+ own reasons — a spec with no context ships `#context` hidden, and the rule
425
+ above would have shown an empty panel. The second selector is there for
426
+ specificity: `.is-decided.show-diff .reviewable` outranks a bare
427
+ `.reviewable[hidden]`. */
428
+ .reviewable[hidden],
429
+ .is-decided.show-diff .reviewable[hidden] { display: none; }
430
+
431
+ .decided {
432
+ margin: 0 0 1.25rem;
433
+ padding: 1.1rem 1.2rem;
434
+ border: 1px solid var(--line);
435
+ border-left: 4px solid var(--accent);
436
+ border-radius: 10px;
437
+ background: var(--panel);
438
+ animation: decided-in .35s ease;
439
+ }
440
+ @keyframes decided-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
441
+ .decided[hidden] { display: none; }
442
+ .decided-what { margin: 0 0 .35rem; font-size: 1.05rem; font-weight: 650; line-height: 1.4; }
443
+ .decided-note { margin: 0 0 .8rem; color: var(--muted); font-size: .88rem; line-height: 1.5; max-width: 62ch; }
444
+ .decided-note:last-child { margin-bottom: 0; }
445
+ .decided-toggle { font-size: .78rem; }
446
+
447
+ /* WHY THE CHANGE EXISTS — the PR description this page never had. It is prose
448
+ to be read rather than data to be scanned, so it gets a measure, air, and a
449
+ quieter voice than the diff below it. It was once full-bleed 15px text at
450
+ 1.5 in a tight box, which read as a wall and got skipped. */
451
+ .context {
452
+ margin: 0 0 1.5rem;
453
+ padding: 1.1rem 1.3rem;
454
+ border: 1px solid var(--line);
455
+ border-radius: 10px;
456
+ background: var(--panel, transparent);
457
+ }
458
+ .context h3 {
459
+ font-size: .72rem;
460
+ text-transform: uppercase;
461
+ letter-spacing: .08em;
462
+ color: var(--muted);
463
+ font-weight: 650;
464
+ margin: 1.4rem 0 .5rem;
465
+ }
466
+ .context h3:first-child { margin-top: 0; }
467
+ /* A MEASURE. Prose set the full width of a 1400px page is measurably harder to
468
+ read — the eye loses the line it is returning to. */
469
+ .context p { margin: 0 0 .7rem; font-size: .92rem; line-height: 1.65; max-width: 72ch; }
470
+ .context p:last-child { margin-bottom: 0; }
471
+ .context table { border-collapse: collapse; width: 100%; font-size: .84rem; margin: .2rem 0 .3rem; }
472
+ .context th, .context td { text-align: left; padding: .35rem .8rem .35rem 0; vertical-align: top; }
473
+ .context th {
474
+ color: var(--muted);
475
+ font-weight: 600;
476
+ font-size: .72rem;
477
+ text-transform: uppercase;
478
+ letter-spacing: .05em;
479
+ border-bottom: 1px solid var(--line);
480
+ padding-bottom: .3rem;
481
+ }
482
+ .context tbody tr + tr td { border-top: 1px solid var(--line); }
483
+ .context ul { margin: 0; padding-left: 1.15rem; max-width: 72ch; }
484
+ .context li { margin: .3rem 0; font-size: .92rem; line-height: 1.6; }
485
+ /* The disclosure is a CONTROL, not a panel. Full-width and boxed, it read as a
486
+ section header that happened to be clickable. */
487
+ .context-more { margin: .9rem 0 0; }
488
+ .context-more > summary {
489
+ display: inline-block;
490
+ width: auto;
491
+ cursor: pointer;
492
+ font-size: .78rem;
493
+ color: var(--muted);
494
+ padding: .25rem .7rem;
495
+ border: 1px solid var(--line);
496
+ border-radius: 999px;
497
+ list-style: none;
498
+ }
499
+ .context-more > summary::-webkit-details-marker { display: none; }
500
+ .context-more > summary:hover { color: var(--accent); border-color: var(--accent); }
501
+ .context-more[open] > summary { margin-bottom: .6rem; }
502
+ .context li.done { color: var(--muted); }
503
+ .context-more > summary { cursor: pointer; color: var(--muted); font-size: .85rem; margin-top: .6rem; }
504
+ .copy-hint { font-size: .8rem; color: var(--muted); margin: .4rem 0 0; }
505
+ .sent-cmd { display: flex; gap: .4rem; align-items: stretch; margin: .4rem 0 0; }
506
+ /* A `display` rule BEATS the browser's own `[hidden] { display: none }`, so
507
+ every element this page hides in JavaScript needs its own override or the
508
+ hide silently does nothing. It shipped without one and a finished review kept
509
+ showing an empty command box. `assets-review` guards the whole class now. */
510
+ .sent-cmd[hidden] { display: none; }
511
+ .sent-cmd-text {
512
+ flex: 1; min-width: 0; font-family: var(--mono, ui-monospace, monospace);
513
+ font-size: .85rem; padding: .35rem .5rem; color: var(--fg);
514
+ background: var(--bg); border: 1px solid var(--line); border-radius: 4px;
515
+ }
516
+ .sent-cmd-copy { white-space: nowrap; }
517
+ /* The claim code is read aloud or copied, so it is set apart from the sentence
518
+ around it rather than buried in it. */
519
+ .copy-hint { user-select: text; }
520
+ </style>
521
+ </head>
522
+ <body>
523
+ <div class="wrap" id="wrap">
524
+ <header>
525
+ <h1 id="title"></h1>
526
+ <p class="sub" id="sub"></p>
527
+ <div class="controls">
528
+ <button id="expand-all" type="button">Show all context</button>
529
+ <button id="collapse-all" type="button">Collapse context</button>
530
+ <label class="toggle"><input type="checkbox" id="show-noise"> <span id="noise-label">Show bookkeeping</span></label>
531
+ <button id="theme" type="button" aria-live="polite">Theme</button>
532
+ </div>
533
+ </header>
534
+
535
+ <div class="layout">
536
+ <details class="tree-wrap" id="tree-wrap" open>
537
+ <summary id="tree-summary">Files</summary>
538
+ <nav class="tree" id="tree"></nav>
539
+ </details>
540
+ <main class="main">
541
+ <!-- WHAT THE CHANGE IS FOR, above the first filename. Composed by the
542
+ engine from the spec's own files — nothing here passed through the
543
+ model, so it is free however large the diff. Absent entirely for a spec
544
+ whose overview cannot be read: the page worked without it before. -->
545
+ <!-- WHAT YOU DECIDED, once the pass has actually been handed over. It sits
546
+ above everything because on a re-open it is the only thing you need:
547
+ the review is over, and this says how it ended. -->
548
+ <section class="decided" id="decided" hidden>
549
+ <p class="decided-what" id="decided-what"></p>
550
+ <p class="decided-note" id="decided-note"></p>
551
+ <button type="button" class="decided-toggle" id="decided-toggle">Show the diff anyway</button>
552
+ </section>
553
+ <section class="context reviewable" id="context" hidden>
554
+ <div class="context-why" id="context-why"></div>
555
+ <details class="context-more" id="context-more" hidden>
556
+ <summary id="context-more-summary">More</summary>
557
+ <div id="context-rest"></div>
558
+ </details>
559
+ </section>
560
+ <div id="review-block" class="reviewable">__REVIEW_BLOCK__</div>
561
+ <div id="files" class="reviewable"></div>
562
+ <!-- THE VERDICT SITS AT THE END, where reading finishes. It began in the
563
+ header and the first person to use it could not find it: you read a
564
+ 350-line diff downward, and the control asking for your conclusion was
565
+ off-screen above you. A conclusion belongs after the thing it is about.
566
+ Not sticky, deliberately — a permanent bar costs vertical space on the
567
+ phone this page is read on, on a page whose whole job is showing code. -->
568
+ <footer class="verdict-end">
569
+ <div class="verdict" id="verdict">
570
+ <span class="verdict-title">Your verdict</span>
571
+ <button id="verdict-commit" class="v-commit" type="button" aria-live="polite">✓ Commit</button>
572
+ <button id="verdict-commit-continue" class="v-commit" type="button" aria-live="polite">✓ Commit &amp; Continue</button>
573
+ <button id="verdict-changes" class="v-changes" type="button">↺ Request changes</button>
574
+ <button id="verdict-discuss" type="button">… Discuss first</button>
575
+ <span class="verdict-count" id="verdict-count" aria-live="polite"></span>
576
+ </div>
577
+ <p class="verdict-log" id="verdict-log" hidden></p>
578
+ <p class="copy-hint" id="copy-hint" hidden></p>
579
+ <p class="sent-cmd" id="sent-cmd" hidden>
580
+ <input class="sent-cmd-text" id="sent-cmd-text" readonly aria-label="Command to run">
581
+ <button type="button" class="sent-cmd-copy" id="sent-cmd-copy" hidden>Copy</button>
582
+ </p>
583
+ <textarea class="copy-out" id="copy-out" readonly hidden aria-label="Review blob to copy"></textarea>
584
+ <p class="drawn-by" id="drawn-by"></p>
585
+ </footer>
586
+ </main>
587
+ </div>
588
+ </div>
589
+
590
+ <script type="application/json" id="review-data">__REVIEW_DATA__</script>
591
+ <script>
592
+ (function () {
593
+ 'use strict'
594
+
595
+ var data = JSON.parse(document.getElementById('review-data').textContent)
596
+
597
+ // Lines kept either side of a change, and how much one "expand" step reveals.
598
+ var CONTEXT = 4
599
+ var STEP = 20
600
+
601
+ // --- patch parsing -------------------------------------------------------
602
+ // Rows carry their own line numbers so the gutter needs no second pass. Hunk
603
+ // headers are emitted ONLY when there is more than one hunk: with whole-file
604
+ // context there is exactly one, and a "@@ -1,400 +1,402 @@" banner above a
605
+ // whole file says nothing a reader wants.
606
+ function parsePatch(patch) {
607
+ var raw = patch.split('\n')
608
+ var hunks = 0
609
+ var i
610
+ for (i = 0; i < raw.length; i++) if (raw[i].slice(0, 2) === '@@') hunks++
611
+
612
+ var rows = []
613
+ var oldNo = 0
614
+ var newNo = 0
615
+ var started = false
616
+ for (i = 0; i < raw.length; i++) {
617
+ var line = raw[i]
618
+ if (line.slice(0, 2) === '@@') {
619
+ var m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
620
+ if (m) { oldNo = +m[1]; newNo = +m[2] }
621
+ started = true
622
+ if (hunks > 1) rows.push({ t: 'hunk', text: line })
623
+ continue
624
+ }
625
+ if (!started) continue
626
+ var c = line.charAt(0)
627
+ if (c === '+') rows.push({ t: 'add', text: line.slice(1), n: newNo++ })
628
+ else if (c === '-') rows.push({ t: 'del', text: line.slice(1), o: oldNo++ })
629
+ else if (c === '\\') rows.push({ t: 'meta', text: line.slice(2) })
630
+ else rows.push({ t: 'ctx', text: line.slice(1), o: oldNo++, n: newNo++ })
631
+ }
632
+ return rows
633
+ }
634
+
635
+ // Which stretches of unchanged context start out hidden. A run touching the
636
+ // top or bottom of the file collapses all the way to the edge — there is no
637
+ // change on that side to need context for.
638
+ function initialBands(rows) {
639
+ var bands = []
640
+ var i = 0
641
+ while (i < rows.length) {
642
+ if (rows[i].t !== 'ctx') { i++; continue }
643
+ var j = i
644
+ while (j < rows.length && rows[j].t === 'ctx') j++
645
+ var atStart = i === 0
646
+ var atEnd = j === rows.length
647
+ var from = atStart ? i : i + CONTEXT
648
+ var to = atEnd ? j : j - CONTEXT
649
+ // A band hiding one or two lines is worse than just showing them.
650
+ if (to - from >= 3) bands.push({ from: from, to: to })
651
+ i = j
652
+ }
653
+ return bands
654
+ }
655
+
656
+ // --- per-file page state -------------------------------------------------
657
+ // Band state lives HERE, never in the emitted data: the page is a view of an
658
+ // immutable review, and expanding context is a property of this reading of it.
659
+ var state = data.files.map(function (f) {
660
+ var rows = parsePatch(f.patch)
661
+ var st = {
662
+ file: f,
663
+ rows: rows,
664
+ bands: initialBands(rows),
665
+ rendered: false,
666
+ el: null,
667
+ // What the page holds about THIS reading: whether the accept box is
668
+ // ticked now, and any notes written but not yet copied out.
669
+ accept: f.accepted === true,
670
+ drafts: [],
671
+ }
672
+ revealCommented(st)
673
+ return st
674
+ })
675
+
676
+ // --- review marks --------------------------------------------------------
677
+ // A comment is useless if the line it points at is hidden, so any band that
678
+ // swallows a commented line is dropped before the first render. Dropping the
679
+ // whole band shows a little more context than strictly needed, which is the
680
+ // harmless direction.
681
+ function revealCommented(st) {
682
+ var lines = (st.file.comments || [])
683
+ .map(function (c) { return c.line })
684
+ .filter(function (n) { return n != null })
685
+ if (!lines.length) return
686
+ var idx = []
687
+ st.rows.forEach(function (row, i) {
688
+ if (row.n != null && lines.indexOf(row.n) !== -1) idx.push(i)
689
+ })
690
+ st.bands = st.bands.filter(function (b) {
691
+ return !idx.some(function (i) { return i >= b.from && i < b.to })
692
+ })
693
+ }
694
+
695
+ // Ids are minted from the render timestamp plus a counter, so they are stable
696
+ // for this page and unique across renders. That is what makes re-pasting the
697
+ // same blob merge instead of doubling every note.
698
+ var seq = 0
699
+ function mintId() {
700
+ seq++
701
+ return data.generatedAt + '-' + seq
702
+ }
703
+
704
+ // Best effort, both ways. Safari refuses storage on file:// by THROWING, and
705
+ // a review page that fails to render because it could not autosave would be a
706
+ // far worse bug than a lost pass.
707
+ // What this reader concluded, once it was actually HANDED OVER — never on a
708
+ // clipboard copy, which is a pass still sitting in the reader's hands.
709
+ var decided = null
710
+ var STORE_KEY = 'skitterspec-review:' + data.spec + ':' + data.generatedAt
711
+ function save() {
712
+ try {
713
+ var accepts = {}
714
+ var drafts = []
715
+ state.forEach(function (st) {
716
+ accepts[st.file.path] = st.accept
717
+ st.drafts.forEach(function (d) { drafts.push(d) })
718
+ })
719
+ window.localStorage.setItem(
720
+ STORE_KEY,
721
+ JSON.stringify({ seq: seq, accepts: accepts, drafts: drafts, replies: replies, decided: decided }),
722
+ )
723
+ } catch (e) { /* no storage, or a quota — the pass still works, it just is not saved */ }
724
+ }
725
+ function restore() {
726
+ var raw = null
727
+ try { raw = window.localStorage.getItem(STORE_KEY) } catch (e) { return }
728
+ if (!raw) return
729
+ var saved
730
+ try { saved = JSON.parse(raw) } catch (e) { return }
731
+ seq = saved.seq || 0
732
+ replies = saved.replies || {}
733
+ // THE DECISION OUTLIVES THE TAB. Keyed to this render like every other
734
+ // mark, so the next render — the one after the commit — is a live page
735
+ // again rather than a page that thinks it is finished.
736
+ if (saved.decided && saved.decided.verdict) decided = saved.decided
737
+ var byPath = {}
738
+ state.forEach(function (st) { byPath[st.file.path] = st })
739
+ Object.keys(saved.accepts || {}).forEach(function (path) {
740
+ if (byPath[path]) byPath[path].accept = Boolean(saved.accepts[path])
741
+ })
742
+ ;(saved.drafts || []).forEach(function (d) {
743
+ if (byPath[d.file]) byPath[d.file].drafts.push(d)
744
+ else orphanDrafts.push(d)
745
+ })
746
+ }
747
+
748
+ // Drafts whose file left the diff between saving and reloading, and replies to
749
+ // the written review keyed by check id. Both still belong in the blob.
750
+ var orphanDrafts = []
751
+ var replies = {}
752
+
753
+ function el(tag, cls, text) {
754
+ var n = document.createElement(tag)
755
+ if (cls) n.className = cls
756
+ if (text != null) n.textContent = text
757
+ return n
758
+ }
759
+
760
+ function gutter(row, st, index) {
761
+ var td = el('td', 'gutter')
762
+ var o = el('span', 'n', row.o == null ? '' : String(row.o))
763
+ var n = el('span', 'n', row.n == null ? '' : String(row.n))
764
+ var s = el('span', 's', row.t === 'add' ? '+' : row.t === 'del' ? '-' : ' ')
765
+ td.appendChild(o); td.appendChild(n); td.appendChild(s)
766
+ if (st) {
767
+ td.className = 'gutter can-note'
768
+ td.title = 'Comment on this line'
769
+ td.addEventListener('click', function () { openDraft(st, index, row) })
770
+ }
771
+ return td
772
+ }
773
+
774
+ function bandRow(st, band, idx) {
775
+ var tr = el('tr', 'band')
776
+ tr.appendChild(el('td', 'gutter', '⋯'))
777
+ var td = el('td', 'src')
778
+ var hidden = band.to - band.from
779
+ td.appendChild(el('span', 'n-hidden', hidden + ' unchanged line' + (hidden === 1 ? '' : 's')))
780
+ var step = el('button', null, '±' + STEP)
781
+ step.type = 'button'
782
+ step.setAttribute('aria-label', 'Expand ' + STEP + ' lines either side')
783
+ step.addEventListener('click', function () {
784
+ band.from += STEP
785
+ band.to -= STEP
786
+ if (band.to - band.from < 3) st.bands.splice(st.bands.indexOf(band), 1)
787
+ renderFile(st)
788
+ })
789
+ var all = el('button', null, 'Show all')
790
+ all.type = 'button'
791
+ all.addEventListener('click', function () {
792
+ st.bands.splice(st.bands.indexOf(band), 1)
793
+ renderFile(st)
794
+ })
795
+ td.appendChild(step)
796
+ td.appendChild(all)
797
+ tr.appendChild(td)
798
+ return tr
799
+ }
800
+
801
+ // Re-render the whole file on every expand. The alternative — pre-rendering
802
+ // thousands of hidden rows and unhiding them — is what makes a whole-file
803
+ // diff page take seconds to open.
804
+ function renderFile(st) {
805
+ var host = st.el
806
+ if (!host) return
807
+ host.textContent = ''
808
+ if (!st.rows.length) {
809
+ host.appendChild(el('p', 'note', st.file.binary ? 'Binary file — not shown.' : 'No textual change.'))
810
+ return
811
+ }
812
+ var box = el('div', 'code')
813
+ var table = el('table', 'diff')
814
+ var body = document.createElement('tbody')
815
+ var bands = st.bands.slice().sort(function (a, b) { return a.from - b.from })
816
+ var b = 0
817
+ var i = 0
818
+ while (i < st.rows.length) {
819
+ if (b < bands.length && i === bands[b].from) {
820
+ body.appendChild(bandRow(st, bands[b], b))
821
+ i = bands[b].to
822
+ b++
823
+ continue
824
+ }
825
+ var row = st.rows[i]
826
+ var tr = el('tr', row.t)
827
+ if (row.t === 'hunk') {
828
+ tr.className = 'band'
829
+ tr.appendChild(el('td', 'gutter', '⋯'))
830
+ tr.appendChild(el('td', 'src', row.text))
831
+ } else {
832
+ tr.appendChild(gutter(row, st, i))
833
+ tr.appendChild(el('td', 'src', row.text === '' ? ' ' : row.text))
834
+ }
835
+ body.appendChild(tr)
836
+ // Anything said about this line sits directly beneath it — a note you
837
+ // have to go and look for is a note nobody reads.
838
+ var here = notesAt(st, row.n)
839
+ if (here.length) tr.className += ' has-note'
840
+ here.forEach(function (c) { body.appendChild(noteRow(st, c)) })
841
+ if (st.draftAt === i) body.appendChild(draftRow(st, row))
842
+ i++
843
+ }
844
+ table.appendChild(body)
845
+ box.appendChild(table)
846
+ host.appendChild(box)
847
+ }
848
+
849
+ // Every note about one line: what the engine stored, plus anything written on
850
+ // this page and not yet copied out.
851
+ function notesAt(st, line) {
852
+ if (line == null) return []
853
+ var out = []
854
+ ;(st.file.comments || []).forEach(function (c) { if (c.line === line) out.push(c) })
855
+ st.drafts.forEach(function (d) { if (d.line === line) out.push(d) })
856
+ return out
857
+ }
858
+
859
+ function noteRow(st, c) {
860
+ var tr = el('tr', 'note-row' + (c.resolved ? ' is-resolved' : ''))
861
+ tr.appendChild(el('td', 'gutter', '💬'))
862
+ var td = el('td', 'src')
863
+ var meta = c.line == null ? 'on this file' : 'line ' + c.line
864
+ if (c.check) meta += ' · answering the review'
865
+ if (!c.raisedAt) meta += ' · not sent yet'
866
+ td.appendChild(el('span', 'note-meta', meta))
867
+ td.appendChild(el('span', 'note-text', c.note))
868
+ if (c.resolved) {
869
+ td.appendChild(el('span', 'note-resolution', '↳ ' + (c.resolved.note || 'resolved')))
870
+ }
871
+ if (!c.raisedAt) {
872
+ var actions = el('div', 'note-actions')
873
+ var del = el('button', null, 'Remove')
874
+ del.type = 'button'
875
+ del.addEventListener('click', function () {
876
+ st.drafts.splice(st.drafts.indexOf(c), 1)
877
+ renderFile(st)
878
+ refresh()
879
+ })
880
+ actions.appendChild(del)
881
+ td.appendChild(actions)
882
+ }
883
+ tr.appendChild(td)
884
+ return tr
885
+ }
886
+
887
+ // The draft box. `row` is null for a note about the file as a whole.
888
+ function draftRow(st, row) {
889
+ var tr = el('tr', 'note-row')
890
+ tr.appendChild(el('td', 'gutter', '💬'))
891
+ var td = el('td', 'src')
892
+ td.appendChild(el('span', 'note-meta', row ? 'line ' + (row.n == null ? '—' : row.n) : 'on this file'))
893
+ td.appendChild(noteEditor(function (text) {
894
+ addDraft(st, text, row ? row.n : null, row ? row.text : null, null)
895
+ st.draftAt = null
896
+ renderFile(st)
897
+ }, function () {
898
+ st.draftAt = null
899
+ renderFile(st)
900
+ }))
901
+ tr.appendChild(td)
902
+ return tr
903
+ }
904
+
905
+ // One editor, used by the gutter, the file strip and the review replies.
906
+ function noteEditor(onSave, onCancel) {
907
+ var wrap = el('div')
908
+ var input = el('textarea', 'note-input')
909
+ input.rows = 2
910
+ input.placeholder = 'What should change here?'
911
+ var actions = el('div', 'note-actions')
912
+ var ok = el('button', null, 'Add note')
913
+ ok.type = 'button'
914
+ ok.addEventListener('click', function () {
915
+ var text = input.value.trim()
916
+ // An empty note would be refused by the engine, so it is refused here —
917
+ // where the person who wrote it is still looking at it.
918
+ if (!text) { input.focus(); return }
919
+ onSave(text)
920
+ })
921
+ actions.appendChild(ok)
922
+ if (onCancel) {
923
+ var no = el('button', null, 'Cancel')
924
+ no.type = 'button'
925
+ no.addEventListener('click', onCancel)
926
+ actions.appendChild(no)
927
+ }
928
+ wrap.appendChild(input)
929
+ wrap.appendChild(actions)
930
+ window.setTimeout(function () { input.focus() }, 0)
931
+ return wrap
932
+ }
933
+
934
+ function addDraft(st, note, line, lineText, check) {
935
+ st.drafts.push({
936
+ id: mintId(),
937
+ file: st.file.path,
938
+ line: line == null ? null : line,
939
+ lineText: lineText == null ? null : lineText,
940
+ check: check || null,
941
+ note: note,
942
+ })
943
+ refresh()
944
+ }
945
+
946
+ function openDraft(st, index, row) {
947
+ st.draftAt = st.draftAt === index ? null : index
948
+ renderFile(st)
949
+ }
950
+
951
+ // Notes about the file rather than a line, and the way in to writing one.
952
+ function fileStrip(st) {
953
+ var strip = el('div', 'strip')
954
+ var loose = (st.file.comments || []).concat(st.drafts).filter(function (c) { return c.line == null })
955
+ loose.forEach(function (c) {
956
+ var row = el('div', c.resolved ? 'is-resolved' : null)
957
+ row.appendChild(el('span', 'note-meta', (c.check ? 'answering the review' : 'on this file') + (c.raisedAt ? '' : ' · not sent yet')))
958
+ row.appendChild(el('span', 'note-text', c.note))
959
+ if (c.resolved) row.appendChild(el('span', 'note-resolution', '↳ ' + (c.resolved.note || 'resolved')))
960
+ strip.appendChild(row)
961
+ })
962
+ var add = el('button', null, 'Note on this file')
963
+ add.type = 'button'
964
+ add.addEventListener('click', function () {
965
+ add.hidden = true
966
+ strip.appendChild(noteEditor(function (text) {
967
+ addDraft(st, text, null, null, null)
968
+ redrawStrip(st)
969
+ }, function () { redrawStrip(st) }))
970
+ })
971
+ strip.appendChild(add)
972
+ return strip
973
+ }
974
+
975
+ function redrawStrip(st) {
976
+ if (!st.stripEl) return
977
+ var next = fileStrip(st)
978
+ st.stripEl.parentNode.replaceChild(next, st.stripEl)
979
+ st.stripEl = next
980
+ }
981
+
982
+ // --- file list -----------------------------------------------------------
983
+ var filesHost = document.getElementById('files')
984
+
985
+ state.forEach(function (st) {
986
+ var f = st.file
987
+ var d = el('details', 'file')
988
+ d.id = 'f-' + encodeURIComponent(f.path)
989
+ var s = el('summary')
990
+ s.appendChild(el('span', 'path', f.path))
991
+ s.appendChild(el('span', 'chip', f.status))
992
+ if (!f.whole) s.appendChild(el('span', 'chip', 'partial context'))
993
+ if (f.noise) s.appendChild(el('span', 'chip', 'bookkeeping'))
994
+ // A lapse is SAID. An accept that quietly vanished because the file moved on
995
+ // would leave you believing you had read code you have not.
996
+ if (f.accepted === 'lapsed') {
997
+ s.appendChild(el('span', 'chip lapsed', 'accepted earlier — changed since'))
998
+ }
999
+ var openCount = (f.comments || []).filter(function (x) { return !x.resolved }).length
1000
+ if (openCount) s.appendChild(el('span', 'chip commented', openCount + ' note' + (openCount === 1 ? '' : 's')))
1001
+
1002
+ var accept = el('button', 'accept', '✓ accept')
1003
+ accept.type = 'button'
1004
+ accept.setAttribute('aria-pressed', String(st.accept))
1005
+ // A summary is a toggle for the <details>; a click on a control inside it
1006
+ // must not also open and close the file.
1007
+ accept.addEventListener('click', function (ev) {
1008
+ ev.preventDefault()
1009
+ ev.stopPropagation()
1010
+ st.accept = !st.accept
1011
+ accept.setAttribute('aria-pressed', String(st.accept))
1012
+ refresh()
1013
+ })
1014
+ s.appendChild(accept)
1015
+
1016
+ var c = el('span', 'counts')
1017
+ c.appendChild(el('span', 'a', '+' + f.additions))
1018
+ c.appendChild(document.createTextNode(' '))
1019
+ c.appendChild(el('span', 'd', '-' + f.deletions))
1020
+ s.appendChild(c)
1021
+ d.appendChild(s)
1022
+ st.stripEl = fileStrip(st)
1023
+ d.appendChild(st.stripEl)
1024
+ var host = el('div')
1025
+ d.appendChild(host)
1026
+ st.el = host
1027
+
1028
+ // Render on first open, so a review of 200 files costs one file's work.
1029
+ d.addEventListener('toggle', function () {
1030
+ if (d.open && !st.rendered) { st.rendered = true; renderFile(st) }
1031
+ })
1032
+ if (!f.noise) { d.open = true; st.rendered = true; renderFile(st) }
1033
+ filesHost.appendChild(d)
1034
+ st.details = d
1035
+ })
1036
+
1037
+ if (!state.length) {
1038
+ filesHost.appendChild(el('p', 'empty', 'Nothing to review — no changes found.'))
1039
+ }
1040
+
1041
+ // --- noise filter --------------------------------------------------------
1042
+ var noiseBox = document.getElementById('show-noise')
1043
+ var noiseCount = state.filter(function (s) { return s.file.noise }).length
1044
+ document.getElementById('noise-label').textContent = 'Show bookkeeping (' + noiseCount + ')'
1045
+ if (!noiseCount) noiseBox.closest('.toggle').hidden = true
1046
+
1047
+ function applyNoise() {
1048
+ state.forEach(function (st) {
1049
+ if (st.file.noise) st.details.hidden = !noiseBox.checked
1050
+ })
1051
+ }
1052
+ noiseBox.addEventListener('change', applyNoise)
1053
+ applyNoise()
1054
+
1055
+ // --- file tree -----------------------------------------------------------
1056
+ // Built from the paths, and it lists EVERY file including the ones the noise
1057
+ // filter is hiding — a tree with an unreachable entry is worse than no tree.
1058
+ function buildTree(files) {
1059
+ var root = { dirs: {}, files: [] }
1060
+ files.forEach(function (f) {
1061
+ var parts = f.path.split('/')
1062
+ var node = root
1063
+ for (var i = 0; i < parts.length - 1; i++) {
1064
+ if (!node.dirs[parts[i]]) node.dirs[parts[i]] = { dirs: {}, files: [] }
1065
+ node = node.dirs[parts[i]]
1066
+ }
1067
+ node.files.push({ name: parts[parts.length - 1], file: f })
1068
+ })
1069
+ return root
1070
+ }
1071
+
1072
+ function reveal(f) {
1073
+ if (f.noise && !noiseBox.checked) { noiseBox.checked = true; applyNoise() }
1074
+ var d = document.getElementById('f-' + encodeURIComponent(f.path))
1075
+ if (!d) return
1076
+ d.open = true
1077
+ d.scrollIntoView({ block: 'start' })
1078
+ }
1079
+
1080
+ function renderTree(node) {
1081
+ var ul = el('ul')
1082
+ Object.keys(node.dirs).sort().forEach(function (name) {
1083
+ var li = el('li')
1084
+ li.appendChild(el('span', 'dir', name + '/'))
1085
+ li.appendChild(renderTree(node.dirs[name]))
1086
+ ul.appendChild(li)
1087
+ })
1088
+ node.files.sort(function (a, b) { return a.name.localeCompare(b.name) }).forEach(function (entry) {
1089
+ var li = el('li')
1090
+ var b = el('button', 'tree-file' + (entry.file.noise ? ' is-noise' : ''))
1091
+ b.type = 'button'
1092
+ b.appendChild(el('span', 'nm', entry.name))
1093
+ var ct = el('span', 'ct')
1094
+ ct.appendChild(el('span', 'a', '+' + entry.file.additions))
1095
+ ct.appendChild(document.createTextNode(' '))
1096
+ ct.appendChild(el('span', 'd', '-' + entry.file.deletions))
1097
+ b.appendChild(ct)
1098
+ b.addEventListener('click', function () { reveal(entry.file) })
1099
+ li.appendChild(b)
1100
+ ul.appendChild(li)
1101
+ })
1102
+ return ul
1103
+ }
1104
+
1105
+ var tree = document.getElementById('tree')
1106
+ tree.appendChild(renderTree(buildTree(data.files)))
1107
+ document.getElementById('tree-summary').textContent =
1108
+ 'Files (' + data.files.length + ')'
1109
+ if (window.matchMedia && !window.matchMedia('(min-width: 60rem)').matches) {
1110
+ document.getElementById('tree-wrap').open = false
1111
+ }
1112
+
1113
+ // --- page-level controls -------------------------------------------------
1114
+ document.getElementById('expand-all').addEventListener('click', function () {
1115
+ state.forEach(function (st) {
1116
+ st.bands = []
1117
+ if (st.rendered) renderFile(st)
1118
+ })
1119
+ })
1120
+ document.getElementById('collapse-all').addEventListener('click', function () {
1121
+ state.forEach(function (st) {
1122
+ st.bands = initialBands(st.rows)
1123
+ if (st.rendered) renderFile(st)
1124
+ })
1125
+ })
1126
+
1127
+ // --- answering the written review ---------------------------------------
1128
+ // A `confirm` exists to ask the author a question, and until now the only way
1129
+ // to answer was to retype it in chat. The engine stamps each check with an id
1130
+ // and the file it is about; a reply becomes an ordinary comment carrying that
1131
+ // id, so it travels in the same blob as everything else.
1132
+ function wireChecks() {
1133
+ var checks = document.querySelectorAll('.check[data-check]')
1134
+ Array.prototype.forEach.call(checks, function (li) {
1135
+ var id = li.getAttribute('data-check')
1136
+ // A check with no file is about the review rather than a line of code.
1137
+ // The sentinel keeps the blob valid and lands it under "not in this diff",
1138
+ // which is exactly where a review-wide remark belongs.
1139
+ var file = li.getAttribute('data-file') || '(review)'
1140
+ var given = el('div', 'reply-given')
1141
+ var box = el('div', 'reply')
1142
+ function draw() {
1143
+ given.textContent = replies[id] ? '↳ ' + replies[id] : ''
1144
+ given.hidden = !replies[id]
1145
+ box.textContent = ''
1146
+ var add = el('button', null, replies[id] ? 'Change answer' : 'Answer')
1147
+ add.type = 'button'
1148
+ add.addEventListener('click', function () {
1149
+ box.textContent = ''
1150
+ box.appendChild(noteEditor(function (text) {
1151
+ replies[id] = text
1152
+ draw()
1153
+ refresh()
1154
+ }, draw))
1155
+ })
1156
+ box.appendChild(add)
1157
+ }
1158
+ draw()
1159
+ li.appendChild(given)
1160
+ li.appendChild(box)
1161
+ li.setAttribute('data-reply-file', file)
1162
+ })
1163
+ }
1164
+
1165
+ // --- the blob -------------------------------------------------------------
1166
+ /**
1167
+ * A DELTA, not a snapshot. The engine merges rather than replaces, so this
1168
+ * says only what changed: files newly ticked, accepts withdrawn, notes
1169
+ * written. A file already accepted with this exact content has nothing to
1170
+ * say, and a file that was never accepted has nothing to withdraw.
1171
+ *
1172
+ * Withdrawal is explicit for the same reason — under a merge, absence means
1173
+ * "not mentioned", never "remove it".
1174
+ */
1175
+ function buildBlob(verdict) {
1176
+ var accepted = []
1177
+ var unaccepted = []
1178
+ var comments = []
1179
+ state.forEach(function (st) {
1180
+ var stored = st.file.accepted
1181
+ if (st.accept) {
1182
+ // No hash means the engine could not read the file; accepting it would
1183
+ // record a vouch for content nobody can identify.
1184
+ if (stored !== true && st.file.hash) accepted.push({ path: st.file.path, hash: st.file.hash })
1185
+ } else if (stored === true || stored === 'lapsed') {
1186
+ unaccepted.push(st.file.path)
1187
+ }
1188
+ st.drafts.forEach(function (d) { comments.push(d) })
1189
+ })
1190
+ orphanDrafts.forEach(function (d) { comments.push(d) })
1191
+ Object.keys(replies).forEach(function (id) {
1192
+ var li = document.querySelector('.check[data-check="' + id + '"]')
1193
+ comments.push({
1194
+ id: data.generatedAt + '-reply-' + id,
1195
+ file: (li && li.getAttribute('data-reply-file')) || '(review)',
1196
+ line: null,
1197
+ lineText: null,
1198
+ check: id,
1199
+ note: replies[id],
1200
+ })
1201
+ })
1202
+ var blob = {
1203
+ version: 1,
1204
+ spec: data.spec,
1205
+ generatedAt: data.generatedAt,
1206
+ accepted: accepted,
1207
+ unaccepted: unaccepted,
1208
+ comments: comments,
1209
+ }
1210
+ // Absent unless a verdict was actually chosen. The engine reads an absent
1211
+ // one as `discuss`, and a blob that never claimed one should keep saying so.
1212
+ if (verdict) blob.verdict = verdict
1213
+ return blob
1214
+ }
1215
+
1216
+ var verdictBtns = {
1217
+ commit: document.getElementById('verdict-commit'),
1218
+ 'commit-continue': document.getElementById('verdict-commit-continue'),
1219
+ changes: document.getElementById('verdict-changes'),
1220
+ discuss: document.getElementById('verdict-discuss'),
1221
+ }
1222
+ // The committing pair, named once. A fifth verdict that commits is added
1223
+ // here, and the shared block below follows for free — the same reason the
1224
+ // engine keeps a `COMMITTING` list rather than a second condition.
1225
+ var COMMITTERS = ['commit', 'commit-continue']
1226
+ // ALL FOUR, because this is now the one place a verdict gets a reader-facing
1227
+ // name: `refresh` relabels only the committing pair, but the decided panel
1228
+ // has to name whichever verdict was actually sent.
1229
+ var LABELS = {
1230
+ commit: '✓ Commit',
1231
+ 'commit-continue': '✓ Commit & Continue',
1232
+ changes: '↺ Request changes',
1233
+ discuss: '… Discuss first',
1234
+ }
1235
+ var TITLES = {
1236
+ commit: 'Commit what you just read, and stop there',
1237
+ // Says what it will NOT do: a reader must not press this expecting the
1238
+ // spec to be finished and landed.
1239
+ 'commit-continue': 'Commit what you just read, then build the next phase — nothing is landed',
1240
+ }
1241
+
1242
+ var verdictCount = document.getElementById('verdict-count')
1243
+ var verdictLog = document.getElementById('verdict-log')
1244
+ var copyOut = document.getElementById('copy-out')
1245
+ var wrapEl = document.getElementById('wrap')
1246
+ var decidedBox = document.getElementById('decided')
1247
+ var decidedWhat = document.getElementById('decided-what')
1248
+ var decidedNote = document.getElementById('decided-note')
1249
+ var decidedToggle = document.getElementById('decided-toggle')
1250
+ var copyHint = document.getElementById('copy-hint')
1251
+ var sentCmd = document.getElementById('sent-cmd')
1252
+ var sentCmdText = document.getElementById('sent-cmd-text')
1253
+ var sentCmdCopy = document.getElementById('sent-cmd-copy')
1254
+
1255
+ function pending() {
1256
+ var b = buildBlob()
1257
+ return b.accepted.length + b.unaccepted.length + b.comments.length
1258
+ }
1259
+
1260
+ /**
1261
+ * How many notes would still be open once this pass lands — the exact number
1262
+ * the engine's `judgeVerdict` will count, which is what makes the disabled
1263
+ * button a preview of the refusal rather than a second opinion about it.
1264
+ *
1265
+ * Stored comments the agent has not answered, PLUS every comment this pass is
1266
+ * about to send (a note you just wrote is open by definition).
1267
+ *
1268
+ * UNACCEPTED FILES ARE NOT COUNTED AND MUST NEVER BE. A comment is a request
1269
+ * you made; an unticked file is merely something you said nothing about, and
1270
+ * counting those would be the tally this whole feature is designed not to be.
1271
+ */
1272
+ function openNotes() {
1273
+ var stored = (data.notes && data.notes.totals && data.notes.totals.unresolved) || 0
1274
+ return stored + buildBlob().comments.length
1275
+ }
1276
+
1277
+ // Redraw the bar and autosave. Called after every mark, so the block lifts
1278
+ // the moment the last note goes and a phone tab switch mid-review costs
1279
+ // nothing.
1280
+ function refresh() {
1281
+ // A decided page is finished. `refresh` runs on every mark and re-enables
1282
+ // the two committing buttons unconditionally, so without this it would undo
1283
+ // the ending every time anything redrew.
1284
+ if (decided) return drawDecided()
1285
+ var n = pending()
1286
+ var open = openNotes()
1287
+ verdictCount.textContent = n
1288
+ ? n + (n === 1 ? ' mark to send' : ' marks to send')
1289
+ : 'Nothing marked — a clean read still commits'
1290
+ // ONE COUNT, BOTH BUTTONS. Two committing controls blocked by two separate
1291
+ // reads is how a page ends up with one disabled and the other not — which
1292
+ // would be a way around the single refusal this page makes.
1293
+ var blocked = open > 0
1294
+ var why = open + (open === 1 ? ' open note' : ' open notes')
1295
+ // Is there a phase left for `/spec-next` to build? The engine answers, and
1296
+ // its ABSENCE is a third state, not a no: a spec whose phases it could not
1297
+ // read — a legacy layout, an inline-phase overview — leaves the button
1298
+ // exactly as it was. Removing an offer that might work is worse than
1299
+ // leaving one that might not.
1300
+ var noPhaseLeft = Boolean(data.phases) && data.phases.hasNextPhase === false
1301
+ COMMITTERS.forEach(function (key) {
1302
+ var btn = verdictBtns[key]
1303
+ // AN OPEN NOTE WINS. Two reasons can be true at once and the label has
1304
+ // room for one — the open note is the one the reader can act on, so a
1305
+ // "no phase left" in its place would send them to fix the wrong thing.
1306
+ var stop = blocked ? why : key === 'commit-continue' && noPhaseLeft ? 'no phase left' : null
1307
+ // The reason goes ON the control. A button that is merely dimmed reads as
1308
+ // a broken page, and a title attribute is unreachable on a touch screen.
1309
+ btn.disabled = Boolean(stop)
1310
+ btn.textContent = stop ? LABELS[key] + ' — ' + stop : LABELS[key]
1311
+ btn.title = blocked
1312
+ ? 'Resolve or remove your notes first — you asked for something, so it cannot also be fine'
1313
+ : key === 'commit-continue' && noPhaseLeft
1314
+ ? 'Every phase of this spec is done — there is nothing left to build'
1315
+ : TITLES[key]
1316
+ })
1317
+ // Said rather than left unset: the committing pair is the ONLY thing this
1318
+ // page ever blocks, and the other two are always available on purpose — a
1319
+ // note you want acted on, and a question, are never the wrong thing to send.
1320
+ verdictBtns.changes.disabled = false
1321
+ verdictBtns.discuss.disabled = false
1322
+ save()
1323
+ }
1324
+
1325
+ /**
1326
+ * End the review, and say how it ended.
1327
+ *
1328
+ * A verdict that has been handed over is the END of this page's job, and the
1329
+ * page used to carry on as if it were not: the diff stayed, the four buttons
1330
+ * stayed live, and a line of small grey text under them said "Sent." A reader
1331
+ * coming back to that tab has no way to tell a sent review from an unsent
1332
+ * one, and pressing a button again reaches an agent that stopped listening.
1333
+ *
1334
+ * So the diff goes, what was decided takes its place, and the controls die
1335
+ * with a reason on them.
1336
+ */
1337
+ function drawDecided() {
1338
+ if (!decided) return
1339
+ var said = SAID[decided.verdict] || decided.verdict
1340
+ var when = String(decided.at || '').slice(0, 10)
1341
+ decidedBox.hidden = false
1342
+ decidedWhat.textContent = 'You chose: ' + (LABELS[decided.verdict] || decided.verdict)
1343
+ decidedNote.textContent =
1344
+ 'Sent to Claude' + (when ? ' on ' + when : '') + ' — this review is ' + said + '. ' +
1345
+ 'Nothing here reaches Claude any more, so the verdict buttons are closed.'
1346
+ wrapEl.className = wrapEl.className.replace(/\s*\bis-decided\b/g, '') + ' is-decided'
1347
+ // EVERY button, not only the committing pair: `refresh` re-enables those two
1348
+ // by design, and a decided page must beat it.
1349
+ Object.keys(verdictBtns).forEach(function (key) {
1350
+ var btn = verdictBtns[key]
1351
+ btn.disabled = true
1352
+ btn.title = 'Already sent — ' + said
1353
+ })
1354
+ verdictCount.textContent = 'Sent — ' + said
1355
+ // ONE STATEMENT, NOT FOUR. The panel, the bar, the history line and the
1356
+ // command box all said the same thing at once. The log line is the panel's
1357
+ // sentence again in smaller type, so it goes.
1358
+ verdictLog.hidden = true
1359
+
1360
+ // THE HAND-OFF SURVIVES THE ENDING — but only where there is something
1361
+ // worth carrying. A code is six digits to transcribe, and the box and its
1362
+ // Copy button exist for exactly that. A bare `/spec-reviewed` is a word the
1363
+ // reader is about to type into the terminal they are already sitting in;
1364
+ // dressing it as a copyable artefact makes the ending look unfinished.
1365
+ if (decided.cmd && /\s/.test(decided.cmd)) {
1366
+ copyHint.hidden = false
1367
+ copyHint.textContent = 'Sent. Run this where Claude is:'
1368
+ showCommand(decided.cmd)
1369
+ return
1370
+ }
1371
+ copyHint.hidden = true
1372
+ sentCmd.hidden = true
1373
+ copyOut.hidden = true
1374
+ if (decided.cmd) {
1375
+ decidedNote.textContent +=
1376
+ ' If Claude was not already waiting, run ' + decided.cmd + ' where it is.'
1377
+ }
1378
+ }
1379
+
1380
+ /** Record a verdict that was actually delivered, and end the page on it. */
1381
+ function markDecided(verdict, cmd) {
1382
+ decided = { verdict: verdict, at: new Date().toISOString(), cmd: cmd || null }
1383
+ save()
1384
+ drawDecided()
1385
+ }
1386
+
1387
+ // A WAY BACK IN, read-only. "It is finished" and "I cannot see what I
1388
+ // approved" are different things, and only the first one is true.
1389
+ decidedToggle.addEventListener('click', function () {
1390
+ var showing = / \bshow-diff\b/.test(' ' + wrapEl.className)
1391
+ wrapEl.className = wrapEl.className.replace(/\s*\bshow-diff\b/g, '') + (showing ? '' : ' show-diff')
1392
+ decidedToggle.textContent = showing ? 'Show the diff anyway' : 'Hide the diff'
1393
+ })
1394
+
1395
+ // How a logged verdict reads. EVERY VERDICT NAMED, and an unknown one says
1396
+ // so: the fallback was `discussed`, which quietly relabelled `commit` — and a
1397
+ // page that tells you a commit was a discussion is worse than one that admits
1398
+ // it does not know the word.
1399
+ var SAID = {
1400
+ approve: 'committed',
1401
+ commit: 'committed',
1402
+ 'commit-continue': 'committed, then carried on',
1403
+ changes: 'changes requested',
1404
+ discuss: 'discussed',
1405
+ skip: 'moved on without a verdict',
1406
+ }
1407
+
1408
+ // The last thing that happened here, as history. It is why the page can look
1409
+ // untouched after a commit: the verdict was consumed, not stored. A SKIP is
1410
+ // history of exactly the same kind — it is the recorded decision to move on,
1411
+ // and hiding it would make a skipped review indistinguishable from one that
1412
+ // never ended.
1413
+ function drawLog() {
1414
+ var last = data.notes && data.notes.lastDecision
1415
+ var skip = data.gate && data.gate.lastSkip
1416
+ // Whichever is more recent. Both are timestamped, and comparing is what
1417
+ // stops an old verdict shadowing a skip taken after it.
1418
+ if (skip && (!last || String(skip.at || '') > String(last.at || ''))) {
1419
+ last = { verdict: 'skip', at: skip.at, note: skip.reason }
1420
+ }
1421
+ if (!last) return
1422
+ var said = SAID[last.verdict] || ('recorded as "' + last.verdict + '"')
1423
+ var when = String(last.at || '').slice(0, 10)
1424
+ verdictLog.hidden = false
1425
+ verdictLog.textContent = said + ' earlier' + (when ? ' · ' + when : '') + (last.note ? ' · ' + last.note : '')
1426
+ }
1427
+
1428
+ /**
1429
+ * Hand over the command itself, where the reader's eye already is.
1430
+ *
1431
+ * Two ways out and the page picks by CAPABILITY, not by trying and failing:
1432
+ * `navigator.clipboard` is secure-context-only, and a page served at
1433
+ * `http://<lan-ip>:7777` is not a secure context — which is the ordinary case
1434
+ * here, not an edge one. A Copy button that does nothing when tapped is worse
1435
+ * than text that was always text, so the button only exists where it can work.
1436
+ */
1437
+ function showCommand(cmd) {
1438
+ sentCmd.hidden = false
1439
+ sentCmdText.value = cmd
1440
+ var canCopy = Boolean(navigator.clipboard && navigator.clipboard.writeText)
1441
+ sentCmdCopy.hidden = !canCopy
1442
+ if (canCopy) return
1443
+ // Selected, and SAID to be selected: a reader who sees nothing happen has
1444
+ // no way to tell a page that did nothing from one that already did the work.
1445
+ sentCmdText.focus()
1446
+ sentCmdText.select()
1447
+ copyHint.textContent = copyHint.textContent + ' (selected — copy it)'
1448
+ }
1449
+
1450
+ if (sentCmdCopy) {
1451
+ sentCmdCopy.addEventListener('click', function () {
1452
+ navigator.clipboard.writeText(sentCmdText.value).then(function () {
1453
+ sentCmdCopy.textContent = 'Copied'
1454
+ }, function () {
1455
+ // The capability was there and the write still failed. Fall back to the
1456
+ // same selection the no-clipboard path uses rather than saying nothing.
1457
+ sentCmdText.focus()
1458
+ sentCmdText.select()
1459
+ sentCmdCopy.textContent = 'Copy failed — selected'
1460
+ })
1461
+ })
1462
+ }
1463
+
1464
+ /**
1465
+ * The header: why this change exists, what it touches, what this phase set
1466
+ * out to do. Every part is optional and each is drawn only if present — a
1467
+ * spec with a Problem and no Impact table is ordinary, not broken.
1468
+ *
1469
+ * BUILT WITH createElement, never innerHTML. The text is markdown from a spec
1470
+ * file, which means it can contain anything someone typed — and this page is
1471
+ * served over the network, so treating it as markup would be the one place a
1472
+ * spec's prose could reach the DOM.
1473
+ */
1474
+ function drawContext() {
1475
+ var ctx = data.context
1476
+ if (!ctx) return
1477
+ var why = document.getElementById('context-why')
1478
+ var rest = document.getElementById('context-rest')
1479
+ var more = document.getElementById('context-more')
1480
+ var drewRest = false
1481
+
1482
+ function head(into, label) {
1483
+ var h = document.createElement('h3')
1484
+ h.textContent = label
1485
+ into.appendChild(h)
1486
+ }
1487
+
1488
+ if (ctx.problem) {
1489
+ // FIRST PARAGRAPH OPEN, the remainder folded. A phone shows perhaps six
1490
+ // lines before the file list is pushed off-screen, and pushing it off is
1491
+ // the thing this header was built to avoid.
1492
+ var paras = String(ctx.problem).split(/\n\s*\n/)
1493
+ head(why, 'Why')
1494
+ var lead = document.createElement('p')
1495
+ lead.textContent = paras[0].replace(/\s+/g, ' ').trim()
1496
+ why.appendChild(lead)
1497
+ for (var i = 1; i < paras.length; i++) {
1498
+ var p = document.createElement('p')
1499
+ p.textContent = paras[i].replace(/\s+/g, ' ').trim()
1500
+ rest.appendChild(p)
1501
+ drewRest = true
1502
+ }
1503
+ }
1504
+
1505
+ if (ctx.impact) {
1506
+ head(rest, 'Touches')
1507
+ drewRest = true
1508
+ if (ctx.impact.rows) {
1509
+ var table = document.createElement('table')
1510
+ var hr = document.createElement('tr')
1511
+ ;['Surface', 'Change', 'Detail'].forEach(function (label) {
1512
+ var th = document.createElement('th')
1513
+ th.textContent = label
1514
+ hr.appendChild(th)
1515
+ })
1516
+ table.appendChild(hr)
1517
+ ctx.impact.rows.forEach(function (row) {
1518
+ var tr = document.createElement('tr')
1519
+ ;[row.surface, row.change, row.detail].forEach(function (cell) {
1520
+ var td = document.createElement('td')
1521
+ td.textContent = cell
1522
+ tr.appendChild(td)
1523
+ })
1524
+ table.appendChild(tr)
1525
+ })
1526
+ rest.appendChild(table)
1527
+ } else {
1528
+ var prose = document.createElement('p')
1529
+ prose.textContent = ctx.impact.prose
1530
+ rest.appendChild(prose)
1531
+ }
1532
+ }
1533
+
1534
+ if (ctx.phase) {
1535
+ head(rest, 'Phase ' + ctx.phase.n + (ctx.phase.title ? ' — ' + ctx.phase.title : ''))
1536
+ drewRest = true
1537
+ if (ctx.phase.goal) {
1538
+ var g = document.createElement('p')
1539
+ g.textContent = ctx.phase.goal
1540
+ rest.appendChild(g)
1541
+ }
1542
+ if (ctx.phase.tasks && ctx.phase.tasks.length) {
1543
+ var ul = document.createElement('ul')
1544
+ ctx.phase.tasks.forEach(function (t) {
1545
+ var li = document.createElement('li')
1546
+ // Done is dimmed rather than struck through: every task is ticked by
1547
+ // the time a phase renders, and a whole list struck through reads as
1548
+ // cancelled work.
1549
+ li.className = t.done ? 'done' : ''
1550
+ li.textContent = (t.done ? '✅ ' : '⬜ ') + t.text
1551
+ ul.appendChild(li)
1552
+ })
1553
+ rest.appendChild(ul)
1554
+ }
1555
+ }
1556
+
1557
+ document.getElementById('context').hidden = false
1558
+ more.hidden = !drewRest
1559
+ }
1560
+
1561
+ function showFallback(json, why) {
1562
+ copyOut.hidden = false
1563
+ copyOut.value = json
1564
+ copyOut.focus()
1565
+ copyOut.select()
1566
+ copyHint.hidden = false
1567
+ copyHint.textContent = why
1568
+ }
1569
+
1570
+ // One action per decision: the verdict button IS the copy button, so the blob
1571
+ // can never carry a verdict other than the one just pressed.
1572
+ /**
1573
+ * Send the pass back over the wire, to the page's own URL.
1574
+ *
1575
+ * The blob never passes through the model this way: the engine holds it and
1576
+ * you read six digits out, where a paste costs context in proportion to how
1577
+ * much you wrote.
1578
+ */
1579
+ function post(json, verdict) {
1580
+ copyHint.hidden = false
1581
+ copyHint.textContent = 'Sending…'
1582
+ fetch(location.pathname, {
1583
+ method: 'POST',
1584
+ headers: { 'content-type': 'application/json' },
1585
+ body: json,
1586
+ }).then(function (res) {
1587
+ return res.text().then(function (text) {
1588
+ if (!res.ok) {
1589
+ // A pass the server REFUSED must never read as sent. Its message
1590
+ // names the entry that was wrong, so it is shown rather than summarised.
1591
+ copyHint.textContent = 'Not sent — ' + (text || res.status)
1592
+ return
1593
+ }
1594
+ var code = ''
1595
+ try { code = JSON.parse(text).code } catch (e) {}
1596
+ // NAME THE COMMAND. Not "claim it with 418207", which instructs a
1597
+ // transcription nobody needs to make, and not "tell Claude it is
1598
+ // waiting", which describes the arrangement `/spec-reviewed` replaced —
1599
+ // narrating that a pass exists and hoping the agent looks. The code
1600
+ // stays, as the thing to CHECK: it is the one part of a waiting pass
1601
+ // that tells yours apart from anyone else's.
1602
+ if (code) {
1603
+ copyHint.textContent = 'Sent. Run this where Claude is:'
1604
+ showCommand('/spec-reviewed ' + code)
1605
+ markDecided(verdict, '/spec-reviewed ' + code)
1606
+ } else {
1607
+ // NO CODE, NO COMMAND. A `/spec-reviewed` with nothing after it still
1608
+ // works — it picks up the single waiting pass — but the page cannot
1609
+ // claim the server named THIS one, so it does not put words in its
1610
+ // mouth. Bare is what the reader is told, because bare is what is true.
1611
+ copyHint.textContent = 'Sent — run /spec-reviewed to pick it up.'
1612
+ markDecided(verdict)
1613
+ }
1614
+ })
1615
+ }, function () {
1616
+ // The server was there when the page was served and is not now. Falling
1617
+ // back to the clipboard keeps the pass recoverable rather than lost.
1618
+ showFallback(json, 'Could not reach the server. Copy this, then paste it to Claude.')
1619
+ })
1620
+ }
1621
+
1622
+ /**
1623
+ * Is this page running as a published Artifact?
1624
+ *
1625
+ * A CAPABILITY CHECK, not a hostname one. `window.claude.use` is the whole
1626
+ * contract for reaching anything the viewer grants, and it exists only where
1627
+ * a viewer is there to grant it — so its presence is what distinguishes the
1628
+ * published page from the two the engine serves, without this file knowing
1629
+ * anything about where it was published.
1630
+ */
1631
+ function isArtifact() {
1632
+ return Boolean(window.claude && typeof window.claude.use === 'function')
1633
+ }
1634
+
1635
+ /**
1636
+ * Hand the pass to the artifact's own store.
1637
+ *
1638
+ * THE THIRD TRANSPORT, for the reader no local server can reach: a phone on
1639
+ * mobile data, or anyone away from the LAN the engine serves on. The pass
1640
+ * lands in a document Claude reads back and deletes, so it is consumed
1641
+ * exactly like a claimed one.
1642
+ *
1643
+ * `use` is asked only once the reader has pressed something. Asking at load
1644
+ * would spend a consent prompt on every render, including the ones nobody
1645
+ * ends in a verdict.
1646
+ */
1647
+ function sendToStore(json, verdict) {
1648
+ copyHint.hidden = false
1649
+ copyHint.textContent = 'Sending…'
1650
+ var landed = false
1651
+ window.claude.use('db').then(function (db) {
1652
+ // `null` means this view cannot run db — not granted, not served, failed
1653
+ // to load, all indistinguishable by design. The clipboard is still there,
1654
+ // so the pass is recoverable rather than lost.
1655
+ if (!db) {
1656
+ showFallback(json, 'Could not reach the store. Copy this, then paste it to Claude.')
1657
+ return
1658
+ }
1659
+ return db
1660
+ .collection('passes')
1661
+ .add({ spec: data.spec, at: new Date().toISOString(), render: data.generatedAt, blob: JSON.parse(json) })
1662
+ .then(function () {
1663
+ landed = true
1664
+ // NO CODE HERE, and none invented. Six digits are the engine's, minted
1665
+ // by the holding area this pass never went through — so the page says
1666
+ // the one thing that is true and actionable.
1667
+ copyHint.textContent = 'Sent. Run this where Claude is:'
1668
+ showCommand('/spec-reviewed')
1669
+ markDecided(verdict, '/spec-reviewed')
1670
+ })
1671
+ }).then(null, function (err) {
1672
+ if (landed) return
1673
+ showFallback(
1674
+ json,
1675
+ 'Could not store the pass' + (err && err.code ? ' (' + err.code + ')' : '') +
1676
+ '. Copy this, then paste it to Claude.',
1677
+ )
1678
+ })
1679
+ }
1680
+
1681
+ function send(verdict) {
1682
+ var json = JSON.stringify(buildBlob(verdict), null, 2)
1683
+ // DECIDED FROM WHAT THE PAGE IS, not from what it can guess. A published
1684
+ // page has a store and no server; a page opened over http was served by the
1685
+ // engine and can hand the pass straight back; a `file://` page has no server
1686
+ // to talk to and never will.
1687
+ //
1688
+ // NOT "try the POST and fall back on failure": a failed POST and an absent
1689
+ // one look identical to the reader, so the fallback would hide a server
1690
+ // that is refusing passes behind a message about copying. The published page
1691
+ // is exactly that trap — its POST would go to claude.ai and fail — which is
1692
+ // why it is answered first, by what the page IS rather than by what failed.
1693
+ if (isArtifact()) {
1694
+ sendToStore(json, verdict)
1695
+ return
1696
+ }
1697
+ if (location.protocol !== 'file:') {
1698
+ post(json, verdict)
1699
+ return
1700
+ }
1701
+ // `file://` is not a secure context everywhere, and the clipboard API is
1702
+ // absent or rejects there — so the textarea is a shipping requirement, not
1703
+ // a nicety. It is the path a local page usually takes.
1704
+ if (!navigator.clipboard || !navigator.clipboard.writeText) {
1705
+ showFallback(json, 'Copy this, then paste it to Claude.')
1706
+ return
1707
+ }
1708
+ navigator.clipboard.writeText(json).then(function () {
1709
+ copyOut.hidden = true
1710
+ copyHint.hidden = false
1711
+ copyHint.textContent = 'Copied — paste it to Claude to pick up this review.'
1712
+ }, function () {
1713
+ showFallback(json, 'Could not reach the clipboard. Copy this, then paste it to Claude.')
1714
+ })
1715
+ }
1716
+
1717
+ Object.keys(verdictBtns).forEach(function (verdict) {
1718
+ verdictBtns[verdict].addEventListener('click', function () {
1719
+ // A disabled button does not fire in a browser; the guard is for every
1720
+ // other caller, and it keeps the block a fact rather than a style.
1721
+ if (verdictBtns[verdict].disabled) return
1722
+ send(verdict)
1723
+ })
1724
+ })
1725
+
1726
+ // Said only when the engine reported one. A page from a render that could not
1727
+ // read its own version says nothing rather than claiming "unknown" — an
1728
+ // absence here is about the lookup, not about the page.
1729
+ if (data.engine) {
1730
+ // `0.0.0` is what the unpublished source package carries, so a page drawn
1731
+ // from a working copy would otherwise claim a version that has never been
1732
+ // released. This line exists to answer "which engine drew this" — and a
1733
+ // string that LOOKS like a version and is not one answers it wrongly, which
1734
+ // is worse than not answering. The field itself is untouched: the serve
1735
+ // daemon compares it to spot a stale renderer, and it must stay a version.
1736
+ document.getElementById('drawn-by').textContent =
1737
+ 'rendered by skitterspec ' + (data.engine === '0.0.0' ? '(unreleased build)' : data.engine)
1738
+ }
1739
+
1740
+ restore()
1741
+ drawContext()
1742
+ drawLog()
1743
+ drawDecided()
1744
+ wireChecks()
1745
+ state.forEach(function (st) {
1746
+ var btn = st.details && st.details.querySelector('.accept')
1747
+ if (btn) btn.setAttribute('aria-pressed', String(st.accept))
1748
+ if (st.rendered) renderFile(st)
1749
+ redrawStrip(st)
1750
+ })
1751
+ refresh()
1752
+
1753
+ // The [data-theme] blocks are unreachable in a local file without this — a
1754
+ // browser only ever supplies prefers-color-scheme.
1755
+ var themeBtn = document.getElementById('theme')
1756
+ themeBtn.addEventListener('click', function () {
1757
+ var root = document.documentElement
1758
+ var now = root.getAttribute('data-theme')
1759
+ var dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
1760
+ var next = now === 'dark' ? 'light' : now === 'light' ? 'dark' : dark ? 'light' : 'dark'
1761
+ root.setAttribute('data-theme', next)
1762
+ themeBtn.textContent = next === 'dark' ? 'Theme: dark' : 'Theme: light'
1763
+ })
1764
+
1765
+ // --- header --------------------------------------------------------------
1766
+ document.getElementById('title').textContent = data.title
1767
+ var t = data.totals
1768
+ var sub = document.getElementById('sub')
1769
+ function part(text, cls) { sub.appendChild(el('span', cls, text)) }
1770
+ function sep() { sub.appendChild(el('span', 'sep', '·')) }
1771
+ part(data.branch)
1772
+ sep()
1773
+ part(data.mode === 'branch'
1774
+ ? 'everything since ' + data.base + (data.fellBack ? ' (working tree clean)' : '')
1775
+ : 'uncommitted work')
1776
+ sep()
1777
+ part(t.files + ' file' + (t.files === 1 ? '' : 's'))
1778
+ sep()
1779
+ part('+' + t.additions, 'a')
1780
+ part(' ')
1781
+ part('-' + t.deletions, 'd')
1782
+ sep()
1783
+ part(data.generatedAt)
1784
+ })()
1785
+ </script>
1786
+ </body>
1787
+ </html>