bmad-plus 0.17.1 → 0.19.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.
@@ -30,15 +30,13 @@
30
30
  --mono-bg: #24282c; --focus: #4fb3b8;
31
31
  }
32
32
  * { box-sizing: border-box; }
33
- /* A class that sets display wins over the hidden attribute: without this rule the steps
34
- showed before any run existed, and a tester's answers were silently discarded. */
35
- [hidden] { display: none !important; }
36
33
  code.cmd { cursor: copy; }
37
34
  code.cmd.copied::after { content: " \2713 " attr(data-copied); font-family: var(--sans); color: var(--ok); }
38
35
  body { background: var(--bg); color: var(--ink); font-family: var(--sans); font-size: 15px; line-height: 1.5; margin: 0; padding-inline: 16px; padding-block: 0 64px; }
39
36
  h1, h2, h3 { font-family: var(--serif); text-wrap: balance; margin: 0; }
40
37
  h1 { font-size: 1.7rem; font-weight: 600; }
41
38
  h2 { font-size: 1.2rem; font-weight: 600; }
39
+ h2:focus-visible { outline: 2px solid var(--focus); outline-offset: 4px; }
42
40
  p { margin: 0; }
43
41
  a { color: var(--accent); }
44
42
  code, .ecran { font-family: var(--mono); font-size: 0.88em; background: var(--mono-bg); padding: 1px 6px; border-radius: 3px; }
@@ -46,9 +44,9 @@
46
44
  .eyebrow { font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); font-weight: 600; }
47
45
  .bar { position: sticky; top: 0; z-index: 5; background: var(--bg); border-bottom: 1px solid var(--line); padding-block: 10px; margin-inline: -16px; padding-inline: 16px; }
48
46
  .bar-in { max-width: 860px; margin: 0 auto; display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; }
49
- .count { display: flex; gap: 14px; font-variant-numeric: tabular-nums; font-size: 0.9rem; }
47
+ .count { display: flex; flex-wrap: wrap; gap: 14px; font-variant-numeric: tabular-nums; font-size: 0.9rem; }
50
48
  .count b { font-weight: 600; }
51
- .count .ok { color: var(--ok); } .count .ko { color: var(--ko); } .count .warn { color: var(--warn); } .count .todo { color: var(--muted); }
49
+ .count .ok { color: var(--ok); } .count .ko { color: var(--ko); } .count .warn { color: var(--warn); } .count .todo { color: var(--muted); } .count .pct { color: var(--ink); }
52
50
  /* One segment per step: green seen, red not seen, orange blocked, grey skipped, empty to do. */
53
51
  .strip { flex: 1 1 100%; display: flex; gap: 3px; min-height: 12px; }
54
52
  .strip a { flex: 1 1 0; min-width: 10px; height: 12px; border-radius: 3px; background: var(--line); border: 1px solid transparent; }
@@ -58,8 +56,14 @@
58
56
  .strip a[data-state="blocked"] { background: var(--warn); }
59
57
  .strip a[data-state="skipped"] { background: var(--line-strong); }
60
58
  .strip a[data-state="partial"] { background: linear-gradient(90deg, var(--ok) 50%, var(--line) 50%); }
59
+ [dir="rtl"] .strip a[data-state="partial"] { background: linear-gradient(270deg, var(--ok) 50%, var(--line) 50%); }
60
+ /* Share of lines answered — answered, not passed: a "not seen" advances the run as much as a "seen". */
61
+ .meter { flex: 1 1 100%; height: 5px; background: var(--line); border-radius: 3px; overflow: hidden; }
62
+ .meter i { display: block; height: 100%; width: 0; background: var(--accent); }
61
63
  .state { font-size: 0.82rem; color: var(--muted); }
62
64
  .state.saved { color: var(--ok); }
65
+ .state.failed { color: var(--ko); font-weight: 600; }
66
+ .run-note { flex: 1 1 100%; font-size: 0.82rem; color: var(--warn); }
63
67
  .head { padding-block: 26px 18px; display: grid; gap: 10px; }
64
68
  .head p { max-width: 65ch; }
65
69
  .lang { display: flex; align-items: center; gap: 8px; font-size: 0.82rem; color: var(--muted); }
@@ -75,10 +79,12 @@
75
79
  .row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
76
80
  .resume { display: grid; gap: 6px; }
77
81
  .resume button { text-align: start; }
82
+ .resume .row { gap: 8px; }
78
83
  .warn-box { border-inline-start: 3px solid var(--warn); background: var(--warn-soft); padding: 10px 12px; border-radius: 4px; }
79
84
  .info-box { border-inline-start: 3px solid var(--accent); background: var(--accent-soft); padding: 10px 12px; border-radius: 4px; }
85
+ .warn-box .btn { margin-inline-start: 8px; }
80
86
  .steps { display: grid; gap: 18px; }
81
- .step { background: var(--card); border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
87
+ .step { background: var(--card); border: 1px solid var(--line); border-radius: 6px; overflow: hidden; scroll-margin-top: 150px; }
82
88
  .step[data-state="passed"] { border-color: var(--ok); }
83
89
  .step[data-state="failed"] { border-color: var(--ko); }
84
90
  .step[data-state="blocked"] { border-color: var(--warn); }
@@ -114,8 +120,14 @@
114
120
  .end { background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 16px; display: grid; gap: 12px; margin-top: 22px; }
115
121
  .json { font-family: var(--mono); font-size: 0.8rem; min-height: 180px; white-space: pre; overflow: auto; direction: ltr; text-align: start; }
116
122
  details > summary { cursor: pointer; font-weight: 600; }
117
- @media (max-width: 480px) { .expect { grid-template-columns: 1fr; } .num { font-size: 1.25rem; } }
123
+ @media (max-width: 480px) { .expect { grid-template-columns: 1fr; } .num { font-size: 1.25rem; } .step { scroll-margin-top: 190px; } }
118
124
  @media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
125
+ /* A class that sets display wins over the hidden attribute: without this rule the steps
126
+ showed before any run existed, and a tester's answers were silently discarded. It is
127
+ !important AND the last rule of the sheet, so it wins under a real cascade and under a
128
+ source-order one (jsdom resolves display by the last matching rule). The build refuses
129
+ a template where anything follows it. */
130
+ [hidden] { display: none !important; }
119
131
  </style>
120
132
  </head>
121
133
  <body>
@@ -127,9 +139,12 @@
127
139
  <span class="ko"><b id="c-failed">0</b> <span id="l-failed"></span></span>
128
140
  <span class="warn"><b id="c-blocked">0</b> <span id="l-blocked"></span></span>
129
141
  <span class="todo"><b id="c-todo">0</b> <span id="l-todo"></span></span>
142
+ <span class="pct"><b id="c-pct">0 %</b> <span id="l-pct"></span></span>
130
143
  </div>
131
144
  <nav class="strip" id="strip"></nav>
132
- <span class="state" id="save-state">—</span>
145
+ <div class="meter" id="meter" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><i id="meter-fill"></i></div>
146
+ <span class="state" id="save-state" role="status" aria-live="polite">—</span>
147
+ <span class="run-note" id="run-note" role="status" hidden></span>
133
148
  </div>
134
149
  </div>
135
150
 
@@ -141,12 +156,14 @@
141
156
  <label for="tester"><span id="t-tester"></span><input type="text" id="tester" autocomplete="name"></label>
142
157
  <div class="row">
143
158
  <button class="btn primary" id="btn-start" type="button"></button>
144
- <span class="state" id="start-state"></span>
159
+ <span class="state" id="start-state" role="status"></span>
145
160
  </div>
161
+ <p class="warn-box" id="draft-problem" hidden><span id="t-draft-problem"></span><button class="btn" id="btn-save-raw" type="button"></button></p>
146
162
  <div class="resume" id="resume" hidden>
147
163
  <span class="eyebrow" id="t-resume"></span>
148
164
  <div id="resume-list" class="resume"></div>
149
165
  </div>
166
+ <p class="info-box" id="storage-note"></p>
150
167
  </section>
151
168
  <main class="steps" id="steps" hidden></main>
152
169
  <section class="end" id="end" hidden>
@@ -157,7 +174,8 @@
157
174
  <button class="btn primary" id="btn-finish" type="button" disabled></button>
158
175
  <button class="btn" id="btn-save" type="button" disabled></button>
159
176
  <button class="btn" id="btn-copy" type="button" disabled></button>
160
- <span class="state" id="end-state"></span>
177
+ <button class="btn" id="btn-another" type="button" disabled></button>
178
+ <span class="state" id="end-state" role="status"></span>
161
179
  </div>
162
180
  <p class="info-box" id="mode-note"></p>
163
181
  <details><summary>JSON</summary><textarea class="json" id="json" readonly aria-label="JSON"></textarea></details>
@@ -172,22 +190,36 @@
172
190
  var SPEC = JSON.parse(document.getElementById("uat-spec").textContent);
173
191
  var ALL = JSON.parse(document.getElementById("uat-strings").textContent);
174
192
  var SPEC_SHA = "__SPEC_SHA__";
193
+ // The language the page was built for: the recipe's own, else the project's, else English.
194
+ var BUILT_LANG = document.documentElement.getAttribute("lang") || "";
175
195
  var RESULTS_SCHEMA = "bmad-plus/uat-results/2";
196
+ var STATES = ["passed", "failed", "blocked", "skipped"];
176
197
  var STEPS = SPEC.steps;
177
198
  var KEY = "uat-" + SPEC.id;
199
+ var LARGE_BODY = 60000; // a keepalive request is refused above 64 KiB: never leave such a body pending
178
200
 
179
201
  var run = null, runId = null, T = null, lang = null;
180
202
  var db = null, dbDoc = null, unsub = null, downloads = null, serve = null;
181
- var timer = null, lastWritten = "";
203
+ var timer = null, pending = null, lastWritten = "", flushSeq = 0, listSeq = 0;
204
+ var localOk = true, remoteState = null, remoteAt = null, unreadable = null;
205
+ var runNote = null, confirmingFinish = false;
182
206
  var $ = function (id) { return document.getElementById(id); };
207
+ var esc = function (value) { return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); };
183
208
  var lsGet = function (k) { try { return localStorage.getItem(k); } catch (e) { return null; } };
184
- var lsSet = function (k, v) { try { localStorage.setItem(k, v); } catch (e) {} };
209
+ // A write is a write only once it reads back: a refused write (quota, private window,
210
+ // blocked storage) returns false, and the page never says "saved" on its own word.
211
+ var lsSet = function (k, v) { try { localStorage.setItem(k, v); return localStorage.getItem(k) === v; } catch (e) { return false; } };
212
+ var lsKeys = function () { var keys = []; try { for (var i = 0; i < localStorage.length; i++) keys.push(localStorage.key(i)); } catch (e) { /* storage unreadable: nothing to list */ } return keys; };
213
+ /** When a document was last saved, as a number; a missing or unreadable date is older than anything. */
214
+ function at(doc) { var t = Date.parse(doc && doc.updatedAt); return isNaN(t) ? -Infinity : t; }
215
+ function setText(el, text) { if (el.textContent !== text) el.textContent = text; }
185
216
 
186
217
  // ── Language: the recipe's own, the tester's browser, or their choice ───
187
218
  function pickLanguage() {
188
219
  var saved = lsGet(KEY + "-lang");
189
220
  if (saved && ALL[saved]) return saved;
190
221
  if (SPEC.language && ALL[SPEC.language]) return SPEC.language;
222
+ if (BUILT_LANG && ALL[BUILT_LANG]) return BUILT_LANG;
191
223
  var tags = (navigator.languages || [navigator.language || ""]).map(String);
192
224
  for (var i = 0; i < tags.length; i++) {
193
225
  var tag = tags[i];
@@ -210,6 +242,20 @@
210
242
  });
211
243
  }
212
244
 
245
+ function modeText() { return db ? T.dbMode : serve ? T.serveMode : T.localMode; }
246
+ function noteText() {
247
+ if (!runNote) return "";
248
+ if (runNote.kind === "carried") return T.carried.replace("{kept}", String(runNote.kept)).replace("{left}", String(runNote.left));
249
+ if (runNote.kind === "otherTab") return T.otherTab;
250
+ if (runNote.kind === "changedAfterFinish") return T.changedAfterFinish;
251
+ return "";
252
+ }
253
+ function showNote(note) {
254
+ runNote = note;
255
+ $("run-note").hidden = !note;
256
+ setText($("run-note"), noteText());
257
+ }
258
+
213
259
  function applyLanguage(code) {
214
260
  lang = ALL[code] ? code : "en";
215
261
  T = ALL[lang];
@@ -223,18 +269,25 @@
223
269
  $("t-resume").textContent = T.resume;
224
270
  $("t-finish").textContent = T.finish;
225
271
  $("t-overall").textContent = T.overall;
226
- $("btn-finish").textContent = run && run.finishedAt ? T.finished : T.finishRun;
272
+ $("btn-finish").textContent = run && run.finishedAt ? T.finished : confirmingFinish ? T.finishAnyway : T.finishRun;
227
273
  $("btn-save").textContent = T.saveJson;
228
274
  $("btn-copy").textContent = T.copyJson;
275
+ $("btn-another").textContent = T.another;
276
+ $("btn-save-raw").textContent = T.saveRaw;
277
+ $("t-draft-problem").textContent = T.draftUnreadable + " ";
229
278
  $("l-passed").textContent = T.countSeen;
230
279
  $("l-failed").textContent = T.countNotSeen;
231
280
  $("l-blocked").textContent = T.countBlocked;
232
281
  $("l-todo").textContent = T.todo;
233
- $("mode-note").textContent = dbDoc ? T.dbMode : serve ? T.serveMode : T.localMode;
282
+ $("l-pct").textContent = T.countAnswered;
283
+ $("meter").setAttribute("aria-label", T.progressLabel);
284
+ $("mode-note").textContent = modeText();
285
+ $("storage-note").textContent = modeText();
286
+ setText($("run-note"), noteText());
234
287
  renderHead();
235
288
  renderSteps();
236
289
  renderStrip();
237
- if (run) reflect();
290
+ if (run) { reflect(); showSaveState(); }
238
291
  listResumable();
239
292
  }
240
293
 
@@ -273,10 +326,20 @@
273
326
  summary: { passed: 0, failed: 0, blocked: 0, skipped: 0, unanswered: 0 },
274
327
  };
275
328
  }
276
- function slug(s) { return String(s).normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase().slice(0, 24) || "anonymous"; }
329
+ // The combining-mark range is written as escapes: a raw range survives only as long as every
330
+ // hop (editor, transfer, server charset) keeps the bytes intact, and one hop did not.
331
+ function slug(s) {
332
+ var latin = String(s).normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase().slice(0, 24);
333
+ if (latin) return latin;
334
+ // A name with no Latin letter (Hebrew, Cyrillic, CJK…) still signs its run: a stable hash, not "anonymous".
335
+ var h = 2166136261;
336
+ for (var i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; }
337
+ return "t" + h.toString(36).slice(0, 7);
338
+ }
277
339
  function newRunId(tester) {
278
340
  var d = new Date(), p = function (n) { return String(n).padStart(2, "0"); };
279
- return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + "-" + slug(tester) + "-" + Math.random().toString(36).slice(2, 6);
341
+ // UTC, like every date the run carries: the file name and "finished on" name the same day.
342
+ return d.getUTCFullYear() + p(d.getUTCMonth() + 1) + p(d.getUTCDate()) + "-" + slug(tester) + "-" + (Math.random().toString(36) + "0000").slice(2, 6);
280
343
  }
281
344
  function stepState(step) {
282
345
  var values = Object.keys(step.expect).map(function (k) { return step.expect[k].state; });
@@ -293,11 +356,40 @@
293
356
  step.state = stepState(step);
294
357
  Object.keys(step.expect).forEach(function (k) {
295
358
  var v = step.expect[k].state;
296
- if (v === null || v === undefined) s.unanswered++; else s[v]++;
359
+ if (v === null || v === undefined) s.unanswered++; else if (STATES.indexOf(v) >= 0) s[v]++; else s.unanswered++;
297
360
  });
298
361
  });
299
362
  run.summary = s;
300
363
  }
364
+ function answeredOf(doc) {
365
+ var total = 0, answered = 0;
366
+ Object.keys(doc.steps).forEach(function (id) {
367
+ Object.keys(doc.steps[id].expect).forEach(function (k) { total++; if (STATES.indexOf(doc.steps[id].expect[k].state) >= 0) answered++; });
368
+ });
369
+ return { answered: answered, total: total };
370
+ }
371
+
372
+ // ── Drafts: what this browser (or the artifact database) holds for this recipe ──
373
+ /** A usable document for THIS recipe, as a private copy, whatever revision it answered; anything else is null. */
374
+ function draftOf(raw) {
375
+ if (raw === null || raw === undefined) return null;
376
+ try {
377
+ // Always a copy: a database snapshot is frozen, and the page writes into what it opens.
378
+ var doc = JSON.parse(typeof raw === "string" ? raw : JSON.stringify(raw));
379
+ return validDraft(doc) ? doc : null;
380
+ } catch (e) { return null; }
381
+ }
382
+ /** Structural, down to every expectation: a half-shaped document must never reach open(). */
383
+ function validDraft(doc) {
384
+ if (!(doc && typeof doc === "object" && doc.schema === RESULTS_SCHEMA && doc.specId === SPEC.id && doc.steps && typeof doc.steps === "object")) return false;
385
+ return Object.keys(doc.steps).every(function (id) {
386
+ var step = doc.steps[id];
387
+ if (!step || typeof step !== "object" || !step.expect || typeof step.expect !== "object") return false;
388
+ return Object.keys(step.expect).every(function (k) { var e = step.expect[k]; return Boolean(e && typeof e === "object"); });
389
+ });
390
+ }
391
+ /** Only a run that answered THIS revision of the recipe is reopened as it is. */
392
+ function sameRevision(doc) { return doc.specSha256 === SPEC_SHA; }
301
393
 
302
394
  // ── Steps ───────────────────────────────────────────────────────────
303
395
  function renderSteps() {
@@ -307,25 +399,25 @@
307
399
  var article = document.createElement("article");
308
400
  article.className = "step";
309
401
  article.id = "step-" + step.id;
310
- var head = '<div class="step-head"><span class="num">' + index + '</span><h2></h2>'
402
+ var head = '<div class="step-head"><span class="num">' + index + '</span><h2 tabindex="-1"></h2>'
311
403
  + (step.duration ? '<span class="duration"></span>' : "")
312
404
  + '<span class="verdict" id="v-' + step.id + '"></span></div>';
313
405
  var body = '<div class="step-body">';
314
406
  if (step.warning) body += '<p class="warn-box">' + step.warning + "</p>";
315
- else if (step.writes) body += '<p class="warn-box"><b>' + T.writesWarning + " " + SPEC.environment.name + ".</b> " + T.intended + "</p>";
407
+ else if (step.writes) body += '<p class="warn-box"><b>' + T.writesWarning + " " + esc(SPEC.environment.name) + ".</b> " + T.intended + "</p>";
316
408
  if (step.optional) body += '<p class="info-box">' + T.optional + ' — <button class="btn" type="button" data-skip="' + step.id + '">' + T.skip + "</button></p>";
317
409
  body += '<div class="section"><span class="eyebrow">' + T.where + "</span><ul>" + step.where.map(function (x) { return "<li>" + x + "</li>"; }).join("") + "</ul></div>";
318
410
  body += '<div class="section"><span class="eyebrow">' + T.doIt + "</span><ol>" + step.do.map(function (x) { return "<li>" + x + "</li>"; }).join("") + "</ol></div>";
319
411
  body += '<div class="section"><span class="eyebrow">' + T.expect + '</span><div class="expects">';
320
412
  step.expect.forEach(function (e) {
321
413
  var name = "e-" + step.id + "-" + e.id;
322
- body += '<div class="expect" id="' + name + '" data-state=""><div class="text">' + e.text + "</div>"
323
- + '<div class="choice" role="radiogroup">'
414
+ body += '<div class="expect" id="' + name + '" data-state=""><div class="text" id="t-' + step.id + "-" + e.id + '">' + e.text + "</div>"
415
+ + '<div class="choice" role="radiogroup" aria-labelledby="t-' + step.id + "-" + e.id + '">'
324
416
  + '<label class="pill passed"><input type="radio" name="' + name + '" id="' + name + '-passed" value="passed"><span>' + T.seen + "</span></label>"
325
417
  + '<label class="pill failed"><input type="radio" name="' + name + '" id="' + name + '-failed" value="failed"><span>' + T.notSeen + "</span></label>"
326
418
  + '<label class="pill blocked"><input type="radio" name="' + name + '" id="' + name + '-blocked" value="blocked"><span>' + T.blocked + "</span></label>"
327
419
  + "</div>"
328
- + '<div class="why" id="why-' + step.id + "-" + e.id + '" hidden><label for="n-' + step.id + "-" + e.id + '"><span>' + T.whyNot + '</span><textarea id="n-' + step.id + "-" + e.id + '"></textarea></label></div>'
420
+ + '<div class="why" id="why-' + step.id + "-" + e.id + '" hidden><label for="n-' + step.id + "-" + e.id + '"><span id="w-' + step.id + "-" + e.id + '">' + T.whyNot + '</span><textarea id="n-' + step.id + "-" + e.id + '"></textarea></label></div>'
329
421
  + "</div>";
330
422
  });
331
423
  body += "</div></div>";
@@ -373,11 +465,15 @@
373
465
  if (event && event.target instanceof HTMLInputElement && event.target.type === "radio") event.target.checked = false;
374
466
  $("startbox").hidden = false;
375
467
  $("start-state").textContent = T.startFirst;
376
- $("startbox").scrollIntoView({ block: "center" });
468
+ if ($("startbox").scrollIntoView) $("startbox").scrollIntoView({ block: "center" });
377
469
  $("tester").focus();
378
470
  return false;
379
471
  }
380
472
 
473
+ function stateLabel(state) {
474
+ return state === "passed" ? T.seen : state === "failed" ? T.notSeen : state === "blocked" ? T.blocked : state === "skipped" ? T.skipped : T.todo;
475
+ }
476
+
381
477
  function reflect() {
382
478
  recount();
383
479
  STEPS.forEach(function (step) {
@@ -385,104 +481,173 @@
385
481
  if (!observed) return;
386
482
  var article = $("step-" + step.id);
387
483
  article.dataset.state = observed.state || "";
388
- var verdict = observed.state === "passed" ? T.seen : observed.state === "failed" ? T.notSeen : observed.state === "blocked" ? T.blocked : observed.state === "skipped" ? T.skip : T.todo;
389
- $("v-" + step.id).textContent = verdict;
484
+ $("v-" + step.id).textContent = stateLabel(observed.state);
390
485
  step.expect.forEach(function (e) {
391
- var name = "e-" + step.id + "-" + e.id, value = observed.expect[e.id].state;
486
+ var name = "e-" + step.id + "-" + e.id, entry = observed.expect[e.id], value = entry ? entry.state : null;
392
487
  ["passed", "failed", "blocked"].forEach(function (state) { $(name + "-" + state).checked = value === state; });
393
488
  $(name).dataset.state = value || "";
394
- $("why-" + step.id + "-" + e.id).hidden = !(value === "failed" || value === "blocked");
395
489
  var field = $("n-" + step.id + "-" + e.id);
396
- if (document.activeElement !== field) field.value = observed.expect[e.id].note || "";
490
+ // The question follows the answer, and a note already written stays in view whatever the answer became.
491
+ $("why-" + step.id + "-" + e.id).hidden = !(value === "failed" || value === "blocked" || (entry && entry.note));
492
+ $("w-" + step.id + "-" + e.id).textContent = value === "blocked" ? T.blockedWhy : T.whyNot;
493
+ // A save never moves the cursor: the field being typed in keeps what is being typed.
494
+ if (document.activeElement !== field) field.value = (entry && entry.note) || "";
397
495
  });
398
496
  var note = $("note-" + step.id);
399
497
  if (document.activeElement !== note) note.value = observed.note || "";
400
498
  });
401
499
  var s = run.summary;
402
500
  var total = s.passed + s.failed + s.blocked + s.skipped + s.unanswered;
501
+ var answered = total - s.unanswered;
502
+ var pct = total ? Math.round((answered / total) * 100) : 0;
403
503
  $("c-passed").textContent = s.passed;
404
504
  $("c-failed").textContent = s.failed;
405
505
  $("c-blocked").textContent = s.blocked;
406
506
  $("c-todo").textContent = s.unanswered;
507
+ // The language decides whether the sign sticks to the number (50%) or not (50 %).
508
+ $("c-pct").textContent = pct + (T.percentAnswered.indexOf("{n}%") >= 0 ? "%" : " %");
407
509
  STEPS.forEach(function (step, index) {
408
510
  var segment = $("s-" + step.id);
409
511
  if (!segment) return;
410
512
  var observed = run.steps[step.id];
513
+ if (!observed) return;
411
514
  var states = Object.keys(observed.expect).map(function (k) { return observed.expect[k].state; });
412
- var answered = states.filter(function (v) { return v !== null && v !== undefined; }).length;
413
- var state = observed.state || (answered ? "partial" : "");
515
+ var done = states.filter(function (v) { return v !== null && v !== undefined; }).length;
516
+ var state = observed.state || (done ? "partial" : "");
414
517
  segment.setAttribute("data-state", state);
415
- segment.title = index + " · " + step.title + " — " + (state === "passed" ? T.seen : state === "failed" ? T.notSeen : state === "blocked" ? T.blocked : state === "skipped" ? T.skip : T.todo) + " (" + answered + "/" + states.length + ")";
518
+ segment.title = index + " · " + step.title + " — " + stateLabel(state === "partial" ? null : state) + " (" + done + "/" + states.length + ")";
416
519
  });
417
- $("strip").setAttribute("aria-label", total + " · " + s.passed + " " + T.seen + " · " + s.failed + " " + T.notSeen + " · " + s.blocked + " " + T.blocked);
520
+ var counts = s.passed + " " + T.seen + " · " + s.failed + " " + T.notSeen + " · " + s.blocked + " " + T.blocked + (s.skipped ? " · " + s.skipped + " " + T.skipped : "");
521
+ $("strip").setAttribute("aria-label", total + " · " + counts);
522
+ // The meter says how much is ANSWERED, never how much passed: "not seen" advances it too.
523
+ var meter = $("meter");
524
+ meter.setAttribute("aria-valuenow", String(pct));
525
+ meter.setAttribute("aria-valuetext", T.percentAnswered.replace("{n}", String(pct)) + " · " + counts);
526
+ $("meter-fill").style.width = pct + "%";
418
527
  // "Run by laurent · finished on 2026-09-16", as the recipes played in claude.ai always read.
419
- $("bar-tester").textContent = T.runBy.replace("{tester}", run.tester) +
420
- (run.finishedAt ? " · " + T.finishedOn.replace("{date}", String(run.finishedAt).slice(0, 10)) : "");
528
+ setText($("bar-tester"), T.runBy.replace("{tester}", run.tester) +
529
+ (run.finishedAt ? " · " + T.finishedOn.replace("{date}", String(run.finishedAt).slice(0, 10)) : ""));
421
530
  var overall = $("overall");
422
531
  if (document.activeElement !== overall) overall.value = run.overallNote || "";
423
532
  $("json").value = JSON.stringify(run, null, 2);
424
533
  $("btn-finish").disabled = Boolean(run.finishedAt);
425
- $("btn-finish").textContent = run.finishedAt ? T.finished : T.finishRun;
534
+ $("btn-finish").textContent = run.finishedAt ? T.finished : confirmingFinish ? T.finishAnyway : T.finishRun;
426
535
  }
427
536
 
428
- // ── Persistence: artifact database, local server, or this browser ────
537
+ // ── Persistence: this browser always, plus the artifact database or the local server ──
538
+ /** Both keys must read back — the run itself, and the pointer that finds it after a reload. */
539
+ function saveLocal() {
540
+ var content = lsSet(KEY + "-" + runId, JSON.stringify(run));
541
+ var index = lsSet(KEY + "-last", runId);
542
+ localOk = content && index;
543
+ return localOk;
544
+ }
545
+ /** The status line reports what actually happened, never what was attempted. Written only on change: it is a live region. */
546
+ function showSaveState() {
547
+ var el = $("save-state"), text, cls = "state";
548
+ var remote = Boolean(dbDoc || serve);
549
+ if (remote && pending) { text = T.saving; }
550
+ else if (remote && remoteState === "ok") { text = T.savedRemote + " " + remoteAt.toLocaleTimeString(); cls = "state saved"; }
551
+ else if (remote && remoteState === "failed") { text = localOk ? T.remoteFailed : T.saveFailed; cls = localOk ? "state" : "state failed"; }
552
+ else if (localOk) { text = T.savedLocal; cls = "state saved"; }
553
+ else { text = T.saveFailed; cls = "state failed"; }
554
+ setText(el, text);
555
+ if (el.className !== cls) el.className = cls;
556
+ }
429
557
  function persist(immediate) {
430
558
  recount();
431
559
  run.updatedAt = new Date().toISOString();
560
+ if (confirmingFinish) { confirmingFinish = false; setText($("end-state"), ""); }
561
+ if (run.finishedAt && (!runNote || runNote.kind !== "changedAfterFinish")) showNote({ kind: "changedAfterFinish" });
432
562
  reflect();
433
- lsSet(KEY + "-" + runId, JSON.stringify(run));
434
- var body = JSON.stringify(run);
435
- if (body === lastWritten) return;
436
- var write = function () {
437
- if (dbDoc) {
438
- dbDoc.set(JSON.parse(body)).then(function () {
439
- lastWritten = body;
440
- $("save-state").textContent = T.savedRemote + " " + new Date().toLocaleTimeString();
441
- $("save-state").className = "state saved";
442
- }).catch(function () { $("save-state").textContent = T.savedLocal; $("save-state").className = "state"; });
443
- return;
563
+ saveLocal();
564
+ if (dbDoc || serve) {
565
+ var body = JSON.stringify(run);
566
+ if (body !== lastWritten) {
567
+ pending = body;
568
+ clearTimeout(timer);
569
+ if (immediate || body.length > LARGE_BODY) flush(); else timer = setTimeout(flush, 500);
444
570
  }
445
- if (serve) {
446
- fetch(serve + "/results/" + runId, { method: "PUT", headers: { "content-type": "application/json" }, body: body })
447
- .then(function (response) {
448
- if (!response.ok) throw new Error(String(response.status));
449
- lastWritten = body;
450
- $("save-state").textContent = T.savedRemote + " " + new Date().toLocaleTimeString();
451
- $("save-state").className = "state saved";
452
- })
453
- .catch(function () { $("save-state").textContent = T.savedLocal; $("save-state").className = "state"; });
454
- return;
455
- }
456
- $("save-state").textContent = T.savedLocal;
457
- };
571
+ }
572
+ showSaveState();
573
+ }
574
+ function flush(unloading) {
458
575
  clearTimeout(timer);
459
- if (immediate) write(); else timer = setTimeout(write, 500);
576
+ timer = null;
577
+ var body = pending;
578
+ if (!body) return;
579
+ // Only the latest request decides the status: an older one settling late says nothing.
580
+ var seq = ++flushSeq;
581
+ var done = function () { if (seq !== flushSeq) return; if (pending === body) pending = null; lastWritten = body; remoteState = "ok"; remoteAt = new Date(); showSaveState(); };
582
+ var failed = function () { if (seq !== flushSeq) return; if (pending === body) pending = null; remoteState = "failed"; showSaveState(); };
583
+ if (dbDoc) { dbDoc.set(JSON.parse(body)).then(done, failed); return; }
584
+ if (serve) {
585
+ var init = { method: "PUT", headers: { "content-type": "application/json" }, body: body };
586
+ if (unloading) init.keepalive = true;
587
+ try {
588
+ fetch(serve + "/results/" + runId, init)
589
+ .then(function (response) { if (!response.ok) throw new Error(String(response.status)); done(); })
590
+ .catch(failed);
591
+ } catch (e) { failed(); }
592
+ }
460
593
  }
461
594
 
462
- function open(id, doc) {
595
+ /** A newer copy of the run arrived from elsewhere (another tab, another device): it is what both now show. */
596
+ function adopt(doc, kind) {
597
+ run = doc;
598
+ run.runId = runId;
599
+ // What this tab had not sent yet described a state that no longer exists: it is not pushed over the newer copy.
600
+ clearTimeout(timer); timer = null; pending = null;
601
+ reflect();
602
+ saveLocal();
603
+ showSaveState();
604
+ if (!runNote || runNote.kind !== kind) showNote({ kind: kind });
605
+ }
606
+
607
+ function attach(id) {
608
+ if (!db) return;
609
+ dbDoc = db.doc("recettes/" + id);
610
+ if (unsub) unsub();
611
+ unsub = dbDoc.onSnapshot(function (snapshot) {
612
+ if (!snapshot.exists) return;
613
+ var data = snapshot.data();
614
+ if (!data || snapshot.metadata.hasPendingWrites) return;
615
+ var doc = draftOf(data);
616
+ if (!doc || !run || at(doc) <= at(run)) return;
617
+ var body = JSON.stringify(doc);
618
+ if (body === JSON.stringify(run)) return;
619
+ lastWritten = body;
620
+ remoteState = "ok"; remoteAt = new Date();
621
+ adopt(doc, "otherTab");
622
+ }, function () {});
623
+ }
624
+
625
+ function open(id, doc, fromRemote) {
463
626
  runId = id;
464
627
  run = doc;
465
628
  run.runId = id;
466
- lsSet(KEY + "-last", id);
629
+ lastWritten = ""; pending = null; remoteState = null; confirmingFinish = false;
630
+ showNote(null);
631
+ setText($("end-state"), "");
467
632
  $("startbox").hidden = true;
468
633
  $("steps").hidden = false;
469
634
  $("end").hidden = false;
470
635
  $("bar").hidden = false;
471
636
  $("btn-save").disabled = false;
472
637
  $("btn-copy").disabled = false;
638
+ $("btn-another").disabled = false;
473
639
  reflect();
474
- if (db) {
475
- dbDoc = db.doc("recettes/" + id);
476
- if (unsub) unsub();
477
- unsub = dbDoc.onSnapshot(function (snapshot) {
478
- if (!snapshot.exists) return;
479
- var data = snapshot.data();
480
- if (!data || snapshot.metadata.hasPendingWrites) return;
481
- var body = JSON.stringify(data);
482
- if (body !== JSON.stringify(run) && data.updatedAt > run.updatedAt) { run = JSON.parse(body); lastWritten = body; reflect(); }
483
- }, function () {});
484
- }
485
- $("mode-note").textContent = dbDoc ? T.dbMode : serve ? T.serveMode : T.localMode;
640
+ attach(id);
641
+ if (fromRemote && dbDoc) { lastWritten = JSON.stringify(run); remoteState = "ok"; remoteAt = new Date(at(run) > -Infinity ? at(run) : Date.now()); }
642
+ // Reopening is a write too: if this browser cannot keep the run, the tester learns it now, not at the end.
643
+ saveLocal();
644
+ showSaveState();
645
+ $("mode-note").textContent = modeText();
646
+ }
647
+
648
+ function focusFirstStep() {
649
+ var heading = $("steps").querySelector("h2");
650
+ if (heading) heading.focus();
486
651
  }
487
652
 
488
653
  function start() {
@@ -492,31 +657,158 @@
492
657
  open(id, newRun(tester));
493
658
  persist(true);
494
659
  window.scrollTo({ top: 0 });
660
+ focusFirstStep();
661
+ }
662
+
663
+ /**
664
+ * A run that answered another revision of the recipe is never poured into this one blindly.
665
+ * The rule, and the only rule: an answer travels when the line it answered is identical —
666
+ * same step, same letter, same text. Everything else is asked again. The earlier run is
667
+ * left exactly as it was; the continuation is a new run that names it.
668
+ */
669
+ function carry(id, doc) {
670
+ var fresh = newRun(doc.tester || "");
671
+ var kept = 0, total = 0;
672
+ STEPS.forEach(function (step) {
673
+ var old = doc.steps[step.id];
674
+ var target = fresh.steps[step.id];
675
+ if (old && old.title === step.title && typeof old.note === "string") target.note = old.note;
676
+ step.expect.forEach(function (e) {
677
+ total++;
678
+ var prior = old && old.expect && old.expect[e.id];
679
+ if (!prior || prior.text !== e.text) return;
680
+ if (STATES.indexOf(prior.state) >= 0) { target.expect[e.id].state = prior.state; kept++; }
681
+ target.expect[e.id].note = typeof prior.note === "string" ? prior.note : "";
682
+ });
683
+ });
684
+ fresh.overallNote = typeof doc.overallNote === "string" ? doc.overallNote : "";
685
+ fresh.carriedFrom = { runId: doc.runId || id, specSha256: doc.specSha256 || null, kept: kept, toAnswer: total - kept };
686
+ open(newRunId(doc.tester || ""), fresh);
687
+ persist(true);
688
+ showNote({ kind: "carried", kept: kept, left: total - kept });
689
+ window.scrollTo({ top: 0 });
690
+ focusFirstStep();
691
+ }
692
+
693
+ function download(name, text) {
694
+ var report = function (message) { setText($("end-state"), message); setText($("start-state"), message); };
695
+ var blob = function () {
696
+ try {
697
+ var url = URL.createObjectURL(new Blob([text], { type: "application/json" }));
698
+ var link = document.createElement("a");
699
+ link.href = url;
700
+ link.download = name;
701
+ document.body.appendChild(link);
702
+ link.click();
703
+ link.remove();
704
+ setTimeout(function () { URL.revokeObjectURL(url); }, 2000);
705
+ report(T.savedAs.replace("{file}", name));
706
+ } catch (e) { report(T.downloadFailed); }
707
+ };
708
+ if (downloads) {
709
+ downloads.save({ filename: name, data: text })
710
+ .then(function () { report(T.savedAs.replace("{file}", name)); })
711
+ // The tester said no: nothing to add. Anything else is a failure worth a word, and worth the other way out.
712
+ .catch(function (error) { if (error && error.code === "declined") return; blob(); });
713
+ return;
714
+ }
715
+ blob();
495
716
  }
496
717
 
497
718
  function listResumable() {
719
+ var seq = ++listSeq;
498
720
  var box = $("resume-list");
499
721
  box.innerHTML = "";
500
- var seen = {};
501
- var add = function (id, doc) {
502
- if (seen[id] || !doc) return;
503
- seen[id] = true;
504
- var button = document.createElement("button");
505
- button.className = "btn";
506
- button.type = "button";
507
- button.textContent = (doc.tester || "?") + " — " + String(doc.startedAt || "").slice(0, 10) + (doc.finishedAt ? " · " + T.finished : "");
508
- button.addEventListener("click", function () { open(id, doc); });
509
- box.appendChild(button);
722
+ var candidates = {};
723
+ var broken = [];
724
+ var button = function (label, action) { var b = document.createElement("button"); b.className = "btn"; b.type = "button"; b.textContent = label; b.addEventListener("click", action); return b; };
725
+ // Every copy of a run is a candidate; the newest wins, wherever it lives.
726
+ var offer = function (id, doc, remote) {
727
+ if (!candidates[id] || at(doc) > at(candidates[id].doc)) candidates[id] = { doc: doc, remote: remote };
510
728
  };
511
- var pending = db
729
+ var render = function () {
730
+ if (seq !== listSeq) return;
731
+ box.innerHTML = "";
732
+ Object.keys(candidates).sort(function (a, b) { return at(candidates[b].doc) - at(candidates[a].doc); }).forEach(function (id) {
733
+ var doc = candidates[id].doc, remote = candidates[id].remote;
734
+ var progress = answeredOf(doc);
735
+ var when = at(doc) > -Infinity ? new Date(at(doc)) : null;
736
+ var label = (doc.tester || "?") + " — " + String(doc.startedAt || "").slice(0, 10)
737
+ + (when ? " " + when.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "")
738
+ + " · " + progress.answered + "/" + progress.total + " " + T.countAnswered
739
+ + (doc.finishedAt ? " · " + T.finished : "");
740
+ if (sameRevision(doc)) { box.appendChild(button(label, function () { open(id, doc, remote); })); return; }
741
+ // Another revision: offered, explained, exportable — never reopened as if nothing had changed.
742
+ var row = document.createElement("div");
743
+ row.className = "row";
744
+ var note = document.createElement("span");
745
+ note.className = "state";
746
+ note.textContent = label + " · " + T.otherRevision;
747
+ row.appendChild(note);
748
+ row.appendChild(button(T.continueHere, function () { carry(id, doc); }));
749
+ row.appendChild(button(T.saveThatJson, function () { download(id + ".json", JSON.stringify(doc, null, 2)); }));
750
+ box.appendChild(row);
751
+ });
752
+ // A stored run that cannot be read is listed too, whatever the pointer says: kept, explained, exportable.
753
+ broken.forEach(function (entry) {
754
+ var row = document.createElement("div");
755
+ row.className = "row";
756
+ var note = document.createElement("span");
757
+ note.className = "state failed";
758
+ note.textContent = T.draftUnreadable;
759
+ row.appendChild(note);
760
+ row.appendChild(button(T.saveRaw, function () { download(entry.key + ".txt", entry.raw); }));
761
+ box.appendChild(row);
762
+ });
763
+ $("resume").hidden = box.children.length === 0;
764
+ };
765
+ var local = function () {
766
+ lsKeys().forEach(function (key) {
767
+ if (key.indexOf(KEY + "-") !== 0 || key === KEY + "-last" || key === KEY + "-lang") return;
768
+ var raw = lsGet(key);
769
+ var doc = draftOf(raw);
770
+ if (doc) offer(key.slice(KEY.length + 1), doc, false);
771
+ else if (raw !== null && !(unreadable && unreadable.key === key)) broken.push({ key: key, raw: raw });
772
+ });
773
+ };
774
+ var remote = db
512
775
  ? db.collection("recettes").where("specId", "==", SPEC.id).limit(50).get()
513
- .then(function (query) { query.docs.forEach(function (d) { add(d.id, d.data()); }); }).catch(function () {})
776
+ .then(function (query) { query.docs.forEach(function (d) { var doc = draftOf(d.data()); if (doc) offer(d.id, doc, true); }); }).catch(function () {})
514
777
  : Promise.resolve();
515
- pending.then(function () {
516
- var last = lsGet(KEY + "-last");
517
- if (last) { var raw = lsGet(KEY + "-" + last); if (raw) { try { add(last, JSON.parse(raw)); } catch (e) {} } }
518
- $("resume").hidden = box.children.length === 0;
519
- });
778
+ remote.then(function () { local(); render(); });
779
+ }
780
+
781
+ /** The browser's own copy comes back first, before any optional capability answers — or fails to. */
782
+ function restoreLocal() {
783
+ var last = lsGet(KEY + "-last");
784
+ if (!last) return;
785
+ var raw = lsGet(KEY + "-" + last);
786
+ if (raw === null) return;
787
+ var doc = draftOf(raw);
788
+ if (!doc) {
789
+ // Not deleted, not hidden: the tester can save it and hand it over.
790
+ unreadable = { key: KEY + "-" + last, raw: raw };
791
+ $("draft-problem").hidden = false;
792
+ return;
793
+ }
794
+ if (sameRevision(doc)) open(last, doc);
795
+ }
796
+
797
+ /** The artifact database answered: the newer copy wins, and an older remote copy never overwrites this browser's. */
798
+ function connectDb() {
799
+ $("mode-note").textContent = modeText();
800
+ $("storage-note").textContent = modeText();
801
+ listResumable();
802
+ var id = runId || lsGet(KEY + "-last");
803
+ if (!id) return;
804
+ db.doc("recettes/" + id).get().then(function (snapshot) {
805
+ var remote = snapshot.exists ? draftOf(snapshot.data()) : null;
806
+ var local = run || draftOf(lsGet(KEY + "-" + id));
807
+ var remoteNewer = Boolean(remote && (!local || at(remote) > at(local)));
808
+ if (remoteNewer && sameRevision(remote)) open(id, remote, true);
809
+ else if (run) { attach(id); persist(true); }
810
+ else if (local && sameRevision(local)) { open(id, local); persist(true); }
811
+ }).catch(function () { if (run) { attach(runId); persist(true); } });
520
812
  }
521
813
 
522
814
  document.addEventListener("change", function (event) {
@@ -525,7 +817,9 @@
525
817
  if (!(target instanceof HTMLInputElement) || target.type !== "radio") return;
526
818
  var m = /^e-(.+)-([a-z])$/.exec(target.name);
527
819
  if (!m || !requireRun(event)) return;
528
- run.steps[m[1]].expect[m[2]].state = target.value;
820
+ var step = run.steps[m[1]], entry = step && step.expect[m[2]];
821
+ if (!entry) return;
822
+ entry.state = target.value;
529
823
  persist();
530
824
  });
531
825
  document.addEventListener("input", function (event) {
@@ -536,7 +830,7 @@
536
830
  var step = /^note-(.+)$/.exec(target.id);
537
831
  if (step && run.steps[step[1]]) { run.steps[step[1]].note = target.value; persist(); return; }
538
832
  var expect = /^n-(.+)-([a-z])$/.exec(target.id);
539
- if (expect && run.steps[expect[1]]) { run.steps[expect[1]].expect[expect[2]].note = target.value; persist(); }
833
+ if (expect && run.steps[expect[1]] && run.steps[expect[1]].expect[expect[2]]) { run.steps[expect[1]].expect[expect[2]].note = target.value; persist(); }
540
834
  });
541
835
  document.addEventListener("keydown", function (event) {
542
836
  var code = event.target && event.target.closest && event.target.closest("code.cmd");
@@ -550,77 +844,101 @@
550
844
  Object.keys(run.steps[id].expect).forEach(function (k) { run.steps[id].expect[k].state = "skipped"; });
551
845
  persist();
552
846
  });
847
+ // The same run open in two tabs of this browser: whichever tab saved last is what both show.
848
+ // Said on screen, so a tester never wonders why a tick changed by itself.
849
+ window.addEventListener("storage", function (event) {
850
+ if (!run || !event || event.key !== KEY + "-" + runId || !event.newValue) return;
851
+ var doc = draftOf(event.newValue);
852
+ if (!doc || at(doc) <= at(run)) return;
853
+ adopt(doc, "otherTab");
854
+ });
855
+ window.addEventListener("pagehide", function () { if (pending) flush(true); });
856
+ // Browsers do not promise to show this; it is a last net under the status line, not a replacement for it.
857
+ window.addEventListener("beforeunload", function (event) {
858
+ if (run && !localOk) { event.preventDefault(); event.returnValue = ""; }
859
+ });
553
860
 
554
861
  $("btn-start").addEventListener("click", start);
555
862
  $("tester").addEventListener("keydown", function (event) { if (event.key === "Enter") start(); });
556
863
  $("btn-finish").addEventListener("click", function () {
557
864
  if (!requireRun()) return;
558
- if (run.summary.unanswered > 0 && !window.confirm(T.confirmUnanswered.replace("{n}", run.summary.unanswered))) return;
865
+ // Unanswered lines are said on the page, never in a dialog a host may swallow: the second click finishes anyway.
866
+ if (run.summary.unanswered > 0 && !confirmingFinish) {
867
+ confirmingFinish = true;
868
+ setText($("end-state"), T.confirmUnanswered.replace("{n}", String(run.summary.unanswered)));
869
+ $("btn-finish").textContent = T.finishAnyway;
870
+ return;
871
+ }
872
+ confirmingFinish = false;
559
873
  run.finishedAt = new Date().toISOString();
560
874
  persist(true);
561
- $("end-state").textContent = T.finished;
875
+ showNote(null);
876
+ // Finished is not accepted: the agent reads and classifies every answer before anything is decided.
877
+ setText($("end-state"), T.finishedNote);
562
878
  });
563
879
  $("btn-copy").addEventListener("click", function () {
564
880
  if (!requireRun()) return;
565
881
  var text = JSON.stringify(run, null, 2);
566
882
  var fallback = function () { $("json").parentElement.open = true; $("json").focus(); $("json").select(); };
567
- if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(function () { $("end-state").textContent = T.copied; }, fallback);
883
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(function () { setText($("end-state"), T.copied); }, fallback);
568
884
  else fallback();
569
885
  });
570
886
  $("btn-save").addEventListener("click", function () {
571
887
  // Never export a run that does not exist: that is how a tester once saved "null.json".
572
888
  if (!requireRun()) return;
573
- var name = runId + ".json";
574
- var text = JSON.stringify(run, null, 2);
575
- if (downloads) {
576
- downloads.save({ filename: name, data: text }).then(function () { $("end-state").textContent = name; }).catch(function () {});
577
- return;
578
- }
579
- try {
580
- var url = URL.createObjectURL(new Blob([text], { type: "application/json" }));
581
- var link = document.createElement("a");
582
- link.href = url;
583
- link.download = name;
584
- document.body.appendChild(link);
585
- link.click();
586
- link.remove();
587
- setTimeout(function () { URL.revokeObjectURL(url); }, 2000);
588
- $("end-state").textContent = name;
589
- } catch (e) { $("end-state").textContent = T.copyJson; }
889
+ download(runId + ".json", JSON.stringify(run, null, 2));
890
+ });
891
+ $("btn-save-raw").addEventListener("click", function () {
892
+ if (!unreadable) return;
893
+ download(unreadable.key + ".txt", unreadable.raw);
894
+ });
895
+ $("btn-another").addEventListener("click", function () {
896
+ if (!requireRun()) return;
897
+ flush();
898
+ if (unsub) { unsub(); unsub = null; }
899
+ dbDoc = null; run = null; runId = null; pending = null; lastWritten = ""; remoteState = null; confirmingFinish = false;
900
+ showNote(null);
901
+ setText($("end-state"), "");
902
+ $("steps").hidden = true;
903
+ $("end").hidden = true;
904
+ $("bar").hidden = true;
905
+ $("startbox").hidden = false;
906
+ $("btn-save").disabled = true;
907
+ $("btn-copy").disabled = true;
908
+ $("btn-finish").disabled = true;
909
+ $("btn-another").disabled = true;
910
+ $("tester").value = "";
911
+ $("start-state").textContent = "";
912
+ listResumable();
913
+ $("tester").focus();
590
914
  });
591
915
 
592
916
  // ── Start: the page is complete without any capability ──────────────
593
917
  lang = pickLanguage();
594
918
  fillLanguageChoice();
595
919
  applyLanguage(lang);
596
- var resumeLast = function () {
597
- var last = lsGet(KEY + "-last");
598
- if (!last || run) return;
599
- var raw = lsGet(KEY + "-" + last);
600
- if (!raw) return;
601
- try { open(last, JSON.parse(raw)); } catch (e) {}
920
+ restoreLocal();
921
+ var probe = function () {
922
+ try { return typeof fetch === "function" ? fetch("__uat/ping", { method: "GET" }) : Promise.reject(new Error("no fetch")); }
923
+ catch (e) { return Promise.reject(e); }
602
924
  };
603
- fetch("__uat/ping", { method: "GET" })
925
+ probe()
604
926
  .then(function (response) { return response.ok ? response.json() : null; })
605
- .then(function (info) { if (info && info.uat) { serve = "__uat"; $("mode-note").textContent = T.serveMode; listResumable(); } })
927
+ .then(function (info) {
928
+ if (info && info.uat) {
929
+ serve = "__uat";
930
+ $("mode-note").textContent = modeText();
931
+ $("storage-note").textContent = modeText();
932
+ if (run) persist(true);
933
+ }
934
+ })
606
935
  .catch(function () {})
607
936
  .then(function () {
608
937
  var use = window.claude && typeof window.claude.use === "function" ? window.claude.use.bind(window.claude) : function () { return Promise.resolve(null); };
609
- return use("db").then(function (namespace) {
610
- db = namespace;
611
- listResumable();
612
- if (run && db && !dbDoc) { open(runId, run); persist(true); return; }
613
- var last = lsGet(KEY + "-last");
614
- if (last && !run && db) {
615
- db.doc("recettes/" + last).get()
616
- .then(function (snapshot) { if (snapshot.exists) open(last, snapshot.data()); else resumeLast(); })
617
- .catch(resumeLast);
618
- } else resumeLast();
619
- });
620
- })
621
- .catch(resumeLast);
938
+ return use("db").then(function (namespace) { db = namespace || null; if (db) connectDb(); }).catch(function () {});
939
+ });
622
940
  if (window.claude && typeof window.claude.use === "function") {
623
- window.claude.use("downloads").then(function (namespace) { downloads = namespace; });
941
+ window.claude.use("downloads").then(function (namespace) { downloads = namespace; }).catch(function () {});
624
942
  }
625
943
  })();
626
944
  </script>