@skitterbyte/skitterspec-linear 13.0.0 → 15.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.
@@ -386,7 +386,13 @@ tr.note-row td {
386
386
  .verdict-title { font-size: .8rem; color: var(--muted); }
387
387
  .verdict-count { font-size: .8rem; color: var(--muted); }
388
388
  .v-commit { color: var(--good-fg); background: var(--good-bg); }
389
+ /* Continue borrows the same affirmative pair: it is the mid-run reader saying
390
+ carry on, and it never appears beside a commit button to be confused with. */
391
+ .v-continue { color: var(--good-fg); background: var(--good-bg); }
389
392
  .v-changes { color: var(--flag-fg); background: var(--flag-bg); }
393
+ /* A button the render did not ask for is GONE, not dimmed — a disabled control
394
+ invites a reader to work out how to enable it, and there is nothing to do. */
395
+ .verdict button[hidden] { display: none; }
390
396
  /* A blocked control is dashed as well as dimmed, so the block survives a
391
397
  greyscale screen — and the reason rides in the label, never in a tooltip
392
398
  nobody hovers on a phone. */
@@ -411,20 +417,103 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
411
417
  padding: .5rem;
412
418
  }
413
419
  .copy-out[hidden] { display: none; }
414
- .context { margin: 0 0 1rem; padding: .75rem .9rem; border: 1px solid var(--line); border-radius: 6px; background: var(--panel, transparent); }
415
- .context h3 { font-size: .78rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: .9rem 0 .35rem; }
420
+ /* THE REVIEW, ONCE IT IS OVER. A verdict that has actually been handed over
421
+ ends the page: the diff fades out and what you decided takes its place. The
422
+ fade is a transition on a class rather than a timer, so nothing depends on
423
+ JavaScript finishing, and `Show the diff anyway` brings it back — read-only,
424
+ with the buttons dead. */
425
+ .reviewable { transition: opacity .35s ease; }
426
+ .is-decided .reviewable { display: none; }
427
+ .is-decided.show-diff .reviewable { display: block; opacity: .75; }
428
+ .is-decided.show-diff .reviewable:hover { opacity: 1; }
429
+ /* Bringing the diff back must not resurrect a section that was hidden for its
430
+ own reasons — a spec with no context ships `#context` hidden, and the rule
431
+ above would have shown an empty panel. The second selector is there for
432
+ specificity: `.is-decided.show-diff .reviewable` outranks a bare
433
+ `.reviewable[hidden]`. */
434
+ .reviewable[hidden],
435
+ .is-decided.show-diff .reviewable[hidden] { display: none; }
436
+
437
+ .decided {
438
+ margin: 0 0 1.25rem;
439
+ padding: 1.1rem 1.2rem;
440
+ border: 1px solid var(--line);
441
+ border-left: 4px solid var(--accent);
442
+ border-radius: 10px;
443
+ background: var(--panel);
444
+ animation: decided-in .35s ease;
445
+ }
446
+ @keyframes decided-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
447
+ .decided[hidden] { display: none; }
448
+ .decided-what { margin: 0 0 .35rem; font-size: 1.05rem; font-weight: 650; line-height: 1.4; }
449
+ .decided-note { margin: 0 0 .8rem; color: var(--muted); font-size: .88rem; line-height: 1.5; max-width: 62ch; }
450
+ .decided-note:last-child { margin-bottom: 0; }
451
+ .decided-toggle { font-size: .78rem; }
452
+
453
+ /* WHY THE CHANGE EXISTS — the PR description this page never had. It is prose
454
+ to be read rather than data to be scanned, so it gets a measure, air, and a
455
+ quieter voice than the diff below it. It was once full-bleed 15px text at
456
+ 1.5 in a tight box, which read as a wall and got skipped. */
457
+ .context {
458
+ margin: 0 0 1.5rem;
459
+ padding: 1.1rem 1.3rem;
460
+ border: 1px solid var(--line);
461
+ border-radius: 10px;
462
+ background: var(--panel, transparent);
463
+ }
464
+ .context h3 {
465
+ font-size: .72rem;
466
+ text-transform: uppercase;
467
+ letter-spacing: .08em;
468
+ color: var(--muted);
469
+ font-weight: 650;
470
+ margin: 1.4rem 0 .5rem;
471
+ }
416
472
  .context h3:first-child { margin-top: 0; }
417
- .context p { margin: 0 0 .5rem; line-height: 1.5; }
473
+ /* A MEASURE. Prose set the full width of a 1400px page is measurably harder to
474
+ read — the eye loses the line it is returning to. */
475
+ .context p { margin: 0 0 .7rem; font-size: .92rem; line-height: 1.65; max-width: 72ch; }
418
476
  .context p:last-child { margin-bottom: 0; }
419
- .context table { border-collapse: collapse; width: 100%; font-size: .85rem; }
420
- .context th, .context td { text-align: left; padding: .2rem .5rem .2rem 0; vertical-align: top; }
421
- .context th { color: var(--muted); font-weight: 600; }
422
- .context ul { margin: 0; padding-left: 1.1rem; }
423
- .context li { margin: .15rem 0; line-height: 1.45; }
477
+ .context table { border-collapse: collapse; width: 100%; font-size: .84rem; margin: .2rem 0 .3rem; }
478
+ .context th, .context td { text-align: left; padding: .35rem .8rem .35rem 0; vertical-align: top; }
479
+ .context th {
480
+ color: var(--muted);
481
+ font-weight: 600;
482
+ font-size: .72rem;
483
+ text-transform: uppercase;
484
+ letter-spacing: .05em;
485
+ border-bottom: 1px solid var(--line);
486
+ padding-bottom: .3rem;
487
+ }
488
+ .context tbody tr + tr td { border-top: 1px solid var(--line); }
489
+ .context ul { margin: 0; padding-left: 1.15rem; max-width: 72ch; }
490
+ .context li { margin: .3rem 0; font-size: .92rem; line-height: 1.6; }
491
+ /* The disclosure is a CONTROL, not a panel. Full-width and boxed, it read as a
492
+ section header that happened to be clickable. */
493
+ .context-more { margin: .9rem 0 0; }
494
+ .context-more > summary {
495
+ display: inline-block;
496
+ width: auto;
497
+ cursor: pointer;
498
+ font-size: .78rem;
499
+ color: var(--muted);
500
+ padding: .25rem .7rem;
501
+ border: 1px solid var(--line);
502
+ border-radius: 999px;
503
+ list-style: none;
504
+ }
505
+ .context-more > summary::-webkit-details-marker { display: none; }
506
+ .context-more > summary:hover { color: var(--accent); border-color: var(--accent); }
507
+ .context-more[open] > summary { margin-bottom: .6rem; }
424
508
  .context li.done { color: var(--muted); }
425
509
  .context-more > summary { cursor: pointer; color: var(--muted); font-size: .85rem; margin-top: .6rem; }
426
510
  .copy-hint { font-size: .8rem; color: var(--muted); margin: .4rem 0 0; }
427
511
  .sent-cmd { display: flex; gap: .4rem; align-items: stretch; margin: .4rem 0 0; }
512
+ /* A `display` rule BEATS the browser's own `[hidden] { display: none }`, so
513
+ every element this page hides in JavaScript needs its own override or the
514
+ hide silently does nothing. It shipped without one and a finished review kept
515
+ showing an empty command box. `assets-review` guards the whole class now. */
516
+ .sent-cmd[hidden] { display: none; }
428
517
  .sent-cmd-text {
429
518
  flex: 1; min-width: 0; font-family: var(--mono, ui-monospace, monospace);
430
519
  font-size: .85rem; padding: .35rem .5rem; color: var(--fg);
@@ -437,7 +526,7 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
437
526
  </style>
438
527
  </head>
439
528
  <body>
440
- <div class="wrap">
529
+ <div class="wrap" id="wrap">
441
530
  <header>
442
531
  <h1 id="title"></h1>
443
532
  <p class="sub" id="sub"></p>
@@ -459,15 +548,23 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
459
548
  engine from the spec's own files — nothing here passed through the
460
549
  model, so it is free however large the diff. Absent entirely for a spec
461
550
  whose overview cannot be read: the page worked without it before. -->
462
- <section class="context" id="context" hidden>
551
+ <!-- WHAT YOU DECIDED, once the pass has actually been handed over. It sits
552
+ above everything because on a re-open it is the only thing you need:
553
+ the review is over, and this says how it ended. -->
554
+ <section class="decided" id="decided" hidden>
555
+ <p class="decided-what" id="decided-what"></p>
556
+ <p class="decided-note" id="decided-note"></p>
557
+ <button type="button" class="decided-toggle" id="decided-toggle">Show the diff anyway</button>
558
+ </section>
559
+ <section class="context reviewable" id="context" hidden>
463
560
  <div class="context-why" id="context-why"></div>
464
561
  <details class="context-more" id="context-more" hidden>
465
562
  <summary id="context-more-summary">More</summary>
466
563
  <div id="context-rest"></div>
467
564
  </details>
468
565
  </section>
469
- <div id="review-block">__REVIEW_BLOCK__</div>
470
- <div id="files"></div>
566
+ <div id="review-block" class="reviewable">__REVIEW_BLOCK__</div>
567
+ <div id="files" class="reviewable"></div>
471
568
  <!-- THE VERDICT SITS AT THE END, where reading finishes. It began in the
472
569
  header and the first person to use it could not find it: you read a
473
570
  350-line diff downward, and the control asking for your conclusion was
@@ -479,6 +576,12 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
479
576
  <span class="verdict-title">Your verdict</span>
480
577
  <button id="verdict-commit" class="v-commit" type="button" aria-live="polite">✓ Commit</button>
481
578
  <button id="verdict-commit-continue" class="v-commit" type="button" aria-live="polite">✓ Commit &amp; Continue</button>
579
+ <!-- THE MID-RUN VERDICT, hidden unless the render asked for it. "Commit"
580
+ is the wrong verb for unfinished work, so a page rendered part-way
581
+ through a run offers this instead of the committing pair: I have
582
+ read it, carry on. It is not the removed `none` verdict — that
583
+ recorded itself and did nothing, while this one resumes the run. -->
584
+ <button id="verdict-continue" class="v-continue" type="button" hidden>→ Continue</button>
482
585
  <button id="verdict-changes" class="v-changes" type="button">↺ Request changes</button>
483
586
  <button id="verdict-discuss" type="button">… Discuss first</button>
484
587
  <span class="verdict-count" id="verdict-count" aria-live="polite"></span>
@@ -613,6 +716,9 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
613
716
  // Best effort, both ways. Safari refuses storage on file:// by THROWING, and
614
717
  // a review page that fails to render because it could not autosave would be a
615
718
  // far worse bug than a lost pass.
719
+ // What this reader concluded, once it was actually HANDED OVER — never on a
720
+ // clipboard copy, which is a pass still sitting in the reader's hands.
721
+ var decided = null
616
722
  var STORE_KEY = 'skitterspec-review:' + data.spec + ':' + data.generatedAt
617
723
  function save() {
618
724
  try {
@@ -622,7 +728,10 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
622
728
  accepts[st.file.path] = st.accept
623
729
  st.drafts.forEach(function (d) { drafts.push(d) })
624
730
  })
625
- window.localStorage.setItem(STORE_KEY, JSON.stringify({ seq: seq, accepts: accepts, drafts: drafts, replies: replies }))
731
+ window.localStorage.setItem(
732
+ STORE_KEY,
733
+ JSON.stringify({ seq: seq, accepts: accepts, drafts: drafts, replies: replies, decided: decided }),
734
+ )
626
735
  } catch (e) { /* no storage, or a quota — the pass still works, it just is not saved */ }
627
736
  }
628
737
  function restore() {
@@ -633,6 +742,10 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
633
742
  try { saved = JSON.parse(raw) } catch (e) { return }
634
743
  seq = saved.seq || 0
635
744
  replies = saved.replies || {}
745
+ // THE DECISION OUTLIVES THE TAB. Keyed to this render like every other
746
+ // mark, so the next render — the one after the commit — is a live page
747
+ // again rather than a page that thinks it is finished.
748
+ if (saved.decided && saved.decided.verdict) decided = saved.decided
636
749
  var byPath = {}
637
750
  state.forEach(function (st) { byPath[st.file.path] = st })
638
751
  Object.keys(saved.accepts || {}).forEach(function (path) {
@@ -1115,6 +1228,7 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1115
1228
  var verdictBtns = {
1116
1229
  commit: document.getElementById('verdict-commit'),
1117
1230
  'commit-continue': document.getElementById('verdict-commit-continue'),
1231
+ continue: document.getElementById('verdict-continue'),
1118
1232
  changes: document.getElementById('verdict-changes'),
1119
1233
  discuss: document.getElementById('verdict-discuss'),
1120
1234
  }
@@ -1122,17 +1236,55 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1122
1236
  // here, and the shared block below follows for free — the same reason the
1123
1237
  // engine keeps a `COMMITTING` list rather than a second condition.
1124
1238
  var COMMITTERS = ['commit', 'commit-continue']
1125
- var LABELS = { commit: '✓ Commit', 'commit-continue': '✓ Commit & Continue' }
1239
+
1240
+ /*
1241
+ * WHICH BUTTONS THIS PAGE OFFERS, and it comes from the render rather than
1242
+ * from the gate. Deriving it — mid-run iff nothing is armed — would take the
1243
+ * committing buttons away from every page in a project running
1244
+ * `review.required: false`, which never arms and whose readers must still be
1245
+ * able to commit from the page. The engine's `BUTTON_SETS` is the same
1246
+ * decision on the other side of the wire.
1247
+ *
1248
+ * ABSENT IS THE COMMITTING SET. A page rendered before any of this existed,
1249
+ * and a caller that did not ask, both land here — so nothing about their page
1250
+ * changes.
1251
+ */
1252
+ var MIDRUN = data.buttons === 'midrun'
1253
+ var OFFERED = MIDRUN ? ['continue', 'changes', 'discuss'] : ['commit', 'commit-continue', 'changes', 'discuss']
1254
+ Object.keys(verdictBtns).forEach(function (key) {
1255
+ verdictBtns[key].hidden = OFFERED.indexOf(key) === -1
1256
+ })
1257
+
1258
+ // EVERY VERDICT, because this is the one place a verdict gets a reader-facing
1259
+ // name: `refresh` relabels only the committing pair, but the decided panel
1260
+ // has to name whichever verdict was actually sent — including one this page
1261
+ // never offered, since a decision restored from storage outlives the render
1262
+ // that made it.
1263
+ var LABELS = {
1264
+ commit: '✓ Commit',
1265
+ 'commit-continue': '✓ Commit & Continue',
1266
+ continue: '→ Continue',
1267
+ changes: '↺ Request changes',
1268
+ discuss: '… Discuss first',
1269
+ }
1126
1270
  var TITLES = {
1127
1271
  commit: 'Commit what you just read, and stop there',
1128
1272
  // Says what it will NOT do: a reader must not press this expecting the
1129
1273
  // spec to be finished and landed.
1130
1274
  'commit-continue': 'Commit what you just read, then build the next phase — nothing is landed',
1275
+ // Says what it will NOT do, for the same reason: nothing is committed by
1276
+ // this, so the work it resumes is still uncommitted afterwards.
1277
+ continue: 'You have read it — carry on with the run; nothing is committed',
1131
1278
  }
1132
1279
 
1133
1280
  var verdictCount = document.getElementById('verdict-count')
1134
1281
  var verdictLog = document.getElementById('verdict-log')
1135
1282
  var copyOut = document.getElementById('copy-out')
1283
+ var wrapEl = document.getElementById('wrap')
1284
+ var decidedBox = document.getElementById('decided')
1285
+ var decidedWhat = document.getElementById('decided-what')
1286
+ var decidedNote = document.getElementById('decided-note')
1287
+ var decidedToggle = document.getElementById('decided-toggle')
1136
1288
  var copyHint = document.getElementById('copy-hint')
1137
1289
  var sentCmd = document.getElementById('sent-cmd')
1138
1290
  var sentCmdText = document.getElementById('sent-cmd-text')
@@ -1164,11 +1316,17 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1164
1316
  // the moment the last note goes and a phone tab switch mid-review costs
1165
1317
  // nothing.
1166
1318
  function refresh() {
1319
+ // A decided page is finished. `refresh` runs on every mark and re-enables
1320
+ // the two committing buttons unconditionally, so without this it would undo
1321
+ // the ending every time anything redrew.
1322
+ if (decided) return drawDecided()
1167
1323
  var n = pending()
1168
1324
  var open = openNotes()
1169
1325
  verdictCount.textContent = n
1170
1326
  ? n + (n === 1 ? ' mark to send' : ' marks to send')
1171
- : 'Nothing marked a clean read still commits'
1327
+ // Names the action this page actually offers. A mid-run page saying "a
1328
+ // clean read still commits" would promise the one thing it cannot do.
1329
+ : MIDRUN ? 'Nothing marked — a clean read still carries on' : 'Nothing marked — a clean read still commits'
1172
1330
  // ONE COUNT, BOTH BUTTONS. Two committing controls blocked by two separate
1173
1331
  // reads is how a page ends up with one disabled and the other not — which
1174
1332
  // would be a way around the single refusal this page makes.
@@ -1197,19 +1355,119 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1197
1355
  : TITLES[key]
1198
1356
  })
1199
1357
  // Said rather than left unset: the committing pair is the ONLY thing this
1200
- // page ever blocks, and the other two are always available on purpose — a
1201
- // note you want acted on, and a question, are never the wrong thing to send.
1358
+ // page ever blocks, and the others are always available on purpose — a note
1359
+ // you want acted on, and a question, are never the wrong thing to send.
1360
+ //
1361
+ // CONTINUE IS NOT BLOCKED, and that mirrors the engine rather than softening
1362
+ // it: `judgeVerdict` refuses a COMMITTING verdict against an open note, and
1363
+ // `continue` is deliberately not one. A disabled button here would be a
1364
+ // second opinion about a refusal the engine does not make, which is the one
1365
+ // thing this bar must never be.
1366
+ verdictBtns.continue.disabled = false
1202
1367
  verdictBtns.changes.disabled = false
1203
1368
  verdictBtns.discuss.disabled = false
1204
1369
  save()
1205
1370
  }
1206
1371
 
1207
- // The last honoured verdict, as history. It is why the page can look
1208
- // untouched after an approval: the verdict was consumed, not stored.
1372
+ /**
1373
+ * End the review, and say how it ended.
1374
+ *
1375
+ * A verdict that has been handed over is the END of this page's job, and the
1376
+ * page used to carry on as if it were not: the diff stayed, the four buttons
1377
+ * stayed live, and a line of small grey text under them said "Sent." A reader
1378
+ * coming back to that tab has no way to tell a sent review from an unsent
1379
+ * one, and pressing a button again reaches an agent that stopped listening.
1380
+ *
1381
+ * So the diff goes, what was decided takes its place, and the controls die
1382
+ * with a reason on them.
1383
+ */
1384
+ function drawDecided() {
1385
+ if (!decided) return
1386
+ var said = SAID[decided.verdict] || decided.verdict
1387
+ var when = String(decided.at || '').slice(0, 10)
1388
+ decidedBox.hidden = false
1389
+ decidedWhat.textContent = 'You chose: ' + (LABELS[decided.verdict] || decided.verdict)
1390
+ decidedNote.textContent =
1391
+ 'Sent to Claude' + (when ? ' on ' + when : '') + ' — this review is ' + said + '. ' +
1392
+ 'Nothing here reaches Claude any more, so the verdict buttons are closed.'
1393
+ wrapEl.className = wrapEl.className.replace(/\s*\bis-decided\b/g, '') + ' is-decided'
1394
+ // EVERY button, not only the committing pair: `refresh` re-enables those two
1395
+ // by design, and a decided page must beat it.
1396
+ Object.keys(verdictBtns).forEach(function (key) {
1397
+ var btn = verdictBtns[key]
1398
+ btn.disabled = true
1399
+ btn.title = 'Already sent — ' + said
1400
+ })
1401
+ verdictCount.textContent = 'Sent — ' + said
1402
+ // ONE STATEMENT, NOT FOUR. The panel, the bar, the history line and the
1403
+ // command box all said the same thing at once. The log line is the panel's
1404
+ // sentence again in smaller type, so it goes.
1405
+ verdictLog.hidden = true
1406
+
1407
+ // THE HAND-OFF SURVIVES THE ENDING — but only where there is something
1408
+ // worth carrying. A code is six digits to transcribe, and the box and its
1409
+ // Copy button exist for exactly that. A bare `/spec-reviewed` is a word the
1410
+ // reader is about to type into the terminal they are already sitting in;
1411
+ // dressing it as a copyable artefact makes the ending look unfinished.
1412
+ if (decided.cmd && /\s/.test(decided.cmd)) {
1413
+ copyHint.hidden = false
1414
+ copyHint.textContent = 'Sent. Run this where Claude is:'
1415
+ showCommand(decided.cmd)
1416
+ return
1417
+ }
1418
+ copyHint.hidden = true
1419
+ sentCmd.hidden = true
1420
+ copyOut.hidden = true
1421
+ if (decided.cmd) {
1422
+ decidedNote.textContent +=
1423
+ ' If Claude was not already waiting, run ' + decided.cmd + ' where it is.'
1424
+ }
1425
+ }
1426
+
1427
+ /** Record a verdict that was actually delivered, and end the page on it. */
1428
+ function markDecided(verdict, cmd) {
1429
+ decided = { verdict: verdict, at: new Date().toISOString(), cmd: cmd || null }
1430
+ save()
1431
+ drawDecided()
1432
+ }
1433
+
1434
+ // A WAY BACK IN, read-only. "It is finished" and "I cannot see what I
1435
+ // approved" are different things, and only the first one is true.
1436
+ decidedToggle.addEventListener('click', function () {
1437
+ var showing = / \bshow-diff\b/.test(' ' + wrapEl.className)
1438
+ wrapEl.className = wrapEl.className.replace(/\s*\bshow-diff\b/g, '') + (showing ? '' : ' show-diff')
1439
+ decidedToggle.textContent = showing ? 'Show the diff anyway' : 'Hide the diff'
1440
+ })
1441
+
1442
+ // How a logged verdict reads. EVERY VERDICT NAMED, and an unknown one says
1443
+ // so: the fallback was `discussed`, which quietly relabelled `commit` — and a
1444
+ // page that tells you a commit was a discussion is worse than one that admits
1445
+ // it does not know the word.
1446
+ var SAID = {
1447
+ approve: 'committed',
1448
+ commit: 'committed',
1449
+ 'commit-continue': 'committed, then carried on',
1450
+ continue: 'read it and carried on',
1451
+ changes: 'changes requested',
1452
+ discuss: 'discussed',
1453
+ skip: 'moved on without a verdict',
1454
+ }
1455
+
1456
+ // The last thing that happened here, as history. It is why the page can look
1457
+ // untouched after a commit: the verdict was consumed, not stored. A SKIP is
1458
+ // history of exactly the same kind — it is the recorded decision to move on,
1459
+ // and hiding it would make a skipped review indistinguishable from one that
1460
+ // never ended.
1209
1461
  function drawLog() {
1210
1462
  var last = data.notes && data.notes.lastDecision
1463
+ var skip = data.gate && data.gate.lastSkip
1464
+ // Whichever is more recent. Both are timestamped, and comparing is what
1465
+ // stops an old verdict shadowing a skip taken after it.
1466
+ if (skip && (!last || String(skip.at || '') > String(last.at || ''))) {
1467
+ last = { verdict: 'skip', at: skip.at, note: skip.reason }
1468
+ }
1211
1469
  if (!last) return
1212
- var said = last.verdict === 'approve' ? 'approved' : last.verdict === 'changes' ? 'changes requested' : 'discussed'
1470
+ var said = SAID[last.verdict] || ('recorded as "' + last.verdict + '"')
1213
1471
  var when = String(last.at || '').slice(0, 10)
1214
1472
  verdictLog.hidden = false
1215
1473
  verdictLog.textContent = said + ' earlier' + (when ? ' · ' + when : '') + (last.note ? ' · ' + last.note : '')
@@ -1366,7 +1624,7 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1366
1624
  * you read six digits out, where a paste costs context in proportion to how
1367
1625
  * much you wrote.
1368
1626
  */
1369
- function post(json) {
1627
+ function post(json, verdict) {
1370
1628
  copyHint.hidden = false
1371
1629
  copyHint.textContent = 'Sending…'
1372
1630
  fetch(location.pathname, {
@@ -1392,12 +1650,14 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1392
1650
  if (code) {
1393
1651
  copyHint.textContent = 'Sent. Run this where Claude is:'
1394
1652
  showCommand('/spec-reviewed ' + code)
1653
+ markDecided(verdict, '/spec-reviewed ' + code)
1395
1654
  } else {
1396
1655
  // NO CODE, NO COMMAND. A `/spec-reviewed` with nothing after it still
1397
1656
  // works — it picks up the single waiting pass — but the page cannot
1398
1657
  // claim the server named THIS one, so it does not put words in its
1399
1658
  // mouth. Bare is what the reader is told, because bare is what is true.
1400
1659
  copyHint.textContent = 'Sent — run /spec-reviewed to pick it up.'
1660
+ markDecided(verdict)
1401
1661
  }
1402
1662
  })
1403
1663
  }, function () {
@@ -1407,17 +1667,83 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1407
1667
  })
1408
1668
  }
1409
1669
 
1670
+ /**
1671
+ * Is this page running as a published Artifact?
1672
+ *
1673
+ * A CAPABILITY CHECK, not a hostname one. `window.claude.use` is the whole
1674
+ * contract for reaching anything the viewer grants, and it exists only where
1675
+ * a viewer is there to grant it — so its presence is what distinguishes the
1676
+ * published page from the two the engine serves, without this file knowing
1677
+ * anything about where it was published.
1678
+ */
1679
+ function isArtifact() {
1680
+ return Boolean(window.claude && typeof window.claude.use === 'function')
1681
+ }
1682
+
1683
+ /**
1684
+ * Hand the pass to the artifact's own store.
1685
+ *
1686
+ * THE THIRD TRANSPORT, for the reader no local server can reach: a phone on
1687
+ * mobile data, or anyone away from the LAN the engine serves on. The pass
1688
+ * lands in a document Claude reads back and deletes, so it is consumed
1689
+ * exactly like a claimed one.
1690
+ *
1691
+ * `use` is asked only once the reader has pressed something. Asking at load
1692
+ * would spend a consent prompt on every render, including the ones nobody
1693
+ * ends in a verdict.
1694
+ */
1695
+ function sendToStore(json, verdict) {
1696
+ copyHint.hidden = false
1697
+ copyHint.textContent = 'Sending…'
1698
+ var landed = false
1699
+ window.claude.use('db').then(function (db) {
1700
+ // `null` means this view cannot run db — not granted, not served, failed
1701
+ // to load, all indistinguishable by design. The clipboard is still there,
1702
+ // so the pass is recoverable rather than lost.
1703
+ if (!db) {
1704
+ showFallback(json, 'Could not reach the store. Copy this, then paste it to Claude.')
1705
+ return
1706
+ }
1707
+ return db
1708
+ .collection('passes')
1709
+ .add({ spec: data.spec, at: new Date().toISOString(), render: data.generatedAt, blob: JSON.parse(json) })
1710
+ .then(function () {
1711
+ landed = true
1712
+ // NO CODE HERE, and none invented. Six digits are the engine's, minted
1713
+ // by the holding area this pass never went through — so the page says
1714
+ // the one thing that is true and actionable.
1715
+ copyHint.textContent = 'Sent. Run this where Claude is:'
1716
+ showCommand('/spec-reviewed')
1717
+ markDecided(verdict, '/spec-reviewed')
1718
+ })
1719
+ }).then(null, function (err) {
1720
+ if (landed) return
1721
+ showFallback(
1722
+ json,
1723
+ 'Could not store the pass' + (err && err.code ? ' (' + err.code + ')' : '') +
1724
+ '. Copy this, then paste it to Claude.',
1725
+ )
1726
+ })
1727
+ }
1728
+
1410
1729
  function send(verdict) {
1411
1730
  var json = JSON.stringify(buildBlob(verdict), null, 2)
1412
- // DECIDED FROM WHAT THE PAGE IS, not from what it can guess. A page opened
1413
- // over http was served by the engine and can hand the pass straight back; a
1414
- // `file://` page has no server to talk to and never will.
1731
+ // DECIDED FROM WHAT THE PAGE IS, not from what it can guess. A published
1732
+ // page has a store and no server; a page opened over http was served by the
1733
+ // engine and can hand the pass straight back; a `file://` page has no server
1734
+ // to talk to and never will.
1415
1735
  //
1416
1736
  // NOT "try the POST and fall back on failure": a failed POST and an absent
1417
1737
  // one look identical to the reader, so the fallback would hide a server
1418
- // that is refusing passes behind a message about copying.
1738
+ // that is refusing passes behind a message about copying. The published page
1739
+ // is exactly that trap — its POST would go to claude.ai and fail — which is
1740
+ // why it is answered first, by what the page IS rather than by what failed.
1741
+ if (isArtifact()) {
1742
+ sendToStore(json, verdict)
1743
+ return
1744
+ }
1419
1745
  if (location.protocol !== 'file:') {
1420
- post(json)
1746
+ post(json, verdict)
1421
1747
  return
1422
1748
  }
1423
1749
  // `file://` is not a secure context everywhere, and the clipboard API is
@@ -1438,9 +1764,11 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1438
1764
 
1439
1765
  Object.keys(verdictBtns).forEach(function (verdict) {
1440
1766
  verdictBtns[verdict].addEventListener('click', function () {
1441
- // A disabled button does not fire in a browser; the guard is for every
1442
- // other caller, and it keeps the block a fact rather than a style.
1443
- if (verdictBtns[verdict].disabled) return
1767
+ // A disabled or hidden button does not fire in a browser; the guard is
1768
+ // for every other caller, and it keeps both the block and the button set
1769
+ // facts rather than styles — a verdict this render never offered must not
1770
+ // be reachable by dispatching at it.
1771
+ if (verdictBtns[verdict].disabled || verdictBtns[verdict].hidden) return
1444
1772
  send(verdict)
1445
1773
  })
1446
1774
  })
@@ -1449,12 +1777,20 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1449
1777
  // read its own version says nothing rather than claiming "unknown" — an
1450
1778
  // absence here is about the lookup, not about the page.
1451
1779
  if (data.engine) {
1452
- document.getElementById('drawn-by').textContent = 'rendered by skitterspec ' + data.engine
1780
+ // `0.0.0` is what the unpublished source package carries, so a page drawn
1781
+ // from a working copy would otherwise claim a version that has never been
1782
+ // released. This line exists to answer "which engine drew this" — and a
1783
+ // string that LOOKS like a version and is not one answers it wrongly, which
1784
+ // is worse than not answering. The field itself is untouched: the serve
1785
+ // daemon compares it to spot a stale renderer, and it must stay a version.
1786
+ document.getElementById('drawn-by').textContent =
1787
+ 'rendered by skitterspec ' + (data.engine === '0.0.0' ? '(unreleased build)' : data.engine)
1453
1788
  }
1454
1789
 
1455
1790
  restore()
1456
1791
  drawContext()
1457
1792
  drawLog()
1793
+ drawDecided()
1458
1794
  wireChecks()
1459
1795
  state.forEach(function (st) {
1460
1796
  var btn = st.details && st.details.querySelector('.accept')