@skitterbyte/skitterspec 21.0.0 → 22.1.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.
- package/MIGRATION.md +218 -0
- package/README.md +127 -6
- package/assets/claude-md-section.md +42 -18
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-remote-review.md +22 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/env.config.json.example +6 -3
- package/assets/core/env.config.md +183 -25
- package/assets/hooks/main-guard.cjs +145 -0
- package/assets/hooks/review-gate.cjs +8 -3
- package/assets/review/page.html +1174 -101
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +195 -14
- package/assets/rules/spec-reports.md +181 -29
- package/assets/skills/no-spec/SKILL.md +219 -0
- package/assets/skills/spec/SKILL.md +248 -21
- package/assets/skills/spec-bug/SKILL.md +34 -24
- package/assets/skills/spec-cancel/SKILL.md +29 -0
- package/assets/skills/spec-complete/SKILL.md +25 -0
- package/assets/skills/spec-diff/SKILL.md +164 -37
- package/assets/skills/spec-hotfix/SKILL.md +22 -19
- package/assets/skills/spec-init/SKILL.md +4 -2
- package/assets/skills/spec-next/SKILL.md +166 -58
- package/assets/skills/spec-review/SKILL.md +87 -0
- package/assets/skills/spec-reviewed/SKILL.md +31 -5
- package/assets/skills/spec-start/SKILL.md +33 -0
- package/package.json +1 -1
- package/src/cli.js +1612 -172
- package/src/env/classify.js +87 -2
- package/src/env/config.js +333 -19
- package/src/env/hooks.js +102 -33
- package/src/env/live.js +124 -1
- package/src/env/mainguard.js +240 -0
- package/src/env/provision.js +106 -10
- package/src/env/registry.js +70 -6
- package/src/env/resolve.js +128 -5
- package/src/env/review.js +854 -29
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +474 -50
- package/src/env/supervise.js +8 -1
- package/src/env/teardown.js +12 -1
- package/src/init.js +185 -24
package/assets/review/page.html
CHANGED
|
@@ -168,25 +168,58 @@ input:focus-visible,
|
|
|
168
168
|
font-size: .85rem;
|
|
169
169
|
font-weight: 600;
|
|
170
170
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
.
|
|
174
|
-
|
|
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; }
|
|
175
182
|
.tree-file {
|
|
176
183
|
display: flex;
|
|
177
184
|
gap: .4rem;
|
|
178
185
|
width: 100%;
|
|
179
186
|
text-align: left;
|
|
180
|
-
|
|
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);
|
|
181
190
|
border: 0;
|
|
182
191
|
border-radius: 6px;
|
|
183
|
-
padding
|
|
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;
|
|
184
199
|
color: inherit;
|
|
185
200
|
font-size: .82rem;
|
|
186
201
|
}
|
|
187
202
|
.tree-file:hover { background: var(--panel-2); color: inherit; }
|
|
188
|
-
.tree-file .nm {
|
|
189
|
-
|
|
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
|
+
}
|
|
190
223
|
.tree-file.is-noise .nm { color: var(--muted); }
|
|
191
224
|
|
|
192
225
|
.file {
|
|
@@ -289,8 +322,63 @@ tr.band button { font-size: .72rem; padding: .1rem .5rem; margin-right: .3rem; }
|
|
|
289
322
|
font-size: .78rem;
|
|
290
323
|
color: var(--muted);
|
|
291
324
|
overflow-wrap: anywhere;
|
|
325
|
+
/* A button, because it jumps to the code — but it must keep reading as the
|
|
326
|
+
quiet path label it has always been, not gain a control's chrome. */
|
|
327
|
+
background: none;
|
|
328
|
+
border: 0;
|
|
329
|
+
padding: 0;
|
|
330
|
+
text-align: left;
|
|
331
|
+
cursor: pointer;
|
|
332
|
+
text-decoration: underline;
|
|
333
|
+
text-decoration-style: dotted;
|
|
334
|
+
text-underline-offset: 2px;
|
|
335
|
+
}
|
|
336
|
+
.check-file:hover { color: inherit; }
|
|
337
|
+
/* WHO SAID IT. Absent on a written review's checks, which is why it is a
|
|
338
|
+
separate span rather than a prefix on the note: a page with one author looks
|
|
339
|
+
exactly as it always did. */
|
|
340
|
+
.check-src {
|
|
341
|
+
grid-column: 2;
|
|
342
|
+
font-size: .68rem;
|
|
343
|
+
text-transform: uppercase;
|
|
344
|
+
letter-spacing: .04em;
|
|
345
|
+
font-weight: 700;
|
|
346
|
+
color: var(--muted);
|
|
347
|
+
margin-right: .4rem;
|
|
292
348
|
}
|
|
349
|
+
.check-src + .check-file { grid-column: 2; }
|
|
293
350
|
.check-note { grid-column: 2; }
|
|
351
|
+
|
|
352
|
+
/* --- the reviewer strip --------------------------------------------------
|
|
353
|
+
One line per configured reviewer, present even when every one found nothing.
|
|
354
|
+
A reviewer that was rate-limited and one that read the diff and approved are
|
|
355
|
+
the same page without it, and the reader is about to decide whether to
|
|
356
|
+
commit. */
|
|
357
|
+
.reviewers { list-style: none; margin: 0 0 .8rem; padding: 0; display: grid; gap: .25rem; }
|
|
358
|
+
.reviewer {
|
|
359
|
+
display: flex;
|
|
360
|
+
flex-wrap: wrap;
|
|
361
|
+
gap: .5rem;
|
|
362
|
+
align-items: baseline;
|
|
363
|
+
font-size: .8rem;
|
|
364
|
+
color: var(--muted);
|
|
365
|
+
}
|
|
366
|
+
.reviewer-name {
|
|
367
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
368
|
+
font-weight: 700;
|
|
369
|
+
color: var(--fg);
|
|
370
|
+
}
|
|
371
|
+
.reviewer-name::after { content: ' —'; color: var(--muted); font-weight: 400; }
|
|
372
|
+
/* A reviewer that could not run is marked, not hidden — the distinction between
|
|
373
|
+
"clean" and "never answered" is the whole reason this strip exists. */
|
|
374
|
+
.reviewer.failed .reviewer-said,
|
|
375
|
+
.reviewer.missing .reviewer-said,
|
|
376
|
+
.reviewer.timeout .reviewer-said { color: var(--flag-fg); }
|
|
377
|
+
.reviewer.findings .reviewer-said { color: var(--fg); }
|
|
378
|
+
|
|
379
|
+
/* A line a check pointed at, so a jump lands somewhere visible rather than
|
|
380
|
+
leaving the reader to find the row themselves. */
|
|
381
|
+
tr.goto-hit > td { background: var(--confirm-bg); }
|
|
294
382
|
.check.flag { background: var(--flag-bg); }
|
|
295
383
|
.check.flag .check-level { color: var(--flag-fg); }
|
|
296
384
|
.check.confirm { background: var(--confirm-bg); }
|
|
@@ -382,6 +470,28 @@ tr.note-row td {
|
|
|
382
470
|
border-radius: 10px;
|
|
383
471
|
background: var(--panel);
|
|
384
472
|
}
|
|
473
|
+
/* WHERE THIS REVIEW LIVES, and whether it is also running. It sits ABOVE the
|
|
474
|
+
verdicts because the question it answers — do I want to look at this running
|
|
475
|
+
before I conclude? — comes before the conclusion, not after it. Visually its
|
|
476
|
+
own strip, because it is not a verdict: pressing one of these changes what is
|
|
477
|
+
running (or which surfaces are permitted) and hands the reader straight back
|
|
478
|
+
to the same page with the same options. */
|
|
479
|
+
.surfaces {
|
|
480
|
+
margin: 1.2rem 0 0;
|
|
481
|
+
padding: .55rem .7rem;
|
|
482
|
+
border: 1px solid var(--line);
|
|
483
|
+
border-radius: 10px;
|
|
484
|
+
background: var(--panel);
|
|
485
|
+
}
|
|
486
|
+
.surfaces[hidden] { display: none; }
|
|
487
|
+
.surfaces-title { display: block; font-size: .8rem; color: var(--muted); margin: 0 0 .35rem; }
|
|
488
|
+
.surface-row { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin: .3rem 0; }
|
|
489
|
+
.surface-label { font-size: .8rem; font-weight: 600; min-width: 4.5rem; }
|
|
490
|
+
.surface-state { font-size: .8rem; color: var(--muted); }
|
|
491
|
+
.surface-note { font-size: .78rem; color: var(--muted); }
|
|
492
|
+
/* The command, for a page with no transport. Same treatment as the verdict
|
|
493
|
+
command list: plain text a single tap selects whole, never an input. */
|
|
494
|
+
.surface-cmd { font-size: .78rem; user-select: all; }
|
|
385
495
|
.verdict-end { margin: 1.2rem 0 0; }
|
|
386
496
|
.verdict-title { font-size: .8rem; color: var(--muted); }
|
|
387
497
|
.verdict-count { font-size: .8rem; color: var(--muted); }
|
|
@@ -393,6 +503,13 @@ tr.note-row td {
|
|
|
393
503
|
/* A button the render did not ask for is GONE, not dimmed — a disabled control
|
|
394
504
|
invites a reader to work out how to enable it, and there is nothing to do. */
|
|
395
505
|
.verdict button[hidden] { display: none; }
|
|
506
|
+
/* AND THE BAR ITSELF. `display: flex` beats the browser's `[hidden]`, so the
|
|
507
|
+
whole bar stayed on screen on a `file://` page — offering four buttons that
|
|
508
|
+
POST to a server that is not there, directly above the command list that
|
|
509
|
+
exists because they cannot. The guard in `assets-review` only read the
|
|
510
|
+
template for a `hidden` attribute, and this element is hidden at runtime, so
|
|
511
|
+
it never saw it; the guard reads the script now too. */
|
|
512
|
+
.verdict[hidden] { display: none; }
|
|
396
513
|
/* A blocked control is dashed as well as dimmed, so the block survives a
|
|
397
514
|
greyscale screen — and the reason rides in the label, never in a tooltip
|
|
398
515
|
nobody hovers on a phone. */
|
|
@@ -448,7 +565,10 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
448
565
|
.decided-what { margin: 0 0 .35rem; font-size: 1.05rem; font-weight: 650; line-height: 1.4; }
|
|
449
566
|
.decided-note { margin: 0 0 .8rem; color: var(--muted); font-size: .88rem; line-height: 1.5; max-width: 62ch; }
|
|
450
567
|
.decided-note:last-child { margin-bottom: 0; }
|
|
451
|
-
.
|
|
568
|
+
/* SPACE ABOVE IT. The toggle sits under whatever the panel ended on — a
|
|
569
|
+
sentence, or the command callout — and butted straight against the callout's
|
|
570
|
+
border it read as part of it rather than as the way back to the diff. */
|
|
571
|
+
.decided-toggle { font-size: .78rem; margin-top: .8rem; }
|
|
452
572
|
|
|
453
573
|
/* WHY THE CHANGE EXISTS — the PR description this page never had. It is prose
|
|
454
574
|
to be read rather than data to be scanned, so it gets a measure, air, and a
|
|
@@ -506,6 +626,17 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
506
626
|
.context-more > summary:hover { color: var(--accent); border-color: var(--accent); }
|
|
507
627
|
.context-more[open] > summary { margin-bottom: .6rem; }
|
|
508
628
|
.context li.done { color: var(--muted); }
|
|
629
|
+
/* The ticket rides ON the title line rather than under it — it is the other
|
|
630
|
+
name for this work, not a fact about it. Sized down and tinted so the title
|
|
631
|
+
still reads as the title. */
|
|
632
|
+
.ticket {
|
|
633
|
+
font-size: .62em; font-weight: 600; vertical-align: middle;
|
|
634
|
+
font-family: var(--mono, ui-monospace, monospace);
|
|
635
|
+
color: var(--accent); text-decoration: none;
|
|
636
|
+
padding: .12em .45em; border: 1px solid var(--line); border-radius: 999px;
|
|
637
|
+
white-space: nowrap;
|
|
638
|
+
}
|
|
639
|
+
a.ticket:hover { border-color: var(--accent); }
|
|
509
640
|
.context-more > summary { cursor: pointer; color: var(--muted); font-size: .85rem; margin-top: .6rem; }
|
|
510
641
|
.copy-hint { font-size: .8rem; color: var(--muted); margin: .4rem 0 0; }
|
|
511
642
|
.sent-cmd { display: flex; gap: .4rem; align-items: stretch; margin: .4rem 0 0; }
|
|
@@ -518,8 +649,102 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
518
649
|
flex: 1; min-width: 0; font-family: var(--mono, ui-monospace, monospace);
|
|
519
650
|
font-size: .85rem; padding: .35rem .5rem; color: var(--fg);
|
|
520
651
|
background: var(--bg); border: 1px solid var(--line); border-radius: 4px;
|
|
652
|
+
/* One tap takes the whole command — the gesture that still works on a page
|
|
653
|
+
served over plain http, where the clipboard API is withheld. */
|
|
654
|
+
user-select: all; -webkit-user-select: all;
|
|
655
|
+
overflow-x: auto; white-space: pre; line-height: 1.6;
|
|
521
656
|
}
|
|
522
657
|
.sent-cmd-copy { white-space: nowrap; }
|
|
658
|
+
/* ONE GRID FOR THE WHOLE LIST, so the command column starts at the same x on
|
|
659
|
+
every row. A per-row layout cannot do that: each row would size its own label
|
|
660
|
+
column to its own label, and `Commit` and `Commit & Continue` are not the
|
|
661
|
+
same width. The label column is `max-content` — the longest label decides,
|
|
662
|
+
which is a fact about the labels rather than a rem value someone has to
|
|
663
|
+
remember to update when one changes. */
|
|
664
|
+
.cmd-list { display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content; margin: .6rem 0 0; }
|
|
665
|
+
.cmd-list[hidden] { display: none; }
|
|
666
|
+
.cmd-lead { grid-column: 1 / -1; font-size: .85rem; color: var(--muted); margin: 0 0 .5rem; }
|
|
667
|
+
/* ONE LINE: label, command, Copy. It was three lines deep — the label on its
|
|
668
|
+
own row above the command — which spent a screenful on four rows that fit on
|
|
669
|
+
four lines. */
|
|
670
|
+
.cmd-row {
|
|
671
|
+
display: grid;
|
|
672
|
+
grid-column: 1 / -1;
|
|
673
|
+
/* FALLBACK FIRST, then subgrid. A browser without subgrid drops the second
|
|
674
|
+
declaration and keeps this one, which still gives one line per row — just
|
|
675
|
+
each sized to itself. Without a fallback the row would fall back to a
|
|
676
|
+
single auto column and stack, which is worse than the misalignment. */
|
|
677
|
+
grid-template-columns: max-content minmax(0, 1fr) max-content;
|
|
678
|
+
grid-template-columns: subgrid;
|
|
679
|
+
gap: .5rem; align-items: center;
|
|
680
|
+
padding: .4rem .5rem; margin-bottom: .35rem;
|
|
681
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: 6px;
|
|
682
|
+
}
|
|
683
|
+
.cmd-row[hidden] { display: none; }
|
|
684
|
+
.cmd-label { font-weight: 600; font-size: .85rem; white-space: nowrap; }
|
|
685
|
+
/* A TINTED EDGE, NOT A TINTED BOX. Four filled rows read as four warnings —
|
|
686
|
+
the colour shouted where it only needed to identify. The left border and the
|
|
687
|
+
label carry it now, which is enough to recognise the choice by and quiet
|
|
688
|
+
enough to read past. No new tokens: `v-commit` and `v-changes` above borrow
|
|
689
|
+
exactly this pair. */
|
|
690
|
+
.cmd-row[data-verdict="commit"],
|
|
691
|
+
.cmd-row[data-verdict="commit-start"],
|
|
692
|
+
.cmd-row[data-verdict="commit-land"],
|
|
693
|
+
.cmd-row[data-verdict="commit-continue"],
|
|
694
|
+
.cmd-row[data-verdict="continue"] { border-left: 3px solid var(--good-fg); }
|
|
695
|
+
.cmd-row[data-verdict="commit"] .cmd-label,
|
|
696
|
+
.cmd-row[data-verdict="commit-start"] .cmd-label,
|
|
697
|
+
.cmd-row[data-verdict="commit-land"] .cmd-label,
|
|
698
|
+
.cmd-row[data-verdict="commit-continue"] .cmd-label,
|
|
699
|
+
.cmd-row[data-verdict="continue"] .cmd-label { color: var(--good-fg); }
|
|
700
|
+
.cmd-row[data-verdict="changes"] { border-left: 3px solid var(--flag-fg); }
|
|
701
|
+
.cmd-row[data-verdict="changes"] .cmd-label { color: var(--flag-fg); }
|
|
702
|
+
/* Discuss keeps the neutral panel, exactly as its button does — it is the
|
|
703
|
+
ending that decides nothing, and colouring it would say otherwise. */
|
|
704
|
+
.cmd-text {
|
|
705
|
+
/* No `flex` — the list's grid column sizes this now. min-width:0 still
|
|
706
|
+
earns its place: it is what lets a long command scroll inside its own box
|
|
707
|
+
instead of widening the column and pushing Copy off the row. */
|
|
708
|
+
min-width: 0; font-family: var(--mono, ui-monospace, monospace);
|
|
709
|
+
font-size: .85rem; padding: .3rem .45rem; color: var(--fg);
|
|
710
|
+
background: var(--bg); border: 1px solid var(--line); border-radius: 4px;
|
|
711
|
+
/* One tap takes the whole command — see `.sent-cmd-text`. */
|
|
712
|
+
user-select: all; -webkit-user-select: all;
|
|
713
|
+
overflow-x: auto; white-space: pre; line-height: 1.6;
|
|
714
|
+
}
|
|
715
|
+
.cmd-copy { white-space: nowrap; }
|
|
716
|
+
/* AN ACTION, NOT A NOTE. The command reached the reader as a grey line under a
|
|
717
|
+
bar that had just closed, and it read as a footnote to something finished —
|
|
718
|
+
so it is now a filled callout inside the decided panel, which is the block a
|
|
719
|
+
re-opened page shows first. It reuses the add palette rather than minting a
|
|
720
|
+
green of its own: those three tokens are already defined on bare `:root` and
|
|
721
|
+
redefined in both theme blocks, which is the property `assets-review` guards. */
|
|
722
|
+
.sent-cmd.act {
|
|
723
|
+
flex-wrap: wrap; align-items: center; margin-top: .7rem;
|
|
724
|
+
padding: .55rem .6rem; border-radius: 6px;
|
|
725
|
+
background: var(--add-bg); border: 1px solid var(--add-mark); color: var(--add-fg);
|
|
726
|
+
}
|
|
727
|
+
.sent-cmd-lead { flex-basis: 100%; font-size: .85rem; font-weight: 600; color: var(--add-fg); }
|
|
728
|
+
/* A FAILED SEND, IN THE PALETTE OF A FAILURE. The same callout the waiting-pass
|
|
729
|
+
case uses, in the delete colours rather than the add ones — `--del-bg` /
|
|
730
|
+
`--del-fg` / `--del-mark` are already defined on bare `:root` and redefined in
|
|
731
|
+
both theme blocks, which is the property `assets-review` guards. No new token
|
|
732
|
+
is minted for this. */
|
|
733
|
+
.sent-cmd.act.fail {
|
|
734
|
+
background: var(--del-bg); border-color: var(--del-mark); color: var(--del-fg);
|
|
735
|
+
}
|
|
736
|
+
.sent-cmd.act.fail .sent-cmd-lead { color: var(--del-fg); }
|
|
737
|
+
/* THE OUTCOME THE READER MUST ACT ON, AS LOUD AS THE ONE THEY NEED NOT.
|
|
738
|
+
A failed send used to be a small grey line in the footer, under a verdict bar
|
|
739
|
+
that had just closed — quieter than the success it was not. It gets the panel
|
|
740
|
+
the decided page gets, at the top of the column, in the delete palette. What
|
|
741
|
+
it does NOT get is `is-decided`: nothing was delivered, so the diff stays and
|
|
742
|
+
the verdict buttons stay live for a second try. */
|
|
743
|
+
.decided.failed {
|
|
744
|
+
border-left-color: var(--del-mark);
|
|
745
|
+
background: var(--panel);
|
|
746
|
+
}
|
|
747
|
+
.decided.failed .decided-what { color: var(--del-mark); }
|
|
523
748
|
/* The claim code is read aloud or copied, so it is set apart from the sentence
|
|
524
749
|
around it rather than buried in it. */
|
|
525
750
|
.copy-hint { user-select: text; }
|
|
@@ -548,12 +773,44 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
548
773
|
engine from the spec's own files — nothing here passed through the
|
|
549
774
|
model, so it is free however large the diff. Absent entirely for a spec
|
|
550
775
|
whose overview cannot be read: the page worked without it before. -->
|
|
776
|
+
<!-- A SEND THAT DID NOT LAND. Same shape as the panel below, delete palette,
|
|
777
|
+
and deliberately NOT the same section: this one leaves the page
|
|
778
|
+
undecided, because nothing was delivered and the reader still has a
|
|
779
|
+
verdict to give. It names which failure happened, since a server that
|
|
780
|
+
has moved and a pass the engine refused send the reader to different
|
|
781
|
+
places. -->
|
|
782
|
+
<section class="decided failed" id="send-failed" hidden>
|
|
783
|
+
<p class="decided-what" id="send-failed-what"></p>
|
|
784
|
+
<p class="decided-note" id="send-failed-note"></p>
|
|
785
|
+
<p class="sent-cmd act fail" id="send-failed-cmd" hidden>
|
|
786
|
+
<span class="sent-cmd-lead" id="send-failed-lead"></span>
|
|
787
|
+
<code class="sent-cmd-text" id="send-failed-text"></code>
|
|
788
|
+
<button type="button" class="sent-cmd-copy" id="send-failed-copy" hidden>Copy</button>
|
|
789
|
+
</p>
|
|
790
|
+
</section>
|
|
551
791
|
<!-- WHAT YOU DECIDED, once the pass has actually been handed over. It sits
|
|
552
792
|
above everything because on a re-open it is the only thing you need:
|
|
553
793
|
the review is over, and this says how it ended. -->
|
|
554
794
|
<section class="decided" id="decided" hidden>
|
|
555
795
|
<p class="decided-what" id="decided-what"></p>
|
|
556
796
|
<p class="decided-note" id="decided-note"></p>
|
|
797
|
+
<!-- THE ONE THING LEFT TO DO, and it lives HERE rather than under the
|
|
798
|
+
verdict bar because that is where it was being missed: a grey line
|
|
799
|
+
below a bar that had just closed, in the type size of a footnote,
|
|
800
|
+
saying the same words whether it mattered or not. It is shown only
|
|
801
|
+
where the page has ESTABLISHED that the pass is still sitting there,
|
|
802
|
+
so when it appears it is always an instruction. -->
|
|
803
|
+
<p class="sent-cmd act" id="sent-cmd" hidden>
|
|
804
|
+
<span class="sent-cmd-lead" id="sent-cmd-lead"></span>
|
|
805
|
+
<!-- PLAIN TEXT, NOT AN INPUT. A readonly field still looks like
|
|
806
|
+
something to type into, and on a phone tapping it raises the
|
|
807
|
+
keyboard for a value nobody can change. It is a command to read and
|
|
808
|
+
copy, so it is marked up as one — `user-select: all` makes a single
|
|
809
|
+
tap take the whole thing, which is the gesture that works where no
|
|
810
|
+
clipboard API does. -->
|
|
811
|
+
<code class="sent-cmd-text" id="sent-cmd-text"></code>
|
|
812
|
+
<button type="button" class="sent-cmd-copy" id="sent-cmd-copy" hidden>Copy</button>
|
|
813
|
+
</p>
|
|
557
814
|
<button type="button" class="decided-toggle" id="decided-toggle">Show the diff anyway</button>
|
|
558
815
|
</section>
|
|
559
816
|
<section class="context reviewable" id="context" hidden>
|
|
@@ -565,6 +822,13 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
565
822
|
</section>
|
|
566
823
|
<div id="review-block" class="reviewable">__REVIEW_BLOCK__</div>
|
|
567
824
|
<div id="files" class="reviewable"></div>
|
|
825
|
+
<!-- THE SURFACES STRIP. Built by the script from `data.surfaces`, and
|
|
826
|
+
absent entirely when the render carried none — a `--docs` page has no
|
|
827
|
+
branch to put live, so it gets no strip and no explanation of why. -->
|
|
828
|
+
<section class="surfaces reviewable" id="surfaces" hidden>
|
|
829
|
+
<span class="surfaces-title">Where this runs</span>
|
|
830
|
+
<div id="surface-rows"></div>
|
|
831
|
+
</section>
|
|
568
832
|
<!-- THE VERDICT SITS AT THE END, where reading finishes. It began in the
|
|
569
833
|
header and the first person to use it could not find it: you read a
|
|
570
834
|
350-line diff downward, and the control asking for your conclusion was
|
|
@@ -582,16 +846,37 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
582
846
|
read it, carry on. It is not the removed `none` verdict — that
|
|
583
847
|
recorded itself and did nothing, while this one resumes the run. -->
|
|
584
848
|
<button id="verdict-continue" class="v-continue" type="button" hidden>→ Continue</button>
|
|
849
|
+
<!-- THE AUTHORING VERDICT, hidden unless the render asked for it. A spec
|
|
850
|
+
that has just been written has no phase in flight to continue, so
|
|
851
|
+
"Commit & Continue" is the wrong verb for it: this one commits the
|
|
852
|
+
spec and puts it IN FLIGHT, which is the `commit && /spec-start`
|
|
853
|
+
that was typed by hand until now. -->
|
|
854
|
+
<button id="verdict-commit-start" class="v-commit" type="button" hidden>✓ Commit & Start</button>
|
|
855
|
+
<!-- THE /no-spec VERDICT, hidden unless the render asked for it. Work with
|
|
856
|
+
no spec has no next phase and nothing to put in flight, so neither
|
|
857
|
+
committing verb above fits: this one commits, fast-forwards the base
|
|
858
|
+
branch, and tears the worktree down. Its own word rather than a
|
|
859
|
+
context-dependent reading of "Commit", because one of those two
|
|
860
|
+
readings destroys a worktree. -->
|
|
861
|
+
<button id="verdict-commit-land" class="v-commit" type="button" hidden>✓ Commit & Land</button>
|
|
585
862
|
<button id="verdict-changes" class="v-changes" type="button">↺ Request changes</button>
|
|
586
863
|
<button id="verdict-discuss" type="button">… Discuss first</button>
|
|
587
864
|
<span class="verdict-count" id="verdict-count" aria-live="polite"></span>
|
|
588
865
|
</div>
|
|
866
|
+
<!-- WHERE THE BUTTONS CANNOT DELIVER, the commands stand in their place.
|
|
867
|
+
A `file://` page has no server to POST to and no store to write to,
|
|
868
|
+
so a verdict button there only ever built a blob for the reader to
|
|
869
|
+
paste — a wall of JSON standing in for one word. Each row is named
|
|
870
|
+
with the BUTTON'S own label, because that is the word the reader
|
|
871
|
+
chose everywhere else; `commit-continue` is the wire spelling, and
|
|
872
|
+
nobody should have to learn a second vocabulary to recognise their
|
|
873
|
+
own decision. Built by the script, so there is exactly one list of
|
|
874
|
+
verdicts and it is the one the bar already uses. -->
|
|
875
|
+
<div class="cmd-list" id="cmd-list" hidden>
|
|
876
|
+
<p class="cmd-lead" id="cmd-lead"></p>
|
|
877
|
+
</div>
|
|
589
878
|
<p class="verdict-log" id="verdict-log" hidden></p>
|
|
590
879
|
<p class="copy-hint" id="copy-hint" hidden></p>
|
|
591
|
-
<p class="sent-cmd" id="sent-cmd" hidden>
|
|
592
|
-
<input class="sent-cmd-text" id="sent-cmd-text" readonly aria-label="Command to run">
|
|
593
|
-
<button type="button" class="sent-cmd-copy" id="sent-cmd-copy" hidden>Copy</button>
|
|
594
|
-
</p>
|
|
595
880
|
<textarea class="copy-out" id="copy-out" readonly hidden aria-label="Review blob to copy"></textarea>
|
|
596
881
|
<p class="drawn-by" id="drawn-by"></p>
|
|
597
882
|
</footer>
|
|
@@ -843,6 +1128,10 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
843
1128
|
} else {
|
|
844
1129
|
tr.appendChild(gutter(row, st, i))
|
|
845
1130
|
tr.appendChild(el('td', 'src', row.text === '' ? ' ' : row.text))
|
|
1131
|
+
// The line number, so a check can point at this row. Set here rather
|
|
1132
|
+
// than derived on demand because the file re-renders on every expand,
|
|
1133
|
+
// and a lookup built once would go stale the first time a band opened.
|
|
1134
|
+
if (row.n != null) tr.setAttribute('data-n', String(row.n))
|
|
846
1135
|
}
|
|
847
1136
|
body.appendChild(tr)
|
|
848
1137
|
// Anything said about this line sits directly beneath it — a note you
|
|
@@ -1089,28 +1378,104 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1089
1378
|
d.scrollIntoView({ block: 'start' })
|
|
1090
1379
|
}
|
|
1091
1380
|
|
|
1381
|
+
/**
|
|
1382
|
+
* Jump to a file, and to one line inside it when a check named one.
|
|
1383
|
+
*
|
|
1384
|
+
* THREE STATES, AND ONLY ONE OF THEM IS A JUMP. A file that is not in this
|
|
1385
|
+
* diff, and a line that is not in that file's patch, both do nothing beyond
|
|
1386
|
+
* revealing what IS there — a reviewer's finding can name a line that has
|
|
1387
|
+
* since moved, and a page that scrolled somewhere arbitrary would be worse
|
|
1388
|
+
* than one that scrolled nowhere.
|
|
1389
|
+
*/
|
|
1390
|
+
function gotoLine(path, line) {
|
|
1391
|
+
var st = null
|
|
1392
|
+
state.forEach(function (s) { if (s.file.path === path) st = s })
|
|
1393
|
+
if (!st) return
|
|
1394
|
+
reveal(st.file)
|
|
1395
|
+
if (line == null) return
|
|
1396
|
+
// A collapsed band hides the row, so drop any band covering it first. The
|
|
1397
|
+
// file re-renders anyway; this just decides what is on screen when it does.
|
|
1398
|
+
var idx = -1
|
|
1399
|
+
for (var i = 0; i < st.rows.length; i++) {
|
|
1400
|
+
if (st.rows[i].n === line) { idx = i; break }
|
|
1401
|
+
}
|
|
1402
|
+
if (idx < 0) return
|
|
1403
|
+
st.bands = st.bands.filter(function (b) { return !(idx >= b.from && idx < b.to) })
|
|
1404
|
+
renderFile(st)
|
|
1405
|
+
if (!st.el) return
|
|
1406
|
+
// Attribute-only, no tag prefix: within one file's element nothing but a
|
|
1407
|
+
// code row carries `data-n`, and the narrower selector buys nothing.
|
|
1408
|
+
var tr = st.el.querySelector('[data-n="' + line + '"]')
|
|
1409
|
+
if (!tr) return
|
|
1410
|
+
var prev = document.querySelector('.goto-hit')
|
|
1411
|
+
if (prev) prev.className = prev.className.replace(/ ?goto-hit/, '')
|
|
1412
|
+
tr.className += ' goto-hit'
|
|
1413
|
+
tr.scrollIntoView({ block: 'center' })
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// Wired per button rather than delegated from `document`. The review block is
|
|
1417
|
+
// spliced in as markup by the engine, so it is all present by the time this
|
|
1418
|
+
// runs — and a listener on each element is what the rest of this page does.
|
|
1419
|
+
function wireGotos() {
|
|
1420
|
+
var btns = document.querySelectorAll('[data-goto]')
|
|
1421
|
+
Array.prototype.forEach.call(btns, function (btn) {
|
|
1422
|
+
btn.addEventListener('click', function () {
|
|
1423
|
+
var n = btn.getAttribute('data-goto-line')
|
|
1424
|
+
gotoLine(btn.getAttribute('data-goto'), n == null ? null : parseInt(n, 10))
|
|
1425
|
+
})
|
|
1426
|
+
})
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// ONE FLAT LIST, indented by per-row padding rather than by nesting <ul>s.
|
|
1430
|
+
//
|
|
1431
|
+
// That is not a tidy-up: the change counts are `position: sticky; right: 0`,
|
|
1432
|
+
// and a sticky box can only pin within its own row. Nested <ul>s make each
|
|
1433
|
+
// level narrower than its parent, so a deep row ends before the scrollport's
|
|
1434
|
+
// right edge and its counts slide away the moment you scroll sideways. Flat
|
|
1435
|
+
// rows all share the widest row's width, so every one of them has something
|
|
1436
|
+
// to pin to.
|
|
1437
|
+
//
|
|
1438
|
+
// Depth goes on as an inline style because it is data, not a class: the tree
|
|
1439
|
+
// is however deep the diff happens to be, and a rule per level would be a
|
|
1440
|
+
// guess at the maximum.
|
|
1441
|
+
function indent(node, depth, base) {
|
|
1442
|
+
// Rounded, because 3 * 0.8 is 2.4000000000000004 in binary floating point
|
|
1443
|
+
// and that lands verbatim in the attribute. CSS copes; a person reading the
|
|
1444
|
+
// page source does not, and neither does a test asserting the value.
|
|
1445
|
+
var rem = Math.round((base + depth * 0.8) * 100) / 100
|
|
1446
|
+
node.setAttribute('style', 'padding-left:' + rem + 'rem')
|
|
1447
|
+
return node
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1092
1450
|
function renderTree(node) {
|
|
1093
1451
|
var ul = el('ul')
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
b.
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1452
|
+
;(function walk(n, depth) {
|
|
1453
|
+
Object.keys(n.dirs).sort().forEach(function (name) {
|
|
1454
|
+
var li = el('li')
|
|
1455
|
+
li.appendChild(indent(el('span', 'dir', name + '/'), depth, 0.6))
|
|
1456
|
+
ul.appendChild(li)
|
|
1457
|
+
// The subtree goes in AFTER its own label and before this level's
|
|
1458
|
+
// files, which is the order the nested form produced.
|
|
1459
|
+
walk(n.dirs[name], depth + 1)
|
|
1460
|
+
})
|
|
1461
|
+
n.files.sort(function (a, b) { return a.name.localeCompare(b.name) }).forEach(function (entry) {
|
|
1462
|
+
var li = el('li')
|
|
1463
|
+
var b = el('button', 'tree-file' + (entry.file.noise ? ' is-noise' : ''))
|
|
1464
|
+
b.type = 'button'
|
|
1465
|
+
// The same .6rem base as a dir label, so a file sits level with the
|
|
1466
|
+
// directory it is in. It is on the name rather than the row because the
|
|
1467
|
+
// row must reach the scrollport edge — see `.tree-file` padding.
|
|
1468
|
+
b.appendChild(indent(el('span', 'nm', entry.name), depth, 0.6))
|
|
1469
|
+
var ct = el('span', 'ct')
|
|
1470
|
+
ct.appendChild(el('span', 'a', '+' + entry.file.additions))
|
|
1471
|
+
ct.appendChild(document.createTextNode(' '))
|
|
1472
|
+
ct.appendChild(el('span', 'd', '-' + entry.file.deletions))
|
|
1473
|
+
b.appendChild(ct)
|
|
1474
|
+
b.addEventListener('click', function () { reveal(entry.file) })
|
|
1475
|
+
li.appendChild(b)
|
|
1476
|
+
ul.appendChild(li)
|
|
1477
|
+
})
|
|
1478
|
+
})(node, 0)
|
|
1114
1479
|
return ul
|
|
1115
1480
|
}
|
|
1116
1481
|
|
|
@@ -1228,6 +1593,8 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1228
1593
|
var verdictBtns = {
|
|
1229
1594
|
commit: document.getElementById('verdict-commit'),
|
|
1230
1595
|
'commit-continue': document.getElementById('verdict-commit-continue'),
|
|
1596
|
+
'commit-start': document.getElementById('verdict-commit-start'),
|
|
1597
|
+
'commit-land': document.getElementById('verdict-commit-land'),
|
|
1231
1598
|
continue: document.getElementById('verdict-continue'),
|
|
1232
1599
|
changes: document.getElementById('verdict-changes'),
|
|
1233
1600
|
discuss: document.getElementById('verdict-discuss'),
|
|
@@ -1235,7 +1602,7 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1235
1602
|
// The committing pair, named once. A fifth verdict that commits is added
|
|
1236
1603
|
// here, and the shared block below follows for free — the same reason the
|
|
1237
1604
|
// engine keeps a `COMMITTING` list rather than a second condition.
|
|
1238
|
-
var COMMITTERS = ['commit', 'commit-continue']
|
|
1605
|
+
var COMMITTERS = ['commit', 'commit-continue', 'commit-start', 'commit-land']
|
|
1239
1606
|
|
|
1240
1607
|
/*
|
|
1241
1608
|
* WHICH BUTTONS THIS PAGE OFFERS, and it comes from the render rather than
|
|
@@ -1250,7 +1617,16 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1250
1617
|
* changes.
|
|
1251
1618
|
*/
|
|
1252
1619
|
var MIDRUN = data.buttons === 'midrun'
|
|
1253
|
-
|
|
1620
|
+
// ONE TABLE, keyed by the set the engine named. A chain of ternaries is how a
|
|
1621
|
+
// third set becomes a fourth condition somebody forgets to add a button to.
|
|
1622
|
+
var OFFERS = {
|
|
1623
|
+
midrun: ['continue', 'changes', 'discuss'],
|
|
1624
|
+
authoring: ['commit-start', 'commit', 'changes', 'discuss'],
|
|
1625
|
+
refresh: ['commit', 'changes', 'discuss'],
|
|
1626
|
+
nospec: ['commit-land', 'commit', 'changes', 'discuss'],
|
|
1627
|
+
committing: ['commit', 'commit-continue', 'changes', 'discuss'],
|
|
1628
|
+
}
|
|
1629
|
+
var OFFERED = OFFERS[data.buttons] || OFFERS.committing
|
|
1254
1630
|
Object.keys(verdictBtns).forEach(function (key) {
|
|
1255
1631
|
verdictBtns[key].hidden = OFFERED.indexOf(key) === -1
|
|
1256
1632
|
})
|
|
@@ -1263,6 +1639,8 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1263
1639
|
var LABELS = {
|
|
1264
1640
|
commit: '✓ Commit',
|
|
1265
1641
|
'commit-continue': '✓ Commit & Continue',
|
|
1642
|
+
'commit-start': '✓ Commit & Start',
|
|
1643
|
+
'commit-land': '✓ Commit & Land',
|
|
1266
1644
|
continue: '→ Continue',
|
|
1267
1645
|
changes: '↺ Request changes',
|
|
1268
1646
|
discuss: '… Discuss first',
|
|
@@ -1272,11 +1650,19 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1272
1650
|
// Says what it will NOT do: a reader must not press this expecting the
|
|
1273
1651
|
// spec to be finished and landed.
|
|
1274
1652
|
'commit-continue': 'Commit what you just read, then build the next phase — nothing is landed',
|
|
1653
|
+
// Says what it WILL do, because this is the one verdict that provisions:
|
|
1654
|
+
// a worktree appears and phase 1 starts.
|
|
1655
|
+
'commit-start': 'Commit this spec, then put it in flight and build phase 1',
|
|
1656
|
+
// Says what it WILL do, and every destructive part of it: this is the only
|
|
1657
|
+
// verdict that removes a worktree, so the reader must not learn that from
|
|
1658
|
+
// the outcome afterwards.
|
|
1659
|
+
'commit-land': 'Commit this, fast-forward the base branch to it, and remove the worktree',
|
|
1275
1660
|
// Says what it will NOT do, for the same reason: nothing is committed by
|
|
1276
1661
|
// this, so the work it resumes is still uncommitted afterwards.
|
|
1277
1662
|
continue: 'You have read it — carry on with the run; nothing is committed',
|
|
1278
1663
|
}
|
|
1279
1664
|
|
|
1665
|
+
var verdictEl = document.getElementById('verdict')
|
|
1280
1666
|
var verdictCount = document.getElementById('verdict-count')
|
|
1281
1667
|
var verdictLog = document.getElementById('verdict-log')
|
|
1282
1668
|
var copyOut = document.getElementById('copy-out')
|
|
@@ -1289,6 +1675,14 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1289
1675
|
var sentCmd = document.getElementById('sent-cmd')
|
|
1290
1676
|
var sentCmdText = document.getElementById('sent-cmd-text')
|
|
1291
1677
|
var sentCmdCopy = document.getElementById('sent-cmd-copy')
|
|
1678
|
+
var sentCmdLead = document.getElementById('sent-cmd-lead')
|
|
1679
|
+
var failBox = document.getElementById('send-failed')
|
|
1680
|
+
var failWhat = document.getElementById('send-failed-what')
|
|
1681
|
+
var failNote = document.getElementById('send-failed-note')
|
|
1682
|
+
var failCmd = document.getElementById('send-failed-cmd')
|
|
1683
|
+
var failLead = document.getElementById('send-failed-lead')
|
|
1684
|
+
var failText = document.getElementById('send-failed-text')
|
|
1685
|
+
var failCopy = document.getElementById('send-failed-copy')
|
|
1292
1686
|
|
|
1293
1687
|
function pending() {
|
|
1294
1688
|
var b = buildBlob()
|
|
@@ -1320,6 +1714,10 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1320
1714
|
// the two committing buttons unconditionally, so without this it would undo
|
|
1321
1715
|
// the ending every time anything redrew.
|
|
1322
1716
|
if (decided) return drawDecided()
|
|
1717
|
+
// Which shape this page offers is re-answered here rather than at load: a
|
|
1718
|
+
// reader who ticks an accept half-way through has changed what their pass
|
|
1719
|
+
// contains, and a command line cannot carry the new contents.
|
|
1720
|
+
drawCmdList()
|
|
1323
1721
|
var n = pending()
|
|
1324
1722
|
var open = openNotes()
|
|
1325
1723
|
verdictCount.textContent = n
|
|
@@ -1391,6 +1789,9 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1391
1789
|
'Sent to Claude' + (when ? ' on ' + when : '') + ' — this review is ' + said + '. ' +
|
|
1392
1790
|
'Nothing here reaches Claude any more, so the verdict buttons are closed.'
|
|
1393
1791
|
wrapEl.className = wrapEl.className.replace(/\s*\bis-decided\b/g, '') + ' is-decided'
|
|
1792
|
+
// The commands go with the bar. A decided page offers no way to decide
|
|
1793
|
+
// again, whichever shape it was offering.
|
|
1794
|
+
if (cmdList) cmdList.hidden = true
|
|
1394
1795
|
// EVERY button, not only the committing pair: `refresh` re-enables those two
|
|
1395
1796
|
// by design, and a decided page must beat it.
|
|
1396
1797
|
Object.keys(verdictBtns).forEach(function (key) {
|
|
@@ -1404,29 +1805,54 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1404
1805
|
// sentence again in smaller type, so it goes.
|
|
1405
1806
|
verdictLog.hidden = true
|
|
1406
1807
|
|
|
1808
|
+
// WHAT THE PAGE ESTABLISHED, replayed — never re-derived. `picked` is the
|
|
1809
|
+
// answer the poll got, and a re-opened tab has no way to ask again for a
|
|
1810
|
+
// pass that is long gone, so it says what it said before rather than
|
|
1811
|
+
// quietly changing its mind a day later.
|
|
1812
|
+
copyHint.hidden = true
|
|
1813
|
+
copyOut.hidden = true
|
|
1814
|
+
if (decided.picked === true) {
|
|
1815
|
+
// NOTHING TO RUN, and so nothing offered. This is the whole point of
|
|
1816
|
+
// asking: the command used to appear here on every single ending, which
|
|
1817
|
+
// taught the reader it was decoration.
|
|
1818
|
+
sentCmd.hidden = true
|
|
1819
|
+
decidedNote.textContent += ' Claude picked this up — there is nothing to run.'
|
|
1820
|
+
return
|
|
1821
|
+
}
|
|
1407
1822
|
// THE HAND-OFF SURVIVES THE ENDING — but only where there is something
|
|
1408
1823
|
// worth carrying. A code is six digits to transcribe, and the box and its
|
|
1409
1824
|
// Copy button exist for exactly that. A bare `/spec-reviewed` is a word the
|
|
1410
1825
|
// reader is about to type into the terminal they are already sitting in;
|
|
1411
1826
|
// dressing it as a copyable artefact makes the ending look unfinished.
|
|
1412
1827
|
if (decided.cmd && /\s/.test(decided.cmd)) {
|
|
1413
|
-
|
|
1414
|
-
copyHint.textContent = 'Sent. Run this where Claude is:'
|
|
1828
|
+
decidedNote.textContent += ' It is still waiting to be picked up.'
|
|
1415
1829
|
showCommand(decided.cmd)
|
|
1416
1830
|
return
|
|
1417
1831
|
}
|
|
1418
|
-
copyHint.hidden = true
|
|
1419
1832
|
sentCmd.hidden = true
|
|
1420
|
-
copyOut.hidden = true
|
|
1421
1833
|
if (decided.cmd) {
|
|
1422
1834
|
decidedNote.textContent +=
|
|
1423
1835
|
' If Claude was not already waiting, run ' + decided.cmd + ' where it is.'
|
|
1424
1836
|
}
|
|
1425
1837
|
}
|
|
1426
1838
|
|
|
1427
|
-
/**
|
|
1428
|
-
|
|
1429
|
-
|
|
1839
|
+
/**
|
|
1840
|
+
* Record a verdict that was actually delivered, and end the page on it.
|
|
1841
|
+
*
|
|
1842
|
+
* `picked` is three-valued and the third value is the useful one: `true` the
|
|
1843
|
+
* engine confirmed somebody claimed this pass, `false` it confirmed nobody
|
|
1844
|
+
* has, and `null` NOTHING COULD BE ESTABLISHED — an older daemon with no
|
|
1845
|
+
* lookup, a poll that never came back, a page that stopped asking. Only
|
|
1846
|
+
* `true` takes the command away, so every way of not knowing leaves it on
|
|
1847
|
+
* screen (`.claude/rules/negative-checks.md` rule 4).
|
|
1848
|
+
*/
|
|
1849
|
+
function markDecided(verdict, cmd, picked) {
|
|
1850
|
+
decided = {
|
|
1851
|
+
verdict: verdict,
|
|
1852
|
+
at: new Date().toISOString(),
|
|
1853
|
+
cmd: cmd || null,
|
|
1854
|
+
picked: picked === undefined ? null : picked,
|
|
1855
|
+
}
|
|
1430
1856
|
save()
|
|
1431
1857
|
drawDecided()
|
|
1432
1858
|
}
|
|
@@ -1447,6 +1873,8 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1447
1873
|
approve: 'committed',
|
|
1448
1874
|
commit: 'committed',
|
|
1449
1875
|
'commit-continue': 'committed, then carried on',
|
|
1876
|
+
'commit-start': 'committed, then put in flight',
|
|
1877
|
+
'commit-land': 'committed, landed, and torn down',
|
|
1450
1878
|
continue: 'read it and carried on',
|
|
1451
1879
|
changes: 'changes requested',
|
|
1452
1880
|
discuss: 'discussed',
|
|
@@ -1482,29 +1910,122 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1482
1910
|
* here, not an edge one. A Copy button that does nothing when tapped is worse
|
|
1483
1911
|
* than text that was always text, so the button only exists where it can work.
|
|
1484
1912
|
*/
|
|
1913
|
+
/**
|
|
1914
|
+
* Put the whole command in the reader's selection. Returns whether it worked.
|
|
1915
|
+
*
|
|
1916
|
+
* REPORTED, NOT ASSUMED. It replaces `input.select()`, which the box no longer
|
|
1917
|
+
* has anything to call — and the honest thing to do when a browser withholds
|
|
1918
|
+
* `getSelection` is to stop claiming the text is selected, rather than say so
|
|
1919
|
+
* and leave someone hunting for a highlight that is not there.
|
|
1920
|
+
*/
|
|
1921
|
+
function selectCommand() {
|
|
1922
|
+
return selectNode(sentCmdText)
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
/**
|
|
1926
|
+
* Put a node's whole text in the reader's selection. Returns whether it took.
|
|
1927
|
+
*/
|
|
1928
|
+
function selectNode(node) {
|
|
1929
|
+
try {
|
|
1930
|
+
var range = document.createRange()
|
|
1931
|
+
range.selectNodeContents(node)
|
|
1932
|
+
var sel = window.getSelection && window.getSelection()
|
|
1933
|
+
if (!sel) return false
|
|
1934
|
+
sel.removeAllRanges()
|
|
1935
|
+
sel.addRange(range)
|
|
1936
|
+
return true
|
|
1937
|
+
} catch (e) {
|
|
1938
|
+
return false
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
/**
|
|
1943
|
+
* Can this browser copy AT ALL — by either route?
|
|
1944
|
+
*
|
|
1945
|
+
* WHAT THIS GOT WRONG, and why the button kept vanishing where it was needed
|
|
1946
|
+
* most: `navigator.clipboard` is **secure-context only**, so it is absent on
|
|
1947
|
+
* every `http://<lan-ip>:7777` page — which is exactly the page someone reads
|
|
1948
|
+
* on a phone. Asking only about that API therefore hid the Copy button in the
|
|
1949
|
+
* one case the button exists for, and left a phone reader long-pressing to
|
|
1950
|
+
* select text on a page that could have copied for them all along.
|
|
1951
|
+
*
|
|
1952
|
+
* `document.execCommand('copy')` is deprecated and universally implemented,
|
|
1953
|
+
* and it works in a non-secure context inside a user gesture. So there are
|
|
1954
|
+
* two routes, and the rule below is unchanged: offer the button only where
|
|
1955
|
+
* one of them exists.
|
|
1956
|
+
*/
|
|
1957
|
+
function canCopy() {
|
|
1958
|
+
return Boolean(
|
|
1959
|
+
(navigator.clipboard && navigator.clipboard.writeText) ||
|
|
1960
|
+
(document.execCommand && window.getSelection),
|
|
1961
|
+
)
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
/**
|
|
1965
|
+
* Copy `text`, by whichever route this browser has. Resolves on success and
|
|
1966
|
+
* rejects otherwise, so every caller reports the same three outcomes.
|
|
1967
|
+
*
|
|
1968
|
+
* `node` is what gets left selected when copying fails — the reader can then
|
|
1969
|
+
* do it by hand, which is the behaviour this button replaced rather than
|
|
1970
|
+
* removed.
|
|
1971
|
+
*/
|
|
1972
|
+
function copyText(text, node) {
|
|
1973
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
1974
|
+
return navigator.clipboard.writeText(text)
|
|
1975
|
+
}
|
|
1976
|
+
return new Promise(function (resolve, reject) {
|
|
1977
|
+
// The selection IS the mechanism here, not a consolation: execCommand
|
|
1978
|
+
// copies what is selected. So it is made first and deliberately left in
|
|
1979
|
+
// place — a failed copy then lands the reader exactly where the old
|
|
1980
|
+
// no-clipboard path did.
|
|
1981
|
+
if (!selectNode(node)) return reject(new Error('no selection'))
|
|
1982
|
+
var ok = false
|
|
1983
|
+
try {
|
|
1984
|
+
ok = document.execCommand('copy')
|
|
1985
|
+
} catch (e) {
|
|
1986
|
+
ok = false
|
|
1987
|
+
}
|
|
1988
|
+
return ok ? resolve() : reject(new Error('execCommand refused'))
|
|
1989
|
+
})
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1485
1992
|
function showCommand(cmd) {
|
|
1486
1993
|
sentCmd.hidden = false
|
|
1487
|
-
sentCmdText.
|
|
1488
|
-
var
|
|
1489
|
-
sentCmdCopy.hidden = !
|
|
1490
|
-
|
|
1491
|
-
//
|
|
1492
|
-
//
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1994
|
+
sentCmdText.textContent = cmd
|
|
1995
|
+
var copyable = canCopy()
|
|
1996
|
+
sentCmdCopy.hidden = !copyable
|
|
1997
|
+
sentCmdCopy.textContent = 'Copy'
|
|
1998
|
+
// SET HERE, NOT LEFT TO THE MARKUP. The lead says two different things and
|
|
1999
|
+
// the box is shown more than once in a page's life, so a line that was only
|
|
2000
|
+
// ever correct on first render would go stale the moment either changed.
|
|
2001
|
+
if (copyable) {
|
|
2002
|
+
sentCmdLead.textContent = 'Run this where Claude is:'
|
|
2003
|
+
// NOT SELECTED when there is a button. Pre-selecting made the line look
|
|
2004
|
+
// like a focused input, which is what it was mistaken for — and it is the
|
|
2005
|
+
// long-press-to-select gesture the button is here to spare a phone reader.
|
|
2006
|
+
return
|
|
2007
|
+
}
|
|
2008
|
+
// Selected where that is possible, and SAID to be selected: a reader who
|
|
2009
|
+
// sees nothing happen has no way to tell a page that did nothing from one
|
|
2010
|
+
// that already did the work. The lead carries it now that the box lives in
|
|
2011
|
+
// the decided panel — `copy-hint` is down in the footer with the verdict
|
|
2012
|
+
// bar, and appending to it put the sentence half a page from the thing it
|
|
2013
|
+
// describes.
|
|
2014
|
+
sentCmdLead.textContent = selectCommand()
|
|
2015
|
+
? 'Run this where Claude is (selected — copy it):'
|
|
2016
|
+
: 'Run this where Claude is — copy it:'
|
|
1496
2017
|
}
|
|
1497
2018
|
|
|
1498
2019
|
if (sentCmdCopy) {
|
|
1499
2020
|
sentCmdCopy.addEventListener('click', function () {
|
|
1500
|
-
|
|
2021
|
+
copyText(sentCmdText.textContent, sentCmdText).then(function () {
|
|
1501
2022
|
sentCmdCopy.textContent = 'Copied'
|
|
1502
2023
|
}, function () {
|
|
1503
2024
|
// The capability was there and the write still failed. Fall back to the
|
|
1504
|
-
// same selection the no-clipboard path uses rather than saying nothing
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
sentCmdCopy.textContent = 'Copy failed — selected'
|
|
2025
|
+
// same selection the no-clipboard path uses rather than saying nothing —
|
|
2026
|
+
// and if even that is unavailable, say THAT, because a button reading
|
|
2027
|
+
// "selected" over nothing highlighted is worse than an honest failure.
|
|
2028
|
+
sentCmdCopy.textContent = selectCommand() ? 'Copy failed — selected' : 'Copy failed'
|
|
1508
2029
|
})
|
|
1509
2030
|
})
|
|
1510
2031
|
}
|
|
@@ -1533,16 +2054,64 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1533
2054
|
into.appendChild(h)
|
|
1534
2055
|
}
|
|
1535
2056
|
|
|
2057
|
+
/**
|
|
2058
|
+
* THE PHASE COMES FIRST, and the spec's Problem is folded away behind it.
|
|
2059
|
+
*
|
|
2060
|
+
* The open part of this header used to be `Why` — the overview's Problem,
|
|
2061
|
+
* which is about the whole spec. A reader opening a page for phase 3 got
|
|
2062
|
+
* the same paragraph they had already read at phase 1, and had to expand
|
|
2063
|
+
* `More` to reach the only thing that told them what THIS review covers.
|
|
2064
|
+
* The question a review page answers is "what am I looking at", so the
|
|
2065
|
+
* phase answers it, and the background it sits in is one tap away.
|
|
2066
|
+
*
|
|
2067
|
+
* The TASK LIST is open too, deliberately. It is the cheapest statement of
|
|
2068
|
+
* what is and is not in this diff — which matters most half-way through a
|
|
2069
|
+
* phase, where a `/spec-diff` shows three ticked boxes and four empty ones
|
|
2070
|
+
* and the empty ones are the reason the diff looks unfinished.
|
|
2071
|
+
*/
|
|
2072
|
+
if (ctx.phase) {
|
|
2073
|
+
head(why, 'Phase ' + ctx.phase.n + (ctx.phase.title ? ' — ' + ctx.phase.title : ''))
|
|
2074
|
+
if (ctx.phase.goal) {
|
|
2075
|
+
var g = document.createElement('p')
|
|
2076
|
+
g.textContent = ctx.phase.goal
|
|
2077
|
+
why.appendChild(g)
|
|
2078
|
+
}
|
|
2079
|
+
if (ctx.phase.tasks && ctx.phase.tasks.length) {
|
|
2080
|
+
var ul = document.createElement('ul')
|
|
2081
|
+
ul.className = 'tasks'
|
|
2082
|
+
ctx.phase.tasks.forEach(function (task) {
|
|
2083
|
+
var li = document.createElement('li')
|
|
2084
|
+
// Done is dimmed rather than struck through: every task is ticked by
|
|
2085
|
+
// the time a phase renders, and a whole list struck through reads as
|
|
2086
|
+
// cancelled work.
|
|
2087
|
+
li.className = task.done ? 'done' : ''
|
|
2088
|
+
li.textContent = (task.done ? '✅ ' : '⬜ ') + task.text
|
|
2089
|
+
ul.appendChild(li)
|
|
2090
|
+
})
|
|
2091
|
+
why.appendChild(ul)
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
|
|
1536
2095
|
if (ctx.problem) {
|
|
1537
|
-
//
|
|
1538
|
-
//
|
|
1539
|
-
// the
|
|
2096
|
+
// FOLDED, ALL OF IT. With the phase open above, the spec's Problem is
|
|
2097
|
+
// background rather than the answer — and on a phone every line of it is
|
|
2098
|
+
// a line of the file list pushed off-screen, which is the thing this
|
|
2099
|
+
// header was built to avoid. Where there is NO phase (a branch-wide diff,
|
|
2100
|
+
// a spec with no live phase) the first paragraph stays open, because then
|
|
2101
|
+
// it is the only thing the header can say.
|
|
1540
2102
|
var paras = String(ctx.problem).split(/\n\s*\n/)
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
2103
|
+
var from = 0
|
|
2104
|
+
if (!ctx.phase) {
|
|
2105
|
+
head(why, 'Why')
|
|
2106
|
+
var lead = document.createElement('p')
|
|
2107
|
+
lead.textContent = paras[0].replace(/\s+/g, ' ').trim()
|
|
2108
|
+
why.appendChild(lead)
|
|
2109
|
+
from = 1
|
|
2110
|
+
} else if (paras.length) {
|
|
2111
|
+
head(rest, 'Why')
|
|
2112
|
+
drewRest = true
|
|
2113
|
+
}
|
|
2114
|
+
for (var i = from; i < paras.length; i++) {
|
|
1546
2115
|
var p = document.createElement('p')
|
|
1547
2116
|
p.textContent = paras[i].replace(/\s+/g, ' ').trim()
|
|
1548
2117
|
rest.appendChild(p)
|
|
@@ -1579,29 +2148,6 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1579
2148
|
}
|
|
1580
2149
|
}
|
|
1581
2150
|
|
|
1582
|
-
if (ctx.phase) {
|
|
1583
|
-
head(rest, 'Phase ' + ctx.phase.n + (ctx.phase.title ? ' — ' + ctx.phase.title : ''))
|
|
1584
|
-
drewRest = true
|
|
1585
|
-
if (ctx.phase.goal) {
|
|
1586
|
-
var g = document.createElement('p')
|
|
1587
|
-
g.textContent = ctx.phase.goal
|
|
1588
|
-
rest.appendChild(g)
|
|
1589
|
-
}
|
|
1590
|
-
if (ctx.phase.tasks && ctx.phase.tasks.length) {
|
|
1591
|
-
var ul = document.createElement('ul')
|
|
1592
|
-
ctx.phase.tasks.forEach(function (t) {
|
|
1593
|
-
var li = document.createElement('li')
|
|
1594
|
-
// Done is dimmed rather than struck through: every task is ticked by
|
|
1595
|
-
// the time a phase renders, and a whole list struck through reads as
|
|
1596
|
-
// cancelled work.
|
|
1597
|
-
li.className = t.done ? 'done' : ''
|
|
1598
|
-
li.textContent = (t.done ? '✅ ' : '⬜ ') + t.text
|
|
1599
|
-
ul.appendChild(li)
|
|
1600
|
-
})
|
|
1601
|
-
rest.appendChild(ul)
|
|
1602
|
-
}
|
|
1603
|
-
}
|
|
1604
|
-
|
|
1605
2151
|
document.getElementById('context').hidden = false
|
|
1606
2152
|
more.hidden = !drewRest
|
|
1607
2153
|
}
|
|
@@ -1615,6 +2161,91 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1615
2161
|
copyHint.textContent = why
|
|
1616
2162
|
}
|
|
1617
2163
|
|
|
2164
|
+
/**
|
|
2165
|
+
* Put the whole recovery command in the failure callout, with its Copy control.
|
|
2166
|
+
*
|
|
2167
|
+
* THE SAME CONTROL THE `file://` PAGE ALREADY USES — `/spec-reviewed <verdict>`
|
|
2168
|
+
* — so there is one command row rather than a second kind. It reaches the
|
|
2169
|
+
* agent regardless of any server, which is the only reason a recovery can be
|
|
2170
|
+
* offered from a failed send at all.
|
|
2171
|
+
*
|
|
2172
|
+
* `canCopy` decides whether there is a button, exactly as `showCommand` does:
|
|
2173
|
+
* a control that does nothing on tap reads as a broken page, and one that is
|
|
2174
|
+
* missing where it would have worked reads as a page that never offered it.
|
|
2175
|
+
*/
|
|
2176
|
+
function showFailCommand(cmd) {
|
|
2177
|
+
failCmd.hidden = false
|
|
2178
|
+
failText.textContent = cmd
|
|
2179
|
+
var copyable = canCopy()
|
|
2180
|
+
failCopy.hidden = !copyable
|
|
2181
|
+
failCopy.textContent = 'Copy'
|
|
2182
|
+
failLead.textContent = copyable
|
|
2183
|
+
? 'Your verdict is not lost — run this where Claude is:'
|
|
2184
|
+
: selectNode(failText)
|
|
2185
|
+
? 'Your verdict is not lost — run this where Claude is (selected — copy it):'
|
|
2186
|
+
: 'Your verdict is not lost — run this where Claude is — copy it:'
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
if (failCopy) {
|
|
2190
|
+
failCopy.addEventListener('click', function () {
|
|
2191
|
+
copyText(failText.textContent, failText).then(function () {
|
|
2192
|
+
failCopy.textContent = 'Copied'
|
|
2193
|
+
}, function () {
|
|
2194
|
+
failCopy.textContent = selectNode(failText) ? 'Copy failed — selected' : 'Copy failed'
|
|
2195
|
+
})
|
|
2196
|
+
})
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
/** Take the failure panel down — a fresh attempt, or one that landed. */
|
|
2200
|
+
function clearSendFailure() {
|
|
2201
|
+
failBox.hidden = true
|
|
2202
|
+
failCmd.hidden = true
|
|
2203
|
+
failWhat.textContent = ''
|
|
2204
|
+
failNote.textContent = ''
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
/**
|
|
2208
|
+
* Say that a send did not land, as loudly as a send that did.
|
|
2209
|
+
*
|
|
2210
|
+
* THE ONE OUTCOME THE READER MUST ACT ON used to be reported more quietly than
|
|
2211
|
+
* the one they need do nothing about: a small grey line in the footer, under a
|
|
2212
|
+
* verdict bar that had just closed. It gets the panel at the top of the column
|
|
2213
|
+
* now, in the delete palette.
|
|
2214
|
+
*
|
|
2215
|
+
* IT DOES NOT DECIDE THE PAGE. Nothing was delivered, so the diff stays and
|
|
2216
|
+
* the verdict buttons stay live — a page that closed its controls over a
|
|
2217
|
+
* failed send would strand the reader with no way to try again.
|
|
2218
|
+
*
|
|
2219
|
+
* A pass carrying MARKS still goes to the clipboard, because a command line
|
|
2220
|
+
* cannot carry notes and a failed send must not become the moment they are
|
|
2221
|
+
* silently dropped.
|
|
2222
|
+
*/
|
|
2223
|
+
function sendFailed(json, verdict, what, note) {
|
|
2224
|
+
copyHint.hidden = true
|
|
2225
|
+
copyHint.textContent = ''
|
|
2226
|
+
failBox.hidden = false
|
|
2227
|
+
failWhat.textContent = what
|
|
2228
|
+
if (pending() > 0) {
|
|
2229
|
+
failCmd.hidden = true
|
|
2230
|
+
failNote.textContent =
|
|
2231
|
+
note +
|
|
2232
|
+
' You have marked things up, and a command line cannot carry notes — the whole pass is ' +
|
|
2233
|
+
'below. Copy it and paste it to Claude.'
|
|
2234
|
+
copyOut.hidden = false
|
|
2235
|
+
copyOut.value = json
|
|
2236
|
+
copyOut.focus()
|
|
2237
|
+
copyOut.select()
|
|
2238
|
+
} else {
|
|
2239
|
+
failNote.textContent = note
|
|
2240
|
+
showFailCommand('/spec-reviewed ' + verdict)
|
|
2241
|
+
}
|
|
2242
|
+
// WHERE THE READER IS LOOKING. The verdict bar is at the foot of the page
|
|
2243
|
+
// and this panel is at the head of it, so a failure announced without this
|
|
2244
|
+
// is a failure announced off-screen — which is the shape of the bug being
|
|
2245
|
+
// fixed, one scroll position removed.
|
|
2246
|
+
if (failBox.scrollIntoView) failBox.scrollIntoView()
|
|
2247
|
+
}
|
|
2248
|
+
|
|
1618
2249
|
// One action per decision: the verdict button IS the copy button, so the blob
|
|
1619
2250
|
// can never carry a verdict other than the one just pressed.
|
|
1620
2251
|
/**
|
|
@@ -1625,6 +2256,7 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1625
2256
|
* much you wrote.
|
|
1626
2257
|
*/
|
|
1627
2258
|
function post(json, verdict) {
|
|
2259
|
+
clearSendFailure()
|
|
1628
2260
|
copyHint.hidden = false
|
|
1629
2261
|
copyHint.textContent = 'Sending…'
|
|
1630
2262
|
fetch(location.pathname, {
|
|
@@ -1634,11 +2266,45 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1634
2266
|
}).then(function (res) {
|
|
1635
2267
|
return res.text().then(function (text) {
|
|
1636
2268
|
if (!res.ok) {
|
|
1637
|
-
// A pass the server REFUSED must never read as sent
|
|
1638
|
-
//
|
|
1639
|
-
|
|
2269
|
+
// A pass the server REFUSED must never read as sent — and WHICH
|
|
2270
|
+
// refusal decides where the reader goes next, so the two are named
|
|
2271
|
+
// separately rather than collapsed into one "not sent".
|
|
2272
|
+
if (res.status === 404) {
|
|
2273
|
+
// THE PORT MOVED, or another repo's daemon holds this one. The token
|
|
2274
|
+
// in this URL means nothing there, so re-opening the page from the
|
|
2275
|
+
// session that produced it is the fix — the link is stale, the pass
|
|
2276
|
+
// is fine.
|
|
2277
|
+
sendFailed(
|
|
2278
|
+
json,
|
|
2279
|
+
verdict,
|
|
2280
|
+
'Not sent — this page\'s server is gone.',
|
|
2281
|
+
'Nothing is answering at this address any more: the review server moved to another ' +
|
|
2282
|
+
'port, or a different repo holds this one. Re-open the page from the Claude Code ' +
|
|
2283
|
+
'session that produced it and the buttons below will send.',
|
|
2284
|
+
)
|
|
2285
|
+
} else if (res.status === 422) {
|
|
2286
|
+
// THE ENGINE READ IT AND SAID NO. Its message names the entry that
|
|
2287
|
+
// was wrong, so it is relayed verbatim — paraphrasing is how a
|
|
2288
|
+
// reader ends up hunting for a problem the tool had already named.
|
|
2289
|
+
// AND NO "re-open": the page is fine, and re-opening would only be
|
|
2290
|
+
// refused again.
|
|
2291
|
+
sendFailed(
|
|
2292
|
+
json,
|
|
2293
|
+
verdict,
|
|
2294
|
+
'Not sent — the engine refused this pass.',
|
|
2295
|
+
text || 'It answered ' + res.status + ' without saying why.',
|
|
2296
|
+
)
|
|
2297
|
+
} else {
|
|
2298
|
+
sendFailed(
|
|
2299
|
+
json,
|
|
2300
|
+
verdict,
|
|
2301
|
+
'Not sent — the server answered ' + res.status + '.',
|
|
2302
|
+
text || 'It gave no reason.',
|
|
2303
|
+
)
|
|
2304
|
+
}
|
|
1640
2305
|
return
|
|
1641
2306
|
}
|
|
2307
|
+
clearSendFailure()
|
|
1642
2308
|
var code = ''
|
|
1643
2309
|
try { code = JSON.parse(text).code } catch (e) {}
|
|
1644
2310
|
// NAME THE COMMAND. Not "claim it with 418207", which instructs a
|
|
@@ -1648,25 +2314,194 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1648
2314
|
// stays, as the thing to CHECK: it is the one part of a waiting pass
|
|
1649
2315
|
// that tells yours apart from anyone else's.
|
|
1650
2316
|
if (code) {
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
2317
|
+
// SENT, AND NOW ASK WHAT BECAME OF IT. The page used to name the
|
|
2318
|
+
// command here unconditionally — the same sentence whether a session
|
|
2319
|
+
// was waiting to claim the pass or nothing was — because it had no
|
|
2320
|
+
// way to find out. It has one now.
|
|
2321
|
+
markDecided(verdict, '/spec-reviewed ' + code, null)
|
|
2322
|
+
watchPass(code, verdict)
|
|
1654
2323
|
} else {
|
|
1655
2324
|
// NO CODE, NO COMMAND. A `/spec-reviewed` with nothing after it still
|
|
1656
2325
|
// works — it picks up the single waiting pass — but the page cannot
|
|
1657
2326
|
// claim the server named THIS one, so it does not put words in its
|
|
1658
2327
|
// mouth. Bare is what the reader is told, because bare is what is true.
|
|
1659
2328
|
copyHint.textContent = 'Sent — run /spec-reviewed to pick it up.'
|
|
1660
|
-
markDecided(verdict)
|
|
2329
|
+
markDecided(verdict, null, null)
|
|
1661
2330
|
}
|
|
1662
2331
|
})
|
|
1663
2332
|
}, function () {
|
|
1664
|
-
//
|
|
1665
|
-
//
|
|
1666
|
-
|
|
2333
|
+
// THE REQUEST NEVER ARRIVED — the network, or a server that has stopped.
|
|
2334
|
+
// Distinct from both refusals above: there is no status and no message,
|
|
2335
|
+
// because nothing answered. Re-opening may fix it and the command
|
|
2336
|
+
// certainly will, so both are offered.
|
|
2337
|
+
sendFailed(
|
|
2338
|
+
json,
|
|
2339
|
+
verdict,
|
|
2340
|
+
'Not sent — could not reach the server.',
|
|
2341
|
+
'The request did not arrive: the server has stopped, or this device cannot reach it. ' +
|
|
2342
|
+
'Re-open the page from the session that produced it to try again.',
|
|
2343
|
+
)
|
|
2344
|
+
})
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
/* ==========================================================================
|
|
2348
|
+
* The command list — a verdict bar for a page that cannot deliver one
|
|
2349
|
+
*
|
|
2350
|
+
* A `file://` page has no server and no store, so its buttons never sent
|
|
2351
|
+
* anything: they built a JSON blob and put it on the clipboard to paste. That
|
|
2352
|
+
* is still the only way a pass carrying MARKS can travel, and it stays. What
|
|
2353
|
+
* changes is the common case — a reader who ticked nothing and wrote nothing,
|
|
2354
|
+
* whose whole pass is one word — where a command they run in the terminal
|
|
2355
|
+
* beside them beats a wall of text every time.
|
|
2356
|
+
*
|
|
2357
|
+
* WHICH SHAPE IS LIVE IS DECIDED BY WHAT THE PASS CARRIES, and it is
|
|
2358
|
+
* re-decided on every mark. Tick one accept and the rows stand down, because
|
|
2359
|
+
* a command line cannot carry someone's notes and dropping them silently
|
|
2360
|
+
* would be worse than asking for the paste. Clear it again and they return.
|
|
2361
|
+
* ======================================================================== */
|
|
2362
|
+
|
|
2363
|
+
// Only a `file://` page. A served page posts; a published one writes to its
|
|
2364
|
+
// store. Both can deliver, so both keep their buttons — decided from what the
|
|
2365
|
+
// page IS, exactly as `send` decides.
|
|
2366
|
+
var NO_TRANSPORT = location.protocol === 'file:' && !isArtifact()
|
|
2367
|
+
var cmdList = document.getElementById('cmd-list')
|
|
2368
|
+
var cmdLead = document.getElementById('cmd-lead')
|
|
2369
|
+
var cmdRows = {}
|
|
2370
|
+
|
|
2371
|
+
function buildCmdRows() {
|
|
2372
|
+
if (!NO_TRANSPORT || !cmdList) return
|
|
2373
|
+
// WHERE, not just what. "Run one of these where it is" assumed the reader
|
|
2374
|
+
// knew which terminal counts, and there is usually more than one open. The
|
|
2375
|
+
// answer is specific and worth saying: the session that produced this page
|
|
2376
|
+
// is the one holding the review.
|
|
2377
|
+
cmdLead.textContent =
|
|
2378
|
+
'This page has no way to reach Claude. Copy one of these and paste it into the ' +
|
|
2379
|
+
'Claude Code session that produced this review — that is the one waiting for your verdict.'
|
|
2380
|
+
// THE SAME LIST THE BAR USES. `OFFERED` already answers which verdicts this
|
|
2381
|
+
// render offers — a mid-run page lists Continue and never the committing
|
|
2382
|
+
// pair — so deriving the rows from it is what keeps the two from drifting.
|
|
2383
|
+
OFFERED.forEach(function (verdict) {
|
|
2384
|
+
var row = el('div', 'cmd-row')
|
|
2385
|
+
row.setAttribute('data-verdict', verdict)
|
|
2386
|
+
var label = el('span', 'cmd-label', LABELS[verdict])
|
|
2387
|
+
var cmd = el('code', 'cmd-text', '/spec-reviewed ' + verdict)
|
|
2388
|
+
row.appendChild(label)
|
|
2389
|
+
row.appendChild(cmd)
|
|
2390
|
+
// NEVER A BUTTON THAT CANNOT COPY — the same rule `showCommand` follows,
|
|
2391
|
+
// through the same `canCopy`. A control that does nothing on tap reads as
|
|
2392
|
+
// a broken page; a control that is MISSING where it would have worked
|
|
2393
|
+
// reads as a page that never offered it, which is the failure this shares
|
|
2394
|
+
// its capability check to avoid.
|
|
2395
|
+
if (canCopy()) {
|
|
2396
|
+
var copy = el('button', 'cmd-copy', 'Copy')
|
|
2397
|
+
copy.setAttribute('type', 'button')
|
|
2398
|
+
copy.addEventListener('click', function () {
|
|
2399
|
+
copyText(cmd.textContent, cmd).then(function () {
|
|
2400
|
+
// COPIED IS DECIDED. The reader has the command; what happens next
|
|
2401
|
+
// happens in their terminal, and the page has nothing left to do
|
|
2402
|
+
// but say what they chose — with the command repeated, because a
|
|
2403
|
+
// copy can fail silently and an hour later this box is the only
|
|
2404
|
+
// record of it.
|
|
2405
|
+
markDecided(verdict, cmd.textContent, false)
|
|
2406
|
+
}, function () {
|
|
2407
|
+
copy.textContent = selectNode(cmd) ? 'Copy failed — selected' : 'Copy failed'
|
|
2408
|
+
})
|
|
2409
|
+
})
|
|
2410
|
+
row.appendChild(copy)
|
|
2411
|
+
}
|
|
2412
|
+
cmdList.appendChild(row)
|
|
2413
|
+
cmdRows[verdict] = row
|
|
1667
2414
|
})
|
|
1668
2415
|
}
|
|
1669
2416
|
|
|
2417
|
+
/**
|
|
2418
|
+
* Decide, now, which of the two shapes this page is offering.
|
|
2419
|
+
*
|
|
2420
|
+
* Called from `refresh`, so it re-answers on every mark rather than once at
|
|
2421
|
+
* load: the reader who ticks an accept half-way through has changed what
|
|
2422
|
+
* their pass contains, and the page has to change with them.
|
|
2423
|
+
*/
|
|
2424
|
+
function drawCmdList() {
|
|
2425
|
+
if (!NO_TRANSPORT || !cmdList) return
|
|
2426
|
+
var marked = pending() > 0
|
|
2427
|
+
cmdList.hidden = marked
|
|
2428
|
+
Object.keys(cmdRows).forEach(function (k) { cmdRows[k].hidden = marked })
|
|
2429
|
+
// The bar comes back to carry what a command cannot, and SAYS SO. A reader
|
|
2430
|
+
// whose notes had been dropped would not find out until the review came
|
|
2431
|
+
// back without them.
|
|
2432
|
+
verdictEl.hidden = !marked
|
|
2433
|
+
if (marked) {
|
|
2434
|
+
copyHint.hidden = false
|
|
2435
|
+
copyHint.textContent =
|
|
2436
|
+
'You have marked something up, and a command line cannot carry notes — ' +
|
|
2437
|
+
'use a verdict button below and paste what it copies.'
|
|
2438
|
+
} else if (copyHint.textContent.indexOf('cannot carry notes') !== -1) {
|
|
2439
|
+
copyHint.hidden = true
|
|
2440
|
+
copyHint.textContent = ''
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
// How long to keep asking, and how often. A claim is a file-watch waking a
|
|
2445
|
+
// session that is already sitting there, so the answer normally arrives in
|
|
2446
|
+
// well under a second; these are sized for the case where it does not.
|
|
2447
|
+
var PASS_POLL_MS = 900
|
|
2448
|
+
var PASS_POLL_ROUNDS = 8
|
|
2449
|
+
|
|
2450
|
+
/**
|
|
2451
|
+
* Ask the engine what became of the pass we just sent, and end on the answer.
|
|
2452
|
+
*
|
|
2453
|
+
* THE PAGE STOPPED GUESSING. It used to hand over `/spec-reviewed <code>` on
|
|
2454
|
+
* every successful POST, because it could not tell an auto-claimed pass from
|
|
2455
|
+
* one nobody was listening for — so the reader was told to run a command they
|
|
2456
|
+
* almost never needed, in the same words used when running it was the only
|
|
2457
|
+
* thing that would save their review. Both sentences are now earned.
|
|
2458
|
+
*
|
|
2459
|
+
* ONE ANSWER TAKES THE COMMAND AWAY, and it is the positive one: `claimed`,
|
|
2460
|
+
* which the engine reads off a decision log that NAMES this code. Everything
|
|
2461
|
+
* else — `waiting`, `unknown`, a server that never replied, a body that did
|
|
2462
|
+
* not parse, an older daemon with no lookup, the rounds running out — leaves
|
|
2463
|
+
* the command exactly where it is. A command nobody needed to run costs a
|
|
2464
|
+
* glance; a pass nobody claims costs the review.
|
|
2465
|
+
*
|
|
2466
|
+
* WHAT WOULD FOOL A LOOSER VERSION: treating a failed poll as "probably fine".
|
|
2467
|
+
* The failures that make a poll fail are the same ones that make a claim fail
|
|
2468
|
+
* — the machine sleeping, the daemon restarting, the network going — so the
|
|
2469
|
+
* moment this is least able to see is the moment its answer matters most.
|
|
2470
|
+
*/
|
|
2471
|
+
function watchPass(code, verdict) {
|
|
2472
|
+
var rounds = 0
|
|
2473
|
+
// `?pass=` on the page's own path, so the serve token in the URL guards
|
|
2474
|
+
// this exactly as it guards everything else and there is no second address.
|
|
2475
|
+
var url = location.pathname + '?pass=' + encodeURIComponent(code)
|
|
2476
|
+
|
|
2477
|
+
function stop(picked) {
|
|
2478
|
+
markDecided(verdict, '/spec-reviewed ' + code, picked)
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
function ask() {
|
|
2482
|
+
rounds++
|
|
2483
|
+
fetch(url, { method: 'GET', headers: { accept: 'application/json' } }).then(function (res) {
|
|
2484
|
+
if (!res || !res.ok) return again()
|
|
2485
|
+
return res.text().then(function (text) {
|
|
2486
|
+
var state = null
|
|
2487
|
+
try { state = JSON.parse(text).state } catch (e) {}
|
|
2488
|
+
if (state === 'claimed') return stop(true)
|
|
2489
|
+
return again()
|
|
2490
|
+
})
|
|
2491
|
+
}, again)
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
function again() {
|
|
2495
|
+
// Out of rounds is a CANNOT-TELL, not a no. Nobody is obliged to claim a
|
|
2496
|
+
// pass inside seven seconds, and a page that concluded "abandoned" would
|
|
2497
|
+
// be inventing an answer out of its own impatience.
|
|
2498
|
+
if (rounds >= PASS_POLL_ROUNDS) return stop(false)
|
|
2499
|
+
window.setTimeout(ask, PASS_POLL_MS)
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
ask()
|
|
2503
|
+
}
|
|
2504
|
+
|
|
1670
2505
|
/**
|
|
1671
2506
|
* Is this page running as a published Artifact?
|
|
1672
2507
|
*
|
|
@@ -1714,7 +2549,11 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1714
2549
|
// the one thing that is true and actionable.
|
|
1715
2550
|
copyHint.textContent = 'Sent. Run this where Claude is:'
|
|
1716
2551
|
showCommand('/spec-reviewed')
|
|
1717
|
-
|
|
2552
|
+
// A PUBLISHED PAGE CANNOT ASK. The pass went into the artifact's own
|
|
2553
|
+
// store, which the engine only reads when someone tells it to, so
|
|
2554
|
+
// nothing here could ever come back `true` — and the command is the
|
|
2555
|
+
// whole ending rather than a fallback within it.
|
|
2556
|
+
markDecided(verdict, '/spec-reviewed', null)
|
|
1718
2557
|
})
|
|
1719
2558
|
}).then(null, function (err) {
|
|
1720
2559
|
if (landed) return
|
|
@@ -1762,6 +2601,186 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1762
2601
|
})
|
|
1763
2602
|
}
|
|
1764
2603
|
|
|
2604
|
+
/**
|
|
2605
|
+
* The surfaces strip: where this review lives, and whether it is also running.
|
|
2606
|
+
*
|
|
2607
|
+
* AN ACTION IS NOT A VERDICT, and the whole strip exists to make that visible.
|
|
2608
|
+
* A verdict is the reader's conclusion, consumed once. These change what is
|
|
2609
|
+
* RUNNING (or which tiers are permitted) and hand the reader straight back to
|
|
2610
|
+
* the same page with the same options — so pressing one must not close the
|
|
2611
|
+
* page into its decided state, and must not clear a gate.
|
|
2612
|
+
*
|
|
2613
|
+
* ENABLE-ONLY for the tiers. Turning `network` off from a page reached over
|
|
2614
|
+
* the network kills the page doing the turning, and the reader gains nothing
|
|
2615
|
+
* they could not get by typing the command — so the engine's `tierAction`
|
|
2616
|
+
* never offers a disable and this renders whatever it is given.
|
|
2617
|
+
*/
|
|
2618
|
+
var ACTION_LABEL = {
|
|
2619
|
+
// NAMES THE COMMIT IT PERFORMS. `live take` refuses a dirty worktree, so
|
|
2620
|
+
// the action commits the phase before taking the instance — and a press
|
|
2621
|
+
// that commits behind a label reading only "put it live" is a side effect
|
|
2622
|
+
// nobody agreed to. The reader has still approved nothing: this is a
|
|
2623
|
+
// precondition of the thing they asked for, not a verdict on the diff.
|
|
2624
|
+
'live-on': '▶ Commit & put it live',
|
|
2625
|
+
'allow-network': '▶ Allow network',
|
|
2626
|
+
'allow-remote': '▶ Allow remote',
|
|
2627
|
+
}
|
|
2628
|
+
var ACTION_CMD = {
|
|
2629
|
+
'live-on': '/spec-live',
|
|
2630
|
+
'allow-network': 'skitterspec spec-env review allow network',
|
|
2631
|
+
'allow-remote': '/spec-remote-review',
|
|
2632
|
+
}
|
|
2633
|
+
var SURFACE_LABEL = { live: 'live', network: 'network', remote: 'remote' }
|
|
2634
|
+
|
|
2635
|
+
function surfaceStateText(row) {
|
|
2636
|
+
if (row.kind === 'live') {
|
|
2637
|
+
if (row.state === 'on') return row.url ? 'on — running at ' + row.url : 'on'
|
|
2638
|
+
if (row.state === 'held') return row.reason || 'held by another spec'
|
|
2639
|
+
return 'off'
|
|
2640
|
+
}
|
|
2641
|
+
if (row.state === 'on') return row.url ? 'on — ' + row.url : 'on'
|
|
2642
|
+
return row.state === 'off' ? 'off' : 'unavailable'
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
function buildSurfaces() {
|
|
2646
|
+
var section = document.getElementById('surfaces')
|
|
2647
|
+
var host = document.getElementById('surface-rows')
|
|
2648
|
+
// ABSENT, NOT EMPTY. A render that carried no surfaces gets no strip and no
|
|
2649
|
+
// line explaining the absence — a `--docs` page has no branch to put live,
|
|
2650
|
+
// and that is a healthy page rather than a deprived one.
|
|
2651
|
+
if (!section || !host || !data.surfaces || !data.surfaces.length) return
|
|
2652
|
+
section.hidden = false
|
|
2653
|
+
data.surfaces.forEach(function (row) {
|
|
2654
|
+
var div = document.createElement('div')
|
|
2655
|
+
div.className = 'surface-row'
|
|
2656
|
+
var label = document.createElement('span')
|
|
2657
|
+
label.className = 'surface-label'
|
|
2658
|
+
label.textContent = SURFACE_LABEL[row.kind === 'live' ? 'live' : row.tier] || ''
|
|
2659
|
+
div.appendChild(label)
|
|
2660
|
+
var state = document.createElement('span')
|
|
2661
|
+
state.className = 'surface-state'
|
|
2662
|
+
state.textContent = surfaceStateText(row)
|
|
2663
|
+
div.appendChild(state)
|
|
2664
|
+
if (row.action) {
|
|
2665
|
+
if (NO_TRANSPORT) {
|
|
2666
|
+
// No server and no store, so a button here would build an instruction
|
|
2667
|
+
// with nowhere to go. The command is the honest control — exactly the
|
|
2668
|
+
// trade the verdict bar already makes on a `file://` page.
|
|
2669
|
+
var cmd = document.createElement('code')
|
|
2670
|
+
cmd.className = 'surface-cmd'
|
|
2671
|
+
cmd.textContent = ACTION_CMD[row.action] || ''
|
|
2672
|
+
div.appendChild(cmd)
|
|
2673
|
+
} else {
|
|
2674
|
+
var btn = document.createElement('button')
|
|
2675
|
+
btn.type = 'button'
|
|
2676
|
+
btn.textContent = ACTION_LABEL[row.action] || row.action
|
|
2677
|
+
btn.setAttribute('data-action', row.action)
|
|
2678
|
+
btn.addEventListener('click', function () {
|
|
2679
|
+
if (btn.disabled) return
|
|
2680
|
+
sendAction(row.action, btn)
|
|
2681
|
+
})
|
|
2682
|
+
div.appendChild(btn)
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
// THE COMMAND FOR A DIRECTION THE PAGE DOES NOT OFFER. A live spec shows
|
|
2686
|
+
// no press — handing the instance back to `main` is a workspace decision,
|
|
2687
|
+
// not this review's — so the line names `/spec-live main` instead, and the
|
|
2688
|
+
// reader is never left knowing what they want and not what to type.
|
|
2689
|
+
if (row.command && !row.action) {
|
|
2690
|
+
var cmdOnly = document.createElement('code')
|
|
2691
|
+
cmdOnly.className = 'surface-cmd'
|
|
2692
|
+
cmdOnly.textContent = row.command
|
|
2693
|
+
div.appendChild(cmdOnly)
|
|
2694
|
+
}
|
|
2695
|
+
if (row.note) {
|
|
2696
|
+
var note = document.createElement('span')
|
|
2697
|
+
note.className = 'surface-note'
|
|
2698
|
+
note.textContent = row.note
|
|
2699
|
+
div.appendChild(note)
|
|
2700
|
+
}
|
|
2701
|
+
host.appendChild(div)
|
|
2702
|
+
})
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
/**
|
|
2706
|
+
* Send an action, and leave the page exactly as live as it was.
|
|
2707
|
+
*
|
|
2708
|
+
* IT CARRIES NO MARKS. An action blob is an instruction, not a review pass —
|
|
2709
|
+
* so the accepts and comments stay on the page and travel with the verdict
|
|
2710
|
+
* the reader eventually chooses. Sending them here would merge them early and
|
|
2711
|
+
* then send them again, which is one reader's review counted twice.
|
|
2712
|
+
*
|
|
2713
|
+
* AND IT NEVER CALLS `markDecided`. The reader still owes a verdict; closing
|
|
2714
|
+
* the page over an action would be the record-and-do-nothing ending this
|
|
2715
|
+
* whole contract is against.
|
|
2716
|
+
*/
|
|
2717
|
+
function sendAction(action, btn) {
|
|
2718
|
+
var blob = {
|
|
2719
|
+
version: 1,
|
|
2720
|
+
spec: data.spec,
|
|
2721
|
+
generatedAt: data.generatedAt,
|
|
2722
|
+
accepted: [],
|
|
2723
|
+
unaccepted: [],
|
|
2724
|
+
comments: [],
|
|
2725
|
+
action: action,
|
|
2726
|
+
}
|
|
2727
|
+
btn.disabled = true
|
|
2728
|
+
var was = btn.textContent
|
|
2729
|
+
btn.textContent = 'Sending…'
|
|
2730
|
+
fetch(location.pathname, {
|
|
2731
|
+
method: 'POST',
|
|
2732
|
+
headers: { 'content-type': 'application/json' },
|
|
2733
|
+
body: JSON.stringify(blob),
|
|
2734
|
+
}).then(function (res) {
|
|
2735
|
+
if (!res || !res.ok) {
|
|
2736
|
+
btn.disabled = false
|
|
2737
|
+
btn.textContent = was
|
|
2738
|
+
return
|
|
2739
|
+
}
|
|
2740
|
+
return res.text().then(function (text) {
|
|
2741
|
+
var code = null
|
|
2742
|
+
try { code = JSON.parse(text).code } catch (e) {}
|
|
2743
|
+
btn.textContent = 'Sent'
|
|
2744
|
+
// RELOAD WHEN IT HAS BEEN ACTED ON, not on a timer. The agent re-renders
|
|
2745
|
+
// after the action, so the page the reader wants is the next one — and
|
|
2746
|
+
// `?pass=` already answers whether this exact code was claimed. Running
|
|
2747
|
+
// out of rounds leaves the page alone rather than reloading on a guess.
|
|
2748
|
+
if (code) watchAction(code, btn)
|
|
2749
|
+
})
|
|
2750
|
+
}, function () {
|
|
2751
|
+
btn.disabled = false
|
|
2752
|
+
btn.textContent = was
|
|
2753
|
+
})
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
function watchAction(code, btn) {
|
|
2757
|
+
var rounds = 0
|
|
2758
|
+
var url = location.pathname + '?pass=' + encodeURIComponent(code)
|
|
2759
|
+
function ask() {
|
|
2760
|
+
rounds++
|
|
2761
|
+
fetch(url, { method: 'GET', headers: { accept: 'application/json' } }).then(function (res) {
|
|
2762
|
+
if (!res || !res.ok) return again()
|
|
2763
|
+
return res.text().then(function (text) {
|
|
2764
|
+
var state = null
|
|
2765
|
+
try { state = JSON.parse(text).state } catch (e) {}
|
|
2766
|
+
if (state === 'claimed') return window.location.reload()
|
|
2767
|
+
return again()
|
|
2768
|
+
})
|
|
2769
|
+
}, again)
|
|
2770
|
+
}
|
|
2771
|
+
function again() {
|
|
2772
|
+
// CANNOT TELL, so change nothing. Nobody is obliged to act on an action
|
|
2773
|
+
// inside a few seconds, and a page that reloaded out of impatience would
|
|
2774
|
+
// throw away marks the reader had not sent yet.
|
|
2775
|
+
if (rounds >= PASS_POLL_ROUNDS) {
|
|
2776
|
+
btn.textContent = 'Sent — reload when it is done'
|
|
2777
|
+
return
|
|
2778
|
+
}
|
|
2779
|
+
window.setTimeout(ask, PASS_POLL_MS)
|
|
2780
|
+
}
|
|
2781
|
+
ask()
|
|
2782
|
+
}
|
|
2783
|
+
|
|
1765
2784
|
Object.keys(verdictBtns).forEach(function (verdict) {
|
|
1766
2785
|
verdictBtns[verdict].addEventListener('click', function () {
|
|
1767
2786
|
// A disabled or hidden button does not fire in a browser; the guard is
|
|
@@ -1788,10 +2807,13 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1788
2807
|
}
|
|
1789
2808
|
|
|
1790
2809
|
restore()
|
|
2810
|
+
buildCmdRows()
|
|
2811
|
+
buildSurfaces()
|
|
1791
2812
|
drawContext()
|
|
1792
2813
|
drawLog()
|
|
1793
2814
|
drawDecided()
|
|
1794
2815
|
wireChecks()
|
|
2816
|
+
wireGotos()
|
|
1795
2817
|
state.forEach(function (st) {
|
|
1796
2818
|
var btn = st.details && st.details.querySelector('.accept')
|
|
1797
2819
|
if (btn) btn.setAttribute('aria-pressed', String(st.accept))
|
|
@@ -1813,7 +2835,54 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1813
2835
|
})
|
|
1814
2836
|
|
|
1815
2837
|
// --- header --------------------------------------------------------------
|
|
1816
|
-
document.getElementById('title')
|
|
2838
|
+
var titleEl = document.getElementById('title')
|
|
2839
|
+
titleEl.textContent = data.title
|
|
2840
|
+
/**
|
|
2841
|
+
* The tracker ticket, beside the title and linked where there is a url.
|
|
2842
|
+
*
|
|
2843
|
+
* `href` IS SET ONLY FROM A URL THE ENGINE ALREADY VETTED as http(s) — see
|
|
2844
|
+
* `readTicket`. The value originates in a spec file someone edits, and this
|
|
2845
|
+
* page is served over the network, so a `javascript:` url in an href would be
|
|
2846
|
+
* script execution. An id with no usable url still renders; it just does not
|
|
2847
|
+
* become a link, which is the harmless half of the pair.
|
|
2848
|
+
*/
|
|
2849
|
+
;(function () {
|
|
2850
|
+
var tk = data.context && data.context.ticket
|
|
2851
|
+
if (!tk || !tk.id) return
|
|
2852
|
+
var node
|
|
2853
|
+
if (tk.url) {
|
|
2854
|
+
node = document.createElement('a')
|
|
2855
|
+
node.setAttribute('href', tk.url)
|
|
2856
|
+
node.setAttribute('target', '_blank')
|
|
2857
|
+
node.setAttribute('rel', 'noopener noreferrer')
|
|
2858
|
+
} else {
|
|
2859
|
+
node = document.createElement('span')
|
|
2860
|
+
}
|
|
2861
|
+
node.className = 'ticket'
|
|
2862
|
+
node.textContent = tk.id
|
|
2863
|
+
titleEl.appendChild(document.createTextNode(' '))
|
|
2864
|
+
titleEl.appendChild(node)
|
|
2865
|
+
})()
|
|
2866
|
+
|
|
2867
|
+
/**
|
|
2868
|
+
* An ISO timestamp is a machine's format. The header showed
|
|
2869
|
+
* `2026-09-16T13:53:58.370Z` to a person deciding whether a page was stale,
|
|
2870
|
+
* which is the one question it is there to answer and the hardest way to ask
|
|
2871
|
+
* it. Rendered in the READER's locale and zone, because that is the clock
|
|
2872
|
+
* they are comparing against; the raw value stays as the `title` so nothing
|
|
2873
|
+
* precise is lost, and it survives a browser that throws on either call.
|
|
2874
|
+
*/
|
|
2875
|
+
function whenRendered(iso) {
|
|
2876
|
+
try {
|
|
2877
|
+
var d = new Date(iso)
|
|
2878
|
+
if (isNaN(d.getTime())) return iso
|
|
2879
|
+
return d.toLocaleString(undefined, {
|
|
2880
|
+
day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
|
2881
|
+
})
|
|
2882
|
+
} catch (e) {
|
|
2883
|
+
return iso
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
1817
2886
|
var t = data.totals
|
|
1818
2887
|
var sub = document.getElementById('sub')
|
|
1819
2888
|
function part(text, cls) { sub.appendChild(el('span', cls, text)) }
|
|
@@ -1822,7 +2891,9 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1822
2891
|
sep()
|
|
1823
2892
|
part(data.mode === 'branch'
|
|
1824
2893
|
? 'everything since ' + data.base + (data.fellBack ? ' (working tree clean)' : '')
|
|
1825
|
-
:
|
|
2894
|
+
: data.mode === 'docs'
|
|
2895
|
+
? "the spec's own documents"
|
|
2896
|
+
: 'uncommitted work')
|
|
1826
2897
|
sep()
|
|
1827
2898
|
part(t.files + ' file' + (t.files === 1 ? '' : 's'))
|
|
1828
2899
|
sep()
|
|
@@ -1830,7 +2901,9 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1830
2901
|
part(' ')
|
|
1831
2902
|
part('-' + t.deletions, 'd')
|
|
1832
2903
|
sep()
|
|
1833
|
-
|
|
2904
|
+
var when = el('span', null, whenRendered(data.generatedAt))
|
|
2905
|
+
when.setAttribute('title', data.generatedAt)
|
|
2906
|
+
sub.appendChild(when)
|
|
1834
2907
|
})()
|
|
1835
2908
|
</script>
|
|
1836
2909
|
</body>
|