retake-demos 0.13.1 → 0.13.2

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/AGENTS.md CHANGED
@@ -235,9 +235,14 @@ Most real demos start behind a sign-in. The agent never sees a password:
235
235
  control where nothing is wrong, then reports recall and false positives.
236
236
  Expensive: nine takes, about ten minutes, needs the app running. The report
237
237
  it leaves is cheap to read, and **you should read it before telling anybody
238
- how much a sweep finding is worth.** Its stated limits travel with it: the
239
- seeds were written by somebody who has read the checklist, so recall is an
240
- upper bound.
238
+ how much a sweep finding is worth.** Its stated limits travel with it, and
239
+ all four belong in anything you say about the numbers: the seeds were written
240
+ by somebody who has read the checklist, so recall is an upper bound; injected
241
+ faults are cleaner than real ones; the run names the checklist items nothing
242
+ seeds, and says nothing whatever about those; and a run whose control did not
243
+ record cleanly measured nothing at all and exits 3 rather than printing a
244
+ reassuring zero. From MCP you can only READ the last measurement — making one
245
+ is a terminal command, because it records for ten minutes.
241
246
  - `retake intent` — what the product IS, in the builder's words: what it does,
242
247
  who uses it, and what about it is deliberate. Every visual check reads it.
243
248
  Without it they judge the app as a generic web page — measured on a real
package/dist/cli.js CHANGED
@@ -45,7 +45,7 @@ import { notes } from "./ext/notes.js";
45
45
  import { sense } from "./ext/sense.js";
46
46
  import { sweep } from "./ext/sweep.js";
47
47
  import { TEMPLATE, readIntent, writeIntent } from "./ext/intent.js";
48
- import { planCalibration, report } from "./ext/calibrate.js";
48
+ import { planCalibration, report, usable } from "./ext/calibrate.js";
49
49
  import { checkFlags, flag, unflag } from "./ext/flags.js";
50
50
  import { describeOrphans, findOrphans, healOrphans } from "./ext/heal.js";
51
51
  import { SHAPES, describePlan, describeTrials, planDestroy, refuseToRun, tryCandidates } from "./ext/destroy.js";
@@ -399,27 +399,55 @@ program
399
399
  if (opts.plan)
400
400
  return;
401
401
  const results = [];
402
+ /** Sweep once, and give back what it said. */
403
+ const look = async (m, dir, fresh) => (await sweep(m, dir, () => { }, { demosDir: path.resolve("demos"), fresh })).frames.flatMap((f) => f.issues.map((i) => i.kind));
404
+ let stability = null;
402
405
  for (const v of plan.variants) {
403
406
  const dir = path.join(plan.root, v.name);
404
407
  say(`\n${v.name}${v.defect ? ` — ${v.defect.looks}` : ""}`);
405
408
  try {
406
409
  const take = await record(v.manifest, { outDir: dir, manifestDir: loaded.dir, headed: false, log: () => { } });
407
410
  await render(v.manifest, take, dir, { log: () => { }, noMaster: true });
408
- const s = await sweep(v.manifest, dir, () => { }, { demosDir: path.resolve("demos") });
409
- const took = true; // the seed proved itself in the page, or the take threw
410
- const kinds = s.frames.flatMap((f) => f.issues.map((i) => i.kind));
411
+ // A recording that fell over, recorded nothing, or produced no frames
412
+ // measures nothing — whichever end of it broke. Asking sweep about it
413
+ // and reporting the empty answer is how a run against an app that was
414
+ // not even running came back reading "none on the control".
415
+ const fit = usable(dir, take);
416
+ if (!fit.ok) {
417
+ results.push({ variant: v.name, defect: v.defect, kinds: [], found: false, seeded: false, why: fit.why, other: 0 });
418
+ say(` → not measured: ${fit.why}`);
419
+ continue;
420
+ }
421
+ const kinds = await look(v.manifest, dir, false);
411
422
  const found = !v.defect ? false : kinds.some((k) => k.toUpperCase().startsWith(v.defect.expect.split(" ")[0]));
412
- results.push({ variant: v.name, defect: v.defect, kinds, found: found && took, seeded: took, other: v.defect ? kinds.filter((k) => !k.toUpperCase().startsWith(v.defect.expect.split(" ")[0])).length : 0 });
413
- say(` → ${kinds.join(", ") || "nothing"}${v.defect && !took ? " (the seed did not land — nothing was asked of the check)" : ""}`);
423
+ results.push({ variant: v.name, defect: v.defect, kinds, found, seeded: true, other: v.defect ? kinds.filter((k) => !k.toUpperCase().startsWith(v.defect.expect.split(" ")[0])).length : 0 });
424
+ say(` → ${kinds.join(", ") || "nothing"}`);
425
+ // The control, asked the same question about the same frames a second
426
+ // time. Judges are not deterministic, and a harness that never checks
427
+ // this cannot tell a real finding from a coin landing heads.
428
+ if (!v.defect) {
429
+ say(" asking the control again, to see whether the answer holds");
430
+ stability = { first: kinds, again: await look(v.manifest, dir, true) };
431
+ say(` → ${stability.again.join(", ") || "nothing"}`);
432
+ }
414
433
  }
415
434
  catch (e) {
416
- results.push({ variant: v.name, defect: v.defect, kinds: [], found: false, seeded: !/seed did not take/.test(String(e.message)), other: 0, error: String(e.message).split("\n")[0].slice(0, 90) });
417
- say(` → could not run: ${String(e.message).split("\n")[0].slice(0, 90)}`);
435
+ const msg = String(e.message).split("\n")[0].slice(0, 90);
436
+ results.push({ variant: v.name, defect: v.defect, kinds: [], found: false, seeded: false, why: `the run threw — ${msg}`, other: 0, error: msg });
437
+ say(` → could not run: ${msg}`);
418
438
  }
419
439
  }
420
- for (const l of report(results))
440
+ for (const l of report(results, stability))
421
441
  say(l);
422
- fs.writeFileSync(path.join(plan.root, "report.json"), JSON.stringify({ at: new Date().toISOString(), demo: loaded.manifest.name, results }, null, 2) + "\n");
442
+ fs.writeFileSync(path.join(plan.root, "report.json"), JSON.stringify({ at: new Date().toISOString(), demo: loaded.manifest.name, results, stability }, null, 2) + "\n");
443
+ // The false-positive rate is the number people act on. If the control did
444
+ // not record, this run produced no number at all, and exiting 0 says the
445
+ // opposite.
446
+ const control = results.find((r) => !r.defect);
447
+ if (!control?.seeded) {
448
+ process.exitCode = 3;
449
+ say("\nnothing was measured — fix the run before quoting any of this.");
450
+ }
423
451
  });
424
452
  program
425
453
  .command("intent")
package/dist/dryrun.js CHANGED
@@ -266,8 +266,17 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
266
266
  // records perfectly. The starter demo shipped with `retake init` hits
267
267
  // it, so a new person's first dry run failed on a demo that works.
268
268
  case "scroll":
269
- if (step.to && step.to !== "top" && step.to !== "bottom")
269
+ // And actually scroll. Skipping it left the page at the top while
270
+ // every later step ran, so a dry run rehearsed a different page from
271
+ // the one the recording would see.
272
+ if (step.to === "top")
273
+ await page.evaluate(() => window.scrollTo(0, 0));
274
+ else if (step.to === "bottom")
275
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
276
+ else if (step.to) {
270
277
  await page.locator(step.to).first().boundingBox({ timeout: short });
278
+ await page.locator(step.to).first().scrollIntoViewIfNeeded({ timeout: short });
279
+ }
271
280
  break;
272
281
  case "upload":
273
282
  await page.locator(step.selector).first().waitFor({ timeout: short });
@@ -45,6 +45,7 @@ import fs from "node:fs";
45
45
  import path from "node:path";
46
46
  import YAML from "yaml";
47
47
  import { createHash } from "node:crypto";
48
+ import { LOOK_FOR } from "./sweep.js";
48
49
  /**
49
50
  * Deliberately mechanical. Every one of these is visible to a person in one
50
51
  * glance, which is the bar: if a human cannot see it in the frame, a miss is
@@ -74,7 +75,12 @@ export const DEFECTS = [
74
75
  },
75
76
  {
76
77
  name: "unreadable-contrast",
77
- proof: "getComputedStyle(document.body).color.replace(/\\\\s/g,'') === 'rgb(233,235,228)'",
78
+ // Was `.replace(/\\s/g,'')`, which after string escaping built a regex for
79
+ // a literal backslash followed by "s" — so the spaces in "rgb(233, 235,
80
+ // 228)" were never stripped, the proof was never true, and this seed
81
+ // reported "did not land" on every app it was ever run against. It was
82
+ // counted as one of eight for two releases. No regex now.
83
+ proof: "getComputedStyle(document.body).color.split(' ').join('') === 'rgb(233,235,228)'",
78
84
  expect: "CONTRAST",
79
85
  looks: "body text almost the same colour as the page behind it",
80
86
  css: "body,p,li,span{color:#e9ebe4!important}",
@@ -139,6 +145,12 @@ export const lab = (outRoot, demo) => path.join(outRoot, ".calibrate", demo);
139
145
  export function planCalibration(m, outRoot, opts = {}) {
140
146
  const root = lab(outRoot, m.name);
141
147
  fs.mkdirSync(root, { recursive: true });
148
+ // `--only typo` used to match nothing and run a control alone, then print a
149
+ // recall line reading "0 of 0" — which is not a failure, it is a measurement
150
+ // of nothing wearing the clothes of one.
151
+ const unknown = (opts.only ?? []).filter((n) => !DEFECTS.some((d) => d.name === n));
152
+ if (unknown.length)
153
+ throw new Error(`no such defect: ${unknown.join(", ")} — try ${DEFECTS.map((d) => d.name).join(", ")}`);
142
154
  const want = opts.only?.length ? DEFECTS.filter((d) => opts.only.includes(d.name)) : DEFECTS;
143
155
  const variants = [];
144
156
  const write = (name, defect) => {
@@ -166,6 +178,17 @@ export function planCalibration(m, outRoot, opts = {}) {
166
178
  { action: "evaluate", script: `{ if (!(${defect.proof})) throw new Error("the ${defect.name} seed did not take on this app"); }` },
167
179
  { action: "wait", ms: 300 },
168
180
  ];
181
+ // And again at the END. The setup proof says the seed was on the page
182
+ // before the camera rolled; it says nothing about whether it was still
183
+ // there for the frames that were actually judged. A single-page app that
184
+ // navigates or re-renders can drop it halfway through, and every frame
185
+ // after that point asks the check to find something that is not there.
186
+ // Failing the step is right: a run that lost its seed is not a miss, it
187
+ // is not a measurement.
188
+ clone.steps = [
189
+ ...clone.steps,
190
+ { action: "evaluate", script: `{ if (!(${defect.proof})) throw new Error("the ${defect.name} seed was gone by the end of the run"); }` },
191
+ ];
169
192
  }
170
193
  const file = path.join(root, `${name}.yaml`);
171
194
  fs.writeFileSync(file, [
@@ -220,8 +243,40 @@ export function landed(controlDir, variantDir) {
220
243
  return true; // cannot tell; do not accuse the harness
221
244
  return a.join() !== b.join();
222
245
  }
246
+ /**
247
+ * Is this recording fit to measure anything at all?
248
+ *
249
+ * It was not asked. A control recorded against an app that was not running
250
+ * came back with an empty timeline, `ok: false` and no frames — and the report
251
+ * said "FALSE POSITIVES none on the control", which is the single number this
252
+ * harness exists to produce. Nothing was wrong with the control because
253
+ * nothing had happened in it. A blank measurement that reads as a clean one is
254
+ * worse than no measurement, and it exited 0.
255
+ */
256
+ export function usable(dir, take) {
257
+ if (!take)
258
+ return { ok: false, why: "nothing was recorded" };
259
+ if (!take.timeline?.length)
260
+ return { ok: false, why: "the run recorded no steps at all" };
261
+ if (take.ok === false)
262
+ return { ok: false, why: "the run did not finish cleanly" };
263
+ let stills = 0;
264
+ try {
265
+ stills = fs.readdirSync(path.join(dir, "stills")).filter((f) => /\.(png|jpe?g)$/i.test(f)).length;
266
+ }
267
+ catch { /* none */ }
268
+ if (!stills)
269
+ return { ok: false, why: "the run produced no frames to look at" };
270
+ return { ok: true, why: "" };
271
+ }
272
+ /** The `sweep` items nothing here seeds, named rather than left to arithmetic. */
273
+ export function uncovered() {
274
+ const seeded = new Set(DEFECTS.map((d) => d.expect.toUpperCase()));
275
+ return LOOK_FOR.map((l) => l.split(" — ")[0].trim())
276
+ .filter((kind) => !seeded.has(kind.toUpperCase()));
277
+ }
223
278
  /** Recall, false positives, and the sentence that says what they mean. */
224
- export function report(rs) {
279
+ export function report(rs, stability = null) {
225
280
  const all = rs.filter((r) => r.defect);
226
281
  const seeded = all.filter((r) => r.seeded);
227
282
  const unseeded = all.filter((r) => !r.seeded);
@@ -231,27 +286,56 @@ export function report(rs) {
231
286
  out.push("");
232
287
  out.push(`RECALL ${found} of ${seeded.length} defects that actually appeared were found`);
233
288
  for (const r of seeded) {
234
- out.push(` ${r.found ? "✓" : "✗"} ${r.variant.padEnd(20)} wanted ${String(r.defect.expect).padEnd(19)} got ${r.kinds.join(", ") || (r.error ? `— ${r.error}` : "nothing")}`);
289
+ out.push(` ${r.found ? "\u2713" : "\u2717"} ${r.variant.padEnd(20)} wanted ${String(r.defect.expect).padEnd(19)} got ${r.kinds.join(", ") || (r.error ? `\u2014 ${r.error}` : "nothing")}`);
235
290
  }
236
291
  if (unseeded.length) {
237
292
  out.push("");
238
- out.push(`NOT MEASURED ${unseeded.length} seed(s) produced no visible change, so nothing was asked of the check:`);
293
+ out.push(`NOT MEASURED ${unseeded.length} of ${all.length} seeded runs asked the check nothing:`);
239
294
  for (const r of unseeded)
240
- out.push(` — ${r.variant.padEnd(20)} the injection did not land. Fix the seed, not the checklist.`);
295
+ out.push(` \u2014 ${r.variant.padEnd(20)} ${r.why ?? r.error ?? "the injection did not land"}. Fix the harness, not the checklist.`);
241
296
  }
242
297
  out.push("");
243
- if (control) {
298
+ // The control is the half that needs no injection, so it is the half that
299
+ // travels — and the only one that can speak to false positives. If it did
300
+ // not record, nothing here may be claimed, in either direction.
301
+ if (!control) {
302
+ out.push("FALSE POSITIVES not measured \u2014 no control was run, so no rate can be claimed");
303
+ }
304
+ else if (!control.seeded) {
305
+ out.push(`FALSE POSITIVES NOT MEASURED \u2014 on the control, ${control.why ?? control.error ?? "the run did not record"}. No rate can be claimed from this run, and neither can the recall above: every variant recorded under the same conditions.`);
306
+ }
307
+ else {
244
308
  out.push(control.kinds.length
245
309
  ? `FALSE POSITIVES ${control.kinds.length} finding(s) on the control, where nothing is wrong: ${control.kinds.join(", ")}`
246
310
  : "FALSE POSITIVES none on the control");
247
311
  }
248
- else {
249
- out.push("FALSE POSITIVES not measured — no control was run, so no rate can be claimed");
250
- }
251
312
  const noise = seeded.reduce((n, r) => n + r.other, 0);
252
313
  if (noise)
253
314
  out.push(` and ${noise} extra finding(s) across the variants, beyond the fault seeded in each`);
254
315
  out.push("");
255
- out.push("What this is and is not: the seeds were written by someone who has read the checklist, so recall here is an upper bound — a fault nobody thought to seed is still unmeasured. Injected faults are also cleaner than the ones real apps produce. Treat it as a floor for trust, not a score.");
316
+ // Promised in this file's own header for two releases and never produced.
317
+ // A number nobody measures is a claim, and this harness exists because
318
+ // claims about the checks were not good enough.
319
+ if (!stability) {
320
+ out.push("STABILITY not measured this run \u2014 the control was asked once, so nothing here says whether the same frames give the same answer twice.");
321
+ }
322
+ else {
323
+ const a = [...stability.first].sort().join(", ") || "nothing";
324
+ const b = [...stability.again].sort().join(", ") || "nothing";
325
+ out.push(a === b
326
+ ? `STABILITY the control gave the same answer twice: ${a}`
327
+ : `STABILITY the control gave DIFFERENT answers to the same frames \u2014 first ${a}, then ${b}. Treat single sweep results on this app as one opinion, not a reading.`);
328
+ }
329
+ out.push("");
330
+ const gaps = uncovered();
331
+ if (gaps.length) {
332
+ // The count is of what this run attempted, not of the catalogue — `--only`
333
+ // makes those different numbers, and printing the catalogue's would
334
+ // overstate a narrow run.
335
+ const tried = all.length || DEFECTS.length;
336
+ out.push(`NOT COVERED ${tried} seed${tried === 1 ? "" : "s"} against a ${LOOK_FOR.length}-item checklist. Nothing here measures ${gaps.join(" or ")} at all, so the recall above says nothing about ${gaps.length === 1 ? "it" : "them"}.`);
337
+ out.push("");
338
+ }
339
+ out.push("What this is and is not: the seeds were written by someone who has read the checklist, so recall here is an upper bound \u2014 a fault nobody thought to seed is still unmeasured. Injected faults are also cleaner than the ones real apps produce. Treat it as a floor for trust, not a score.");
256
340
  return out;
257
341
  }
@@ -708,19 +708,21 @@ server.registerTool("intent", {
708
708
  return text(`saved to ${path.relative(ROOT, f)} — every visual check reads it from now on.`);
709
709
  });
710
710
  server.registerTool("calibrate", {
711
- description: "HOW MUCH TO TRUST `sweep`. Reads the last measurement: a working demo is recorded once per known visual defect — clipped text, overlap, unreadable contrast, content off the edge, a doubled element, a stuck spinner, broken media, a dev badge — plus a control where nothing is wrong. Recall is how many seeded faults were found; false positives are findings on the control. Read this BEFORE telling somebody how much a sweep result is worth, and quote the numbers rather than your impression. Reading is instant. `run: true` measures again, which records nine full takes and takes about ten minutes and needs the app running — do not do that without being asked. Its limits are printed with it and should be repeated: the seeds were written by somebody who has read the checklist, so recall is an upper bound, and injected faults are cleaner than real ones.",
712
- inputSchema: { name: z.string().describe("the demo to read (or measure) the calibration of"), run: z.boolean().optional() },
711
+ // It used to take `run: true` and answer, always, with a sentence telling
712
+ // you to run the CLI instead. An advertised parameter that cannot do the
713
+ // thing it names is a worse lie than an absent one: an agent plans around
714
+ // it, calls it, and reports back that it measured something.
715
+ description: "HOW MUCH TO TRUST `sweep`. READS the last measurement; it cannot make one, because measuring records a full take per defect and takes about ten minutes — that is `retake calibrate demos/<name>.yaml` at a terminal. A working demo is recorded once per known visual defect — clipped text, overlap, unreadable contrast, content off the edge, a doubled element, a stuck spinner, broken media, a dev badge — plus a control where nothing is wrong. Recall is how many seeded faults were found; false positives are findings on the control; stability is whether the control gave the same answer twice. Read this BEFORE telling somebody how much a sweep result is worth, and quote the numbers rather than your impression. Its limits are printed with it and should be repeated: the seeds were written by somebody who has read the checklist, so recall is an upper bound; injected faults are cleaner than real ones; and the run names outright which checklist items nothing seeds.",
716
+ inputSchema: { name: z.string().describe("the demo whose calibration to read") },
713
717
  annotations: READ_ONLY,
714
- }, async ({ name, run }) => {
718
+ }, async ({ name }) => {
715
719
  if (!safe(name))
716
720
  return text(`no demo "${name}"`);
717
721
  const file = path.join(OUT, ".calibrate", name, "report.json");
718
- if (run === true)
719
- return text(`Measuring takes about ten minutes and records nine takes, so it is not run from here. Ask the person to run:\n\n retake calibrate demos/${name}.yaml`);
720
722
  if (!fs.existsSync(file))
721
- return text(`${name} has never been calibrated, so there is no measured answer to how much its sweep results are worth. \`retake calibrate demos/${name}.yaml\` produces one.`);
723
+ return text(`${name} has never been calibrated, so there is no measured answer to how much its sweep results are worth. \`retake calibrate demos/${name}.yaml\` produces one — at a terminal, not from here.`);
722
724
  const r = JSON.parse(fs.readFileSync(file, "utf8"));
723
- return text([...describeCalibration(r.results), "", `measured ${r.at}`].join("\n"));
725
+ return text([...describeCalibration(r.results, r.stability ?? null), "", `measured ${r.at}`].join("\n"));
724
726
  });
725
727
  server.registerTool("done", { description: "Call when the demo is recorded and acceptable (or when you are stopping). One sentence for the person.", inputSchema: { summary: z.string(), demo: z.string().optional() }, annotations: RETAKE_WRITE }, async ({ summary, demo }) => {
726
728
  await tell(`Done: ${summary}`);
package/dist/ui/chat.html CHANGED
@@ -11,7 +11,7 @@
11
11
  /* The review surface. Retake is driven from an agent (or the workshop);
12
12
  this page is where a person watches, judges, and collects the output.
13
13
  It accepts no prose on purpose. */
14
- :root { --paper:#edefe8; --card:#f8f9f5; --ink:#191d19; --muted:#767e74; --rule:#dbdfd3; --rule2:#c9cec0; --accent:#1a1c1e; --amber:#e8b04b; --ok:#2f5d3f; --bad:#a23b2c; }
14
+ :root { --paper:#edefe8; --card:#f8f9f5; --ink:#191d19; --muted:#656c63; --rule:#dbdfd3; --rule2:#c9cec0; --accent:#1a1c1e; --amber:#e8b04b; --ok:#2f5d3f; --bad:#a23b2c; }
15
15
  * { box-sizing:border-box; }
16
16
  html,body { margin:0; height:100%; background:var(--paper); color:var(--ink); font:15px/1.6 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Inter, Arial, sans-serif; -webkit-font-smoothing:antialiased; }
17
17
  button, input { font:inherit; color:inherit; }
@@ -105,7 +105,9 @@
105
105
  the recording that were missing; they are optional services you can run. */
106
106
  .review { margin-top:14px; border:1px solid var(--rule); border-radius:12px; padding:10px 14px 12px; background:var(--card); }
107
107
  .rhead { font-size:11.5px; letter-spacing:.04em; text-transform:uppercase; color:var(--muted); display:flex; gap:10px; align-items:baseline; flex-wrap:wrap; }
108
- .rhint { text-transform:none; letter-spacing:0; font-size:12px; opacity:.85; }
108
+ /* No opacity. It took a colour already at the AA line and pushed it under.
109
+ Case and letter-spacing separate this from the label beside it already. */
110
+ .rhint { text-transform:none; letter-spacing:0; font-size:12px; }
109
111
  .checked { margin-top:8px; font-size:12.5px; color:var(--muted); display:flex; gap:14px; flex-wrap:wrap; align-items:baseline; }
110
112
  /* One per row on a phone: four of these side by side break into fragments
111
113
  that read as unrelated words. */
@@ -116,7 +118,6 @@
116
118
  .checked .c { display:inline-flex; gap:5px; align-items:baseline; }
117
119
  .checked .c b { font-weight:500; color:var(--ink); }
118
120
  .checked .c.none b { color:var(--muted); }
119
- .checked .c.old { opacity:.6; }
120
121
  .checked .c.bad b { color:var(--bad); }
121
122
  .checked .why { font-size:12px; }
122
123
  .flagged { margin-top:16px; border:1px solid var(--rule); background:var(--card); border-radius:16px; padding:14px 16px; }
@@ -396,7 +397,9 @@ $('newsBtn').onclick = async () => {
396
397
  localStorage.setItem(SEEN_KEY, rs[0].version);
397
398
  $('newsBtn').querySelector('.ndot')?.remove();
398
399
  veil.onclick = (e) => { if (e.target === veil) veil.remove(); };
399
- document.addEventListener('keydown', function esc2(e) { if (e.key === 'Escape') { shut(); document.removeEventListener('keydown', esc2); } });
400
+ // `shut()` does not exist here — Escape threw a ReferenceError and left the
401
+ // modal sitting there. Copied from a dialog that had one.
402
+ document.addEventListener('keydown', function esc2(e) { if (e.key === 'Escape') { veil.remove(); document.removeEventListener('keydown', esc2); } });
400
403
  };
401
404
 
402
405
  document.getElementById('gremlinBtn').onclick = async () => {
@@ -480,9 +483,12 @@ async function pin(name) {
480
483
  $('vidName').textContent = name;
481
484
  // What the RECORDING did — separate from anything optional that looked at it.
482
485
  const stateEl = $('vidState');
483
- if (t.brisk) { stateEl.className = 'vstate part'; stateEl.textContent = 'Brisk take'; stateEl.title = 'All the steps, none of the pacing. For iterating, not for keeping.'; }
486
+ // Failure first, always. `brisk` was tested before `ok`, so a brisk take
487
+ // whose steps fell over displayed the neutral "Brisk take" and nothing said
488
+ // it had failed — on the one path people use while iterating fast.
489
+ if (t.ok === false) { stateEl.className = 'vstate bad'; stateEl.textContent = t.brisk ? 'Brisk take — steps failed' : 'Steps failed'; stateEl.title = 'Some steps did not pass — see the proof log.'; }
490
+ else if (t.brisk) { stateEl.className = 'vstate part'; stateEl.textContent = 'Brisk take'; stateEl.title = 'All the steps, none of the pacing. For iterating, not for keeping.'; }
484
491
  else if (t.partial) { stateEl.className = 'vstate part'; stateEl.textContent = 'Partial'; stateEl.title = t.partial; }
485
- else if (t.ok === false) { stateEl.className = 'vstate bad'; stateEl.textContent = 'Steps failed'; stateEl.title = 'Some steps did not pass — see the proof log.'; }
486
492
  else { stateEl.className = 'vstate done'; stateEl.textContent = 'Recording complete'; stateEl.title = 'The take is sound: every step passed and the artifacts check out.'; }
487
493
  $('vidMeta').textContent = `${(t.duration - t.trimBefore).toFixed(0)}s · ${t.ok ? 'all steps passed' : failed ? `stopped at step ${failed.index}: ${failed.summary}` : 'some steps failed'}${d.facts ? ` · ${d.facts.width}×${d.facts.height}` : ''}`;
488
494
  $('vidMeta').title = failed?.screen ? 'On screen at that moment: ' + failed.screen : '';
@@ -6,7 +6,7 @@
6
6
  <title>How Retake works</title>
7
7
  <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
8
8
  <style>
9
- :root { --paper:#edefe8; --card:#f8f9f5; --ink:#191d19; --muted:#767e74; --rule:#dbdfd3; --amber:#e8b04b; --accent:#1a1c1e; }
9
+ :root { --paper:#edefe8; --card:#f8f9f5; --ink:#191d19; --muted:#656c63; --rule:#dbdfd3; --amber:#e8b04b; --accent:#1a1c1e; }
10
10
  * { box-sizing:border-box; }
11
11
  body { margin:0; background:var(--paper); color:var(--ink); font:16px/1.7 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Inter, Arial, sans-serif; -webkit-font-smoothing:antialiased; }
12
12
  .top { display:flex; align-items:center; gap:10px; padding:14px 22px; --rule2:#c9cec0; }
package/dist/ui/server.js CHANGED
@@ -202,14 +202,31 @@ function listDemos(project) {
202
202
  return [];
203
203
  const wanted = project?.trim() ? projectKey(project) : null;
204
204
  const assignments = demoProjects();
205
+ // The precedence for what a demo's folder is called, in one place, because
206
+ // it was three places and the release note described a fourth:
207
+ //
208
+ // 1. a folder you assigned by hand
209
+ // 2. a folder assigned by hand to another demo of the same app
210
+ // 3. the name in this demo's own product note
211
+ // 4. the name in a product note written for another demo of the same app
212
+ // 5. the app's address, which is where this started and helps nobody
213
+ //
214
+ // Four is new. Writing `retake intent --demo checkout "# Acme"` named the
215
+ // checkout folder and left the other six Acme demos in a fold called
216
+ // "localhost:3200", which is the exact complaint the naming was added to
217
+ // answer.
205
218
  const byUrl = new Map();
219
+ const namedByUrl = new Map();
206
220
  for (const f of fs.readdirSync(DEMOS).filter((x) => /\.ya?ml$/.test(x))) {
207
221
  const mm = cachedManifest(path.join(DEMOS, f));
208
- if (mm) {
209
- const asg = assignments[mm.name];
210
- if (asg && mm.url && !byUrl.has(mm.url))
211
- byUrl.set(mm.url, asg);
212
- }
222
+ if (!mm)
223
+ continue;
224
+ const asg = assignments[mm.name];
225
+ if (asg && mm.url && !byUrl.has(mm.url))
226
+ byUrl.set(mm.url, asg);
227
+ const named = productName(DEMOS, mm.name);
228
+ if (named && mm.url && !namedByUrl.has(mm.url))
229
+ namedByUrl.set(mm.url, named);
213
230
  }
214
231
  const listed = fs
215
232
  .readdirSync(DEMOS)
@@ -243,7 +260,7 @@ function listDemos(project) {
243
260
  // A product that has said what it is called is filed under that name.
244
261
  // Grouping by URL gave folders like "localhost:3200" — true, and no help
245
262
  // to anybody looking for their charter demos.
246
- const group = assigned ? path.basename(assigned) : (productName(DEMOS, name) ?? shortGroup(url));
263
+ const group = assigned ? path.basename(assigned) : (productName(DEMOS, name) ?? (url ? namedByUrl.get(url) : undefined) ?? shortGroup(url));
247
264
  // Heal a folder whose run was killed mid-recording: the previous take is
248
265
  // in its stash and nothing has put it back yet. The window must never
249
266
  // show someone an empty demo when their recording is safe on disk.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "retake-demos",
3
- "version": "0.13.1",
3
+ "version": "0.13.2",
4
4
  "description": "Demo-as-code for coding agents: a YAML manifest in, a product walkthrough video out. Rerun the demo instead of re-recording it.",
5
5
  "keywords": [
6
6
  "demo",
@@ -1,6 +1,20 @@
1
1
  {
2
2
  "_readme": "What's new, written for the person using Retake — not for whoever wrote it. Both the landing page and the Retake window read THIS file; there is no second copy. Rules for adding one: say what changed for them, name no files, no flags they will not type, no words like refactor or regression. If a line only makes sense to someone who has read the source, it does not belong here. Newest first. Add an entry on every release that changes what people see.",
3
3
  "releases": [
4
+ {
5
+ "version": "0.13.2",
6
+ "date": "2026-08-28",
7
+ "headline": "The thing that measures the checks was not measuring",
8
+ "notes": [
9
+ "Calibration is the number that says how much a visual finding is worth. Run against an app that was not even running, it used to print \"no false positives on the control\" and finish happily — because nothing had happened, so nothing was wrong. It now refuses: a run that fell over, recorded no steps, or produced no frames measures nothing, says which, and does not pretend otherwise.",
10
+ "One of its eight seeded faults could never have worked. A typo in the unreadable-contrast seed meant its own self-check could never be true, so that fault reported \"did not land\" on every app it was ever tried on. Fixed, and every seed is now tested in a real browser so a silently dead one cannot happen again.",
11
+ "Two more honest things it now says out loud: that two of the ten things it looks for have no seeded test at all, so the score says nothing about them; and whether asking the same question about the same pictures twice gives the same answer. That second one was promised in the code for two releases and never actually measured.",
12
+ "The checks run in a browser, and the browser was never installed on the build server — so every automated run since those tests were written failed before it started, and the build and packaging checks after them never ran. Installed now.",
13
+ "Fixed: pressing Escape on the What's New window did nothing. Fixed: a quick take whose steps failed showed a neutral \"Brisk take\" and never said it had failed. Fixed: the small print in the review area was too faint to meet the readability standard for text that size. Fixed: naming one demo's product now names the folder for every demo of the same app, not just that one. Fixed: a dry run now really scrolls when the demo says to scroll.",
14
+ "The agent tool for calibration used to advertise a setting that would run a fresh measurement, and always answered by telling you to go and run it yourself. That setting is gone.",
15
+ "And the landing page said \"nothing uploaded\", which was true of recording and not of the optional visual checks — those send the frames they look at to whichever model you point them at. It now says so."
16
+ ]
17
+ },
4
18
  {
5
19
  "version": "0.13.1",
6
20
  "date": "2026-08-28",
package/skill/SKILL.md CHANGED
@@ -134,13 +134,18 @@ Three things about reading what it says:
134
134
  - **A clean sweep is a floor, not a ceiling.** A closed checklist cannot find
135
135
  what is not on it. It catches the ten things that go wrong in every app; it
136
136
  does not certify the frame.
137
- - **Say what it is worth, if anybody has measured.** `calibrate` seeds each
138
- defect on the checklist into a real recording of the app and counts what
139
- `sweep` finds. On Retake's own guide page: seven or eight of eight found,
140
- and no false positives on the control across repeated runs. Read the report
141
- before telling somebody how much a finding is worth, and repeat its limits
142
- — the seeds were written by somebody who had read the checklist, so recall
143
- is an upper bound. Never run it unasked: nine takes, about ten minutes.
137
+ - **Say what it is worth, if anybody has measured.** `calibrate` seeds known
138
+ defects into a real recording of the app and counts what `sweep` finds. Read
139
+ the report before telling somebody how much a finding is worth, and quote its
140
+ limits rather than only its number: the seeds were written by somebody who
141
+ had read the checklist, so recall is an upper bound; two of the ten checklist
142
+ items have no seed at all, so the number says nothing about them; and a run
143
+ whose control did not record cleanly measured nothing. Earlier versions of
144
+ this file quoted "seven or eight of eight, no false positives" — one of those
145
+ eight seeds could never have landed, because its proof was broken, so the
146
+ denominator was wrong and the claim about repeated runs had never been
147
+ measured. Quote the report you have, not a remembered figure. Never run it
148
+ unasked: nine takes, about ten minutes, and only from a terminal.
144
149
  - **One pass is not reliable, and this is measured.** On a frame with a real
145
150
  overlap on it, three consecutive sweeps missed it and a fourth found it. The
146
151
  judge is not deterministic. So a frame that has already been looked at is