@skitterbyte/skitterspec-jira 0.0.0-stage → 2.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 (113) hide show
  1. package/MIGRATION.md +1266 -0
  2. package/README.md +44 -2
  3. package/assets/claude-md-section.md +85 -0
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-connect.md +13 -0
  6. package/assets/commands/spec-live.md +14 -0
  7. package/assets/commands/spec-remote-review.md +37 -0
  8. package/assets/commands/spec-skip.md +24 -0
  9. package/assets/core/SETUP.md +192 -0
  10. package/assets/core/env.config.json.example +56 -0
  11. package/assets/core/env.config.md +606 -0
  12. package/assets/core/gating.config.json.example +4 -0
  13. package/assets/core/gating.config.md +81 -0
  14. package/assets/core/jira.config.json.example +18 -0
  15. package/assets/core/jira.config.md +88 -0
  16. package/assets/hooks/main-guard.cjs +162 -0
  17. package/assets/hooks/review-gate.cjs +146 -0
  18. package/assets/review/page.html +3384 -0
  19. package/assets/rules/commit-trailers.md +96 -0
  20. package/assets/rules/negative-checks.md +69 -0
  21. package/assets/rules/offered-unblocks.md +100 -0
  22. package/assets/rules/spec-planning.md +702 -0
  23. package/assets/rules/spec-reports.md +572 -0
  24. package/assets/skills/no-spec/SKILL.md +249 -0
  25. package/assets/skills/spec/SKILL.md +714 -0
  26. package/assets/skills/spec-bug/SKILL.md +585 -0
  27. package/assets/skills/spec-cancel/SKILL.md +261 -0
  28. package/assets/skills/spec-complete/SKILL.md +451 -0
  29. package/assets/skills/spec-diff/SKILL.md +870 -0
  30. package/assets/skills/spec-hotfix/SKILL.md +607 -0
  31. package/assets/skills/spec-init/SKILL.md +178 -0
  32. package/assets/skills/spec-jira-setup/SKILL.md +221 -0
  33. package/assets/skills/spec-list/SKILL.md +173 -0
  34. package/assets/skills/spec-next/SKILL.md +678 -0
  35. package/assets/skills/spec-push/SKILL.md +306 -0
  36. package/assets/skills/spec-review/SKILL.md +256 -0
  37. package/assets/skills/spec-reviewed/SKILL.md +284 -0
  38. package/assets/skills/spec-start/SKILL.md +496 -0
  39. package/assets/skills/spec-status/SKILL.md +114 -0
  40. package/assets/skills/spec-sync/SKILL.md +220 -0
  41. package/assets/skills/spec-to-main/SKILL.md +141 -0
  42. package/bin/skitterspec-jira.js +68 -0
  43. package/package.json +40 -4
  44. package/src/cli.js +5967 -0
  45. package/src/deprecate.js +138 -0
  46. package/src/env/bootstrap.js +188 -0
  47. package/src/env/building.js +143 -0
  48. package/src/env/classify.js +176 -0
  49. package/src/env/commitcmd.js +108 -0
  50. package/src/env/config.js +850 -0
  51. package/src/env/dev.js +63 -0
  52. package/src/env/glob.js +34 -0
  53. package/src/env/hooks.js +226 -0
  54. package/src/env/hotfix.js +413 -0
  55. package/src/env/hotfixland.js +302 -0
  56. package/src/env/integrate.js +167 -0
  57. package/src/env/live.js +479 -0
  58. package/src/env/mainguard.js +299 -0
  59. package/src/env/provision.js +466 -0
  60. package/src/env/proxy.js +225 -0
  61. package/src/env/prune.js +119 -0
  62. package/src/env/registry.js +159 -0
  63. package/src/env/render.js +17 -0
  64. package/src/env/resolve.js +716 -0
  65. package/src/env/review.js +2622 -0
  66. package/src/env/reviewers/coderabbit.js +228 -0
  67. package/src/env/reviewers.js +517 -0
  68. package/src/env/serve.js +1044 -0
  69. package/src/env/supervise.js +157 -0
  70. package/src/env/teardown.js +248 -0
  71. package/src/env/trust.js +87 -0
  72. package/src/gating.js +155 -0
  73. package/src/init.js +1150 -0
  74. package/src/lines-diff.js +114 -0
  75. package/src/prompts.js +129 -0
  76. package/src/vendor/jira/adf.js +414 -0
  77. package/src/vendor/jira/api.js +295 -0
  78. package/src/vendor/jira/cli-sync.js +1252 -0
  79. package/src/vendor/jira/commands.js +44 -0
  80. package/src/vendor/jira/config.js +286 -0
  81. package/src/vendor/jira/credentials.js +108 -0
  82. package/src/vendor/jira/dialect.js +45 -0
  83. package/src/vendor/jira/mcp.js +129 -0
  84. package/src/vendor/provider-kit/index.js +41 -0
  85. package/src/vendor/provider-kit/src/adapter.js +23 -0
  86. package/src/vendor/provider-kit/src/apply.js +207 -0
  87. package/src/vendor/provider-kit/src/args.js +68 -0
  88. package/src/vendor/provider-kit/src/assignment.js +277 -0
  89. package/src/vendor/provider-kit/src/config.js +251 -0
  90. package/src/vendor/provider-kit/src/credentials.js +404 -0
  91. package/src/vendor/provider-kit/src/doctor.js +35 -0
  92. package/src/vendor/provider-kit/src/http.js +63 -0
  93. package/src/vendor/provider-kit/src/identity.js +93 -0
  94. package/src/vendor/provider-kit/src/list-assignee.js +71 -0
  95. package/src/vendor/provider-kit/src/list-rows.js +35 -0
  96. package/src/vendor/provider-kit/src/md-compare.js +39 -0
  97. package/src/vendor/provider-kit/src/md-tables.js +94 -0
  98. package/src/vendor/provider-kit/src/prompt.js +50 -0
  99. package/src/vendor/provider-kit/src/released.js +219 -0
  100. package/src/vendor/provider-kit/src/specs.js +248 -0
  101. package/src/vendor/provider-kit/src/transport.js +22 -0
  102. package/src/vendor/sync-core/index.js +92 -0
  103. package/src/vendor/sync-core/src/base.js +81 -0
  104. package/src/vendor/sync-core/src/compare.js +338 -0
  105. package/src/vendor/sync-core/src/dialect.js +51 -0
  106. package/src/vendor/sync-core/src/fields.js +36 -0
  107. package/src/vendor/sync-core/src/legacy.js +100 -0
  108. package/src/vendor/sync-core/src/normalize.js +971 -0
  109. package/src/vendor/sync-core/src/push.js +85 -0
  110. package/src/vendor/sync-core/src/retarget.js +278 -0
  111. package/src/vendor/sync-core/src/task-block.js +308 -0
  112. package/src/vendor/sync-core/src/verify.js +67 -0
  113. package/src/vendor/sync-core/src/write.js +191 -0
@@ -0,0 +1,3384 @@
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
+ /* The tree scrolls SIDEWAYS; names never wrap. A wrapped name turned every
172
+ long path into three ragged lines, which is what made the sidebar hard to
173
+ scan. Indentation is per-row padding rather than nested <ul> padding — see
174
+ renderTree, which explains why the sticky column below depends on it. */
175
+ .tree { padding: 0 0 .7rem; font-size: .82rem; overflow-x: auto; }
176
+ .tree ul { list-style: none; margin: 0; padding: 0; }
177
+ /* max-content makes the list as wide as its widest row, and min-width keeps it
178
+ filling the sidebar when everything fits. Together they give every row the
179
+ same width, which is what `.ct` needs. */
180
+ .tree > ul { width: max-content; min-width: 100%; }
181
+ .tree .dir { color: var(--muted); display: block; padding: .1rem .6rem; white-space: nowrap; }
182
+ .tree-file {
183
+ display: flex;
184
+ gap: .4rem;
185
+ width: 100%;
186
+ text-align: left;
187
+ /* A COLOUR, not `none`: `.ct` inherits it to mask the name scrolling under
188
+ it, and inherit re-resolves on hover so the pinned counts follow the row. */
189
+ background: var(--panel);
190
+ border: 0;
191
+ border-radius: 6px;
192
+ /* NO horizontal padding, deliberately. A sticky box is clamped by its
193
+ containing block — this row's content box — so a .6rem side padding gave
194
+ `.ct` two different resting places: flush to the scrollport while pinned,
195
+ and .6rem inside it once scrolled to the end, which reads as the counts
196
+ sliding as you reach the right. The gutters live on `.nm` and `.ct`
197
+ instead, where they move with the element rather than fencing it in. */
198
+ padding: .15rem 0;
199
+ color: inherit;
200
+ font-size: .82rem;
201
+ }
202
+ .tree-file:hover { background: var(--panel-2); color: inherit; }
203
+ .tree-file .nm { white-space: nowrap; }
204
+ /* Pinned to the right edge of the tree's scrollport, so the change counts stay
205
+ readable however far the names are scrolled. Sticky can only pin within its
206
+ own row, which is why every row spans the full scroll width above. */
207
+ .tree-file .ct {
208
+ margin-left: auto;
209
+ position: sticky;
210
+ right: 0;
211
+ background: inherit;
212
+ /* The right gutter is PADDING on the pinned element, not a `right` offset.
213
+ An offset would hold the box away from the edge and let names scroll
214
+ visibly through the gap beside it; padding keeps the masking background
215
+ running to the scrollport edge with the text inset. */
216
+ padding-left: .55rem;
217
+ padding-right: .6rem;
218
+ box-shadow: -8px 0 8px -8px rgba(0, 0, 0, .45);
219
+ white-space: nowrap;
220
+ font-family: ui-monospace, monospace;
221
+ font-size: .75rem;
222
+ }
223
+ .tree-file.is-noise .nm { color: var(--muted); }
224
+
225
+ /* THE FILE YOU ARE READING — the card crossing the top of the viewport.
226
+ An INSET SHADOW rather than a border or a left padding: either of those
227
+ would change the row's size, so the highlight moving down the list would
228
+ shunt every name sideways as it went. A shadow paints inside the box the
229
+ row already occupies.
230
+
231
+ A DIFFERENT CHANNEL FROM HOVER, deliberately. Hover owns the `--panel-2`
232
+ background, and the current row is very often NOT the row under your
233
+ finger — you scroll with one and point with the other. Sharing the
234
+ treatment would make the two indistinguishable at exactly the moment they
235
+ disagree, so this is a bar and a colour, and the background is left alone.
236
+ Leaving it alone also keeps `.ct` masking the names scrolling under it,
237
+ which is a background the row inherits. */
238
+ .tree-file.is-current { box-shadow: inset 3px 0 0 var(--accent); }
239
+ .tree-file.is-current .nm { color: var(--accent); font-weight: 600; }
240
+
241
+ .file {
242
+ border: 1px solid var(--line);
243
+ border-radius: 10px;
244
+ margin: 0 0 .9rem;
245
+ overflow: hidden;
246
+ background: var(--bg);
247
+ }
248
+ .file > summary {
249
+ cursor: pointer;
250
+ padding: .55rem .7rem;
251
+ background: var(--panel);
252
+ display: flex;
253
+ gap: .55rem;
254
+ align-items: baseline;
255
+ flex-wrap: wrap;
256
+ }
257
+ .file[hidden] { display: none; }
258
+ .path { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .82rem; overflow-wrap: anywhere; }
259
+ .chip {
260
+ font-size: .68rem;
261
+ text-transform: uppercase;
262
+ letter-spacing: .04em;
263
+ color: var(--chip-fg);
264
+ background: var(--chip-bg);
265
+ border-radius: 999px;
266
+ padding: .1rem .45rem;
267
+ }
268
+ .counts { margin-left: auto; font-size: .78rem; font-family: ui-monospace, monospace; white-space: nowrap; }
269
+
270
+ /* The ONLY horizontally scrolling box on the page. */
271
+ .code { overflow-x: auto; border-top: 1px solid var(--line); }
272
+ table.diff { border-collapse: collapse; width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .78rem; }
273
+ table.diff td { padding: 0; vertical-align: top; }
274
+
275
+ /* One sticky gutter cell rather than three: no left-offset arithmetic to get
276
+ wrong, and user-select:none means copying the code does not drag line
277
+ numbers along with it. */
278
+ td.gutter {
279
+ position: sticky;
280
+ left: 0;
281
+ z-index: 1;
282
+ background: var(--panel);
283
+ border-right: 1px solid var(--line);
284
+ user-select: none;
285
+ white-space: pre;
286
+ color: var(--ln-fg);
287
+ padding: 0 .4rem;
288
+ text-align: right;
289
+ }
290
+ td.gutter .n { display: inline-block; min-width: var(--ln-w); }
291
+ td.gutter .s { display: inline-block; min-width: 1.2ch; text-align: center; font-weight: 700; }
292
+ td.src { white-space: pre; padding: 0 .75rem; }
293
+
294
+ tr.add td.src { background: var(--add-bg); color: var(--add-fg); }
295
+ tr.add td.gutter .s { color: var(--add-mark); }
296
+ tr.del td.src { background: var(--del-bg); color: var(--del-fg); }
297
+ tr.del td.gutter .s { color: var(--del-mark); }
298
+ tr.meta td.src { color: var(--muted); font-style: italic; }
299
+
300
+ tr.band td { background: var(--band-bg); color: var(--band-fg); }
301
+ tr.band td.src { padding: .2rem .75rem; white-space: normal; }
302
+ tr.band .n-hidden { margin-right: .6rem; }
303
+ tr.band button { font-size: .72rem; padding: .1rem .5rem; margin-right: .3rem; }
304
+
305
+ .note { color: var(--muted); padding: .6rem .75rem; font-size: .85rem; }
306
+ .empty { color: var(--muted); }
307
+ #review-block:empty { display: none; }
308
+ .review {
309
+ border: 1px solid var(--line);
310
+ border-left: 3px solid var(--accent);
311
+ border-radius: 10px;
312
+ background: var(--panel);
313
+ padding: .8rem 1rem;
314
+ margin: 0 0 1rem;
315
+ }
316
+ .review-summary { margin: 0; }
317
+ .checks { list-style: none; margin: .8rem 0 0; padding: 0; display: grid; gap: .4rem; }
318
+ .check {
319
+ display: grid;
320
+ grid-template-columns: auto 1fr;
321
+ gap: .2rem .6rem;
322
+ align-items: baseline;
323
+ padding: .4rem .6rem;
324
+ border-radius: 8px;
325
+ font-size: .88rem;
326
+ }
327
+ /* The level is a WORD, not a colour — the same reason the diff keeps its +/-
328
+ gutter. Colour is the second signal here, never the only one. */
329
+ .check-level {
330
+ font-size: .68rem;
331
+ text-transform: uppercase;
332
+ letter-spacing: .05em;
333
+ font-weight: 700;
334
+ grid-row: 1 / span 2;
335
+ }
336
+ .check-file {
337
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
338
+ font-size: .78rem;
339
+ color: var(--muted);
340
+ overflow-wrap: anywhere;
341
+ /* A button, because it jumps to the code — but it must keep reading as the
342
+ quiet path label it has always been, not gain a control's chrome. */
343
+ background: none;
344
+ border: 0;
345
+ padding: 0;
346
+ text-align: left;
347
+ cursor: pointer;
348
+ text-decoration: underline;
349
+ text-decoration-style: dotted;
350
+ text-underline-offset: 2px;
351
+ }
352
+ .check-file:hover { color: inherit; }
353
+ /* WHO SAID IT. Absent on a written review's checks, which is why it is a
354
+ separate span rather than a prefix on the note: a page with one author looks
355
+ exactly as it always did. */
356
+ .check-src {
357
+ grid-column: 2;
358
+ font-size: .68rem;
359
+ text-transform: uppercase;
360
+ letter-spacing: .04em;
361
+ font-weight: 700;
362
+ color: var(--muted);
363
+ margin-right: .4rem;
364
+ }
365
+ .check-src + .check-file { grid-column: 2; }
366
+ .check-note { grid-column: 2; }
367
+
368
+ /* --- the reviewer strip --------------------------------------------------
369
+ One line per configured reviewer, present even when every one found nothing.
370
+ A reviewer that was rate-limited and one that read the diff and approved are
371
+ the same page without it, and the reader is about to decide whether to
372
+ commit. */
373
+ .reviewers { list-style: none; margin: 0 0 .8rem; padding: 0; display: grid; gap: .25rem; }
374
+ .reviewer {
375
+ display: flex;
376
+ flex-wrap: wrap;
377
+ gap: .5rem;
378
+ align-items: baseline;
379
+ font-size: .8rem;
380
+ color: var(--muted);
381
+ }
382
+ .reviewer-name {
383
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
384
+ font-weight: 700;
385
+ color: var(--fg);
386
+ }
387
+ .reviewer-name::after { content: ' —'; color: var(--muted); font-weight: 400; }
388
+ /* A reviewer that could not run is marked, not hidden — the distinction between
389
+ "clean" and "never answered" is the whole reason this strip exists. */
390
+ .reviewer.failed .reviewer-said,
391
+ .reviewer.missing .reviewer-said,
392
+ .reviewer.timeout .reviewer-said { color: var(--flag-fg); }
393
+ .reviewer.findings .reviewer-said { color: var(--fg); }
394
+
395
+ /* A line a check pointed at, so a jump lands somewhere visible rather than
396
+ leaving the reader to find the row themselves. */
397
+ tr.goto-hit > td { background: var(--confirm-bg); }
398
+ .check.flag { background: var(--flag-bg); }
399
+ .check.flag .check-level { color: var(--flag-fg); }
400
+ .check.confirm { background: var(--confirm-bg); }
401
+ .check.confirm .check-level { color: var(--confirm-fg); }
402
+ .check.good { background: var(--good-bg); }
403
+ .check.good .check-level { color: var(--good-fg); }
404
+
405
+ /* --- review marks: accepts, comments, replies ---------------------------- */
406
+ /* Every colour below comes from a token defined on bare :root above; nothing
407
+ here gains its first definition inside a theme block. */
408
+ .accept[aria-pressed="true"] {
409
+ background: var(--good-bg);
410
+ color: var(--good-fg);
411
+ border-color: var(--good-fg);
412
+ }
413
+ .chip.accepted { background: var(--good-bg); color: var(--good-fg); }
414
+ .chip.lapsed {
415
+ background: var(--confirm-bg);
416
+ color: var(--confirm-fg);
417
+ text-transform: none;
418
+ letter-spacing: 0;
419
+ }
420
+ .chip.commented { background: var(--note-bg); color: var(--note-fg); }
421
+
422
+ /* The gutter is already user-select:none, so clicking it can never fight with
423
+ selecting the code beside it — which is why the affordance lives there. */
424
+ td.gutter.can-note { cursor: pointer; }
425
+ td.gutter.can-note:hover { color: var(--accent); }
426
+ tr.has-note td.gutter { box-shadow: inset 2px 0 0 var(--note-line); }
427
+
428
+ tr.note-row td {
429
+ background: var(--note-bg);
430
+ color: var(--note-fg);
431
+ white-space: normal;
432
+ padding: .45rem .75rem;
433
+ border-top: 1px solid var(--note-line);
434
+ border-bottom: 1px solid var(--note-line);
435
+ }
436
+ /* A NOTE IS PROSE, AND IT WRAPS TO THE PANE — never to the file.
437
+ The cell it sits in is a column of `table.diff`, so its width is the longest
438
+ line of source in this file (`td.src` is white-space:pre): 4,900px for a
439
+ wrapped markdown spec is ordinary, and a `width:100%` note inside that is four
440
+ screens wide. It scrolled sideways instead of wrapping, which is what a reader
441
+ sees as a box that keeps growing.
442
+ Sticky for the same reason `td.gutter` is: the row can be reached while the
443
+ pane is scrolled right, and a note you cannot see while typing it is no
444
+ better. `--pane-w`/`--gutter-w` are measured per code box by `sizeNotes` —
445
+ measured rather than summed from `--ln-w`, because the gutter is ONE sticky
446
+ cell precisely so nobody has to do that arithmetic.
447
+ `max-width` caps it at the cell, so the note can never be the thing that
448
+ widens the table. */
449
+ .note-body {
450
+ position: sticky;
451
+ left: var(--gutter-w, 0px);
452
+ width: var(--pane-w, 100%);
453
+ max-width: 100%;
454
+ box-sizing: border-box;
455
+ }
456
+ .note-meta { font-size: .72rem; color: var(--muted); display: block; margin-bottom: .15rem; }
457
+ .note-text { overflow-wrap: anywhere; }
458
+ .note-row.is-resolved .note-text { text-decoration: line-through; opacity: .75; }
459
+ .note-resolution { display: block; font-size: .82rem; margin-top: .2rem; }
460
+ .note-input {
461
+ width: 100%;
462
+ font: inherit;
463
+ font-size: .85rem;
464
+ color: var(--fg);
465
+ background: var(--bg);
466
+ border: 1px solid var(--note-line);
467
+ border-radius: 8px;
468
+ padding: .4rem .5rem;
469
+ resize: vertical;
470
+ }
471
+ .note-input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
472
+ .note-actions { display: flex; gap: .4rem; margin-top: .4rem; flex-wrap: wrap; }
473
+
474
+ .strip {
475
+ border-top: 1px solid var(--line);
476
+ background: var(--note-bg);
477
+ color: var(--note-fg);
478
+ padding: .5rem .75rem;
479
+ font-size: .85rem;
480
+ display: grid;
481
+ gap: .4rem;
482
+ }
483
+ .strip .note-input { font-size: .85rem; }
484
+
485
+ .reply { grid-column: 2; margin-top: .35rem; }
486
+ .reply .note-input { font-size: .85rem; }
487
+ .reply-given {
488
+ grid-column: 2;
489
+ margin-top: .35rem;
490
+ font-size: .85rem;
491
+ border-left: 2px solid var(--note-line);
492
+ padding-left: .5rem;
493
+ }
494
+
495
+ /* --- the verdict bar ----------------------------------------------------- */
496
+ /* Every colour here is an existing token: approve borrows the `good` pair and
497
+ changes the `flag` pair, so the bar needs no palette of its own. */
498
+ .verdict {
499
+ display: flex;
500
+ flex-wrap: wrap;
501
+ gap: .5rem;
502
+ align-items: center;
503
+ margin: .9rem 0 0;
504
+ padding: .6rem .7rem;
505
+ border: 1px solid var(--line);
506
+ border-radius: 10px;
507
+ background: var(--panel);
508
+ }
509
+ /* WHERE THIS REVIEW LIVES, and whether it is also running. It sits ABOVE the
510
+ verdicts because the question it answers — do I want to look at this running
511
+ before I conclude? — comes before the conclusion, not after it. Visually its
512
+ own strip, because it is not a verdict: pressing one of these changes what is
513
+ running (or which surfaces are permitted) and hands the reader straight back
514
+ to the same page with the same options. */
515
+ .surfaces {
516
+ margin: 1.2rem 0 0;
517
+ padding: .55rem .7rem;
518
+ border: 1px solid var(--line);
519
+ border-radius: 10px;
520
+ background: var(--panel);
521
+ }
522
+ .surfaces[hidden] { display: none; }
523
+ .surfaces-title { display: block; font-size: .8rem; color: var(--muted); margin: 0 0 .35rem; }
524
+ .surface-row { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin: .3rem 0; }
525
+ .surface-label { font-size: .8rem; font-weight: 600; min-width: 4.5rem; }
526
+ .surface-state { font-size: .8rem; color: var(--muted); }
527
+ .surface-note { font-size: .78rem; color: var(--muted); }
528
+ /* The command, for a page with no transport. Same treatment as the verdict
529
+ command list: plain text a single tap selects whole, never an input. */
530
+ .surface-cmd { font-size: .78rem; user-select: all; }
531
+ .verdict-end { margin: 1.2rem 0 0; }
532
+ .verdict-title { font-size: .8rem; color: var(--muted); }
533
+ .verdict-count { font-size: .8rem; color: var(--muted); }
534
+ .v-commit { color: var(--good-fg); background: var(--good-bg); }
535
+ /* Continue borrows the same affirmative pair: it is the mid-run reader saying
536
+ carry on, and it never appears beside a commit button to be confused with. */
537
+ .v-continue { color: var(--good-fg); background: var(--good-bg); }
538
+ .v-changes { color: var(--flag-fg); background: var(--flag-bg); }
539
+ /* A button the render did not ask for is GONE, not dimmed — a disabled control
540
+ invites a reader to work out how to enable it, and there is nothing to do. */
541
+ .verdict button[hidden] { display: none; }
542
+ /* AND THE BAR ITSELF. `display: flex` beats the browser's `[hidden]`, so the
543
+ whole bar stayed on screen on a `file://` page — offering four buttons that
544
+ POST to a server that is not there, directly above the command list that
545
+ exists because they cannot. The guard in `assets-review` only read the
546
+ template for a `hidden` attribute, and this element is hidden at runtime, so
547
+ it never saw it; the guard reads the script now too. */
548
+ .verdict[hidden] { display: none; }
549
+ /* A blocked control is dashed as well as dimmed, so the block survives a
550
+ greyscale screen — and the reason rides in the label, never in a tooltip
551
+ nobody hovers on a phone. */
552
+ button:disabled { color: var(--muted); background: var(--panel-2); border-style: dashed; cursor: not-allowed; }
553
+ button:disabled:hover { color: var(--muted); border-color: var(--line); }
554
+ .verdict-log { font-size: .8rem; color: var(--muted); margin: .45rem 0 0; }
555
+ .verdict-log[hidden] { display: none; }
556
+ /* Not muted, unlike the log beneath it: this one is the reason a press did
557
+ nothing, and a grey line under a bar that appeared to ignore you reads as a
558
+ broken page. */
559
+ .verdict-warn {
560
+ font-size: .85rem;
561
+ color: var(--flag-fg);
562
+ background: var(--flag-bg);
563
+ border-left: 3px solid var(--flag-fg);
564
+ border-radius: 0 6px 6px 0;
565
+ padding: .45rem .6rem;
566
+ margin: .5rem 0 0;
567
+ }
568
+ .verdict-warn[hidden] { display: none; }
569
+ /* Which engine drew this page. A render is always current and a stale RENDERER
570
+ is invisible without it — the counts move, the date moves, the diff is right. */
571
+ .drawn-by { font-size: .75rem; color: var(--muted); margin: .9rem 0 0; }
572
+
573
+ .copy-out {
574
+ width: 100%;
575
+ min-height: 8rem;
576
+ margin-top: .6rem;
577
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
578
+ font-size: .75rem;
579
+ color: var(--fg);
580
+ background: var(--panel);
581
+ border: 1px solid var(--line);
582
+ border-radius: 8px;
583
+ padding: .5rem;
584
+ }
585
+ .copy-out[hidden] { display: none; }
586
+ /* THE REVIEW, ONCE IT IS OVER. A verdict that has actually been handed over
587
+ ends the page: the diff fades out and what you decided takes its place. The
588
+ fade is a transition on a class rather than a timer, so nothing depends on
589
+ JavaScript finishing, and `Show the diff anyway` brings it back — read-only,
590
+ with the buttons dead. */
591
+ .reviewable { transition: opacity .35s ease; }
592
+ .is-decided .reviewable { display: none; }
593
+ .is-decided.show-diff .reviewable { display: block; opacity: .75; }
594
+ .is-decided.show-diff .reviewable:hover { opacity: 1; }
595
+ /* Bringing the diff back must not resurrect a section that was hidden for its
596
+ own reasons — a spec with no context ships `#context` hidden, and the rule
597
+ above would have shown an empty panel. The second selector is there for
598
+ specificity: `.is-decided.show-diff .reviewable` outranks a bare
599
+ `.reviewable[hidden]`. */
600
+ .reviewable[hidden],
601
+ .is-decided.show-diff .reviewable[hidden] { display: none; }
602
+
603
+ .decided {
604
+ margin: 0 0 1.25rem;
605
+ padding: 1.1rem 1.2rem;
606
+ border: 1px solid var(--line);
607
+ border-left: 4px solid var(--accent);
608
+ border-radius: 10px;
609
+ background: var(--panel);
610
+ animation: decided-in .35s ease;
611
+ }
612
+ @keyframes decided-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
613
+ .decided[hidden] { display: none; }
614
+ .decided-what { margin: 0 0 .35rem; font-size: 1.05rem; font-weight: 650; line-height: 1.4; }
615
+ .decided-note { margin: 0 0 .8rem; color: var(--muted); font-size: .88rem; line-height: 1.5; max-width: 62ch; }
616
+ .decided-note:last-child { margin-bottom: 0; }
617
+ /* SPACE ABOVE IT. The toggle sits under whatever the panel ended on — a
618
+ sentence, or the command callout — and butted straight against the callout's
619
+ border it read as part of it rather than as the way back to the diff. */
620
+ .decided-toggle { font-size: .78rem; margin-top: .8rem; }
621
+
622
+ /* STILL ASKING. The window between the pass landing on the server and the
623
+ engine saying whether a session took it — up to seven seconds, and until now
624
+ the page filled it with the pessimistic answer and retracted it afterwards.
625
+ A spinner is the honest shape for a question with no answer yet. */
626
+ .pickup-wait {
627
+ display: flex;
628
+ align-items: center;
629
+ gap: .5rem;
630
+ margin: 0 0 .8rem;
631
+ color: var(--muted);
632
+ font-size: .88rem;
633
+ }
634
+ .pickup-wait[hidden] { display: none; }
635
+ .pickup-spin {
636
+ width: .85rem;
637
+ height: .85rem;
638
+ flex: none;
639
+ border: 2px solid var(--line);
640
+ /* One side in the accent is what makes a ring read as turning. */
641
+ border-top-color: var(--accent);
642
+ border-radius: 50%;
643
+ animation: pickup-spin .8s linear infinite;
644
+ }
645
+ @keyframes pickup-spin { to { transform: rotate(360deg); } }
646
+ /* A STATIC RING, not a hidden one. Someone who asked for less motion still
647
+ needs to see that something is in progress, and the sentence beside it is
648
+ doing most of that work anyway. */
649
+ @media (prefers-reduced-motion: reduce) {
650
+ .pickup-spin { animation: none; border-top-color: var(--accent); }
651
+ }
652
+
653
+ /* WHY THE CHANGE EXISTS — the PR description this page never had. It is prose
654
+ to be read rather than data to be scanned, so it gets a measure, air, and a
655
+ quieter voice than the diff below it. It was once full-bleed 15px text at
656
+ 1.5 in a tight box, which read as a wall and got skipped. */
657
+ .context {
658
+ margin: 0 0 1.5rem;
659
+ padding: 1.1rem 1.3rem;
660
+ border: 1px solid var(--line);
661
+ border-radius: 10px;
662
+ background: var(--panel, transparent);
663
+ }
664
+ .context h3 {
665
+ font-size: .72rem;
666
+ text-transform: uppercase;
667
+ letter-spacing: .08em;
668
+ color: var(--muted);
669
+ font-weight: 650;
670
+ margin: 1.4rem 0 .5rem;
671
+ }
672
+ .context h3:first-child { margin-top: 0; }
673
+ /* A MEASURE. Prose set the full width of a 1400px page is measurably harder to
674
+ read — the eye loses the line it is returning to. */
675
+ .context p { margin: 0 0 .7rem; font-size: .92rem; line-height: 1.65; max-width: 72ch; }
676
+ .context p:last-child { margin-bottom: 0; }
677
+ .context table { border-collapse: collapse; width: 100%; font-size: .84rem; margin: .2rem 0 .3rem; }
678
+ .context th, .context td { text-align: left; padding: .35rem .8rem .35rem 0; vertical-align: top; }
679
+ .context th {
680
+ color: var(--muted);
681
+ font-weight: 600;
682
+ font-size: .72rem;
683
+ text-transform: uppercase;
684
+ letter-spacing: .05em;
685
+ border-bottom: 1px solid var(--line);
686
+ padding-bottom: .3rem;
687
+ }
688
+ .context tbody tr + tr td { border-top: 1px solid var(--line); }
689
+ .context ul { margin: 0; padding-left: 1.15rem; max-width: 72ch; }
690
+ .context li { margin: .3rem 0; font-size: .92rem; line-height: 1.6; }
691
+ /* The disclosure is a CONTROL, not a panel. Full-width and boxed, it read as a
692
+ section header that happened to be clickable. */
693
+ .context-more { margin: .9rem 0 0; }
694
+ .context-more > summary {
695
+ display: inline-block;
696
+ width: auto;
697
+ cursor: pointer;
698
+ font-size: .78rem;
699
+ color: var(--muted);
700
+ padding: .25rem .7rem;
701
+ border: 1px solid var(--line);
702
+ border-radius: 999px;
703
+ list-style: none;
704
+ }
705
+ .context-more > summary::-webkit-details-marker { display: none; }
706
+ .context-more > summary:hover { color: var(--accent); border-color: var(--accent); }
707
+ .context-more[open] > summary { margin-bottom: .6rem; }
708
+ .context li.done { color: var(--muted); }
709
+ /* The ticket rides ON the title line rather than under it — it is the other
710
+ name for this work, not a fact about it. Sized down and tinted so the title
711
+ still reads as the title. */
712
+ .ticket {
713
+ font-size: .62em; font-weight: 600; vertical-align: middle;
714
+ font-family: var(--mono, ui-monospace, monospace);
715
+ color: var(--accent); text-decoration: none;
716
+ padding: .12em .45em; border: 1px solid var(--line); border-radius: 999px;
717
+ white-space: nowrap;
718
+ }
719
+ a.ticket:hover { border-color: var(--accent); }
720
+ .context-more > summary { cursor: pointer; color: var(--muted); font-size: .85rem; margin-top: .6rem; }
721
+ .copy-hint { font-size: .8rem; color: var(--muted); margin: .4rem 0 0; }
722
+ .sent-cmd { display: flex; gap: .4rem; align-items: stretch; margin: .4rem 0 0; }
723
+ /* A `display` rule BEATS the browser's own `[hidden] { display: none }`, so
724
+ every element this page hides in JavaScript needs its own override or the
725
+ hide silently does nothing. It shipped without one and a finished review kept
726
+ showing an empty command box. `assets-review` guards the whole class now. */
727
+ .sent-cmd[hidden] { display: none; }
728
+ .sent-cmd-text {
729
+ flex: 1; min-width: 0; font-family: var(--mono, ui-monospace, monospace);
730
+ font-size: .85rem; padding: .35rem .5rem; color: var(--fg);
731
+ background: var(--bg); border: 1px solid var(--line); border-radius: 4px;
732
+ /* One tap takes the whole command — the gesture that still works on a page
733
+ served over plain http, where the clipboard API is withheld. */
734
+ user-select: all; -webkit-user-select: all;
735
+ overflow-x: auto; white-space: pre; line-height: 1.6;
736
+ }
737
+ .sent-cmd-copy { white-space: nowrap; }
738
+ /* ONE GRID FOR THE WHOLE LIST, so the command column starts at the same x on
739
+ every row. A per-row layout cannot do that: each row would size its own label
740
+ column to its own label, and `Commit` and `Commit & Continue` are not the
741
+ same width. The label column is `max-content` — the longest label decides,
742
+ which is a fact about the labels rather than a rem value someone has to
743
+ remember to update when one changes. */
744
+ .cmd-list { display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content; margin: .6rem 0 0; }
745
+ .cmd-list[hidden] { display: none; }
746
+ .cmd-lead { grid-column: 1 / -1; font-size: .85rem; color: var(--muted); margin: 0 0 .5rem; }
747
+ /* ONE LINE: label, command, Copy. It was three lines deep — the label on its
748
+ own row above the command — which spent a screenful on four rows that fit on
749
+ four lines. */
750
+ .cmd-row {
751
+ display: grid;
752
+ grid-column: 1 / -1;
753
+ /* FALLBACK FIRST, then subgrid. A browser without subgrid drops the second
754
+ declaration and keeps this one, which still gives one line per row — just
755
+ each sized to itself. Without a fallback the row would fall back to a
756
+ single auto column and stack, which is worse than the misalignment. */
757
+ grid-template-columns: max-content minmax(0, 1fr) max-content;
758
+ grid-template-columns: subgrid;
759
+ gap: .5rem; align-items: center;
760
+ padding: .4rem .5rem; margin-bottom: .35rem;
761
+ background: var(--panel); border: 1px solid var(--line); border-radius: 6px;
762
+ }
763
+ .cmd-row[hidden] { display: none; }
764
+ .cmd-label { font-weight: 600; font-size: .85rem; white-space: nowrap; }
765
+ /* A TINTED EDGE, NOT A TINTED BOX. Four filled rows read as four warnings —
766
+ the colour shouted where it only needed to identify. The left border and the
767
+ label carry it now, which is enough to recognise the choice by and quiet
768
+ enough to read past. No new tokens: `v-commit` and `v-changes` above borrow
769
+ exactly this pair. */
770
+ .cmd-row[data-verdict="commit"],
771
+ .cmd-row[data-verdict="commit-start"],
772
+ .cmd-row[data-verdict="commit-land"],
773
+ .cmd-row[data-verdict="commit-continue"],
774
+ .cmd-row[data-verdict="continue"] { border-left: 3px solid var(--good-fg); }
775
+ .cmd-row[data-verdict="commit"] .cmd-label,
776
+ .cmd-row[data-verdict="commit-start"] .cmd-label,
777
+ .cmd-row[data-verdict="commit-land"] .cmd-label,
778
+ .cmd-row[data-verdict="commit-continue"] .cmd-label,
779
+ .cmd-row[data-verdict="continue"] .cmd-label { color: var(--good-fg); }
780
+ .cmd-row[data-verdict="changes"] { border-left: 3px solid var(--flag-fg); }
781
+ .cmd-row[data-verdict="changes"] .cmd-label { color: var(--flag-fg); }
782
+ /* Discuss keeps the neutral panel, exactly as its button does — it is the
783
+ ending that decides nothing, and colouring it would say otherwise. */
784
+ .cmd-text {
785
+ /* No `flex` — the list's grid column sizes this now. min-width:0 still
786
+ earns its place: it is what lets a long command scroll inside its own box
787
+ instead of widening the column and pushing Copy off the row. */
788
+ min-width: 0; font-family: var(--mono, ui-monospace, monospace);
789
+ font-size: .85rem; padding: .3rem .45rem; color: var(--fg);
790
+ background: var(--bg); border: 1px solid var(--line); border-radius: 4px;
791
+ /* One tap takes the whole command — see `.sent-cmd-text`. */
792
+ user-select: all; -webkit-user-select: all;
793
+ overflow-x: auto; white-space: pre; line-height: 1.6;
794
+ }
795
+ .cmd-copy { white-space: nowrap; }
796
+ /* AN ACTION, NOT A NOTE. The command reached the reader as a grey line under a
797
+ bar that had just closed, and it read as a footnote to something finished —
798
+ so it is now a filled callout inside the decided panel, which is the block a
799
+ re-opened page shows first. It reuses the add palette rather than minting a
800
+ green of its own: those three tokens are already defined on bare `:root` and
801
+ redefined in both theme blocks, which is the property `assets-review` guards. */
802
+ .sent-cmd.act {
803
+ flex-wrap: wrap; align-items: center; margin-top: .7rem;
804
+ padding: .55rem .6rem; border-radius: 6px;
805
+ background: var(--add-bg); border: 1px solid var(--add-mark); color: var(--add-fg);
806
+ }
807
+ .sent-cmd-lead { flex-basis: 100%; font-size: .85rem; font-weight: 600; color: var(--add-fg); }
808
+ /* A FAILED SEND, IN THE PALETTE OF A FAILURE. The same callout the waiting-pass
809
+ case uses, in the delete colours rather than the add ones — `--del-bg` /
810
+ `--del-fg` / `--del-mark` are already defined on bare `:root` and redefined in
811
+ both theme blocks, which is the property `assets-review` guards. No new token
812
+ is minted for this. */
813
+ .sent-cmd.act.fail {
814
+ background: var(--del-bg); border-color: var(--del-mark); color: var(--del-fg);
815
+ }
816
+ .sent-cmd.act.fail .sent-cmd-lead { color: var(--del-fg); }
817
+ /* THE OUTCOME THE READER MUST ACT ON, AS LOUD AS THE ONE THEY NEED NOT.
818
+ A failed send used to be a small grey line in the footer, under a verdict bar
819
+ that had just closed — quieter than the success it was not. It gets the panel
820
+ the decided page gets, at the top of the column, in the delete palette. What
821
+ it does NOT get is `is-decided`: nothing was delivered, so the diff stays and
822
+ the verdict buttons stay live for a second try. */
823
+ .decided.failed {
824
+ border-left-color: var(--del-mark);
825
+ background: var(--panel);
826
+ }
827
+ .decided.failed .decided-what { color: var(--del-mark); }
828
+ /* The claim code is read aloud or copied, so it is set apart from the sentence
829
+ around it rather than buried in it. */
830
+ .copy-hint { user-select: text; }
831
+ </style>
832
+ </head>
833
+ <body>
834
+ <div class="wrap" id="wrap">
835
+ <header>
836
+ <h1 id="title"></h1>
837
+ <p class="sub" id="sub"></p>
838
+ <div class="controls">
839
+ <button id="expand-all" type="button">Show all context</button>
840
+ <button id="collapse-all" type="button">Collapse context</button>
841
+ <label class="toggle"><input type="checkbox" id="show-noise"> <span id="noise-label">Show bookkeeping</span></label>
842
+ <button id="theme" type="button" aria-live="polite">Theme</button>
843
+ </div>
844
+ </header>
845
+
846
+ <div class="layout">
847
+ <details class="tree-wrap" id="tree-wrap" open>
848
+ <summary id="tree-summary">Files</summary>
849
+ <nav class="tree" id="tree"></nav>
850
+ </details>
851
+ <main class="main">
852
+ <!-- WHAT THE CHANGE IS FOR, above the first filename. Composed by the
853
+ engine from the spec's own files — nothing here passed through the
854
+ model, so it is free however large the diff. Absent entirely for a spec
855
+ whose overview cannot be read: the page worked without it before. -->
856
+ <!-- A SEND THAT DID NOT LAND. Same shape as the panel below, delete palette,
857
+ and deliberately NOT the same section: this one leaves the page
858
+ undecided, because nothing was delivered and the reader still has a
859
+ verdict to give. It names which failure happened, since a server that
860
+ has moved and a pass the engine refused send the reader to different
861
+ places. -->
862
+ <section class="decided failed" id="send-failed" hidden>
863
+ <p class="decided-what" id="send-failed-what"></p>
864
+ <p class="decided-note" id="send-failed-note"></p>
865
+ <p class="sent-cmd act fail" id="send-failed-cmd" hidden>
866
+ <span class="sent-cmd-lead" id="send-failed-lead"></span>
867
+ <code class="sent-cmd-text" id="send-failed-text"></code>
868
+ <button type="button" class="sent-cmd-copy" id="send-failed-copy" hidden>Copy</button>
869
+ </p>
870
+ </section>
871
+ <!-- WHAT YOU DECIDED, once the pass has actually been handed over. It sits
872
+ above everything because on a re-open it is the only thing you need:
873
+ the review is over, and this says how it ended. -->
874
+ <section class="decided" id="decided" hidden>
875
+ <p class="decided-what" id="decided-what"></p>
876
+ <p class="decided-note" id="decided-note"></p>
877
+ <!-- SHOWN ONLY WHILE THE PAGE IS ACTUALLY ASKING. A tab reopened
878
+ tomorrow over a stored `null` is reading history, not watching a
879
+ live poll, so it gets the command instead — a spinner that can never
880
+ resolve is worse than no spinner. -->
881
+ <p class="pickup-wait" id="pickup-wait" hidden>
882
+ <span class="pickup-spin" aria-hidden="true"></span>
883
+ <span id="pickup-wait-text">Sent — waiting for Claude to pick it up…</span>
884
+ </p>
885
+ <!-- THE ONE THING LEFT TO DO, and it lives HERE rather than under the
886
+ verdict bar because that is where it was being missed: a grey line
887
+ below a bar that had just closed, in the type size of a footnote,
888
+ saying the same words whether it mattered or not. It is shown only
889
+ where the page has ESTABLISHED that the pass is still sitting there,
890
+ so when it appears it is always an instruction. -->
891
+ <p class="sent-cmd act" id="sent-cmd" hidden>
892
+ <span class="sent-cmd-lead" id="sent-cmd-lead"></span>
893
+ <!-- PLAIN TEXT, NOT AN INPUT. A readonly field still looks like
894
+ something to type into, and on a phone tapping it raises the
895
+ keyboard for a value nobody can change. It is a command to read and
896
+ copy, so it is marked up as one — `user-select: all` makes a single
897
+ tap take the whole thing, which is the gesture that works where no
898
+ clipboard API does. -->
899
+ <code class="sent-cmd-text" id="sent-cmd-text"></code>
900
+ <button type="button" class="sent-cmd-copy" id="sent-cmd-copy" hidden>Copy</button>
901
+ </p>
902
+ <button type="button" class="decided-toggle" id="decided-toggle">Show the diff anyway</button>
903
+ </section>
904
+ <section class="context reviewable" id="context" hidden>
905
+ <div class="context-why" id="context-why"></div>
906
+ <details class="context-more" id="context-more" hidden>
907
+ <summary id="context-more-summary">More</summary>
908
+ <div id="context-rest"></div>
909
+ </details>
910
+ </section>
911
+ <div id="review-block" class="reviewable">__REVIEW_BLOCK__</div>
912
+ <div id="files" class="reviewable"></div>
913
+ <!-- THE SURFACES STRIP. Built by the script from `data.surfaces`, and
914
+ absent entirely when the render carried none — a `--docs` page has no
915
+ branch to put live, so it gets no strip and no explanation of why. -->
916
+ <section class="surfaces reviewable" id="surfaces" hidden>
917
+ <span class="surfaces-title">Where this runs</span>
918
+ <div id="surface-rows"></div>
919
+ </section>
920
+ <!-- THE VERDICT SITS AT THE END, where reading finishes. It began in the
921
+ header and the first person to use it could not find it: you read a
922
+ 350-line diff downward, and the control asking for your conclusion was
923
+ off-screen above you. A conclusion belongs after the thing it is about.
924
+ Not sticky, deliberately — a permanent bar costs vertical space on the
925
+ phone this page is read on, on a page whose whole job is showing code. -->
926
+ <footer class="verdict-end">
927
+ <div class="verdict" id="verdict">
928
+ <span class="verdict-title">Your verdict</span>
929
+ <button id="verdict-commit" class="v-commit" type="button" aria-live="polite">✓ Commit</button>
930
+ <button id="verdict-commit-continue" class="v-commit" type="button" aria-live="polite">✓ Commit &amp; Continue</button>
931
+ <!-- THE MID-RUN VERDICT, hidden unless the render asked for it. "Commit"
932
+ is the wrong verb for unfinished work, so a page rendered part-way
933
+ through a run offers this instead of the committing pair: I have
934
+ read it, carry on. It is not the removed `none` verdict — that
935
+ recorded itself and did nothing, while this one resumes the run. -->
936
+ <button id="verdict-continue" class="v-continue" type="button" hidden>→ Continue</button>
937
+ <!-- THE AUTHORING VERDICT, hidden unless the render asked for it. A spec
938
+ that has just been written has no phase in flight to continue, so
939
+ "Commit & Continue" is the wrong verb for it: this one commits the
940
+ spec and puts it IN FLIGHT, which is the `commit && /spec-start`
941
+ that was typed by hand until now. -->
942
+ <button id="verdict-commit-start" class="v-commit" type="button" hidden>&#10003; Commit &amp; Start</button>
943
+ <!-- THE /no-spec VERDICT, hidden unless the render asked for it. Work with
944
+ no spec has no next phase and nothing to put in flight, so neither
945
+ committing verb above fits: this one commits, fast-forwards the base
946
+ branch, and tears the worktree down. Its own word rather than a
947
+ context-dependent reading of "Commit", because one of those two
948
+ readings destroys a worktree. -->
949
+ <button id="verdict-commit-land" class="v-commit" type="button" hidden>&#10003; Commit &amp; Land</button>
950
+ <button id="verdict-changes" class="v-changes" type="button">↺ Request changes</button>
951
+ <button id="verdict-discuss" type="button">… Discuss first</button>
952
+ <span class="verdict-count" id="verdict-count" aria-live="polite"></span>
953
+ </div>
954
+ <!-- UNSENT TEXT, named at the moment it would have been destroyed. It sits
955
+ directly under the bar rather than beside the editor, because the
956
+ press that raises it happens here and the editor may be a screen
957
+ away. `assertive`: the reader is mid-decision and a polite region
958
+ would be read out after they have pressed again. -->
959
+ <p class="verdict-warn" id="verdict-warn" hidden aria-live="assertive"></p>
960
+ <!-- WHERE THE BUTTONS CANNOT DELIVER, the commands stand in their place.
961
+ A `file://` page has no server to POST to and no store to write to,
962
+ so a verdict button there only ever built a blob for the reader to
963
+ paste — a wall of JSON standing in for one word. Each row is named
964
+ with the BUTTON'S own label, because that is the word the reader
965
+ chose everywhere else; `commit-continue` is the wire spelling, and
966
+ nobody should have to learn a second vocabulary to recognise their
967
+ own decision. Built by the script, so there is exactly one list of
968
+ verdicts and it is the one the bar already uses. -->
969
+ <div class="cmd-list" id="cmd-list" hidden>
970
+ <p class="cmd-lead" id="cmd-lead"></p>
971
+ </div>
972
+ <p class="verdict-log" id="verdict-log" hidden></p>
973
+ <p class="copy-hint" id="copy-hint" hidden></p>
974
+ <textarea class="copy-out" id="copy-out" readonly hidden aria-label="Review blob to copy"></textarea>
975
+ <p class="drawn-by" id="drawn-by"></p>
976
+ </footer>
977
+ </main>
978
+ </div>
979
+ </div>
980
+
981
+ <script type="application/json" id="review-data">__REVIEW_DATA__</script>
982
+ <script>
983
+ (function () {
984
+ 'use strict'
985
+
986
+ var data = JSON.parse(document.getElementById('review-data').textContent)
987
+
988
+ // Lines kept either side of a change, and how much one "expand" step reveals.
989
+ var CONTEXT = 4
990
+ var STEP = 20
991
+
992
+ // --- patch parsing -------------------------------------------------------
993
+ // Rows carry their own line numbers so the gutter needs no second pass. Hunk
994
+ // headers are emitted ONLY when there is more than one hunk: with whole-file
995
+ // context there is exactly one, and a "@@ -1,400 +1,402 @@" banner above a
996
+ // whole file says nothing a reader wants.
997
+ function parsePatch(patch) {
998
+ var raw = patch.split('\n')
999
+ var hunks = 0
1000
+ var i
1001
+ for (i = 0; i < raw.length; i++) if (raw[i].slice(0, 2) === '@@') hunks++
1002
+
1003
+ var rows = []
1004
+ var oldNo = 0
1005
+ var newNo = 0
1006
+ var started = false
1007
+ for (i = 0; i < raw.length; i++) {
1008
+ var line = raw[i]
1009
+ if (line.slice(0, 2) === '@@') {
1010
+ var m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
1011
+ if (m) { oldNo = +m[1]; newNo = +m[2] }
1012
+ started = true
1013
+ if (hunks > 1) rows.push({ t: 'hunk', text: line })
1014
+ continue
1015
+ }
1016
+ if (!started) continue
1017
+ var c = line.charAt(0)
1018
+ if (c === '+') rows.push({ t: 'add', text: line.slice(1), n: newNo++ })
1019
+ else if (c === '-') rows.push({ t: 'del', text: line.slice(1), o: oldNo++ })
1020
+ else if (c === '\\') rows.push({ t: 'meta', text: line.slice(2) })
1021
+ else rows.push({ t: 'ctx', text: line.slice(1), o: oldNo++, n: newNo++ })
1022
+ }
1023
+ return rows
1024
+ }
1025
+
1026
+ // Which stretches of unchanged context start out hidden. A run touching the
1027
+ // top or bottom of the file collapses all the way to the edge — there is no
1028
+ // change on that side to need context for.
1029
+ function initialBands(rows) {
1030
+ var bands = []
1031
+ var i = 0
1032
+ while (i < rows.length) {
1033
+ if (rows[i].t !== 'ctx') { i++; continue }
1034
+ var j = i
1035
+ while (j < rows.length && rows[j].t === 'ctx') j++
1036
+ var atStart = i === 0
1037
+ var atEnd = j === rows.length
1038
+ var from = atStart ? i : i + CONTEXT
1039
+ var to = atEnd ? j : j - CONTEXT
1040
+ // A band hiding one or two lines is worse than just showing them.
1041
+ if (to - from >= 3) bands.push({ from: from, to: to })
1042
+ i = j
1043
+ }
1044
+ return bands
1045
+ }
1046
+
1047
+ // --- one order, for both columns -----------------------------------------
1048
+ // The sidebar and the panes are two views of one list, and they must be the
1049
+ // SAME list in the same order — a reader scrolling the panes watches the
1050
+ // current-file mark walk the sidebar, and a mark that walks backwards is the
1051
+ // whole reason this exists.
1052
+ //
1053
+ // The emitted order cannot be it. `review.js` collects tracked changes and
1054
+ // untracked files in two passes, so a new file is appended after everything
1055
+ // else however its path sorts; git's own order is not the tree's either.
1056
+ //
1057
+ // So the tree's order is the one order, settled here and used by both. It is
1058
+ // expressed as a comparator rather than derived from the built tree because
1059
+ // the panes are built long before the tree is, and a second traversal that
1060
+ // had to agree with the first is the thing that went wrong.
1061
+ function treeOrder(a, b) {
1062
+ var A = a.split('/')
1063
+ var B = b.split('/')
1064
+ var n = Math.min(A.length, B.length)
1065
+ for (var i = 0; i < n; i++) {
1066
+ // A segment with anything after it is a directory. `renderTree` walks
1067
+ // every directory at a level — label and subtree — before that level's
1068
+ // files, so a directory sorts ahead of a file it ties with by name.
1069
+ var aDir = i < A.length - 1
1070
+ var bDir = i < B.length - 1
1071
+ if (aDir !== bDir) return aDir ? -1 : 1
1072
+ if (A[i] !== B[i]) return A[i].localeCompare(B[i])
1073
+ }
1074
+ return 0
1075
+ }
1076
+
1077
+ // A copy: `data` is the emitted review, and the page is a view of it.
1078
+ var files = data.files.slice().sort(function (a, b) { return treeOrder(a.path, b.path) })
1079
+
1080
+ // --- per-file page state -------------------------------------------------
1081
+ // Band state lives HERE, never in the emitted data: the page is a view of an
1082
+ // immutable review, and expanding context is a property of this reading of it.
1083
+ var state = files.map(function (f) {
1084
+ var rows = parsePatch(f.patch)
1085
+ var st = {
1086
+ file: f,
1087
+ rows: rows,
1088
+ bands: initialBands(rows),
1089
+ rendered: false,
1090
+ el: null,
1091
+ // What the page holds about THIS reading: whether the accept box is
1092
+ // ticked now, and any notes written but not yet copied out.
1093
+ accept: f.accepted === true,
1094
+ drafts: [],
1095
+ }
1096
+ revealCommented(st)
1097
+ return st
1098
+ })
1099
+
1100
+ // --- review marks --------------------------------------------------------
1101
+ // A comment is useless if the line it points at is hidden, so any band that
1102
+ // swallows a commented line is dropped before the first render. Dropping the
1103
+ // whole band shows a little more context than strictly needed, which is the
1104
+ // harmless direction.
1105
+ function revealCommented(st) {
1106
+ var lines = (st.file.comments || [])
1107
+ .map(function (c) { return c.line })
1108
+ .filter(function (n) { return n != null })
1109
+ if (!lines.length) return
1110
+ var idx = []
1111
+ st.rows.forEach(function (row, i) {
1112
+ if (row.n != null && lines.indexOf(row.n) !== -1) idx.push(i)
1113
+ })
1114
+ st.bands = st.bands.filter(function (b) {
1115
+ return !idx.some(function (i) { return i >= b.from && i < b.to })
1116
+ })
1117
+ }
1118
+
1119
+ // Ids are minted from the render timestamp plus a counter, so they are stable
1120
+ // for this page and unique across renders. That is what makes re-pasting the
1121
+ // same blob merge instead of doubling every note.
1122
+ var seq = 0
1123
+ function mintId() {
1124
+ seq++
1125
+ return data.generatedAt + '-' + seq
1126
+ }
1127
+
1128
+ // Best effort, both ways. Safari refuses storage on file:// by THROWING, and
1129
+ // a review page that fails to render because it could not autosave would be a
1130
+ // far worse bug than a lost pass.
1131
+ // What this reader concluded, once it was actually HANDED OVER — never on a
1132
+ // clipboard copy, which is a pass still sitting in the reader's hands.
1133
+ var decided = null
1134
+ var STORE_KEY = 'skitterspec-review:' + data.spec + ':' + data.generatedAt
1135
+ function save() {
1136
+ try {
1137
+ var accepts = {}
1138
+ var drafts = []
1139
+ state.forEach(function (st) {
1140
+ accepts[st.file.path] = st.accept
1141
+ st.drafts.forEach(function (d) { drafts.push(d) })
1142
+ })
1143
+ window.localStorage.setItem(
1144
+ STORE_KEY,
1145
+ JSON.stringify({ seq: seq, accepts: accepts, drafts: drafts, replies: replies, decided: decided }),
1146
+ )
1147
+ } catch (e) { /* no storage, or a quota — the pass still works, it just is not saved */ }
1148
+ }
1149
+ function restore() {
1150
+ var raw = null
1151
+ try { raw = window.localStorage.getItem(STORE_KEY) } catch (e) { return }
1152
+ if (!raw) return
1153
+ var saved
1154
+ try { saved = JSON.parse(raw) } catch (e) { return }
1155
+ seq = saved.seq || 0
1156
+ replies = saved.replies || {}
1157
+ // THE DECISION OUTLIVES THE TAB. Keyed to this render like every other
1158
+ // mark, so the next render — the one after the commit — is a live page
1159
+ // again rather than a page that thinks it is finished.
1160
+ if (saved.decided && saved.decided.verdict) decided = saved.decided
1161
+ var byPath = {}
1162
+ state.forEach(function (st) { byPath[st.file.path] = st })
1163
+ Object.keys(saved.accepts || {}).forEach(function (path) {
1164
+ if (byPath[path]) byPath[path].accept = Boolean(saved.accepts[path])
1165
+ })
1166
+ ;(saved.drafts || []).forEach(function (d) {
1167
+ if (byPath[d.file]) byPath[d.file].drafts.push(d)
1168
+ else orphanDrafts.push(d)
1169
+ })
1170
+ }
1171
+
1172
+ // Drafts whose file left the diff between saving and reloading, and replies to
1173
+ // the written review keyed by check id. Both still belong in the blob.
1174
+ var orphanDrafts = []
1175
+ var replies = {}
1176
+
1177
+ function el(tag, cls, text) {
1178
+ var n = document.createElement(tag)
1179
+ if (cls) n.className = cls
1180
+ if (text != null) n.textContent = text
1181
+ return n
1182
+ }
1183
+
1184
+ function gutter(row, st, index) {
1185
+ var td = el('td', 'gutter')
1186
+ var o = el('span', 'n', row.o == null ? '' : String(row.o))
1187
+ var n = el('span', 'n', row.n == null ? '' : String(row.n))
1188
+ var s = el('span', 's', row.t === 'add' ? '+' : row.t === 'del' ? '-' : ' ')
1189
+ td.appendChild(o); td.appendChild(n); td.appendChild(s)
1190
+ if (st) {
1191
+ td.className = 'gutter can-note'
1192
+ td.title = 'Comment on this line'
1193
+ td.addEventListener('click', function () { openDraft(st, index, row) })
1194
+ }
1195
+ return td
1196
+ }
1197
+
1198
+ function bandRow(st, band, idx) {
1199
+ var tr = el('tr', 'band')
1200
+ tr.appendChild(el('td', 'gutter', '⋯'))
1201
+ var td = el('td', 'src')
1202
+ var hidden = band.to - band.from
1203
+ td.appendChild(el('span', 'n-hidden', hidden + ' unchanged line' + (hidden === 1 ? '' : 's')))
1204
+ var step = el('button', null, '±' + STEP)
1205
+ step.type = 'button'
1206
+ step.setAttribute('aria-label', 'Expand ' + STEP + ' lines either side')
1207
+ step.addEventListener('click', function () {
1208
+ band.from += STEP
1209
+ band.to -= STEP
1210
+ if (band.to - band.from < 3) st.bands.splice(st.bands.indexOf(band), 1)
1211
+ renderFile(st)
1212
+ })
1213
+ var all = el('button', null, 'Show all')
1214
+ all.type = 'button'
1215
+ all.addEventListener('click', function () {
1216
+ st.bands.splice(st.bands.indexOf(band), 1)
1217
+ renderFile(st)
1218
+ })
1219
+ td.appendChild(step)
1220
+ td.appendChild(all)
1221
+ tr.appendChild(td)
1222
+ return tr
1223
+ }
1224
+
1225
+ // Re-render the whole file on every expand. The alternative — pre-rendering
1226
+ // thousands of hidden rows and unhiding them — is what makes a whole-file
1227
+ // diff page take seconds to open.
1228
+ function renderFile(st) {
1229
+ var host = st.el
1230
+ if (!host) return
1231
+ host.textContent = ''
1232
+ if (!st.rows.length) {
1233
+ host.appendChild(el('p', 'note', st.file.binary ? 'Binary file — not shown.' : 'No textual change.'))
1234
+ return
1235
+ }
1236
+ var box = el('div', 'code')
1237
+ var table = el('table', 'diff')
1238
+ var body = document.createElement('tbody')
1239
+ var bands = st.bands.slice().sort(function (a, b) { return a.from - b.from })
1240
+ var b = 0
1241
+ var i = 0
1242
+ while (i < st.rows.length) {
1243
+ if (b < bands.length && i === bands[b].from) {
1244
+ body.appendChild(bandRow(st, bands[b], b))
1245
+ i = bands[b].to
1246
+ b++
1247
+ continue
1248
+ }
1249
+ var row = st.rows[i]
1250
+ var tr = el('tr', row.t)
1251
+ if (row.t === 'hunk') {
1252
+ tr.className = 'band'
1253
+ tr.appendChild(el('td', 'gutter', '⋯'))
1254
+ tr.appendChild(el('td', 'src', row.text))
1255
+ } else {
1256
+ tr.appendChild(gutter(row, st, i))
1257
+ tr.appendChild(el('td', 'src', row.text === '' ? ' ' : row.text))
1258
+ // The line number, so a check can point at this row. Set here rather
1259
+ // than derived on demand because the file re-renders on every expand,
1260
+ // and a lookup built once would go stale the first time a band opened.
1261
+ if (row.n != null) tr.setAttribute('data-n', String(row.n))
1262
+ }
1263
+ body.appendChild(tr)
1264
+ // Anything said about this line sits directly beneath it — a note you
1265
+ // have to go and look for is a note nobody reads.
1266
+ var here = notesAt(st, row.n)
1267
+ if (here.length) tr.className += ' has-note'
1268
+ here.forEach(function (c) { body.appendChild(noteRow(st, c)) })
1269
+ if (st.draftAt === i) body.appendChild(draftRow(st, row))
1270
+ i++
1271
+ }
1272
+ table.appendChild(body)
1273
+ box.appendChild(table)
1274
+ host.appendChild(box)
1275
+ sizeNotes(box)
1276
+ watchPane(host)
1277
+ }
1278
+
1279
+ /**
1280
+ * Publish this code box's visible width, for the note bodies inside it.
1281
+ *
1282
+ * A note has to wrap at the width of what the reader can SEE, and the cell it
1283
+ * lives in is a diff-table column sized by the longest line of source in the
1284
+ * file. So the pane's own width is measured here and handed to the CSS.
1285
+ *
1286
+ * MEASURED, NEVER SUMMED. The gutter's width is `--ln-w` plus the +/- column
1287
+ * plus padding plus a border, in whatever font the reader has — and `td.gutter`
1288
+ * is one sticky cell rather than three precisely so that nobody has to get
1289
+ * that arithmetic right. Asking the element is the same decision one layer up.
1290
+ *
1291
+ * A BOX MEASURED AT NOTHING PUBLISHES NOTHING. A file whose <details> has
1292
+ * never been opened reports 0 for everything, and 0 is not a narrow pane —
1293
+ * it is a box that has not been laid out. Writing it would give every note in
1294
+ * that file a zero-width editor on the one render that matters, so the
1295
+ * cannot-tell case leaves whatever was there alone and the CSS falls back to
1296
+ * its own defaults.
1297
+ */
1298
+ function sizeNotes(box) {
1299
+ if (!box || !box.style) return
1300
+ var g = box.querySelector('td.gutter')
1301
+ var gw = (g && g.offsetWidth) || 0
1302
+ var pane = box.clientWidth || 0
1303
+ if (!pane || !gw) return
1304
+ box.style.setProperty('--gutter-w', gw + 'px')
1305
+ box.style.setProperty('--pane-w', Math.max(0, pane - gw) + 'px')
1306
+ }
1307
+
1308
+ // Re-measure when the layout moves under it — a rotated phone, a window
1309
+ // dragged narrower, the file tree folding away. The HOST is watched rather
1310
+ // than the box: a box is rebuilt on every band expansion, and an observer
1311
+ // holding the old ones would keep a whole file's worth of detached rows alive
1312
+ // for the life of the page. Observing the same host twice replaces the
1313
+ // observation, so re-renders cost nothing.
1314
+ //
1315
+ // A browser without ResizeObserver keeps the width it opened at, which is
1316
+ // wrong only for someone who then resizes. Nothing accuses, nothing breaks.
1317
+ var paneWatch = typeof ResizeObserver === 'function'
1318
+ ? new ResizeObserver(function (entries) {
1319
+ entries.forEach(function (e) { sizeNotes(e.target.querySelector('.code')) })
1320
+ })
1321
+ : null
1322
+
1323
+ function watchPane(host) {
1324
+ if (paneWatch && host) paneWatch.observe(host)
1325
+ }
1326
+
1327
+ // Every note about one line: what the engine stored, plus anything written on
1328
+ // this page and not yet copied out.
1329
+ function notesAt(st, line) {
1330
+ if (line == null) return []
1331
+ var out = []
1332
+ ;(st.file.comments || []).forEach(function (c) { if (c.line === line) out.push(c) })
1333
+ st.drafts.forEach(function (d) { if (d.line === line) out.push(d) })
1334
+ return out
1335
+ }
1336
+
1337
+ function noteRow(st, c) {
1338
+ var tr = el('tr', 'note-row' + (c.resolved ? ' is-resolved' : ''))
1339
+ tr.appendChild(el('td', 'gutter', '💬'))
1340
+ var td = el('td', 'src')
1341
+ var body = el('div', 'note-body')
1342
+ var meta = c.line == null ? 'on this file' : 'line ' + c.line
1343
+ if (c.check) meta += ' · answering the review'
1344
+ if (!c.raisedAt) meta += ' · not sent yet'
1345
+ body.appendChild(el('span', 'note-meta', meta))
1346
+ body.appendChild(el('span', 'note-text', c.note))
1347
+ if (c.resolved) {
1348
+ body.appendChild(el('span', 'note-resolution', '↳ ' + (c.resolved.note || 'resolved')))
1349
+ }
1350
+ if (!c.raisedAt) {
1351
+ var actions = el('div', 'note-actions')
1352
+ var del = el('button', null, 'Remove')
1353
+ del.type = 'button'
1354
+ del.addEventListener('click', function () {
1355
+ st.drafts.splice(st.drafts.indexOf(c), 1)
1356
+ renderFile(st)
1357
+ refresh()
1358
+ })
1359
+ actions.appendChild(del)
1360
+ body.appendChild(actions)
1361
+ }
1362
+ td.appendChild(body)
1363
+ tr.appendChild(td)
1364
+ return tr
1365
+ }
1366
+
1367
+ // The draft box. `row` is null for a note about the file as a whole.
1368
+ function draftRow(st, row) {
1369
+ var tr = el('tr', 'note-row')
1370
+ tr.appendChild(el('td', 'gutter', '💬'))
1371
+ var td = el('td', 'src')
1372
+ var body = el('div', 'note-body')
1373
+ body.appendChild(el('span', 'note-meta', row ? 'line ' + (row.n == null ? '—' : row.n) : 'on this file'))
1374
+ body.appendChild(noteEditor(function (text) {
1375
+ addDraft(st, text, row ? row.n : null, row ? row.text : null, null)
1376
+ st.draftAt = null
1377
+ renderFile(st)
1378
+ }, function () {
1379
+ st.draftAt = null
1380
+ renderFile(st)
1381
+ }))
1382
+ td.appendChild(body)
1383
+ tr.appendChild(td)
1384
+ return tr
1385
+ }
1386
+
1387
+ // One editor, used by the gutter, the file strip and the review replies.
1388
+ function noteEditor(onSave, onCancel) {
1389
+ var wrap = el('div')
1390
+ var input = el('textarea', 'note-input')
1391
+ input.rows = 2
1392
+ input.placeholder = 'What should change here?'
1393
+ var actions = el('div', 'note-actions')
1394
+ var ok = el('button', null, 'Add note')
1395
+ ok.type = 'button'
1396
+ ok.addEventListener('click', function () {
1397
+ var text = input.value.trim()
1398
+ // An empty note would be refused by the engine, so it is refused here —
1399
+ // where the person who wrote it is still looking at it.
1400
+ if (!text) { input.focus(); return }
1401
+ onSave(text)
1402
+ })
1403
+ actions.appendChild(ok)
1404
+ if (onCancel) {
1405
+ var no = el('button', null, 'Cancel')
1406
+ no.type = 'button'
1407
+ no.addEventListener('click', onCancel)
1408
+ actions.appendChild(no)
1409
+ }
1410
+ wrap.appendChild(input)
1411
+ wrap.appendChild(actions)
1412
+ window.setTimeout(function () { input.focus() }, 0)
1413
+ return wrap
1414
+ }
1415
+
1416
+ function addDraft(st, note, line, lineText, check) {
1417
+ st.drafts.push({
1418
+ id: mintId(),
1419
+ file: st.file.path,
1420
+ line: line == null ? null : line,
1421
+ lineText: lineText == null ? null : lineText,
1422
+ check: check || null,
1423
+ note: note,
1424
+ })
1425
+ refresh()
1426
+ }
1427
+
1428
+ function openDraft(st, index, row) {
1429
+ st.draftAt = st.draftAt === index ? null : index
1430
+ renderFile(st)
1431
+ }
1432
+
1433
+ // Notes about the file rather than a line, and the way in to writing one.
1434
+ function fileStrip(st) {
1435
+ var strip = el('div', 'strip')
1436
+ var loose = (st.file.comments || []).concat(st.drafts).filter(function (c) { return c.line == null })
1437
+ loose.forEach(function (c) {
1438
+ var row = el('div', c.resolved ? 'is-resolved' : null)
1439
+ row.appendChild(el('span', 'note-meta', (c.check ? 'answering the review' : 'on this file') + (c.raisedAt ? '' : ' · not sent yet')))
1440
+ row.appendChild(el('span', 'note-text', c.note))
1441
+ if (c.resolved) row.appendChild(el('span', 'note-resolution', '↳ ' + (c.resolved.note || 'resolved')))
1442
+ strip.appendChild(row)
1443
+ })
1444
+ var add = el('button', null, 'Note on this file')
1445
+ add.type = 'button'
1446
+ add.addEventListener('click', function () {
1447
+ add.hidden = true
1448
+ strip.appendChild(noteEditor(function (text) {
1449
+ addDraft(st, text, null, null, null)
1450
+ redrawStrip(st)
1451
+ }, function () { redrawStrip(st) }))
1452
+ })
1453
+ strip.appendChild(add)
1454
+ return strip
1455
+ }
1456
+
1457
+ function redrawStrip(st) {
1458
+ if (!st.stripEl) return
1459
+ var next = fileStrip(st)
1460
+ st.stripEl.parentNode.replaceChild(next, st.stripEl)
1461
+ st.stripEl = next
1462
+ }
1463
+
1464
+ // --- file list -----------------------------------------------------------
1465
+ var filesHost = document.getElementById('files')
1466
+
1467
+ state.forEach(function (st) {
1468
+ var f = st.file
1469
+ var d = el('details', 'file')
1470
+ d.id = 'f-' + encodeURIComponent(f.path)
1471
+ // Read back by the current-file observer, which is handed elements rather
1472
+ // than files. The id would do — it is the same path, encoded — but decoding
1473
+ // an id to recover data the element could simply carry is a round trip
1474
+ // through a format that exists for a different reason.
1475
+ d.setAttribute('data-path', f.path)
1476
+ var s = el('summary')
1477
+ s.appendChild(el('span', 'path', f.path))
1478
+ s.appendChild(el('span', 'chip', f.status))
1479
+ if (!f.whole) s.appendChild(el('span', 'chip', 'partial context'))
1480
+ if (f.noise) s.appendChild(el('span', 'chip', 'bookkeeping'))
1481
+ // A lapse is SAID. An accept that quietly vanished because the file moved on
1482
+ // would leave you believing you had read code you have not.
1483
+ if (f.accepted === 'lapsed') {
1484
+ s.appendChild(el('span', 'chip lapsed', 'accepted earlier — changed since'))
1485
+ }
1486
+ var openCount = (f.comments || []).filter(function (x) { return !x.resolved }).length
1487
+ if (openCount) s.appendChild(el('span', 'chip commented', openCount + ' note' + (openCount === 1 ? '' : 's')))
1488
+
1489
+ var accept = el('button', 'accept', '✓ accept')
1490
+ accept.type = 'button'
1491
+ accept.setAttribute('aria-pressed', String(st.accept))
1492
+ // A summary is a toggle for the <details>; a click on a control inside it
1493
+ // must not also open and close the file.
1494
+ accept.addEventListener('click', function (ev) {
1495
+ ev.preventDefault()
1496
+ ev.stopPropagation()
1497
+ st.accept = !st.accept
1498
+ accept.setAttribute('aria-pressed', String(st.accept))
1499
+ refresh()
1500
+ })
1501
+ s.appendChild(accept)
1502
+
1503
+ var c = el('span', 'counts')
1504
+ c.appendChild(el('span', 'a', '+' + f.additions))
1505
+ c.appendChild(document.createTextNode(' '))
1506
+ c.appendChild(el('span', 'd', '-' + f.deletions))
1507
+ s.appendChild(c)
1508
+ d.appendChild(s)
1509
+ st.stripEl = fileStrip(st)
1510
+ d.appendChild(st.stripEl)
1511
+ var host = el('div')
1512
+ d.appendChild(host)
1513
+ st.el = host
1514
+
1515
+ // Render on first open, so a review of 200 files costs one file's work.
1516
+ d.addEventListener('toggle', function () {
1517
+ if (d.open && !st.rendered) { st.rendered = true; renderFile(st) }
1518
+ })
1519
+ if (!f.noise) { d.open = true; st.rendered = true; renderFile(st) }
1520
+ filesHost.appendChild(d)
1521
+ st.details = d
1522
+ })
1523
+
1524
+ if (!state.length) {
1525
+ filesHost.appendChild(el('p', 'empty', 'Nothing to review — no changes found.'))
1526
+ }
1527
+
1528
+ // --- noise filter --------------------------------------------------------
1529
+ var noiseBox = document.getElementById('show-noise')
1530
+ var noiseCount = state.filter(function (s) { return s.file.noise }).length
1531
+ document.getElementById('noise-label').textContent = 'Show bookkeeping (' + noiseCount + ')'
1532
+ if (!noiseCount) noiseBox.closest('.toggle').hidden = true
1533
+
1534
+ function applyNoise() {
1535
+ state.forEach(function (st) {
1536
+ if (st.file.noise) st.details.hidden = !noiseBox.checked
1537
+ })
1538
+ }
1539
+ noiseBox.addEventListener('change', applyNoise)
1540
+ applyNoise()
1541
+
1542
+ // --- file tree -----------------------------------------------------------
1543
+ // Built from the paths, and it lists EVERY file including the ones the noise
1544
+ // filter is hiding — a tree with an unreachable entry is worse than no tree.
1545
+ function buildTree(files) {
1546
+ var root = { dirs: {}, files: [] }
1547
+ files.forEach(function (f) {
1548
+ var parts = f.path.split('/')
1549
+ var node = root
1550
+ for (var i = 0; i < parts.length - 1; i++) {
1551
+ if (!node.dirs[parts[i]]) node.dirs[parts[i]] = { dirs: {}, files: [] }
1552
+ node = node.dirs[parts[i]]
1553
+ }
1554
+ node.files.push({ name: parts[parts.length - 1], file: f })
1555
+ })
1556
+ return root
1557
+ }
1558
+
1559
+ // --- which file am I reading? --------------------------------------------
1560
+ // The tree row for each path, captured as the tree is built. A lookup rather
1561
+ // than a query, because this is answered on every scroll: a `querySelector`
1562
+ // per event would walk the whole sidebar to find something the build already
1563
+ // had in its hand.
1564
+ var rowByPath = {}
1565
+ var currentPath = null
1566
+
1567
+ /**
1568
+ * Mark one file as the one being read.
1569
+ *
1570
+ * ONE ROW AT A TIME, and the previous one is cleared through the same map
1571
+ * rather than by querying for `.is-current`. A map cannot report a row the
1572
+ * tree no longer has, and it cannot miss one a selector spelled slightly
1573
+ * differently.
1574
+ */
1575
+ function setCurrent(path) {
1576
+ if (path === currentPath) return
1577
+ var prev = rowByPath[currentPath]
1578
+ if (prev) prev.className = prev.className.replace(/ ?is-current/, '')
1579
+ currentPath = path
1580
+ var row = rowByPath[path]
1581
+ if (!row) return
1582
+ row.className += ' is-current'
1583
+ followCurrent(row)
1584
+ }
1585
+
1586
+ /**
1587
+ * Drop the highlight entirely.
1588
+ *
1589
+ * For the decided page, where the diff is hidden and no card can qualify —
1590
+ * leaving a row lit there would point at something the reader can no longer
1591
+ * see.
1592
+ */
1593
+ function clearCurrent() {
1594
+ var prev = rowByPath[currentPath]
1595
+ if (prev) prev.className = prev.className.replace(/ ?is-current/, '')
1596
+ currentPath = null
1597
+ }
1598
+
1599
+ /**
1600
+ * Scroll the tree ONLY when the marked row has left its own scrollport.
1601
+ *
1602
+ * The sidebar is a bounded box (`max-height: calc(100vh - 2rem)`), so on a
1603
+ * long diff the highlight is invisible unless the row can be brought in —
1604
+ * and on a short one nothing should ever move. `block: 'nearest'` keeps the
1605
+ * nudge a nudge: re-centring would put the sidebar in constant motion beside
1606
+ * a reader who is already moving, which is the thing a fixed map is for.
1607
+ *
1608
+ * A browser that withholds `getBoundingClientRect` gets the highlight and no
1609
+ * follow. Guessing a position from nothing is how a sidebar ends up scrolled
1610
+ * somewhere arbitrary.
1611
+ */
1612
+ function followCurrent(row) {
1613
+ var box = document.getElementById('tree-wrap')
1614
+ if (!box || !row.getBoundingClientRect || !box.getBoundingClientRect) return
1615
+ var r = row.getBoundingClientRect()
1616
+ var b = box.getBoundingClientRect()
1617
+ if (r.top >= b.top && r.bottom <= b.bottom) return
1618
+ if (row.scrollIntoView) row.scrollIntoView({ block: 'nearest' })
1619
+ }
1620
+
1621
+ // What is currently crossing the band, keyed by path. A SET rather than a
1622
+ // running answer: entries arrive for what CHANGED, so the observer never
1623
+ // hands over the whole picture and the page has to keep it.
1624
+ var onScreen = {}
1625
+
1626
+ /**
1627
+ * The topmost file on screen, in document order.
1628
+ *
1629
+ * NOTHING ON SCREEN IS NOT AN ANSWER, and this returning without calling
1630
+ * `setCurrent` is the whole of that. Scrolled above the first card, past the
1631
+ * last, or with the current file hidden by the noise filter, the previous
1632
+ * row stays lit — clearing it would flicker the highlight off every time a
1633
+ * card boundary crossed the band, and a stale "you were here" is worth more
1634
+ * than nothing at all.
1635
+ */
1636
+ function pickCurrent() {
1637
+ for (var i = 0; i < state.length; i++) {
1638
+ if (onScreen[state[i].file.path]) return setCurrent(state[i].file.path)
1639
+ }
1640
+ }
1641
+
1642
+ /**
1643
+ * Watch the cards against a band at the top of the viewport.
1644
+ *
1645
+ * THE BOTTOM MARGIN IS THE WHOLE TRICK. Pulled up by three quarters of the
1646
+ * viewport, only the top slice counts as "on screen" — so the answer changes
1647
+ * as you scroll. Without it every card on a tall screen intersects at once
1648
+ * and the topmost is whatever is furthest up the document, which is the same
1649
+ * file from the first scroll to the last.
1650
+ *
1651
+ * A browser without IntersectionObserver keeps a page with no highlight and
1652
+ * no error — the shape `paneWatch` takes for ResizeObserver above. Nothing
1653
+ * accuses and nothing breaks; the tree is still a tree.
1654
+ */
1655
+ var currentWatch = typeof IntersectionObserver === 'function'
1656
+ ? new IntersectionObserver(function (entries) {
1657
+ entries.forEach(function (e) {
1658
+ var p = e.target.getAttribute('data-path')
1659
+ if (!p) return
1660
+ if (e.isIntersecting) onScreen[p] = true
1661
+ else delete onScreen[p]
1662
+ })
1663
+ pickCurrent()
1664
+ }, { rootMargin: '0px 0px -75% 0px' })
1665
+ : null
1666
+
1667
+ function reveal(f) {
1668
+ if (f.noise && !noiseBox.checked) { noiseBox.checked = true; applyNoise() }
1669
+ var d = document.getElementById('f-' + encodeURIComponent(f.path))
1670
+ if (!d) return
1671
+ d.open = true
1672
+ d.scrollIntoView({ block: 'start' })
1673
+ // Mark it NOW rather than waiting for the scroll to settle. A click on the
1674
+ // tree is a reader saying which file they mean, and the observer would
1675
+ // agree a moment later — but the moment is visible, and a row that lights
1676
+ // up after you have already looked away reads as a lag rather than an
1677
+ // answer.
1678
+ setCurrent(f.path)
1679
+ }
1680
+
1681
+ /**
1682
+ * Jump to a file, and to one line inside it when a check named one.
1683
+ *
1684
+ * THREE STATES, AND ONLY ONE OF THEM IS A JUMP. A file that is not in this
1685
+ * diff, and a line that is not in that file's patch, both do nothing beyond
1686
+ * revealing what IS there — a reviewer's finding can name a line that has
1687
+ * since moved, and a page that scrolled somewhere arbitrary would be worse
1688
+ * than one that scrolled nowhere.
1689
+ */
1690
+ function gotoLine(path, line) {
1691
+ var st = null
1692
+ state.forEach(function (s) { if (s.file.path === path) st = s })
1693
+ if (!st) return
1694
+ reveal(st.file)
1695
+ if (line == null) return
1696
+ // A collapsed band hides the row, so drop any band covering it first. The
1697
+ // file re-renders anyway; this just decides what is on screen when it does.
1698
+ var idx = -1
1699
+ for (var i = 0; i < st.rows.length; i++) {
1700
+ if (st.rows[i].n === line) { idx = i; break }
1701
+ }
1702
+ if (idx < 0) return
1703
+ st.bands = st.bands.filter(function (b) { return !(idx >= b.from && idx < b.to) })
1704
+ renderFile(st)
1705
+ if (!st.el) return
1706
+ // Attribute-only, no tag prefix: within one file's element nothing but a
1707
+ // code row carries `data-n`, and the narrower selector buys nothing.
1708
+ var tr = st.el.querySelector('[data-n="' + line + '"]')
1709
+ if (!tr) return
1710
+ var prev = document.querySelector('.goto-hit')
1711
+ if (prev) prev.className = prev.className.replace(/ ?goto-hit/, '')
1712
+ tr.className += ' goto-hit'
1713
+ tr.scrollIntoView({ block: 'center' })
1714
+ }
1715
+
1716
+ // Wired per button rather than delegated from `document`. The review block is
1717
+ // spliced in as markup by the engine, so it is all present by the time this
1718
+ // runs — and a listener on each element is what the rest of this page does.
1719
+ function wireGotos() {
1720
+ var btns = document.querySelectorAll('[data-goto]')
1721
+ Array.prototype.forEach.call(btns, function (btn) {
1722
+ btn.addEventListener('click', function () {
1723
+ var n = btn.getAttribute('data-goto-line')
1724
+ gotoLine(btn.getAttribute('data-goto'), n == null ? null : parseInt(n, 10))
1725
+ })
1726
+ })
1727
+ }
1728
+
1729
+ // ONE FLAT LIST, indented by per-row padding rather than by nesting <ul>s.
1730
+ //
1731
+ // That is not a tidy-up: the change counts are `position: sticky; right: 0`,
1732
+ // and a sticky box can only pin within its own row. Nested <ul>s make each
1733
+ // level narrower than its parent, so a deep row ends before the scrollport's
1734
+ // right edge and its counts slide away the moment you scroll sideways. Flat
1735
+ // rows all share the widest row's width, so every one of them has something
1736
+ // to pin to.
1737
+ //
1738
+ // Depth goes on as an inline style because it is data, not a class: the tree
1739
+ // is however deep the diff happens to be, and a rule per level would be a
1740
+ // guess at the maximum.
1741
+ function indent(node, depth, base) {
1742
+ // Rounded, because 3 * 0.8 is 2.4000000000000004 in binary floating point
1743
+ // and that lands verbatim in the attribute. CSS copes; a person reading the
1744
+ // page source does not, and neither does a test asserting the value.
1745
+ var rem = Math.round((base + depth * 0.8) * 100) / 100
1746
+ node.setAttribute('style', 'padding-left:' + rem + 'rem')
1747
+ return node
1748
+ }
1749
+
1750
+ function renderTree(node) {
1751
+ var ul = el('ul')
1752
+ ;(function walk(n, depth) {
1753
+ // `treeOrder` above is the authority and these two sorts must match it,
1754
+ // which is why the directories sort by `localeCompare` rather than by the
1755
+ // default `.sort()`: that compares UTF-16 code units, so it puts every
1756
+ // capitalised name ahead of every lowercase one and the panes would drift
1757
+ // from the sidebar again on the first `README/` in a diff.
1758
+ //
1759
+ // Insertion order would be free — the list arrives sorted — but a key
1760
+ // that looks like an array index (`2024/`) is enumerated first whatever
1761
+ // order it was inserted in, and a dated directory is not exotic.
1762
+ Object.keys(n.dirs).sort(function (a, b) { return a.localeCompare(b) }).forEach(function (name) {
1763
+ var li = el('li')
1764
+ li.appendChild(indent(el('span', 'dir', name + '/'), depth, 0.6))
1765
+ ul.appendChild(li)
1766
+ // The subtree goes in AFTER its own label and before this level's
1767
+ // files, which is the order the nested form produced.
1768
+ walk(n.dirs[name], depth + 1)
1769
+ })
1770
+ n.files.sort(function (a, b) { return a.name.localeCompare(b.name) }).forEach(function (entry) {
1771
+ var li = el('li')
1772
+ var b = el('button', 'tree-file' + (entry.file.noise ? ' is-noise' : ''))
1773
+ b.type = 'button'
1774
+ // The same .6rem base as a dir label, so a file sits level with the
1775
+ // directory it is in. It is on the name rather than the row because the
1776
+ // row must reach the scrollport edge — see `.tree-file` padding.
1777
+ b.appendChild(indent(el('span', 'nm', entry.name), depth, 0.6))
1778
+ var ct = el('span', 'ct')
1779
+ ct.appendChild(el('span', 'a', '+' + entry.file.additions))
1780
+ ct.appendChild(document.createTextNode(' '))
1781
+ ct.appendChild(el('span', 'd', '-' + entry.file.deletions))
1782
+ b.appendChild(ct)
1783
+ b.addEventListener('click', function () { reveal(entry.file) })
1784
+ // The path, not the name: two directories can hold a `README.md`, and
1785
+ // the tree deliberately shows the leaf only.
1786
+ rowByPath[entry.file.path] = b
1787
+ li.appendChild(b)
1788
+ ul.appendChild(li)
1789
+ })
1790
+ })(node, 0)
1791
+ return ul
1792
+ }
1793
+
1794
+ var tree = document.getElementById('tree')
1795
+ tree.appendChild(renderTree(buildTree(files)))
1796
+ document.getElementById('tree-summary').textContent =
1797
+ 'Files (' + files.length + ')'
1798
+ if (window.matchMedia && !window.matchMedia('(min-width: 60rem)').matches) {
1799
+ document.getElementById('tree-wrap').open = false
1800
+ }
1801
+
1802
+ // AFTER the tree, because a mark with no row to put it on is nothing at all.
1803
+ //
1804
+ // It runs on a narrow screen too, where the tree is a collapsed <details> and
1805
+ // nothing is visible: the class is still correct, so a reader who opens the
1806
+ // sidebar half-way down a diff finds it already pointing at where they are,
1807
+ // and `scrollIntoView` inside a closed <details> is a no-op rather than a
1808
+ // special case to write.
1809
+ if (currentWatch) {
1810
+ state.forEach(function (st) { if (st.details) currentWatch.observe(st.details) })
1811
+ }
1812
+
1813
+ // --- page-level controls -------------------------------------------------
1814
+ document.getElementById('expand-all').addEventListener('click', function () {
1815
+ state.forEach(function (st) {
1816
+ st.bands = []
1817
+ if (st.rendered) renderFile(st)
1818
+ })
1819
+ })
1820
+ document.getElementById('collapse-all').addEventListener('click', function () {
1821
+ state.forEach(function (st) {
1822
+ st.bands = initialBands(st.rows)
1823
+ if (st.rendered) renderFile(st)
1824
+ })
1825
+ })
1826
+
1827
+ // --- answering the written review ---------------------------------------
1828
+ // A `confirm` exists to ask the author a question, and until now the only way
1829
+ // to answer was to retype it in chat. The engine stamps each check with an id
1830
+ // and the file it is about; a reply becomes an ordinary comment carrying that
1831
+ // id, so it travels in the same blob as everything else.
1832
+ function wireChecks() {
1833
+ var checks = document.querySelectorAll('.check[data-check]')
1834
+ Array.prototype.forEach.call(checks, function (li) {
1835
+ var id = li.getAttribute('data-check')
1836
+ // A check with no file is about the review rather than a line of code.
1837
+ // The sentinel keeps the blob valid and lands it under "not in this diff",
1838
+ // which is exactly where a review-wide remark belongs.
1839
+ var file = li.getAttribute('data-file') || '(review)'
1840
+ var given = el('div', 'reply-given')
1841
+ var box = el('div', 'reply')
1842
+ function draw() {
1843
+ given.textContent = replies[id] ? '↳ ' + replies[id] : ''
1844
+ given.hidden = !replies[id]
1845
+ box.textContent = ''
1846
+ var add = el('button', null, replies[id] ? 'Change answer' : 'Answer')
1847
+ add.type = 'button'
1848
+ add.addEventListener('click', function () {
1849
+ box.textContent = ''
1850
+ box.appendChild(noteEditor(function (text) {
1851
+ replies[id] = text
1852
+ draw()
1853
+ refresh()
1854
+ }, draw))
1855
+ })
1856
+ box.appendChild(add)
1857
+ }
1858
+ draw()
1859
+ li.appendChild(given)
1860
+ li.appendChild(box)
1861
+ li.setAttribute('data-reply-file', file)
1862
+ })
1863
+ }
1864
+
1865
+ // --- the blob -------------------------------------------------------------
1866
+ /**
1867
+ * A DELTA, not a snapshot. The engine merges rather than replaces, so this
1868
+ * says only what changed: files newly ticked, accepts withdrawn, notes
1869
+ * written. A file already accepted with this exact content has nothing to
1870
+ * say, and a file that was never accepted has nothing to withdraw.
1871
+ *
1872
+ * Withdrawal is explicit for the same reason — under a merge, absence means
1873
+ * "not mentioned", never "remove it".
1874
+ */
1875
+ function buildBlob(verdict) {
1876
+ var accepted = []
1877
+ var unaccepted = []
1878
+ var comments = []
1879
+ state.forEach(function (st) {
1880
+ var stored = st.file.accepted
1881
+ if (st.accept) {
1882
+ // No hash means the engine could not read the file; accepting it would
1883
+ // record a vouch for content nobody can identify.
1884
+ if (stored !== true && st.file.hash) accepted.push({ path: st.file.path, hash: st.file.hash })
1885
+ } else if (stored === true || stored === 'lapsed') {
1886
+ unaccepted.push(st.file.path)
1887
+ }
1888
+ st.drafts.forEach(function (d) { comments.push(d) })
1889
+ })
1890
+ orphanDrafts.forEach(function (d) { comments.push(d) })
1891
+ Object.keys(replies).forEach(function (id) {
1892
+ var li = document.querySelector('.check[data-check="' + id + '"]')
1893
+ comments.push({
1894
+ id: data.generatedAt + '-reply-' + id,
1895
+ file: (li && li.getAttribute('data-reply-file')) || '(review)',
1896
+ line: null,
1897
+ lineText: null,
1898
+ check: id,
1899
+ note: replies[id],
1900
+ })
1901
+ })
1902
+ var blob = {
1903
+ version: 1,
1904
+ spec: data.spec,
1905
+ generatedAt: data.generatedAt,
1906
+ accepted: accepted,
1907
+ unaccepted: unaccepted,
1908
+ comments: comments,
1909
+ }
1910
+ // Absent unless a verdict was actually chosen. The engine reads an absent
1911
+ // one as `discuss`, and a blob that never claimed one should keep saying so.
1912
+ if (verdict) blob.verdict = verdict
1913
+ return blob
1914
+ }
1915
+
1916
+ var verdictBtns = {
1917
+ commit: document.getElementById('verdict-commit'),
1918
+ 'commit-continue': document.getElementById('verdict-commit-continue'),
1919
+ 'commit-start': document.getElementById('verdict-commit-start'),
1920
+ 'commit-land': document.getElementById('verdict-commit-land'),
1921
+ continue: document.getElementById('verdict-continue'),
1922
+ changes: document.getElementById('verdict-changes'),
1923
+ discuss: document.getElementById('verdict-discuss'),
1924
+ }
1925
+ // The committing pair, named once. A fifth verdict that commits is added
1926
+ // here, and the shared block below follows for free — the same reason the
1927
+ // engine keeps a `COMMITTING` list rather than a second condition.
1928
+ var COMMITTERS = ['commit', 'commit-continue', 'commit-start', 'commit-land']
1929
+
1930
+ /*
1931
+ * WHICH BUTTONS THIS PAGE OFFERS, and it comes from the render rather than
1932
+ * from the gate. Deriving it — mid-run iff nothing is armed — would take the
1933
+ * committing buttons away from every page in a project running
1934
+ * `review.required: false`, which never arms and whose readers must still be
1935
+ * able to commit from the page. The engine's `BUTTON_SETS` is the same
1936
+ * decision on the other side of the wire.
1937
+ *
1938
+ * ABSENT IS THE COMMITTING SET. A page rendered before any of this existed,
1939
+ * and a caller that did not ask, both land here — so nothing about their page
1940
+ * changes.
1941
+ */
1942
+ var MIDRUN = data.buttons === 'midrun'
1943
+ // ONE TABLE, keyed by the set the engine named. A chain of ternaries is how a
1944
+ // third set becomes a fourth condition somebody forgets to add a button to.
1945
+ var OFFERS = {
1946
+ midrun: ['continue', 'changes', 'discuss'],
1947
+ authoring: ['commit-start', 'commit', 'changes', 'discuss'],
1948
+ refresh: ['commit', 'changes', 'discuss'],
1949
+ nospec: ['commit-land', 'commit', 'changes', 'discuss'],
1950
+ committing: ['commit', 'commit-continue', 'changes', 'discuss'],
1951
+ // A SINGLE-PASS FIX: the committing set minus the verb it cannot honour.
1952
+ // `/spec-bug` and `/spec-hotfix` render this when the repair took one pass,
1953
+ // because `Commit & Continue` names a next phase a spec with no phase files
1954
+ // does not have. Same three buttons as `refresh`, a different subject.
1955
+ fix: ['commit', 'changes', 'discuss'],
1956
+ }
1957
+ var OFFERED = OFFERS[data.buttons] || OFFERS.committing
1958
+ Object.keys(verdictBtns).forEach(function (key) {
1959
+ verdictBtns[key].hidden = OFFERED.indexOf(key) === -1
1960
+ })
1961
+
1962
+ // EVERY VERDICT, because this is the one place a verdict gets a reader-facing
1963
+ // name: `refresh` relabels only the committing pair, but the decided panel
1964
+ // has to name whichever verdict was actually sent — including one this page
1965
+ // never offered, since a decision restored from storage outlives the render
1966
+ // that made it.
1967
+ var LABELS = {
1968
+ commit: '✓ Commit',
1969
+ 'commit-continue': '✓ Commit & Continue',
1970
+ 'commit-start': '✓ Commit & Start',
1971
+ 'commit-land': '✓ Commit & Land',
1972
+ continue: '→ Continue',
1973
+ changes: '↺ Request changes',
1974
+ discuss: '… Discuss first',
1975
+ }
1976
+ var TITLES = {
1977
+ commit: 'Commit what you just read, and stop there',
1978
+ // Says what it will NOT do: a reader must not press this expecting the
1979
+ // spec to be finished and landed.
1980
+ 'commit-continue': 'Commit what you just read, then build the next phase — nothing is landed',
1981
+ // Says what it WILL do, because this is the one verdict that provisions:
1982
+ // a worktree appears and phase 1 starts.
1983
+ 'commit-start': 'Commit this spec, then put it in flight and build phase 1',
1984
+ // Says what it WILL do, and every destructive part of it: this is the only
1985
+ // verdict that removes a worktree, so the reader must not learn that from
1986
+ // the outcome afterwards.
1987
+ 'commit-land': 'Commit this, fast-forward the base branch to it, and remove the worktree',
1988
+ // Says what it will NOT do, for the same reason: nothing is committed by
1989
+ // this, so the work it resumes is still uncommitted afterwards.
1990
+ continue: 'You have read it — carry on with the run; nothing is committed',
1991
+ }
1992
+
1993
+ var verdictEl = document.getElementById('verdict')
1994
+ var verdictCount = document.getElementById('verdict-count')
1995
+ var verdictLog = document.getElementById('verdict-log')
1996
+ var verdictWarn = document.getElementById('verdict-warn')
1997
+ var copyOut = document.getElementById('copy-out')
1998
+ var wrapEl = document.getElementById('wrap')
1999
+ var decidedBox = document.getElementById('decided')
2000
+ var decidedWhat = document.getElementById('decided-what')
2001
+ var decidedNote = document.getElementById('decided-note')
2002
+ var decidedToggle = document.getElementById('decided-toggle')
2003
+ var copyHint = document.getElementById('copy-hint')
2004
+ var sentCmd = document.getElementById('sent-cmd')
2005
+ var sentCmdText = document.getElementById('sent-cmd-text')
2006
+ var sentCmdCopy = document.getElementById('sent-cmd-copy')
2007
+ var sentCmdLead = document.getElementById('sent-cmd-lead')
2008
+ var failBox = document.getElementById('send-failed')
2009
+ var failWhat = document.getElementById('send-failed-what')
2010
+ var failNote = document.getElementById('send-failed-note')
2011
+ var failCmd = document.getElementById('send-failed-cmd')
2012
+ var failLead = document.getElementById('send-failed-lead')
2013
+ var failText = document.getElementById('send-failed-text')
2014
+ var failCopy = document.getElementById('send-failed-copy')
2015
+ var pickupWait = document.getElementById('pickup-wait')
2016
+
2017
+ // IS A POLL RUNNING RIGHT NOW? Deliberately not persisted, and that is the
2018
+ // whole of what it buys: a stored decision reloaded tomorrow carries the same
2019
+ // `picked: null` — nothing was ever established — but nothing is asking any
2020
+ // more, so it must not spin. Session state answers a question about the
2021
+ // session.
2022
+ var asking = false
2023
+
2024
+ // Has the close already been scheduled? `drawDecided` runs on every redraw
2025
+ // once a page is decided, so without this a redraw would queue another timer.
2026
+ var closing = false
2027
+
2028
+ // Long enough for the confirmation to register, short enough not to read as
2029
+ // a hang. It only matters where the close is actually permitted.
2030
+ var CLOSE_AFTER_MS = 900
2031
+
2032
+ /**
2033
+ * This tab's job is over — best-effort, and only from the one state that
2034
+ * earns it.
2035
+ *
2036
+ * ONLY ON CONFIRMED PICKUP. Reached from the branch where the engine has
2037
+ * SAID a session claimed the pass, which is the only ending with provably
2038
+ * nothing left on the page to read: no code to quote, no command to run, no
2039
+ * blob to paste. Every other one — still asking, established-nobody-did, a
2040
+ * poll that failed, a `file://` page whose pass is on the clipboard, a
2041
+ * published page whose verdict needs `/spec-reviewed` — keeps the tab,
2042
+ * because each leaves something the reader still needs.
2043
+ *
2044
+ * BEST-EFFORT BY CONSTRUCTION, not by caution. Browsers refuse
2045
+ * `window.close()` on a tab the script did not open, and that is every tab
2046
+ * here: somebody typed the URL or tapped a link. So the confirmation is
2047
+ * drawn FIRST and the close attempted after it — a refused close leaves
2048
+ * exactly the right ending standing, and a permitted one still reads as
2049
+ * acknowledgement rather than a tab vanishing under the press.
2050
+ */
2051
+ function closeWhenDone() {
2052
+ if (closing) return
2053
+ closing = true
2054
+ if (typeof window.close !== 'function' || !window.setTimeout) return
2055
+ window.setTimeout(function () {
2056
+ // A refusal can throw as well as do nothing, depending on the browser.
2057
+ // Neither is a failure of the review, so neither reaches the reader.
2058
+ try { window.close() } catch (e) {}
2059
+ }, CLOSE_AFTER_MS)
2060
+ }
2061
+
2062
+ function pending() {
2063
+ var b = buildBlob()
2064
+ return b.accepted.length + b.unaccepted.length + b.comments.length
2065
+ }
2066
+
2067
+ /**
2068
+ * How many notes would still be open once this pass lands — the exact number
2069
+ * the engine's `judgeVerdict` will count, which is what makes the disabled
2070
+ * button a preview of the refusal rather than a second opinion about it.
2071
+ *
2072
+ * Stored comments the agent has not answered, PLUS every comment this pass is
2073
+ * about to send (a note you just wrote is open by definition).
2074
+ *
2075
+ * UNACCEPTED FILES ARE NOT COUNTED AND MUST NEVER BE. A comment is a request
2076
+ * you made; an unticked file is merely something you said nothing about, and
2077
+ * counting those would be the tally this whole feature is designed not to be.
2078
+ */
2079
+ function openNotes() {
2080
+ var stored = (data.notes && data.notes.totals && data.notes.totals.unresolved) || 0
2081
+ return stored + buildBlob().comments.length
2082
+ }
2083
+
2084
+ // Redraw the bar and autosave. Called after every mark, so the block lifts
2085
+ // the moment the last note goes and a phone tab switch mid-review costs
2086
+ // nothing.
2087
+ function refresh() {
2088
+ // A decided page is finished. `refresh` runs on every mark and re-enables
2089
+ // the two committing buttons unconditionally, so without this it would undo
2090
+ // the ending every time anything redrew.
2091
+ if (decided) return drawDecided()
2092
+ // Which shape this page offers is re-answered here rather than at load: a
2093
+ // reader who ticks an accept half-way through has changed what their pass
2094
+ // contains, and a command line cannot carry the new contents.
2095
+ drawCmdList()
2096
+ var n = pending()
2097
+ var open = openNotes()
2098
+ verdictCount.textContent = n
2099
+ ? n + (n === 1 ? ' mark to send' : ' marks to send')
2100
+ // Names the action this page actually offers. A mid-run page saying "a
2101
+ // clean read still commits" would promise the one thing it cannot do.
2102
+ : MIDRUN ? 'Nothing marked — a clean read still carries on' : 'Nothing marked — a clean read still commits'
2103
+ // ONE COUNT, BOTH BUTTONS. Two committing controls blocked by two separate
2104
+ // reads is how a page ends up with one disabled and the other not — which
2105
+ // would be a way around the single refusal this page makes.
2106
+ var blocked = open > 0
2107
+ var why = open + (open === 1 ? ' open note' : ' open notes')
2108
+ // Is there a phase left for `/spec-next` to build? The engine answers, and
2109
+ // its ABSENCE is a third state, not a no: a spec whose phases it could not
2110
+ // read — a legacy layout, an inline-phase overview — leaves the button
2111
+ // exactly as it was. Removing an offer that might work is worse than
2112
+ // leaving one that might not.
2113
+ var noPhaseLeft = Boolean(data.phases) && data.phases.hasNextPhase === false
2114
+ COMMITTERS.forEach(function (key) {
2115
+ var btn = verdictBtns[key]
2116
+ // AN OPEN NOTE WINS. Two reasons can be true at once and the label has
2117
+ // room for one — the open note is the one the reader can act on, so a
2118
+ // "no phase left" in its place would send them to fix the wrong thing.
2119
+ var stop = blocked ? why : key === 'commit-continue' && noPhaseLeft ? 'no phase left' : null
2120
+ // The reason goes ON the control. A button that is merely dimmed reads as
2121
+ // a broken page, and a title attribute is unreachable on a touch screen.
2122
+ btn.disabled = Boolean(stop)
2123
+ btn.textContent = stop ? LABELS[key] + ' — ' + stop : LABELS[key]
2124
+ btn.title = blocked
2125
+ ? 'Resolve or remove your notes first — you asked for something, so it cannot also be fine'
2126
+ : key === 'commit-continue' && noPhaseLeft
2127
+ ? 'Every phase of this spec is done — there is nothing left to build'
2128
+ : TITLES[key]
2129
+ })
2130
+ // Said rather than left unset: the committing pair is the ONLY thing this
2131
+ // page ever blocks, and the others are always available on purpose — a note
2132
+ // you want acted on, and a question, are never the wrong thing to send.
2133
+ //
2134
+ // CONTINUE IS NOT BLOCKED, and that mirrors the engine rather than softening
2135
+ // it: `judgeVerdict` refuses a COMMITTING verdict against an open note, and
2136
+ // `continue` is deliberately not one. A disabled button here would be a
2137
+ // second opinion about a refusal the engine does not make, which is the one
2138
+ // thing this bar must never be.
2139
+ verdictBtns.continue.disabled = false
2140
+ verdictBtns.changes.disabled = false
2141
+ verdictBtns.discuss.disabled = false
2142
+ // Adding the note is the ordinary way out of a warning, and every mark comes
2143
+ // through here — so the line goes when the situation it described does,
2144
+ // rather than standing over a page where nothing is unsent any more.
2145
+ clearWarn()
2146
+ save()
2147
+ }
2148
+
2149
+ /**
2150
+ * End the review, and say how it ended.
2151
+ *
2152
+ * A verdict that has been handed over is the END of this page's job, and the
2153
+ * page used to carry on as if it were not: the diff stayed, the four buttons
2154
+ * stayed live, and a line of small grey text under them said "Sent." A reader
2155
+ * coming back to that tab has no way to tell a sent review from an unsent
2156
+ * one, and pressing a button again reaches an agent that stopped listening.
2157
+ *
2158
+ * So the diff goes, what was decided takes its place, and the controls die
2159
+ * with a reason on them.
2160
+ */
2161
+ function drawDecided() {
2162
+ if (!decided) return
2163
+ var said = SAID[decided.verdict] || decided.verdict
2164
+ var when = String(decided.at || '').slice(0, 10)
2165
+ decidedBox.hidden = false
2166
+ decidedWhat.textContent = 'You chose: ' + (LABELS[decided.verdict] || decided.verdict)
2167
+ decidedNote.textContent =
2168
+ 'Sent to Claude' + (when ? ' on ' + when : '') + ' — this review is ' + said + '. ' +
2169
+ 'Nothing here reaches Claude any more, so the verdict buttons are closed.'
2170
+ wrapEl.className = wrapEl.className.replace(/\s*\bis-decided\b/g, '') + ' is-decided'
2171
+ // The diff is hidden from here on, so there is no card to be reading and
2172
+ // nothing for the tree to point at. `drawDecided` runs on every redraw once
2173
+ // a page is decided, and clearing something already cleared is free.
2174
+ clearCurrent()
2175
+ // The commands go with the bar. A decided page offers no way to decide
2176
+ // again, whichever shape it was offering.
2177
+ if (cmdList) cmdList.hidden = true
2178
+ // EVERY button, not only the committing pair: `refresh` re-enables those two
2179
+ // by design, and a decided page must beat it.
2180
+ Object.keys(verdictBtns).forEach(function (key) {
2181
+ var btn = verdictBtns[key]
2182
+ btn.disabled = true
2183
+ btn.title = 'Already sent — ' + said
2184
+ })
2185
+ verdictCount.textContent = 'Sent — ' + said
2186
+ // ONE STATEMENT, NOT FOUR. The panel, the bar, the history line and the
2187
+ // command box all said the same thing at once. The log line is the panel's
2188
+ // sentence again in smaller type, so it goes.
2189
+ verdictLog.hidden = true
2190
+
2191
+ // WHAT THE PAGE ESTABLISHED, replayed — never re-derived. `picked` is the
2192
+ // answer the poll got, and a re-opened tab has no way to ask again for a
2193
+ // pass that is long gone, so it says what it said before rather than
2194
+ // quietly changing its mind a day later.
2195
+ copyHint.hidden = true
2196
+ copyOut.hidden = true
2197
+ pickupWait.hidden = true
2198
+ // STILL ASKING — and saying so, which is the state this panel used not to
2199
+ // have. `watchPass` takes up to seven seconds to establish whether a
2200
+ // session claimed the pass, and the page used to spend that window
2201
+ // asserting the pessimistic answer: "still waiting to be picked up", with
2202
+ // a command under it, retracted a moment later on the usual outcome. The
2203
+ // reader was being told to run something they almost never needed, in the
2204
+ // same words used when running it is the only thing that saves their
2205
+ // review.
2206
+ //
2207
+ // `asking` and not merely `picked === null`. A tab reopened over a stored
2208
+ // decision has the same `null` — nothing was ever established — but no
2209
+ // poll is running to resolve it, so it falls through to the command below.
2210
+ // A spinner that can never stop is worse than no spinner.
2211
+ // (`.claude/rules/negative-checks.md` rule 4: the cannot-tell case takes
2212
+ // the harmless branch, which here is claiming neither answer.)
2213
+ if (decided.picked === null && asking) {
2214
+ sentCmd.hidden = true
2215
+ pickupWait.hidden = false
2216
+ return
2217
+ }
2218
+ if (decided.picked === true) {
2219
+ // NOTHING TO RUN, and so nothing offered. This is the whole point of
2220
+ // asking: the command used to appear here on every single ending, which
2221
+ // taught the reader it was decoration.
2222
+ sentCmd.hidden = true
2223
+ decidedNote.textContent += ' Claude picked this up — there is nothing to run.'
2224
+ closeWhenDone()
2225
+ return
2226
+ }
2227
+ // THE HAND-OFF SURVIVES THE ENDING — but only where there is something
2228
+ // worth carrying. A code is six digits to transcribe, and the box and its
2229
+ // Copy button exist for exactly that. A bare `/spec-reviewed` is a word the
2230
+ // reader is about to type into the terminal they are already sitting in;
2231
+ // dressing it as a copyable artefact makes the ending look unfinished.
2232
+ if (decided.cmd && /\s/.test(decided.cmd)) {
2233
+ decidedNote.textContent += ' It is still waiting to be picked up.'
2234
+ showCommand(decided.cmd)
2235
+ return
2236
+ }
2237
+ sentCmd.hidden = true
2238
+ if (decided.cmd) {
2239
+ decidedNote.textContent +=
2240
+ ' If Claude was not already waiting, run ' + decided.cmd + ' where it is.'
2241
+ }
2242
+ }
2243
+
2244
+ /**
2245
+ * Record a verdict that was actually delivered, and end the page on it.
2246
+ *
2247
+ * `picked` is three-valued and the third value is the useful one: `true` the
2248
+ * engine confirmed somebody claimed this pass, `false` it confirmed nobody
2249
+ * has, and `null` NOTHING COULD BE ESTABLISHED — an older daemon with no
2250
+ * lookup, a poll that never came back, a page that stopped asking. Only
2251
+ * `true` takes the command away, so every way of not knowing leaves it on
2252
+ * screen (`.claude/rules/negative-checks.md` rule 4).
2253
+ */
2254
+ function markDecided(verdict, cmd, picked) {
2255
+ decided = {
2256
+ verdict: verdict,
2257
+ at: new Date().toISOString(),
2258
+ cmd: cmd || null,
2259
+ picked: picked === undefined ? null : picked,
2260
+ }
2261
+ save()
2262
+ drawDecided()
2263
+ }
2264
+
2265
+ // A WAY BACK IN, read-only. "It is finished" and "I cannot see what I
2266
+ // approved" are different things, and only the first one is true.
2267
+ decidedToggle.addEventListener('click', function () {
2268
+ var showing = / \bshow-diff\b/.test(' ' + wrapEl.className)
2269
+ wrapEl.className = wrapEl.className.replace(/\s*\bshow-diff\b/g, '') + (showing ? '' : ' show-diff')
2270
+ decidedToggle.textContent = showing ? 'Show the diff anyway' : 'Hide the diff'
2271
+ })
2272
+
2273
+ // How a logged verdict reads. EVERY VERDICT NAMED, and an unknown one says
2274
+ // so: the fallback was `discussed`, which quietly relabelled `commit` — and a
2275
+ // page that tells you a commit was a discussion is worse than one that admits
2276
+ // it does not know the word.
2277
+ var SAID = {
2278
+ approve: 'committed',
2279
+ commit: 'committed',
2280
+ 'commit-continue': 'committed, then carried on',
2281
+ 'commit-start': 'committed, then put in flight',
2282
+ 'commit-land': 'committed, landed, and torn down',
2283
+ continue: 'read it and carried on',
2284
+ changes: 'changes requested',
2285
+ discuss: 'discussed',
2286
+ skip: 'moved on without a verdict',
2287
+ }
2288
+
2289
+ // The last thing that happened here, as history. It is why the page can look
2290
+ // untouched after a commit: the verdict was consumed, not stored. A SKIP is
2291
+ // history of exactly the same kind — it is the recorded decision to move on,
2292
+ // and hiding it would make a skipped review indistinguishable from one that
2293
+ // never ended.
2294
+ function drawLog() {
2295
+ var last = data.notes && data.notes.lastDecision
2296
+ var skip = data.gate && data.gate.lastSkip
2297
+ // Whichever is more recent. Both are timestamped, and comparing is what
2298
+ // stops an old verdict shadowing a skip taken after it.
2299
+ if (skip && (!last || String(skip.at || '') > String(last.at || ''))) {
2300
+ last = { verdict: 'skip', at: skip.at, note: skip.reason }
2301
+ }
2302
+ if (!last) return
2303
+ var said = SAID[last.verdict] || ('recorded as "' + last.verdict + '"')
2304
+ var when = String(last.at || '').slice(0, 10)
2305
+ verdictLog.hidden = false
2306
+ verdictLog.textContent = said + ' earlier' + (when ? ' · ' + when : '') + (last.note ? ' · ' + last.note : '')
2307
+ }
2308
+
2309
+ /**
2310
+ * Hand over the command itself, where the reader's eye already is.
2311
+ *
2312
+ * Two ways out and the page picks by CAPABILITY, not by trying and failing:
2313
+ * `navigator.clipboard` is secure-context-only, and a page served at
2314
+ * `http://<lan-ip>:7777` is not a secure context — which is the ordinary case
2315
+ * here, not an edge one. A Copy button that does nothing when tapped is worse
2316
+ * than text that was always text, so the button only exists where it can work.
2317
+ */
2318
+ /**
2319
+ * Put the whole command in the reader's selection. Returns whether it worked.
2320
+ *
2321
+ * REPORTED, NOT ASSUMED. It replaces `input.select()`, which the box no longer
2322
+ * has anything to call — and the honest thing to do when a browser withholds
2323
+ * `getSelection` is to stop claiming the text is selected, rather than say so
2324
+ * and leave someone hunting for a highlight that is not there.
2325
+ */
2326
+ function selectCommand() {
2327
+ return selectNode(sentCmdText)
2328
+ }
2329
+
2330
+ /**
2331
+ * Put a node's whole text in the reader's selection. Returns whether it took.
2332
+ */
2333
+ function selectNode(node) {
2334
+ try {
2335
+ var range = document.createRange()
2336
+ range.selectNodeContents(node)
2337
+ var sel = window.getSelection && window.getSelection()
2338
+ if (!sel) return false
2339
+ sel.removeAllRanges()
2340
+ sel.addRange(range)
2341
+ return true
2342
+ } catch (e) {
2343
+ return false
2344
+ }
2345
+ }
2346
+
2347
+ /**
2348
+ * Can this browser copy AT ALL — by either route?
2349
+ *
2350
+ * WHAT THIS GOT WRONG, and why the button kept vanishing where it was needed
2351
+ * most: `navigator.clipboard` is **secure-context only**, so it is absent on
2352
+ * every `http://<lan-ip>:7777` page — which is exactly the page someone reads
2353
+ * on a phone. Asking only about that API therefore hid the Copy button in the
2354
+ * one case the button exists for, and left a phone reader long-pressing to
2355
+ * select text on a page that could have copied for them all along.
2356
+ *
2357
+ * `document.execCommand('copy')` is deprecated and universally implemented,
2358
+ * and it works in a non-secure context inside a user gesture. So there are
2359
+ * two routes, and the rule below is unchanged: offer the button only where
2360
+ * one of them exists.
2361
+ */
2362
+ function canCopy() {
2363
+ return Boolean(
2364
+ (navigator.clipboard && navigator.clipboard.writeText) ||
2365
+ (document.execCommand && window.getSelection),
2366
+ )
2367
+ }
2368
+
2369
+ /**
2370
+ * Copy `text`, by whichever route this browser has. Resolves on success and
2371
+ * rejects otherwise, so every caller reports the same three outcomes.
2372
+ *
2373
+ * `node` is what gets left selected when copying fails — the reader can then
2374
+ * do it by hand, which is the behaviour this button replaced rather than
2375
+ * removed.
2376
+ */
2377
+ function copyText(text, node) {
2378
+ if (navigator.clipboard && navigator.clipboard.writeText) {
2379
+ return navigator.clipboard.writeText(text)
2380
+ }
2381
+ return new Promise(function (resolve, reject) {
2382
+ // The selection IS the mechanism here, not a consolation: execCommand
2383
+ // copies what is selected. So it is made first and deliberately left in
2384
+ // place — a failed copy then lands the reader exactly where the old
2385
+ // no-clipboard path did.
2386
+ if (!selectNode(node)) return reject(new Error('no selection'))
2387
+ var ok = false
2388
+ try {
2389
+ ok = document.execCommand('copy')
2390
+ } catch (e) {
2391
+ ok = false
2392
+ }
2393
+ return ok ? resolve() : reject(new Error('execCommand refused'))
2394
+ })
2395
+ }
2396
+
2397
+ function showCommand(cmd) {
2398
+ sentCmd.hidden = false
2399
+ sentCmdText.textContent = cmd
2400
+ var copyable = canCopy()
2401
+ sentCmdCopy.hidden = !copyable
2402
+ sentCmdCopy.textContent = 'Copy'
2403
+ // SET HERE, NOT LEFT TO THE MARKUP. The lead says two different things and
2404
+ // the box is shown more than once in a page's life, so a line that was only
2405
+ // ever correct on first render would go stale the moment either changed.
2406
+ if (copyable) {
2407
+ sentCmdLead.textContent = 'Run this where Claude is:'
2408
+ // NOT SELECTED when there is a button. Pre-selecting made the line look
2409
+ // like a focused input, which is what it was mistaken for — and it is the
2410
+ // long-press-to-select gesture the button is here to spare a phone reader.
2411
+ return
2412
+ }
2413
+ // Selected where that is possible, and SAID to be selected: a reader who
2414
+ // sees nothing happen has no way to tell a page that did nothing from one
2415
+ // that already did the work. The lead carries it now that the box lives in
2416
+ // the decided panel — `copy-hint` is down in the footer with the verdict
2417
+ // bar, and appending to it put the sentence half a page from the thing it
2418
+ // describes.
2419
+ sentCmdLead.textContent = selectCommand()
2420
+ ? 'Run this where Claude is (selected — copy it):'
2421
+ : 'Run this where Claude is — copy it:'
2422
+ }
2423
+
2424
+ if (sentCmdCopy) {
2425
+ sentCmdCopy.addEventListener('click', function () {
2426
+ copyText(sentCmdText.textContent, sentCmdText).then(function () {
2427
+ sentCmdCopy.textContent = 'Copied'
2428
+ }, function () {
2429
+ // The capability was there and the write still failed. Fall back to the
2430
+ // same selection the no-clipboard path uses rather than saying nothing —
2431
+ // and if even that is unavailable, say THAT, because a button reading
2432
+ // "selected" over nothing highlighted is worse than an honest failure.
2433
+ sentCmdCopy.textContent = selectCommand() ? 'Copy failed — selected' : 'Copy failed'
2434
+ })
2435
+ })
2436
+ }
2437
+
2438
+ /**
2439
+ * The header: why this change exists, what it touches, what this phase set
2440
+ * out to do. Every part is optional and each is drawn only if present — a
2441
+ * spec with a Problem and no Impact table is ordinary, not broken.
2442
+ *
2443
+ * BUILT WITH createElement, never innerHTML. The text is markdown from a spec
2444
+ * file, which means it can contain anything someone typed — and this page is
2445
+ * served over the network, so treating it as markup would be the one place a
2446
+ * spec's prose could reach the DOM.
2447
+ */
2448
+ function drawContext() {
2449
+ var ctx = data.context
2450
+ if (!ctx) return
2451
+ var why = document.getElementById('context-why')
2452
+ var rest = document.getElementById('context-rest')
2453
+ var more = document.getElementById('context-more')
2454
+ var drewRest = false
2455
+
2456
+ function head(into, label) {
2457
+ var h = document.createElement('h3')
2458
+ h.textContent = label
2459
+ into.appendChild(h)
2460
+ }
2461
+
2462
+ /**
2463
+ * THE PHASE COMES FIRST, and the spec's Problem is folded away behind it.
2464
+ *
2465
+ * The open part of this header used to be `Why` — the overview's Problem,
2466
+ * which is about the whole spec. A reader opening a page for phase 3 got
2467
+ * the same paragraph they had already read at phase 1, and had to expand
2468
+ * `More` to reach the only thing that told them what THIS review covers.
2469
+ * The question a review page answers is "what am I looking at", so the
2470
+ * phase answers it, and the background it sits in is one tap away.
2471
+ *
2472
+ * The TASK LIST is open too, deliberately. It is the cheapest statement of
2473
+ * what is and is not in this diff — which matters most half-way through a
2474
+ * phase, where a `/spec-diff` shows three ticked boxes and four empty ones
2475
+ * and the empty ones are the reason the diff looks unfinished.
2476
+ */
2477
+ if (ctx.phase) {
2478
+ head(why, 'Phase ' + ctx.phase.n + (ctx.phase.title ? ' — ' + ctx.phase.title : ''))
2479
+ if (ctx.phase.goal) {
2480
+ var g = document.createElement('p')
2481
+ g.textContent = ctx.phase.goal
2482
+ why.appendChild(g)
2483
+ }
2484
+ if (ctx.phase.tasks && ctx.phase.tasks.length) {
2485
+ var ul = document.createElement('ul')
2486
+ ul.className = 'tasks'
2487
+ ctx.phase.tasks.forEach(function (task) {
2488
+ var li = document.createElement('li')
2489
+ // Done is dimmed rather than struck through: every task is ticked by
2490
+ // the time a phase renders, and a whole list struck through reads as
2491
+ // cancelled work.
2492
+ li.className = task.done ? 'done' : ''
2493
+ li.textContent = (task.done ? '✅ ' : '⬜ ') + task.text
2494
+ ul.appendChild(li)
2495
+ })
2496
+ why.appendChild(ul)
2497
+ }
2498
+ }
2499
+
2500
+ if (ctx.problem) {
2501
+ // FOLDED, ALL OF IT. With the phase open above, the spec's Problem is
2502
+ // background rather than the answer — and on a phone every line of it is
2503
+ // a line of the file list pushed off-screen, which is the thing this
2504
+ // header was built to avoid. Where there is NO phase (a branch-wide diff,
2505
+ // a spec with no live phase) the first paragraph stays open, because then
2506
+ // it is the only thing the header can say.
2507
+ var paras = String(ctx.problem).split(/\n\s*\n/)
2508
+ var from = 0
2509
+ if (!ctx.phase) {
2510
+ head(why, 'Why')
2511
+ var lead = document.createElement('p')
2512
+ lead.textContent = paras[0].replace(/\s+/g, ' ').trim()
2513
+ why.appendChild(lead)
2514
+ from = 1
2515
+ } else if (paras.length) {
2516
+ head(rest, 'Why')
2517
+ drewRest = true
2518
+ }
2519
+ for (var i = from; i < paras.length; i++) {
2520
+ var p = document.createElement('p')
2521
+ p.textContent = paras[i].replace(/\s+/g, ' ').trim()
2522
+ rest.appendChild(p)
2523
+ drewRest = true
2524
+ }
2525
+ }
2526
+
2527
+ if (ctx.impact) {
2528
+ head(rest, 'Touches')
2529
+ drewRest = true
2530
+ if (ctx.impact.rows) {
2531
+ var table = document.createElement('table')
2532
+ var hr = document.createElement('tr')
2533
+ ;['Surface', 'Change', 'Detail'].forEach(function (label) {
2534
+ var th = document.createElement('th')
2535
+ th.textContent = label
2536
+ hr.appendChild(th)
2537
+ })
2538
+ table.appendChild(hr)
2539
+ ctx.impact.rows.forEach(function (row) {
2540
+ var tr = document.createElement('tr')
2541
+ ;[row.surface, row.change, row.detail].forEach(function (cell) {
2542
+ var td = document.createElement('td')
2543
+ td.textContent = cell
2544
+ tr.appendChild(td)
2545
+ })
2546
+ table.appendChild(tr)
2547
+ })
2548
+ rest.appendChild(table)
2549
+ } else {
2550
+ var prose = document.createElement('p')
2551
+ prose.textContent = ctx.impact.prose
2552
+ rest.appendChild(prose)
2553
+ }
2554
+ }
2555
+
2556
+ document.getElementById('context').hidden = false
2557
+ more.hidden = !drewRest
2558
+ }
2559
+
2560
+ function showFallback(json, why) {
2561
+ copyOut.hidden = false
2562
+ copyOut.value = json
2563
+ copyOut.focus()
2564
+ copyOut.select()
2565
+ copyHint.hidden = false
2566
+ copyHint.textContent = why
2567
+ }
2568
+
2569
+ /**
2570
+ * Put the whole recovery command in the failure callout, with its Copy control.
2571
+ *
2572
+ * THE SAME CONTROL THE `file://` PAGE ALREADY USES — `/spec-reviewed <verdict>`
2573
+ * — so there is one command row rather than a second kind. It reaches the
2574
+ * agent regardless of any server, which is the only reason a recovery can be
2575
+ * offered from a failed send at all.
2576
+ *
2577
+ * `canCopy` decides whether there is a button, exactly as `showCommand` does:
2578
+ * a control that does nothing on tap reads as a broken page, and one that is
2579
+ * missing where it would have worked reads as a page that never offered it.
2580
+ */
2581
+ function showFailCommand(cmd) {
2582
+ failCmd.hidden = false
2583
+ failText.textContent = cmd
2584
+ var copyable = canCopy()
2585
+ failCopy.hidden = !copyable
2586
+ failCopy.textContent = 'Copy'
2587
+ failLead.textContent = copyable
2588
+ ? 'Your verdict is not lost — run this where Claude is:'
2589
+ : selectNode(failText)
2590
+ ? 'Your verdict is not lost — run this where Claude is (selected — copy it):'
2591
+ : 'Your verdict is not lost — run this where Claude is — copy it:'
2592
+ }
2593
+
2594
+ if (failCopy) {
2595
+ failCopy.addEventListener('click', function () {
2596
+ copyText(failText.textContent, failText).then(function () {
2597
+ failCopy.textContent = 'Copied'
2598
+ }, function () {
2599
+ failCopy.textContent = selectNode(failText) ? 'Copy failed — selected' : 'Copy failed'
2600
+ })
2601
+ })
2602
+ }
2603
+
2604
+ /** Take the failure panel down — a fresh attempt, or one that landed. */
2605
+ function clearSendFailure() {
2606
+ failBox.hidden = true
2607
+ failCmd.hidden = true
2608
+ failWhat.textContent = ''
2609
+ failNote.textContent = ''
2610
+ }
2611
+
2612
+ /**
2613
+ * Say that a send did not land, as loudly as a send that did.
2614
+ *
2615
+ * THE ONE OUTCOME THE READER MUST ACT ON used to be reported more quietly than
2616
+ * the one they need do nothing about: a small grey line in the footer, under a
2617
+ * verdict bar that had just closed. It gets the panel at the top of the column
2618
+ * now, in the delete palette.
2619
+ *
2620
+ * IT DOES NOT DECIDE THE PAGE. Nothing was delivered, so the diff stays and
2621
+ * the verdict buttons stay live — a page that closed its controls over a
2622
+ * failed send would strand the reader with no way to try again.
2623
+ *
2624
+ * A pass carrying MARKS still goes to the clipboard, because a command line
2625
+ * cannot carry notes and a failed send must not become the moment they are
2626
+ * silently dropped.
2627
+ */
2628
+ function sendFailed(json, verdict, what, note) {
2629
+ copyHint.hidden = true
2630
+ copyHint.textContent = ''
2631
+ failBox.hidden = false
2632
+ failWhat.textContent = what
2633
+ if (pending() > 0) {
2634
+ failCmd.hidden = true
2635
+ failNote.textContent =
2636
+ note +
2637
+ ' You have marked things up, and a command line cannot carry notes — the whole pass is ' +
2638
+ 'below. Copy it and paste it to Claude.'
2639
+ copyOut.hidden = false
2640
+ copyOut.value = json
2641
+ copyOut.focus()
2642
+ copyOut.select()
2643
+ } else {
2644
+ failNote.textContent = note
2645
+ showFailCommand('/spec-reviewed ' + verdict)
2646
+ }
2647
+ // WHERE THE READER IS LOOKING. The verdict bar is at the foot of the page
2648
+ // and this panel is at the head of it, so a failure announced without this
2649
+ // is a failure announced off-screen — which is the shape of the bug being
2650
+ // fixed, one scroll position removed.
2651
+ if (failBox.scrollIntoView) failBox.scrollIntoView()
2652
+ }
2653
+
2654
+ // One action per decision: the verdict button IS the copy button, so the blob
2655
+ // can never carry a verdict other than the one just pressed.
2656
+ /**
2657
+ * Send the pass back over the wire, to the page's own URL.
2658
+ *
2659
+ * The blob never passes through the model this way: the engine holds it and
2660
+ * you read six digits out, where a paste costs context in proportion to how
2661
+ * much you wrote.
2662
+ */
2663
+ function post(json, verdict) {
2664
+ clearSendFailure()
2665
+ copyHint.hidden = false
2666
+ copyHint.textContent = 'Sending…'
2667
+ fetch(location.pathname, {
2668
+ method: 'POST',
2669
+ headers: { 'content-type': 'application/json' },
2670
+ body: json,
2671
+ }).then(function (res) {
2672
+ return res.text().then(function (text) {
2673
+ if (!res.ok) {
2674
+ // A pass the server REFUSED must never read as sent — and WHICH
2675
+ // refusal decides where the reader goes next, so the two are named
2676
+ // separately rather than collapsed into one "not sent".
2677
+ if (res.status === 404) {
2678
+ // THE PORT MOVED, or another repo's daemon holds this one. The token
2679
+ // in this URL means nothing there, so re-opening the page from the
2680
+ // session that produced it is the fix — the link is stale, the pass
2681
+ // is fine.
2682
+ sendFailed(
2683
+ json,
2684
+ verdict,
2685
+ 'Not sent — this page\'s server is gone.',
2686
+ 'Nothing is answering at this address any more: the review server moved to another ' +
2687
+ 'port, or a different repo holds this one. Re-open the page from the Claude Code ' +
2688
+ 'session that produced it and the buttons below will send.',
2689
+ )
2690
+ } else if (res.status === 422) {
2691
+ // THE ENGINE READ IT AND SAID NO. Its message names the entry that
2692
+ // was wrong, so it is relayed verbatim — paraphrasing is how a
2693
+ // reader ends up hunting for a problem the tool had already named.
2694
+ // AND NO "re-open": the page is fine, and re-opening would only be
2695
+ // refused again.
2696
+ sendFailed(
2697
+ json,
2698
+ verdict,
2699
+ 'Not sent — the engine refused this pass.',
2700
+ text || 'It answered ' + res.status + ' without saying why.',
2701
+ )
2702
+ } else {
2703
+ sendFailed(
2704
+ json,
2705
+ verdict,
2706
+ 'Not sent — the server answered ' + res.status + '.',
2707
+ text || 'It gave no reason.',
2708
+ )
2709
+ }
2710
+ return
2711
+ }
2712
+ clearSendFailure()
2713
+ var code = ''
2714
+ try { code = JSON.parse(text).code } catch (e) {}
2715
+ // NAME THE COMMAND. Not "claim it with 418207", which instructs a
2716
+ // transcription nobody needs to make, and not "tell Claude it is
2717
+ // waiting", which describes the arrangement `/spec-reviewed` replaced —
2718
+ // narrating that a pass exists and hoping the agent looks. The code
2719
+ // stays, as the thing to CHECK: it is the one part of a waiting pass
2720
+ // that tells yours apart from anyone else's.
2721
+ if (code) {
2722
+ // SENT, AND NOW ASK WHAT BECAME OF IT. The page used to name the
2723
+ // command here unconditionally — the same sentence whether a session
2724
+ // was waiting to claim the pass or nothing was — because it had no
2725
+ // way to find out. It has one now.
2726
+ // Set BEFORE the draw, so the first render of the decided panel is
2727
+ // already the waiting shape rather than the pessimistic one.
2728
+ asking = true
2729
+ markDecided(verdict, '/spec-reviewed ' + code, null)
2730
+ watchPass(code, verdict)
2731
+ } else {
2732
+ // NO CODE, NO COMMAND. A `/spec-reviewed` with nothing after it still
2733
+ // works — it picks up the single waiting pass — but the page cannot
2734
+ // claim the server named THIS one, so it does not put words in its
2735
+ // mouth. Bare is what the reader is told, because bare is what is true.
2736
+ copyHint.textContent = 'Sent — run /spec-reviewed to pick it up.'
2737
+ markDecided(verdict, null, null)
2738
+ }
2739
+ })
2740
+ }, function () {
2741
+ // THE REQUEST NEVER ARRIVED — the network, or a server that has stopped.
2742
+ // Distinct from both refusals above: there is no status and no message,
2743
+ // because nothing answered. Re-opening may fix it and the command
2744
+ // certainly will, so both are offered.
2745
+ sendFailed(
2746
+ json,
2747
+ verdict,
2748
+ 'Not sent — could not reach the server.',
2749
+ 'The request did not arrive: the server has stopped, or this device cannot reach it. ' +
2750
+ 'Re-open the page from the session that produced it to try again.',
2751
+ )
2752
+ })
2753
+ }
2754
+
2755
+ /* ==========================================================================
2756
+ * The command list — a verdict bar for a page that cannot deliver one
2757
+ *
2758
+ * A `file://` page has no server and no store, so its buttons never sent
2759
+ * anything: they built a JSON blob and put it on the clipboard to paste. That
2760
+ * is still the only way a pass carrying MARKS can travel, and it stays. What
2761
+ * changes is the common case — a reader who ticked nothing and wrote nothing,
2762
+ * whose whole pass is one word — where a command they run in the terminal
2763
+ * beside them beats a wall of text every time.
2764
+ *
2765
+ * WHICH SHAPE IS LIVE IS DECIDED BY WHAT THE PASS CARRIES, and it is
2766
+ * re-decided on every mark. Tick one accept and the rows stand down, because
2767
+ * a command line cannot carry someone's notes and dropping them silently
2768
+ * would be worse than asking for the paste. Clear it again and they return.
2769
+ * ======================================================================== */
2770
+
2771
+ // Only a `file://` page. A served page posts; a published one writes to its
2772
+ // store. Both can deliver, so both keep their buttons — decided from what the
2773
+ // page IS, exactly as `send` decides.
2774
+ var NO_TRANSPORT = location.protocol === 'file:' && !isArtifact()
2775
+ var cmdList = document.getElementById('cmd-list')
2776
+ var cmdLead = document.getElementById('cmd-lead')
2777
+ var cmdRows = {}
2778
+
2779
+ function buildCmdRows() {
2780
+ if (!NO_TRANSPORT || !cmdList) return
2781
+ // WHERE, not just what. "Run one of these where it is" assumed the reader
2782
+ // knew which terminal counts, and there is usually more than one open. The
2783
+ // answer is specific and worth saying: the session that produced this page
2784
+ // is the one holding the review.
2785
+ cmdLead.textContent =
2786
+ 'This page has no way to reach Claude. Copy one of these and paste it into the ' +
2787
+ 'Claude Code session that produced this review — that is the one waiting for your verdict.'
2788
+ // THE SAME LIST THE BAR USES. `OFFERED` already answers which verdicts this
2789
+ // render offers — a mid-run page lists Continue and never the committing
2790
+ // pair — so deriving the rows from it is what keeps the two from drifting.
2791
+ OFFERED.forEach(function (verdict) {
2792
+ var row = el('div', 'cmd-row')
2793
+ row.setAttribute('data-verdict', verdict)
2794
+ var label = el('span', 'cmd-label', LABELS[verdict])
2795
+ var cmd = el('code', 'cmd-text', '/spec-reviewed ' + verdict)
2796
+ row.appendChild(label)
2797
+ row.appendChild(cmd)
2798
+ // NEVER A BUTTON THAT CANNOT COPY — the same rule `showCommand` follows,
2799
+ // through the same `canCopy`. A control that does nothing on tap reads as
2800
+ // a broken page; a control that is MISSING where it would have worked
2801
+ // reads as a page that never offered it, which is the failure this shares
2802
+ // its capability check to avoid.
2803
+ if (canCopy()) {
2804
+ var copy = el('button', 'cmd-copy', 'Copy')
2805
+ copy.setAttribute('type', 'button')
2806
+ copy.addEventListener('click', function () {
2807
+ copyText(cmd.textContent, cmd).then(function () {
2808
+ // COPIED IS DECIDED. The reader has the command; what happens next
2809
+ // happens in their terminal, and the page has nothing left to do
2810
+ // but say what they chose — with the command repeated, because a
2811
+ // copy can fail silently and an hour later this box is the only
2812
+ // record of it.
2813
+ markDecided(verdict, cmd.textContent, false)
2814
+ }, function () {
2815
+ copy.textContent = selectNode(cmd) ? 'Copy failed — selected' : 'Copy failed'
2816
+ })
2817
+ })
2818
+ row.appendChild(copy)
2819
+ }
2820
+ cmdList.appendChild(row)
2821
+ cmdRows[verdict] = row
2822
+ })
2823
+ }
2824
+
2825
+ /**
2826
+ * Decide, now, which of the two shapes this page is offering.
2827
+ *
2828
+ * Called from `refresh`, so it re-answers on every mark rather than once at
2829
+ * load: the reader who ticks an accept half-way through has changed what
2830
+ * their pass contains, and the page has to change with them.
2831
+ */
2832
+ function drawCmdList() {
2833
+ if (!NO_TRANSPORT || !cmdList) return
2834
+ var marked = pending() > 0
2835
+ cmdList.hidden = marked
2836
+ Object.keys(cmdRows).forEach(function (k) { cmdRows[k].hidden = marked })
2837
+ // The bar comes back to carry what a command cannot, and SAYS SO. A reader
2838
+ // whose notes had been dropped would not find out until the review came
2839
+ // back without them.
2840
+ verdictEl.hidden = !marked
2841
+ if (marked) {
2842
+ copyHint.hidden = false
2843
+ copyHint.textContent =
2844
+ 'You have marked something up, and a command line cannot carry notes — ' +
2845
+ 'use a verdict button below and paste what it copies.'
2846
+ } else if (copyHint.textContent.indexOf('cannot carry notes') !== -1) {
2847
+ copyHint.hidden = true
2848
+ copyHint.textContent = ''
2849
+ }
2850
+ }
2851
+
2852
+ // How long to keep asking, and how often. A claim is a file-watch waking a
2853
+ // session that is already sitting there, so the answer normally arrives in
2854
+ // well under a second; these are sized for the case where it does not.
2855
+ var PASS_POLL_MS = 900
2856
+ var PASS_POLL_ROUNDS = 8
2857
+
2858
+ /**
2859
+ * Ask the engine what became of the pass we just sent, and end on the answer.
2860
+ *
2861
+ * THE PAGE STOPPED GUESSING. It used to hand over `/spec-reviewed <code>` on
2862
+ * every successful POST, because it could not tell an auto-claimed pass from
2863
+ * one nobody was listening for — so the reader was told to run a command they
2864
+ * almost never needed, in the same words used when running it was the only
2865
+ * thing that would save their review. Both sentences are now earned.
2866
+ *
2867
+ * ONE ANSWER TAKES THE COMMAND AWAY, and it is the positive one: `claimed`,
2868
+ * which the engine reads off a decision log that NAMES this code. Everything
2869
+ * else — `waiting`, `unknown`, a server that never replied, a body that did
2870
+ * not parse, an older daemon with no lookup, the rounds running out — leaves
2871
+ * the command exactly where it is. A command nobody needed to run costs a
2872
+ * glance; a pass nobody claims costs the review.
2873
+ *
2874
+ * WHAT WOULD FOOL A LOOSER VERSION: treating a failed poll as "probably fine".
2875
+ * The failures that make a poll fail are the same ones that make a claim fail
2876
+ * — the machine sleeping, the daemon restarting, the network going — so the
2877
+ * moment this is least able to see is the moment its answer matters most.
2878
+ */
2879
+ function watchPass(code, verdict) {
2880
+ var rounds = 0
2881
+ // `?pass=` on the page's own path, so the serve token in the URL guards
2882
+ // this exactly as it guards everything else and there is no second address.
2883
+ var url = location.pathname + '?pass=' + encodeURIComponent(code)
2884
+
2885
+ function stop(picked) {
2886
+ // Every exit from the poll comes through here — claimed, out of rounds,
2887
+ // a server that stopped answering — so this is the one place the spinner
2888
+ // can be retired without listing the ways it might end.
2889
+ asking = false
2890
+ markDecided(verdict, '/spec-reviewed ' + code, picked)
2891
+ }
2892
+
2893
+ function ask() {
2894
+ rounds++
2895
+ fetch(url, { method: 'GET', headers: { accept: 'application/json' } }).then(function (res) {
2896
+ if (!res || !res.ok) return again()
2897
+ return res.text().then(function (text) {
2898
+ var state = null
2899
+ try { state = JSON.parse(text).state } catch (e) {}
2900
+ if (state === 'claimed') return stop(true)
2901
+ return again()
2902
+ })
2903
+ }, again)
2904
+ }
2905
+
2906
+ function again() {
2907
+ // Out of rounds is a CANNOT-TELL, not a no. Nobody is obliged to claim a
2908
+ // pass inside seven seconds, and a page that concluded "abandoned" would
2909
+ // be inventing an answer out of its own impatience.
2910
+ if (rounds >= PASS_POLL_ROUNDS) return stop(false)
2911
+ window.setTimeout(ask, PASS_POLL_MS)
2912
+ }
2913
+
2914
+ ask()
2915
+ }
2916
+
2917
+ /**
2918
+ * Is this page running as a published Artifact?
2919
+ *
2920
+ * A CAPABILITY CHECK, not a hostname one. `window.claude.use` is the whole
2921
+ * contract for reaching anything the viewer grants, and it exists only where
2922
+ * a viewer is there to grant it — so its presence is what distinguishes the
2923
+ * published page from the two the engine serves, without this file knowing
2924
+ * anything about where it was published.
2925
+ */
2926
+ function isArtifact() {
2927
+ return Boolean(window.claude && typeof window.claude.use === 'function')
2928
+ }
2929
+
2930
+ /**
2931
+ * Hand the pass to the artifact's own store.
2932
+ *
2933
+ * THE THIRD TRANSPORT, for the reader no local server can reach: a phone on
2934
+ * mobile data, or anyone away from the LAN the engine serves on. The pass
2935
+ * lands in a document Claude reads back and deletes, so it is consumed
2936
+ * exactly like a claimed one.
2937
+ *
2938
+ * `use` is asked only once the reader has pressed something. Asking at load
2939
+ * would spend a consent prompt on every render, including the ones nobody
2940
+ * ends in a verdict.
2941
+ */
2942
+ function sendToStore(json, verdict) {
2943
+ copyHint.hidden = false
2944
+ copyHint.textContent = 'Sending…'
2945
+ var landed = false
2946
+ window.claude.use('db').then(function (db) {
2947
+ // `null` means this view cannot run db — not granted, not served, failed
2948
+ // to load, all indistinguishable by design. The clipboard is still there,
2949
+ // so the pass is recoverable rather than lost.
2950
+ if (!db) {
2951
+ showFallback(json, 'Could not reach the store. Copy this, then paste it to Claude.')
2952
+ return
2953
+ }
2954
+ return db
2955
+ .collection('passes')
2956
+ .add({ spec: data.spec, at: new Date().toISOString(), render: data.generatedAt, blob: JSON.parse(json) })
2957
+ .then(function () {
2958
+ landed = true
2959
+ // NO CODE HERE, and none invented. Six digits are the engine's, minted
2960
+ // by the holding area this pass never went through — so the page says
2961
+ // the one thing that is true and actionable.
2962
+ copyHint.textContent = 'Sent. Run this where Claude is:'
2963
+ showCommand('/spec-reviewed')
2964
+ // A PUBLISHED PAGE CANNOT ASK. The pass went into the artifact's own
2965
+ // store, which the engine only reads when someone tells it to, so
2966
+ // nothing here could ever come back `true` — and the command is the
2967
+ // whole ending rather than a fallback within it.
2968
+ markDecided(verdict, '/spec-reviewed', null)
2969
+ })
2970
+ }).then(null, function (err) {
2971
+ if (landed) return
2972
+ showFallback(
2973
+ json,
2974
+ 'Could not store the pass' + (err && err.code ? ' (' + err.code + ')' : '') +
2975
+ '. Copy this, then paste it to Claude.',
2976
+ )
2977
+ })
2978
+ }
2979
+
2980
+ function send(verdict) {
2981
+ var json = JSON.stringify(buildBlob(verdict), null, 2)
2982
+ // DECIDED FROM WHAT THE PAGE IS, not from what it can guess. A published
2983
+ // page has a store and no server; a page opened over http was served by the
2984
+ // engine and can hand the pass straight back; a `file://` page has no server
2985
+ // to talk to and never will.
2986
+ //
2987
+ // NOT "try the POST and fall back on failure": a failed POST and an absent
2988
+ // one look identical to the reader, so the fallback would hide a server
2989
+ // that is refusing passes behind a message about copying. The published page
2990
+ // is exactly that trap — its POST would go to claude.ai and fail — which is
2991
+ // why it is answered first, by what the page IS rather than by what failed.
2992
+ if (isArtifact()) {
2993
+ sendToStore(json, verdict)
2994
+ return
2995
+ }
2996
+ if (location.protocol !== 'file:') {
2997
+ post(json, verdict)
2998
+ return
2999
+ }
3000
+ // `file://` is not a secure context everywhere, and the clipboard API is
3001
+ // absent or rejects there — so the textarea is a shipping requirement, not
3002
+ // a nicety. It is the path a local page usually takes.
3003
+ if (!navigator.clipboard || !navigator.clipboard.writeText) {
3004
+ showFallback(json, 'Copy this, then paste it to Claude.')
3005
+ return
3006
+ }
3007
+ navigator.clipboard.writeText(json).then(function () {
3008
+ copyOut.hidden = true
3009
+ copyHint.hidden = false
3010
+ copyHint.textContent = 'Copied — paste it to Claude to pick up this review.'
3011
+ }, function () {
3012
+ showFallback(json, 'Could not reach the clipboard. Copy this, then paste it to Claude.')
3013
+ })
3014
+ }
3015
+
3016
+ /**
3017
+ * The surfaces strip: where this review lives, and whether it is also running.
3018
+ *
3019
+ * AN ACTION IS NOT A VERDICT, and the whole strip exists to make that visible.
3020
+ * A verdict is the reader's conclusion, consumed once. These change what is
3021
+ * RUNNING (or which tiers are permitted) and hand the reader straight back to
3022
+ * the same page with the same options — so pressing one must not close the
3023
+ * page into its decided state, and must not clear a gate.
3024
+ *
3025
+ * ENABLE-ONLY for the tiers. Turning `network` off from a page reached over
3026
+ * the network kills the page doing the turning, and the reader gains nothing
3027
+ * they could not get by typing the command — so the engine's `tierAction`
3028
+ * never offers a disable and this renders whatever it is given.
3029
+ */
3030
+ var ACTION_LABEL = {
3031
+ // NAMES THE COMMIT IT PERFORMS. `live take` refuses a dirty worktree, so
3032
+ // the action commits the phase before taking the instance — and a press
3033
+ // that commits behind a label reading only "put it live" is a side effect
3034
+ // nobody agreed to. The reader has still approved nothing: this is a
3035
+ // precondition of the thing they asked for, not a verdict on the diff.
3036
+ 'live-on': '▶ Commit & put it live',
3037
+ 'allow-network': '▶ Allow network',
3038
+ 'allow-remote': '▶ Allow remote',
3039
+ }
3040
+ var ACTION_CMD = {
3041
+ 'live-on': '/spec-live',
3042
+ 'allow-network': 'skitterspec spec-env review allow network',
3043
+ 'allow-remote': '/spec-remote-review',
3044
+ }
3045
+ var SURFACE_LABEL = { live: 'live', network: 'network', remote: 'remote' }
3046
+
3047
+ function surfaceStateText(row) {
3048
+ if (row.kind === 'live') {
3049
+ if (row.state === 'on') return row.url ? 'on — running at ' + row.url : 'on'
3050
+ if (row.state === 'held') return row.reason || 'held by another spec'
3051
+ return 'off'
3052
+ }
3053
+ if (row.state === 'on') return row.url ? 'on — ' + row.url : 'on'
3054
+ return row.state === 'off' ? 'off' : 'unavailable'
3055
+ }
3056
+
3057
+ function buildSurfaces() {
3058
+ var section = document.getElementById('surfaces')
3059
+ var host = document.getElementById('surface-rows')
3060
+ // ABSENT, NOT EMPTY. A render that carried no surfaces gets no strip and no
3061
+ // line explaining the absence — a `--docs` page has no branch to put live,
3062
+ // and that is a healthy page rather than a deprived one.
3063
+ if (!section || !host || !data.surfaces || !data.surfaces.length) return
3064
+ section.hidden = false
3065
+ data.surfaces.forEach(function (row) {
3066
+ var div = document.createElement('div')
3067
+ div.className = 'surface-row'
3068
+ var label = document.createElement('span')
3069
+ label.className = 'surface-label'
3070
+ label.textContent = SURFACE_LABEL[row.kind === 'live' ? 'live' : row.tier] || ''
3071
+ div.appendChild(label)
3072
+ var state = document.createElement('span')
3073
+ state.className = 'surface-state'
3074
+ state.textContent = surfaceStateText(row)
3075
+ div.appendChild(state)
3076
+ if (row.action) {
3077
+ if (NO_TRANSPORT) {
3078
+ // No server and no store, so a button here would build an instruction
3079
+ // with nowhere to go. The command is the honest control — exactly the
3080
+ // trade the verdict bar already makes on a `file://` page.
3081
+ var cmd = document.createElement('code')
3082
+ cmd.className = 'surface-cmd'
3083
+ cmd.textContent = ACTION_CMD[row.action] || ''
3084
+ div.appendChild(cmd)
3085
+ } else {
3086
+ var btn = document.createElement('button')
3087
+ btn.type = 'button'
3088
+ btn.textContent = ACTION_LABEL[row.action] || row.action
3089
+ btn.setAttribute('data-action', row.action)
3090
+ btn.addEventListener('click', function () {
3091
+ if (btn.disabled) return
3092
+ sendAction(row.action, btn)
3093
+ })
3094
+ div.appendChild(btn)
3095
+ }
3096
+ }
3097
+ // THE COMMAND FOR A DIRECTION THE PAGE DOES NOT OFFER. A live spec shows
3098
+ // no press — handing the instance back to `main` is a workspace decision,
3099
+ // not this review's — so the line names `/spec-live main` instead, and the
3100
+ // reader is never left knowing what they want and not what to type.
3101
+ if (row.command && !row.action) {
3102
+ var cmdOnly = document.createElement('code')
3103
+ cmdOnly.className = 'surface-cmd'
3104
+ cmdOnly.textContent = row.command
3105
+ div.appendChild(cmdOnly)
3106
+ }
3107
+ if (row.note) {
3108
+ var note = document.createElement('span')
3109
+ note.className = 'surface-note'
3110
+ note.textContent = row.note
3111
+ div.appendChild(note)
3112
+ }
3113
+ host.appendChild(div)
3114
+ })
3115
+ }
3116
+
3117
+ /**
3118
+ * Send an action, and leave the page exactly as live as it was.
3119
+ *
3120
+ * IT CARRIES NO MARKS. An action blob is an instruction, not a review pass —
3121
+ * so the accepts and comments stay on the page and travel with the verdict
3122
+ * the reader eventually chooses. Sending them here would merge them early and
3123
+ * then send them again, which is one reader's review counted twice.
3124
+ *
3125
+ * AND IT NEVER CALLS `markDecided`. The reader still owes a verdict; closing
3126
+ * the page over an action would be the record-and-do-nothing ending this
3127
+ * whole contract is against.
3128
+ */
3129
+ function sendAction(action, btn) {
3130
+ var blob = {
3131
+ version: 1,
3132
+ spec: data.spec,
3133
+ generatedAt: data.generatedAt,
3134
+ accepted: [],
3135
+ unaccepted: [],
3136
+ comments: [],
3137
+ action: action,
3138
+ }
3139
+ btn.disabled = true
3140
+ var was = btn.textContent
3141
+ btn.textContent = 'Sending…'
3142
+ fetch(location.pathname, {
3143
+ method: 'POST',
3144
+ headers: { 'content-type': 'application/json' },
3145
+ body: JSON.stringify(blob),
3146
+ }).then(function (res) {
3147
+ if (!res || !res.ok) {
3148
+ btn.disabled = false
3149
+ btn.textContent = was
3150
+ return
3151
+ }
3152
+ return res.text().then(function (text) {
3153
+ var code = null
3154
+ try { code = JSON.parse(text).code } catch (e) {}
3155
+ btn.textContent = 'Sent'
3156
+ // RELOAD WHEN IT HAS BEEN ACTED ON, not on a timer. The agent re-renders
3157
+ // after the action, so the page the reader wants is the next one — and
3158
+ // `?pass=` already answers whether this exact code was claimed. Running
3159
+ // out of rounds leaves the page alone rather than reloading on a guess.
3160
+ if (code) watchAction(code, btn)
3161
+ })
3162
+ }, function () {
3163
+ btn.disabled = false
3164
+ btn.textContent = was
3165
+ })
3166
+ }
3167
+
3168
+ function watchAction(code, btn) {
3169
+ var rounds = 0
3170
+ var url = location.pathname + '?pass=' + encodeURIComponent(code)
3171
+ function ask() {
3172
+ rounds++
3173
+ fetch(url, { method: 'GET', headers: { accept: 'application/json' } }).then(function (res) {
3174
+ if (!res || !res.ok) return again()
3175
+ return res.text().then(function (text) {
3176
+ var state = null
3177
+ try { state = JSON.parse(text).state } catch (e) {}
3178
+ if (state === 'claimed') return window.location.reload()
3179
+ return again()
3180
+ })
3181
+ }, again)
3182
+ }
3183
+ function again() {
3184
+ // CANNOT TELL, so change nothing. Nobody is obliged to act on an action
3185
+ // inside a few seconds, and a page that reloaded out of impatience would
3186
+ // throw away marks the reader had not sent yet.
3187
+ if (rounds >= PASS_POLL_ROUNDS) {
3188
+ btn.textContent = 'Sent — reload when it is done'
3189
+ return
3190
+ }
3191
+ window.setTimeout(ask, PASS_POLL_MS)
3192
+ }
3193
+ ask()
3194
+ }
3195
+
3196
+ /**
3197
+ * TEXT STILL IN AN EDITOR — the one thing on this page a verdict destroys.
3198
+ *
3199
+ * Typing in the box is not a note; `Add note` is. So a reader who writes three
3200
+ * lines and then presses a verdict sends a pass that does not contain them,
3201
+ * `drawDecided` takes the diff away, and the text goes with it. Nothing said
3202
+ * so, and there is nothing to go back to.
3203
+ *
3204
+ * WARNED, NOT REFUSED, and the distinction is the whole design. Discarding it
3205
+ * is often exactly what the reader means — half a sentence, a note they
3206
+ * thought better of — and this page makes ONE refusal on purpose (an open
3207
+ * note blocks a committing verdict, mirroring `judgeVerdict`) and must not
3208
+ * grow a second one it invented. So the first press says what would be lost
3209
+ * and sends nothing; the same press again sends.
3210
+ *
3211
+ * THE CONFIRMATION IS OF WHAT THE READER WAS SHOWN, which is why the text
3212
+ * itself is remembered and not just the verdict. Otherwise a warning raised
3213
+ * over one note would silently carry away a second one written after it.
3214
+ */
3215
+ var warned = null
3216
+
3217
+ function unsentEditors() {
3218
+ var out = []
3219
+ Array.prototype.forEach.call(document.querySelectorAll('.note-input'), function (b) {
3220
+ if (String(b.value || '').trim()) out.push(b)
3221
+ })
3222
+ return out
3223
+ }
3224
+
3225
+ function clearWarn() {
3226
+ warned = null
3227
+ if (!verdictWarn) return
3228
+ verdictWarn.hidden = true
3229
+ verdictWarn.textContent = ''
3230
+ }
3231
+
3232
+ function mayLose(verdict) {
3233
+ var open = unsentEditors()
3234
+ // Whitespace is not unfinished work, and neither is an editor opened and
3235
+ // left empty. A page that stopped on those would teach the reader to press
3236
+ // everything twice, which is how a warning stops being read.
3237
+ if (!open.length) { clearWarn(); return true }
3238
+ var text = open.map(function (b) { return b.value }).join('\u0000')
3239
+ if (warned && warned.verdict === verdict && warned.text === text) { clearWarn(); return true }
3240
+ warned = { verdict: verdict, text: text }
3241
+ if (verdictWarn) {
3242
+ verdictWarn.hidden = false
3243
+ verdictWarn.textContent =
3244
+ open.length + (open.length === 1 ? ' note is' : ' notes are') +
3245
+ ' still in the box and not added. Press “Add note” to keep ' +
3246
+ (open.length === 1 ? 'it' : 'them') + ', or press “' + (LABELS[verdict] || verdict) +
3247
+ '” again to send without ' + (open.length === 1 ? 'it' : 'them') + '.'
3248
+ }
3249
+ // Take the reader to the first one. The bar is at the foot of the page and
3250
+ // the editor can be a screen away, so a line naming a note they cannot see
3251
+ // is only half an answer.
3252
+ open[0].scrollIntoView()
3253
+ open[0].focus()
3254
+ return false
3255
+ }
3256
+
3257
+ Object.keys(verdictBtns).forEach(function (verdict) {
3258
+ verdictBtns[verdict].addEventListener('click', function () {
3259
+ // A disabled or hidden button does not fire in a browser; the guard is
3260
+ // for every other caller, and it keeps both the block and the button set
3261
+ // facts rather than styles — a verdict this render never offered must not
3262
+ // be reachable by dispatching at it.
3263
+ if (verdictBtns[verdict].disabled || verdictBtns[verdict].hidden) return
3264
+ if (!mayLose(verdict)) return
3265
+ send(verdict)
3266
+ })
3267
+ })
3268
+
3269
+ // Said only when the engine reported one. A page from a render that could not
3270
+ // read its own version says nothing rather than claiming "unknown" — an
3271
+ // absence here is about the lookup, not about the page.
3272
+ if (data.engine) {
3273
+ // `0.0.0` is what the unpublished source package carries, so a page drawn
3274
+ // from a working copy would otherwise claim a version that has never been
3275
+ // released. This line exists to answer "which engine drew this" — and a
3276
+ // string that LOOKS like a version and is not one answers it wrongly, which
3277
+ // is worse than not answering. The field itself is untouched: the serve
3278
+ // daemon compares it to spot a stale renderer, and it must stay a version.
3279
+ document.getElementById('drawn-by').textContent =
3280
+ 'rendered by skitterspec ' + (data.engine === '0.0.0' ? '(unreleased build)' : data.engine)
3281
+ }
3282
+
3283
+ restore()
3284
+ buildCmdRows()
3285
+ buildSurfaces()
3286
+ drawContext()
3287
+ drawLog()
3288
+ drawDecided()
3289
+ wireChecks()
3290
+ wireGotos()
3291
+ state.forEach(function (st) {
3292
+ var btn = st.details && st.details.querySelector('.accept')
3293
+ if (btn) btn.setAttribute('aria-pressed', String(st.accept))
3294
+ if (st.rendered) renderFile(st)
3295
+ redrawStrip(st)
3296
+ })
3297
+ refresh()
3298
+
3299
+ // The [data-theme] blocks are unreachable in a local file without this — a
3300
+ // browser only ever supplies prefers-color-scheme.
3301
+ var themeBtn = document.getElementById('theme')
3302
+ themeBtn.addEventListener('click', function () {
3303
+ var root = document.documentElement
3304
+ var now = root.getAttribute('data-theme')
3305
+ var dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
3306
+ var next = now === 'dark' ? 'light' : now === 'light' ? 'dark' : dark ? 'light' : 'dark'
3307
+ root.setAttribute('data-theme', next)
3308
+ themeBtn.textContent = next === 'dark' ? 'Theme: dark' : 'Theme: light'
3309
+ })
3310
+
3311
+ // --- header --------------------------------------------------------------
3312
+ var titleEl = document.getElementById('title')
3313
+ titleEl.textContent = data.title
3314
+ /**
3315
+ * The tracker ticket, beside the title and linked where there is a url.
3316
+ *
3317
+ * `href` IS SET ONLY FROM A URL THE ENGINE ALREADY VETTED as http(s) — see
3318
+ * `readTicket`. The value originates in a spec file someone edits, and this
3319
+ * page is served over the network, so a `javascript:` url in an href would be
3320
+ * script execution. An id with no usable url still renders; it just does not
3321
+ * become a link, which is the harmless half of the pair.
3322
+ */
3323
+ ;(function () {
3324
+ var tk = data.context && data.context.ticket
3325
+ if (!tk || !tk.id) return
3326
+ var node
3327
+ if (tk.url) {
3328
+ node = document.createElement('a')
3329
+ node.setAttribute('href', tk.url)
3330
+ node.setAttribute('target', '_blank')
3331
+ node.setAttribute('rel', 'noopener noreferrer')
3332
+ } else {
3333
+ node = document.createElement('span')
3334
+ }
3335
+ node.className = 'ticket'
3336
+ node.textContent = tk.id
3337
+ titleEl.appendChild(document.createTextNode(' '))
3338
+ titleEl.appendChild(node)
3339
+ })()
3340
+
3341
+ /**
3342
+ * An ISO timestamp is a machine's format. The header showed
3343
+ * `2026-09-16T13:53:58.370Z` to a person deciding whether a page was stale,
3344
+ * which is the one question it is there to answer and the hardest way to ask
3345
+ * it. Rendered in the READER's locale and zone, because that is the clock
3346
+ * they are comparing against; the raw value stays as the `title` so nothing
3347
+ * precise is lost, and it survives a browser that throws on either call.
3348
+ */
3349
+ function whenRendered(iso) {
3350
+ try {
3351
+ var d = new Date(iso)
3352
+ if (isNaN(d.getTime())) return iso
3353
+ return d.toLocaleString(undefined, {
3354
+ day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
3355
+ })
3356
+ } catch (e) {
3357
+ return iso
3358
+ }
3359
+ }
3360
+ var t = data.totals
3361
+ var sub = document.getElementById('sub')
3362
+ function part(text, cls) { sub.appendChild(el('span', cls, text)) }
3363
+ function sep() { sub.appendChild(el('span', 'sep', '·')) }
3364
+ part(data.branch)
3365
+ sep()
3366
+ part(data.mode === 'branch'
3367
+ ? 'everything since ' + data.base + (data.fellBack ? ' (working tree clean)' : '')
3368
+ : data.mode === 'docs'
3369
+ ? "the spec's own documents"
3370
+ : 'uncommitted work')
3371
+ sep()
3372
+ part(t.files + ' file' + (t.files === 1 ? '' : 's'))
3373
+ sep()
3374
+ part('+' + t.additions, 'a')
3375
+ part(' ')
3376
+ part('-' + t.deletions, 'd')
3377
+ sep()
3378
+ var when = el('span', null, whenRendered(data.generatedAt))
3379
+ when.setAttribute('title', data.generatedAt)
3380
+ sub.appendChild(when)
3381
+ })()
3382
+ </script>
3383
+ </body>
3384
+ </html>