retake-demos 0.12.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
@@ -226,6 +226,23 @@ Most real demos start behind a sign-in. The agent never sees a password:
226
226
  and cuts the few seconds of video that show each one, so a long demo does not
227
227
  have to be re-watched to learn whether one thing changed. Exit 3 while
228
228
  anything flagged is still wrong. `retake unflag` stops watching one.
229
+ - **Name things so a person can find them.** The window groups demos by
230
+ whatever it can work out — which, with no product note, means folders called
231
+ `localhost:3200`. The first heading of a product note names that folder.
232
+ Writing one is part of setting a demo up, not an afterthought.
233
+ - `retake calibrate demos/<name>.yaml` — how much `sweep` is worth on this app.
234
+ Records the demo once per known visual defect, seeded for real, plus a
235
+ control where nothing is wrong, then reports recall and false positives.
236
+ Expensive: nine takes, about ten minutes, needs the app running. The report
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, 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.
229
246
  - `retake intent` — what the product IS, in the builder's words: what it does,
230
247
  who uses it, and what about it is deliberate. Every visual check reads it.
231
248
  Without it they judge the app as a generic web page — measured on a real
package/dist/cli.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * retake render outputs/x re-render from the existing take (--preset to switch)
12
12
  * retake check outputs/x pass/fail on resolution, fps, duration, files
13
13
  * retake verify outputs/x did it LOOK right — each scene's `expect`, judged
14
+ * retake calibrate demos/x.yaml how much to trust `sweep` — seed defects, count what it finds
14
15
  * retake intent what this product IS — context every visual check needs
15
16
  * retake sweep outputs/x look at every frame as a whole — what nobody thought to ask about
16
17
  * retake sense outputs/x does the run ADD UP — what went in vs what came out
@@ -44,6 +45,7 @@ import { notes } from "./ext/notes.js";
44
45
  import { sense } from "./ext/sense.js";
45
46
  import { sweep } from "./ext/sweep.js";
46
47
  import { TEMPLATE, readIntent, writeIntent } from "./ext/intent.js";
48
+ import { planCalibration, report, usable } from "./ext/calibrate.js";
47
49
  import { checkFlags, flag, unflag } from "./ext/flags.js";
48
50
  import { describeOrphans, findOrphans, healOrphans } from "./ext/heal.js";
49
51
  import { SHAPES, describePlan, describeTrials, planDestroy, refuseToRun, tryCandidates } from "./ext/destroy.js";
@@ -382,6 +384,71 @@ program
382
384
  if (trials.some((t) => t.verdict === "broke"))
383
385
  process.exitCode = 3;
384
386
  });
387
+ program
388
+ .command("calibrate")
389
+ .description("how much to trust `sweep` — seeds a known defect into a working demo, records each one, and counts what it finds")
390
+ .argument("<manifest>", "a demo whose app is running")
391
+ .option("-o, --out <dir>", "output root", "outputs")
392
+ .option("--only <names>", "just these defects, comma-separated")
393
+ .option("--plan", "write the variants and stop", false)
394
+ .action(async (file, opts) => {
395
+ const loaded = loadManifest(file);
396
+ const outRoot = path.resolve(opts.out);
397
+ const plan = planCalibration(loaded.manifest, outRoot, { only: opts.only?.split(",").map((x) => x.trim()).filter(Boolean) });
398
+ say(`${plan.variants.length} variants in ${path.relative(process.cwd(), plan.root)}/ — one control, ${plan.variants.length - 1} seeded`);
399
+ if (opts.plan)
400
+ return;
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;
405
+ for (const v of plan.variants) {
406
+ const dir = path.join(plan.root, v.name);
407
+ say(`\n${v.name}${v.defect ? ` — ${v.defect.looks}` : ""}`);
408
+ try {
409
+ const take = await record(v.manifest, { outDir: dir, manifestDir: loaded.dir, headed: false, log: () => { } });
410
+ await render(v.manifest, take, dir, { log: () => { }, noMaster: true });
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);
422
+ const found = !v.defect ? false : kinds.some((k) => k.toUpperCase().startsWith(v.defect.expect.split(" ")[0]));
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
+ }
433
+ }
434
+ catch (e) {
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}`);
438
+ }
439
+ }
440
+ for (const l of report(results, stability))
441
+ say(l);
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
+ }
451
+ });
385
452
  program
386
453
  .command("intent")
387
454
  .description("what this product IS — the context every visual check is missing without it")
package/dist/dryrun.js CHANGED
@@ -260,9 +260,23 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
260
260
  }
261
261
  break;
262
262
  }
263
+ // `to: top` and `to: bottom` are the PAGE's ends, not selectors — the
264
+ // recorder reads scrollY and scrollHeight for them. Dry was looking for
265
+ // an element called "bottom", timing out, and failing a step that
266
+ // records perfectly. The starter demo shipped with `retake init` hits
267
+ // it, so a new person's first dry run failed on a demo that works.
263
268
  case "scroll":
264
- if (step.to)
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) {
265
277
  await page.locator(step.to).first().boundingBox({ timeout: short });
278
+ await page.locator(step.to).first().scrollIntoViewIfNeeded({ timeout: short });
279
+ }
266
280
  break;
267
281
  case "upload":
268
282
  await page.locator(step.selector).first().waitFor({ timeout: short });
@@ -0,0 +1,341 @@
1
+ /**
2
+ * How much should anybody trust what `sweep` says?
3
+ *
4
+ * Every other check in Retake is tested: the seam, the guard, the parity, the
5
+ * refusals. The one thing nothing measures is whether the model-based checks
6
+ * are RIGHT — and that is the part the product is now sold on. Today `sweep`
7
+ * found three real bugs, produced at least one clear false positive, and
8
+ * missed a genuine overlap three passes running. Three anecdotes are not a
9
+ * number.
10
+ *
11
+ * Retake can measure this with what it already has. `evaluate` injects
12
+ * anything into the page before the camera rolls, so a defect can be seeded
13
+ * deterministically: one variant of a working demo per known fault, recorded
14
+ * for real, plus a clean control. Then:
15
+ *
16
+ * recall how many seeded defects were found
17
+ * false positives what was reported on the control, where nothing is wrong
18
+ * stability whether the same frame gives the same answer twice
19
+ *
20
+ * WHERE THIS WORKS, AND WHERE IT DOES NOT.
21
+ *
22
+ * On a page that is served and then left alone — a document, a landing page,
23
+ * Retake's own guide — injection is reliable and the numbers mean something:
24
+ * eight of eight seeded faults found, nothing reported on the control.
25
+ *
26
+ * On a live single-page app it is not. Against Capture, seeds provably present
27
+ * in the DOM after hydration were provably absent from every recorded frame,
28
+ * four different ways of injecting them, for reasons not isolated. A harness
29
+ * built on injection therefore measures the injection as much as the check,
30
+ * and a low recall on such an app is not evidence about the check at all.
31
+ *
32
+ * So: use this to calibrate against static pages, and take the control result
33
+ * — findings where nothing is wrong — from anywhere, because that half needs
34
+ * no injection and is the half that matters most for trust. To calibrate a
35
+ * real app, label real recordings and have a person adjudicate them. That is
36
+ * slower, and it is the only thing that answers the question honestly.
37
+ *
38
+ * Two honest limits, stated rather than buried. The person writing the seeds
39
+ * is not blind to the checklist, so recall here is an upper bound — a defect
40
+ * nobody thought to seed is still not measured. And CSS-injected faults are
41
+ * cleaner than the ones real apps produce; a laboratory number, not a field
42
+ * one. Both are worth having, neither is the whole answer.
43
+ */
44
+ import fs from "node:fs";
45
+ import path from "node:path";
46
+ import YAML from "yaml";
47
+ import { createHash } from "node:crypto";
48
+ import { LOOK_FOR } from "./sweep.js";
49
+ /**
50
+ * Deliberately mechanical. Every one of these is visible to a person in one
51
+ * glance, which is the bar: if a human cannot see it in the frame, a miss is
52
+ * not evidence of anything.
53
+ *
54
+ * And every one targets ORDINARY TEXT — p, li, headings — rather than a
55
+ * specific element. Three seeds failed here before that rule existed: they
56
+ * changed parts of the page the demo's frames never showed, so the run
57
+ * reported a miss when nothing had been asked of the check. A calibration
58
+ * harness that can silently measure nothing is worse than none, because its
59
+ * number looks the same either way.
60
+ */
61
+ export const DEFECTS = [
62
+ {
63
+ name: "clipped-text",
64
+ proof: "[...document.querySelectorAll('p,li,h1,h2,h3')].some(e => e.scrollHeight > e.clientHeight + 4)",
65
+ expect: "CLIPPED",
66
+ looks: "headings cut off mid-letter by a box too short for them",
67
+ css: "h1,h2,h3,li,p{max-height:14px!important;overflow:hidden!important;display:block!important}",
68
+ },
69
+ {
70
+ name: "overlapping-text",
71
+ proof: "[...document.querySelectorAll('p,li')].some(e => parseFloat(getComputedStyle(e).marginBottom) < -10)",
72
+ expect: "OVERLAP",
73
+ looks: "paragraphs sitting on top of each other",
74
+ css: "p,li{margin-bottom:-26px!important;position:relative!important;z-index:2!important}",
75
+ },
76
+ {
77
+ name: "unreadable-contrast",
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)'",
84
+ expect: "CONTRAST",
85
+ looks: "body text almost the same colour as the page behind it",
86
+ css: "body,p,li,span{color:#e9ebe4!important}",
87
+ },
88
+ {
89
+ name: "runs-off-the-edge",
90
+ proof: 'document.documentElement.scrollWidth > document.documentElement.clientWidth + 40',
91
+ expect: "CUT OFF",
92
+ looks: "content pushed off the right-hand side of the frame",
93
+ css: "main,body>*{margin-left:62%!important;white-space:nowrap!important}",
94
+ },
95
+ {
96
+ name: "doubled-heading",
97
+ proof: "!!document.querySelector('[data-retake-dup]')",
98
+ expect: "DOUBLED",
99
+ looks: "every heading rendered twice, one under the other",
100
+ // The first version targeted h2 and used attr(data-x), which is empty —
101
+ // so nothing appeared and the run looked like a MISS. A seed that fails
102
+ // silently is indistinguishable from a check that failed, which is why
103
+ // `landed` below exists.
104
+ // Twice wrong before this: CSS pseudo-content on headings, which produced
105
+ // nothing the first time and nothing IN FRAME the second. A seed that is
106
+ // not plainly visible in the frames being judged measures nothing, and the
107
+ // temptation to keep tuning it until the number improves is exactly how a
108
+ // calibration harness starts lying. So it clones the real node instead.
109
+ css: "",
110
+ js: "for (const h of [...document.querySelectorAll('h1,h2,h3,p,li')]) { if (h.textContent && h.textContent.trim().length > 8) { const c = h.cloneNode(true); c.setAttribute('data-retake-dup',''); h.after(c); } }",
111
+ },
112
+ {
113
+ name: "stuck-spinner",
114
+ proof: "!!document.getElementById('retake-seed-spinner')",
115
+ expect: "UNFINISHED",
116
+ looks: "a loading spinner sitting on the page that never resolves",
117
+ css: "",
118
+ js: "const d=document.createElement('div');d.id='retake-seed-spinner';d.textContent='Loading…';d.style.cssText='position:fixed;top:14px;left:50%;transform:translateX(-50%);background:#fff;border:1px solid #ccc;border-radius:999px;padding:6px 16px;font:14px sans-serif;z-index:2147483647';document.body.appendChild(d);",
119
+ },
120
+ {
121
+ name: "broken-image",
122
+ proof: "!!document.getElementById('retake-seed-img')",
123
+ expect: "BROKEN MEDIA",
124
+ looks: "an image that failed to load, showing its alt text instead",
125
+ css: "",
126
+ js: "const i=document.createElement('img');i.id='retake-seed-img';i.src='/definitely-not-here-'+'x'.repeat(4)+'.png';i.alt='product photograph';i.style.cssText='position:fixed;top:90px;left:24px;width:150px;height:100px;border:1px solid #ccc;background:#fff;z-index:2147483647';document.body.appendChild(i);",
127
+ },
128
+ {
129
+ name: "debug-badge",
130
+ proof: "!!document.getElementById('retake-seed-badge')",
131
+ expect: "NOT FOR THE CAMERA",
132
+ looks: "a development badge left in the corner of the screen",
133
+ css: "",
134
+ js: "const b=document.createElement('div');b.id='retake-seed-badge';b.textContent='DEV BUILD · staging';b.style.cssText='position:fixed;bottom:12px;left:12px;background:#111;color:#0f0;font:12px monospace;padding:5px 10px;border-radius:5px;z-index:2147483647';document.body.appendChild(b);",
135
+ },
136
+ ];
137
+ /** Where calibration writes. Never near a real take. */
138
+ export const lab = (outRoot, demo) => path.join(outRoot, ".calibrate", demo);
139
+ /**
140
+ * A control and one variant per defect, as ordinary manifests.
141
+ *
142
+ * The control matters more than any single variant: without a run where
143
+ * nothing is wrong, a low false-positive rate cannot be claimed at all.
144
+ */
145
+ export function planCalibration(m, outRoot, opts = {}) {
146
+ const root = lab(outRoot, m.name);
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(", ")}`);
154
+ const want = opts.only?.length ? DEFECTS.filter((d) => opts.only.includes(d.name)) : DEFECTS;
155
+ const variants = [];
156
+ const write = (name, defect) => {
157
+ const clone = JSON.parse(JSON.stringify(m));
158
+ clone.name = `${m.name}-${name}`.slice(0, 60);
159
+ if (defect) {
160
+ // Before anything is recorded, so every frame carries the fault.
161
+ clone.setup = [
162
+ ...(clone.setup ?? []),
163
+ // A stylesheet in <head> survives re-render; the elements below may not.
164
+ ...(defect.css ? [{ action: "evaluate", script: `{const s=document.createElement('style');s.id='retake-seed-css';s.textContent=${JSON.stringify(defect.css)};document.head.appendChild(s);setInterval(()=>{if(!document.getElementById('retake-seed-css'))document.head.appendChild(s);},250);}` }] : []),
165
+ // Re-applied on a timer, because a React app hydrates and re-renders
166
+ // after the seed runs and takes anything appended to the body with it.
167
+ // Capture did exactly that: the element passed its own proof at seed
168
+ // time and was gone from every recorded frame, which looked identical
169
+ // to the check missing it.
170
+ ...(defect.js ? [{ action: "evaluate", script: `{ const seed = () => { try { ${defect.js} } catch {} }; seed(); setInterval(() => { if (!(${defect.proof})) seed(); }, 250); }` }] : []),
171
+ // The seed proves itself, in the page, before anything is recorded.
172
+ // A seed that did not render must fail the take rather than produce
173
+ // frames that look exactly like a check missing something.
174
+ { action: "wait", ms: 1200 },
175
+ // Checked after the app has had time to hydrate and re-render, not at
176
+ // the instant of injection — that was the difference between a seed
177
+ // that exists and a seed that is in the picture.
178
+ { action: "evaluate", script: `{ if (!(${defect.proof})) throw new Error("the ${defect.name} seed did not take on this app"); }` },
179
+ { action: "wait", ms: 300 },
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
+ ];
192
+ }
193
+ const file = path.join(root, `${name}.yaml`);
194
+ fs.writeFileSync(file, [
195
+ defect ? `# ${defect.name} — ${defect.looks}.` : "# control — nothing is wrong with this one.",
196
+ `# Generated by \`retake calibrate ${m.name}\`. Recording it seeds the fault for real.`,
197
+ "",
198
+ YAML.stringify(clone),
199
+ ].join("\n"));
200
+ return { name, defect, file, manifest: clone };
201
+ };
202
+ variants.push(write("control", null));
203
+ for (const d of want)
204
+ variants.push(write(d.name, d));
205
+ return { root, variants };
206
+ }
207
+ /**
208
+ * Did the seed actually take?
209
+ *
210
+ * A variant whose frames are byte-identical to the control's is a variant
211
+ * where nothing was injected — the CSS missed, the selector was wrong, the
212
+ * page changed. Counting that as a MISS blames the check for the harness's
213
+ * mistake, and it happened on the very first run: a doubled-heading seed that
214
+ * targeted the wrong element reported 7 of 8 when the honest answer was 7 of
215
+ * 7 and one seed that never landed.
216
+ */
217
+ /**
218
+ * @deprecated Seeds prove themselves in the page now — see `Defect.proof`.
219
+ *
220
+ * Kept only as a record of why: this compares a variant's frames to the
221
+ * control's, which cannot work on an app whose content changes between runs.
222
+ * Capture's board carries timestamps and counts, so every variant differed
223
+ * from its control whether or not anything had been injected, and this
224
+ * reported eight seeds as landed when at least one plainly had not.
225
+ */
226
+ export function landed(controlDir, variantDir) {
227
+ // NOTE what this does and does not prove: that the frames differ from the
228
+ // control's, so SOMETHING was injected. It cannot prove the fault is
229
+ // plainly visible in them. A seed that changes the page off-screen passes
230
+ // here and still measures nothing — which happened, twice, before the
231
+ // doubled-heading seed was rewritten to clone a real node.
232
+ const shots = (d) => {
233
+ try {
234
+ return fs.readdirSync(path.join(d, "stills")).sort()
235
+ .map((f) => createHash("sha1").update(fs.readFileSync(path.join(d, "stills", f))).digest("hex"));
236
+ }
237
+ catch {
238
+ return [];
239
+ }
240
+ };
241
+ const a = shots(controlDir), b = shots(variantDir);
242
+ if (!a.length || !b.length)
243
+ return true; // cannot tell; do not accuse the harness
244
+ return a.join() !== b.join();
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
+ }
278
+ /** Recall, false positives, and the sentence that says what they mean. */
279
+ export function report(rs, stability = null) {
280
+ const all = rs.filter((r) => r.defect);
281
+ const seeded = all.filter((r) => r.seeded);
282
+ const unseeded = all.filter((r) => !r.seeded);
283
+ const control = rs.find((r) => !r.defect);
284
+ const found = seeded.filter((r) => r.found).length;
285
+ const out = [];
286
+ out.push("");
287
+ out.push(`RECALL ${found} of ${seeded.length} defects that actually appeared were found`);
288
+ for (const r of seeded) {
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")}`);
290
+ }
291
+ if (unseeded.length) {
292
+ out.push("");
293
+ out.push(`NOT MEASURED ${unseeded.length} of ${all.length} seeded runs asked the check nothing:`);
294
+ for (const r of unseeded)
295
+ out.push(` \u2014 ${r.variant.padEnd(20)} ${r.why ?? r.error ?? "the injection did not land"}. Fix the harness, not the checklist.`);
296
+ }
297
+ out.push("");
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 {
308
+ out.push(control.kinds.length
309
+ ? `FALSE POSITIVES ${control.kinds.length} finding(s) on the control, where nothing is wrong: ${control.kinds.join(", ")}`
310
+ : "FALSE POSITIVES none on the control");
311
+ }
312
+ const noise = seeded.reduce((n, r) => n + r.other, 0);
313
+ if (noise)
314
+ out.push(` and ${noise} extra finding(s) across the variants, beyond the fault seeded in each`);
315
+ out.push("");
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.");
340
+ return out;
341
+ }
package/dist/ext/flags.js CHANGED
@@ -113,6 +113,10 @@ function stillFor(outDir, take, label) {
113
113
  * have to be re-watched.
114
114
  */
115
115
  export async function checkFlags(manifestFile, _m, outDir, opts = {}) {
116
+ // Both derived from the manifest's own path, so `fixed` reads the note that
117
+ // belongs to this demo's app.
118
+ const demosDir = path.dirname(manifestFile);
119
+ const demoName = path.basename(manifestFile).replace(/\.(ya?ml|json)$/i, "");
116
120
  const lines = [];
117
121
  const say = (l) => { lines.push(l); opts.log?.(l); };
118
122
  const flags = readFlags(manifestFile);
@@ -139,8 +143,11 @@ export async function checkFlags(manifestFile, _m, outDir, opts = {}) {
139
143
  why = `no still for scene "${f.scene}" in the newest take — was that scene recorded?`;
140
144
  else if (!provider)
141
145
  why = noJudge ?? "nothing available to look at the frame";
146
+ // The same product context `verify` gets. Without it `fixed` judged the
147
+ // same frames by a generic standard while `verify` judged them knowing
148
+ // what the app is — two answers to one question, by design accident.
142
149
  else
143
- ({ ok, why } = await judgeWith(provider, still, f.expect));
150
+ ({ ok, why } = await judgeWith(provider, still, f.expect, demosDir, demoName));
144
151
  let clip;
145
152
  let clipError;
146
153
  if (opts.clips !== false) {
@@ -20,6 +20,30 @@
20
20
  */
21
21
  import fs from "node:fs";
22
22
  import path from "node:path";
23
+ /**
24
+ * A demo name that cannot leave the folder it is meant to be in.
25
+ *
26
+ * `intentPath` and `writeIntent` put this straight into `path.join`, so
27
+ * `--demo ../../escaped` wrote its file outside `demos/` entirely. Measured,
28
+ * not theorised, and in a released package. Kebab-case is the rule every other
29
+ * name in Retake already follows; the containment check afterwards is the belt
30
+ * for anything that rule ever stops catching.
31
+ */
32
+ export function safeDemo(demo) {
33
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(demo)) {
34
+ throw new Error(`"${demo}" is not a demo name — lowercase letters, numbers and hyphens only`);
35
+ }
36
+ return demo;
37
+ }
38
+ /** Belt for the braces: whatever we built, it has to be inside `dir`. */
39
+ function within(dir, file) {
40
+ const root = path.resolve(dir);
41
+ const full = path.resolve(file);
42
+ if (full !== root && !full.startsWith(root + path.sep)) {
43
+ throw new Error(`refusing to write outside ${dir}`);
44
+ }
45
+ return full;
46
+ }
23
47
  /**
24
48
  * Where a demo's product note lives.
25
49
  *
@@ -30,13 +54,15 @@ import path from "node:path";
30
54
  */
31
55
  export function intentPath(demosDir, demo) {
32
56
  if (demo) {
33
- const own = path.join(demosDir, `${demo}.product.md`);
57
+ const own = within(demosDir, path.join(demosDir, `${safeDemo(demo)}.product.md`));
34
58
  if (fs.existsSync(own))
35
59
  return own;
36
60
  }
37
- return path.join(demosDir, "product.md");
61
+ return within(demosDir, path.join(demosDir, "product.md"));
38
62
  }
39
63
  export function readIntent(demosDir, demo) {
64
+ // A bad name is not an error when reading — it is simply no note. Only
65
+ // writing refuses, because writing is where the damage would be.
40
66
  try {
41
67
  const t = fs.readFileSync(intentPath(demosDir, demo), "utf8").trim();
42
68
  return filled(t) ? t : null;
@@ -57,15 +83,37 @@ export function filled(text) {
57
83
  return words.length > 0;
58
84
  }
59
85
  export function writeIntent(demosDir, text, demo) {
86
+ const f = within(demosDir, path.join(demosDir, demo ? `${safeDemo(demo)}.product.md` : "product.md"));
60
87
  fs.mkdirSync(demosDir, { recursive: true });
61
- const f = demo ? path.join(demosDir, `${demo}.product.md`) : path.join(demosDir, "product.md");
62
88
  fs.writeFileSync(f, text.trim() + "\n");
63
89
  return f;
64
90
  }
91
+ /**
92
+ * The product's name, from the first heading of its note.
93
+ *
94
+ * The window groups demos by URL, so a workspace of real products showed
95
+ * folders called "localhost:3200" and "localhost:4990" — accurate and
96
+ * meaningless. Naming the product once, for the checks, names its folder too.
97
+ * Returns null for the starter's own placeholder, so an unfilled note never
98
+ * becomes a folder called "What this product is".
99
+ */
100
+ export function productName(demosDir, demo) {
101
+ const t = readIntent(demosDir, demo);
102
+ if (!t)
103
+ return null;
104
+ const h = /^#\s+(.+?)\s*$/m.exec(t);
105
+ const name = h?.[1]?.trim();
106
+ if (!name || /^what this product is$/i.test(name))
107
+ return null;
108
+ return name.slice(0, 32);
109
+ }
65
110
  /** The starter, so nobody has to guess what belongs in it. */
66
- export const TEMPLATE = `# What this product is
111
+ export const TEMPLATE = `# Name this product
67
112
 
68
- <!-- One or two sentences. What it does, and for whom. -->
113
+ <!-- Replace the heading above with what this product is CALLED — that name
114
+ becomes its folder in the Retake window, which otherwise says something
115
+ like "localhost:3200". Then, below: one or two sentences on what it does
116
+ and for whom. -->
69
117
 
70
118
  ## Who uses it
71
119
 
package/dist/ext/notes.js CHANGED
@@ -56,10 +56,19 @@ function readRun(dir) {
56
56
  return null; // an empty folder is not a run
57
57
  return { name, dir, take, used, priors, when, checks: readChecks(dir) };
58
58
  }
59
- /** Every scene label in a used manifest that carries no `expect:`. */
60
- function scenesWithoutExpect(used) {
59
+ /**
60
+ * How many scenes carry an `expect:`, and how many do not.
61
+ *
62
+ * The note below used to fire on any demo with two bare scenes and say
63
+ * "nothing has ever checked how it looks". It said that about a demo carrying
64
+ * seventeen expectations that `verify` had just answered thirty-nine questions
65
+ * from. A watcher that says something plainly untrue about the workspace it is
66
+ * watching is worse than one that says nothing.
67
+ */
68
+ function expectCounts(used) {
61
69
  const steps = used?.steps ?? [];
62
- return steps.filter((s) => s.action === "scene" && !s.expect).length;
70
+ const scenes = steps.filter((s) => s.action === "scene");
71
+ return { with: scenes.filter((s) => s.expect).length, without: scenes.filter((s) => !s.expect).length };
63
72
  }
64
73
  /** The selector a failed step was reaching for, when it named one. */
65
74
  function failedSelector(summary, error) {
@@ -185,7 +194,9 @@ export function collect(root, sinceDays = 14) {
185
194
  // for: a demo recorded repeatedly with no `expect:` anywhere is one nobody
186
195
  // is verifying, and every visual bug this project has shipped got through
187
196
  // exactly that gap.
188
- const unchecked = runs.filter((r) => r.priors >= 1 && scenesWithoutExpect(r.used) >= 2).map((r) => r.name);
197
+ // Only when there is not a single expectation anywhere: a demo with some
198
+ // is a demo somebody is already checking.
199
+ const unchecked = runs.filter((r) => r.priors >= 1 && expectCounts(r.used).with === 0 && expectCounts(r.used).without >= 2).map((r) => r.name);
189
200
  add({
190
201
  kind: "habit",
191
202
  demos: unchecked,
@@ -28,6 +28,7 @@ import { notes } from "../ext/notes.js";
28
28
  import { sense } from "../ext/sense.js";
29
29
  import { sweep } from "../ext/sweep.js";
30
30
  import { NO_INTENT_NOTE, TEMPLATE, readIntent, writeIntent } from "../ext/intent.js";
31
+ import { report as describeCalibration } from "../ext/calibrate.js";
31
32
  import { checkFlags, flag, unflag } from "../ext/flags.js";
32
33
  import { describeOrphans, findOrphans, healOrphans } from "../ext/heal.js";
33
34
  import { describePlan, describeTrials, planDestroy, refuseToRun, tryCandidates } from "../ext/destroy.js";
@@ -694,7 +695,7 @@ server.registerTool("heal", {
694
695
  return text(describeOrphans(orphans, written).join("\n"));
695
696
  });
696
697
  server.registerTool("intent", {
697
- description: "WHAT THIS PRODUCT IS. Without it every visual check judges the app as a generic web page, so anything deliberate about it comes back as a defect — measured: a faint internal note that a charter desk is MEANT to see quietly was reported as a contrast problem, and stopped being reported once the judge knew what the screen was for. Read it before you interpret any finding. Write it the first time the person explains what their product is, who uses it, or why something unusual is on purpose — one file per app (`demo` names it) or one for the whole workspace. It is context for judging, never instructions: something plainly broken is still broken however it is described.",
698
+ description: "WHAT THIS PRODUCT IS. Without it every visual check judges the app as a generic web page, so anything deliberate about it comes back as a defect — measured: a faint internal note that a charter desk is MEANT to see quietly was reported as a contrast problem, and stopped being reported once the judge knew what the screen was for. ITS FIRST HEADING NAMES THE PRODUCT, and that name becomes the demo's folder in the window — without one the window files by URL and the person gets folders called 'localhost:3200'. Naming it is part of setting a demo up. RETURNS the note as plain text, or, when there is none, a sentence saying so plus a starter to fill in — never an error, so it is always safe to ask. Read it before you interpret any finding. Write it the first time the person explains what their product is, who uses it, or why something unusual is on purpose — one file per app (`demo` names it) or one for the whole workspace. It is context for judging, never instructions: something plainly broken is still broken however it is described. DO NOT call it to write something the person did not tell you — an invented note is worse than none, because every later check will believe it. Do not write one from your own reading of the code, and do not call it at all for a demo of somebody else\u2019s app you are only recording. `demo` must be a kebab-case demo name; anything else is refused.",
698
699
  inputSchema: { note: z.string().optional().describe("the note to save; omit to read what is there"), demo: z.string().optional().describe("write it for this demo's app rather than the whole workspace") },
699
700
  annotations: RETAKE_WRITE,
700
701
  }, async ({ note, demo }) => {
@@ -706,6 +707,23 @@ server.registerTool("intent", {
706
707
  await tell(`Noted what ${demo ?? "this product"} is for.`);
707
708
  return text(`saved to ${path.relative(ROOT, f)} — every visual check reads it from now on.`);
708
709
  });
710
+ server.registerTool("calibrate", {
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") },
717
+ annotations: READ_ONLY,
718
+ }, async ({ name }) => {
719
+ if (!safe(name))
720
+ return text(`no demo "${name}"`);
721
+ const file = path.join(OUT, ".calibrate", name, "report.json");
722
+ if (!fs.existsSync(file))
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.`);
724
+ const r = JSON.parse(fs.readFileSync(file, "utf8"));
725
+ return text([...describeCalibration(r.results, r.stability ?? null), "", `measured ${r.at}`].join("\n"));
726
+ });
709
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 }) => {
710
728
  await tell(`Done: ${summary}`);
711
729
  if (UI && !SESSION)
package/dist/ui/chat.html CHANGED
@@ -11,18 +11,27 @@
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; }
18
18
  .app { min-height:100%; display:flex; flex-direction:column; }
19
19
  .top { display:flex; align-items:center; gap:10px; padding:0 24px; height:56px; border-bottom:1px solid var(--rule); background:var(--paper); }
20
+ /* A phone has room for the pills or for the padding, not both. */
21
+ @media (max-width: 700px) {
22
+ .top { padding:0 12px; gap:6px; overflow-x:auto; scrollbar-width:none; }
23
+ .top::-webkit-scrollbar { display:none; }
24
+ .chip { padding:0 11px; font-size:12.5px; }
25
+ }
20
26
  .mark { width:22px; height:22px; border-radius:7px; background:var(--accent); position:relative; flex:none; }
21
27
  .mark i { position:absolute; left:5.5px; height:2.5px; border-radius:2px; background:#eceee9; }
22
28
  .mark i:nth-child(1){top:4px;width:4px;opacity:.55} .mark i:nth-child(2){top:8px;width:7px;opacity:.8} .mark i:nth-child(3){top:12px;width:11px;background:var(--amber)} .mark i:nth-child(4){top:16px;width:7px;opacity:.8}
23
29
  .top h1 { font-size:15px; font-weight:600; margin:0; }
24
30
  .controls { position:relative; display:flex; align-items:center; gap:8px; margin-left:8px; }
25
- .chip { border:1px solid var(--rule2); background:#fff; border-radius:999px; height:32px; padding:0 14px; display:inline-flex; align-items:center; font-size:13px; font-weight:500; color:var(--ink); cursor:pointer; transition:border-color .12s; }
31
+ /* `white-space: nowrap` because these are pills: without it "How it works"
32
+ and "What's new" wrapped INSIDE their own borders on a phone, which reads
33
+ as a broken control rather than a narrow screen. */
34
+ .chip { border:1px solid var(--rule2); background:#fff; border-radius:999px; height:32px; padding:0 14px; display:inline-flex; align-items:center; white-space:nowrap; flex:none; font-size:13px; font-weight:500; color:var(--ink); cursor:pointer; transition:border-color .12s; }
26
35
  .chip:hover { border-color:var(--ink); }
27
36
  .pop { position:absolute; left:0; top:38px; z-index:40; background:#fff; border:1px solid var(--rule2); border-radius:14px; box-shadow:0 12px 34px rgba(24,28,24,.13); padding:8px; width:290px; display:none; }
28
37
  .pop.show { display:block; }
@@ -64,6 +73,13 @@
64
73
  .under { display:flex; align-items:baseline; gap:8px; margin-top:16px; flex-wrap:wrap; }
65
74
  .vtitle { font-size:15px; font-weight:600; color:var(--ink); }
66
75
  .vmeta { font-size:13px; color:var(--muted); }
76
+ /* The recording's own state, in its own colour. A finished take was quiet
77
+ grey metadata while a failed OPTIONAL check was a red mark — so a demo
78
+ that recorded perfectly read as broken. */
79
+ .vstate { font-size:12px; font-weight:500; border-radius:999px; padding:2px 9px; white-space:nowrap; }
80
+ .vstate.done { color:var(--ok); background:rgba(47,93,63,.10); }
81
+ .vstate.part { color:var(--amber); background:rgba(232,176,75,.16); }
82
+ .vstate.bad { color:var(--bad); background:rgba(162,59,44,.10); }
67
83
  .under a, .under button { color:var(--ink); text-decoration:none; border:1px solid var(--rule2); background:#fff; border-radius:999px; padding:0 14px; cursor:pointer; font-size:13px; font-weight:500; display:inline-flex; align-items:center; transition:border-color .12s; }
68
84
  .under a:hover, .under button:hover { border-color:var(--ink); }
69
85
  /* A native select with its arrow removed leaves lopsided padding; make it
@@ -72,16 +88,36 @@
72
88
  .under a, .under button { height:32px; box-sizing:border-box; align-self:center; }
73
89
  .under select:hover { border-color:var(--ink); }
74
90
  .frames { margin-top:16px; border:1px solid var(--rule); background:var(--card); border-radius:16px; padding:14px 16px; }
91
+ /* On a phone the heading, its hint and "Use current frame" fought for one
92
+ line and the button wrapped to three. Stack them instead. */
93
+ @media (max-width: 700px) {
94
+ .frames .fhead { flex-wrap:wrap; row-gap:8px; }
95
+ .frames .fhead .sp { display:none; }
96
+ .frames .fhead .chip { order:3; width:100%; justify-content:center; }
97
+ .frames .fhead .hint { order:2; flex-basis:100%; }
98
+ }
75
99
  /* What you flagged, and whether it is still wrong. Shown only when there is
76
100
  something flagged — an empty one would be clutter with a heading on it. */
77
101
  /* One line: what has actually been looked at, and whether the answer is
78
102
  about the recording that is there now. Never a panel — the question is
79
103
  "did anyone check this", and that is one line's worth of answer. */
80
- .checked { margin-top:10px; font-size:12.5px; color:var(--muted); display:flex; gap:14px; flex-wrap:wrap; align-items:baseline; }
104
+ /* Its own area, named. Loose grey dashes under the video read as parts of
105
+ the recording that were missing; they are optional services you can run. */
106
+ .review { margin-top:14px; border:1px solid var(--rule); border-radius:12px; padding:10px 14px 12px; background:var(--card); }
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
+ /* 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; }
111
+ .checked { margin-top:8px; font-size:12.5px; color:var(--muted); display:flex; gap:14px; flex-wrap:wrap; align-items:baseline; }
112
+ /* One per row on a phone: four of these side by side break into fragments
113
+ that read as unrelated words. */
114
+ @media (max-width: 700px) {
115
+ .checked { display:grid; grid-template-columns:1fr; gap:6px; }
116
+ .checked .c { justify-content:flex-start; }
117
+ }
81
118
  .checked .c { display:inline-flex; gap:5px; align-items:baseline; }
82
119
  .checked .c b { font-weight:500; color:var(--ink); }
83
120
  .checked .c.none b { color:var(--muted); }
84
- .checked .c.old { opacity:.6; }
85
121
  .checked .c.bad b { color:var(--bad); }
86
122
  .checked .why { font-size:12px; }
87
123
  .flagged { margin-top:16px; border:1px solid var(--rule); background:var(--card); border-radius:16px; padding:14px 16px; }
@@ -238,6 +274,7 @@
238
274
  <video id="vid" controls playsinline></video>
239
275
  <div class="under">
240
276
  <span class="vtitle" id="vidName"></span>
277
+ <span class="vstate" id="vidState"></span>
241
278
  <span class="vmeta" id="vidMeta"></span>
242
279
  <span class="sp"></span>
243
280
  <select id="vidSpeed" title="Playback speed — preview now, and Download gets this speed">
@@ -256,7 +293,10 @@
256
293
  </div>
257
294
  <div id="flagRows"></div>
258
295
  </div>
259
- <div class="checked" id="checked"></div>
296
+ <div class="review" id="review" style="display:none">
297
+ <div class="rhead">Optional review<span class="rhint">checks you can run against this recording — it is complete without them</span></div>
298
+ <div class="checked" id="checked"></div>
299
+ </div>
260
300
  <div class="frames">
261
301
  <div class="fhead">
262
302
  <span class="lab">Poster & frames</span>
@@ -335,20 +375,31 @@ $('newsBtn').onclick = async () => {
335
375
  const fmt = (d) => { const x = new Date(d + 'T00:00:00'); return isNaN(x) ? '' : x.toLocaleDateString(undefined, { month: 'long', day: 'numeric', year: 'numeric' }); };
336
376
  const veil = document.createElement('div');
337
377
  veil.className = 'veil';
338
- veil.innerHTML = '<div class="modal wide"><h3>What\'s new</h3>' +
339
- '<div class="scroll">' + rs.map((r, i) =>
378
+ // The newest release, and a way to the rest. Every release at once made the
379
+ // one thing that changed today compete with everything that ever changed.
380
+ let showAll = false;
381
+ const body = () => (showAll ? rs : rs.slice(0, 1)).map((r, i) =>
340
382
  '<div class="rel' + (i ? '' : ' latest') + '">' +
341
383
  '<div class="relhead"><b>' + esc(r.headline) + '</b><span class="relmeta">' + esc(r.version) + (r.date ? ' · ' + fmt(r.date) : '') + '</span></div>' +
342
384
  '<ul>' + (r.notes || []).map((n) => '<li>' + esc(n) + '</li>').join('') + '</ul>' +
343
- '</div>').join('') + '</div>' +
344
- '<div class="row" style="margin-top:18px"><button class="btn" id="newsClose">Close</button></div></div>';
385
+ '</div>').join('');
386
+ const render = () => {
387
+ veil.innerHTML = '<div class="modal wide"><h3>What\'s new</h3><div class="scroll">' + body() + '</div>' +
388
+ '<div class="row" style="margin-top:18px">' +
389
+ (showAll || rs.length < 2 ? '' : '<button class="btn" id="newsAll">All ' + rs.length + ' releases</button>') +
390
+ '<span class="sp"></span><button class="btn" id="newsClose">Close</button></div></div>';
391
+ const all = veil.querySelector('#newsAll');
392
+ if (all) all.onclick = () => { showAll = true; render(); };
393
+ veil.querySelector('#newsClose').onclick = () => veil.remove();
394
+ };
395
+ render();
345
396
  document.body.appendChild(veil);
346
397
  localStorage.setItem(SEEN_KEY, rs[0].version);
347
398
  $('newsBtn').querySelector('.ndot')?.remove();
348
- const shut = () => veil.remove();
349
- veil.querySelector('#newsClose').onclick = shut;
350
- veil.onclick = (e) => { if (e.target === veil) shut(); };
351
- document.addEventListener('keydown', function esc2(e) { if (e.key === 'Escape') { shut(); document.removeEventListener('keydown', esc2); } });
399
+ veil.onclick = (e) => { if (e.target === veil) veil.remove(); };
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); } });
352
403
  };
353
404
 
354
405
  document.getElementById('gremlinBtn').onclick = async () => {
@@ -430,6 +481,15 @@ async function pin(name) {
430
481
  const t = d.take;
431
482
  const failed = (t.timeline || []).find((e) => e.ok === false);
432
483
  $('vidName').textContent = name;
484
+ // What the RECORDING did — separate from anything optional that looked at it.
485
+ const stateEl = $('vidState');
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.'; }
491
+ else if (t.partial) { stateEl.className = 'vstate part'; stateEl.textContent = 'Partial'; stateEl.title = t.partial; }
492
+ else { stateEl.className = 'vstate done'; stateEl.textContent = 'Recording complete'; stateEl.title = 'The take is sound: every step passed and the artifacts check out.'; }
433
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}` : ''}`;
434
494
  $('vidMeta').title = failed?.screen ? 'On screen at that moment: ' + failed.screen : '';
435
495
  $('vidDl').href = `/out/${name}/demo.mp4${q}`; $('vidDl').setAttribute('download', name + '.mp4');
@@ -484,7 +544,8 @@ function drawChecked(checks, take) {
484
544
  const now = take && take.finishedAt;
485
545
  // Labelled, because four bare dashes read as four things missing. They are
486
546
  // optional checks against a finished recording, not parts of it.
487
- box.innerHTML = `<span class="c none" title="These are optional checks you can run against this take. The recording is complete without them.">checked:</span>` + CHECKS.map(([key, label, title]) => {
547
+ $('review').style.display = '';
548
+ box.innerHTML = CHECKS.map(([key, label, title]) => {
488
549
  const r = checks && checks[key];
489
550
  if (!r) return `<span class="c none" title="${esc(title)} — not run on this take. Optional; the recording is complete without it.">${label} <b>—</b></span>`;
490
551
  const stale = !now || r.takeFinishedAt !== now;
@@ -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; }
@@ -16,6 +16,8 @@
16
16
  .top a { display:flex; align-items:center; gap:10px; text-decoration:none; color:inherit; }
17
17
  .top h1 { font-size:15px; font-weight:600; margin:0; }
18
18
  main { max-width:640px; margin:0 auto; padding:28px 22px 80px; }
19
+ /* The flow needs three columns of room; the prose below stays narrow. */
20
+ @media (min-width: 900px) { main { max-width:960px; } main > section, main > .lead, main > h2, main > .loop { max-width:640px; } }
19
21
  h2 { font-size:22px; letter-spacing:-.01em; margin:0 0 6px; }
20
22
  .lead { color:var(--muted); margin:0 0 34px; }
21
23
  section { margin-bottom:34px; }
@@ -28,6 +30,24 @@
28
30
  .quiet { color:var(--muted); font-size:14px; }
29
31
  code { background:var(--card); border:1px solid var(--rule); border-radius:6px; padding:1px 7px; font:14px ui-monospace, Menlo, monospace; }
30
32
  hr { border:0; border-top:1px solid var(--rule); margin:34px 0; }
33
+ /* The first three steps are one loop, not three articles. Prose can say
34
+ that; only a shape shows it. */
35
+ .flow { display:grid; grid-template-columns:1fr auto 1fr auto 1fr; gap:14px; align-items:start; margin:10px 0 18px; }
36
+ .beat { background:var(--card); border:1px solid var(--rule); border-radius:14px; padding:16px; }
37
+ .beat .n { display:inline-flex; align-items:center; justify-content:center; width:22px; height:22px; border-radius:999px; background:var(--accent); color:#eceee9; font-size:12px; font-weight:600; }
38
+ .beat h3 { margin:10px 0 8px; font-size:15px; }
39
+ .beat p { font-size:13.5px; line-height:1.6; color:var(--muted); margin:10px 0 0; }
40
+ .beat .say { font-size:13.5px; margin:0; padding:10px 13px; }
41
+ .thing { display:flex; flex-direction:column; gap:5px; background:var(--paper); border:1px solid var(--rule); border-radius:10px; padding:11px 13px; font-size:14px; }
42
+ .thing span { color:var(--muted); font-size:13px; }
43
+ .arrow { color:#c9cec0; font-size:20px; padding-top:56px; }
44
+ .loop { display:flex; gap:10px; align-items:baseline; font-size:14px; color:var(--muted); background:var(--card); border:1px solid var(--rule); border-radius:12px; padding:12px 16px; }
45
+ .loop span { color:var(--amber); font-size:16px; }
46
+ /* Stacked on a phone, with the arrows turned to point down the page. */
47
+ @media (max-width: 760px) {
48
+ .flow { grid-template-columns:1fr; gap:10px; }
49
+ .arrow { padding:0; text-align:center; transform:rotate(90deg); }
50
+ }
31
51
  </style>
32
52
  </head>
33
53
  <body>
@@ -40,33 +60,59 @@
40
60
  <h2>How Retake works</h2>
41
61
  <p class="lead">Describe a demo of your app in a sentence. Retake drives a real browser through it and hands you the video. When the app changes, you re-run — nobody re-records anything by hand.</p>
42
62
 
43
- <section>
44
- <h3>1 · Ask for a demo</h3>
45
- <p>Talk to the agent you already use — Claude Code or Codex. It has Retake's tools.</p>
46
- <div class="say">Record a demo of my app at localhost:3000 showing the sign-up flow.</div>
47
- <div class="say">What demos are worth recording for this project? Record the best three.</div>
48
- <p class="quiet">The agent reads your app, writes the steps, checks every one of them against the live page, and only then records. You can watch it work on this window's front page.</p>
49
- </section>
63
+ <div class="flow">
64
+ <div class="beat">
65
+ <span class="n">1</span>
66
+ <h3>You say it</h3>
67
+ <div class="say">Record a demo of my app at localhost:3000 showing the sign-up flow.</div>
68
+ <p>To the agent you already use. It reads your app, writes the steps, checks every one against the live page, and only then records.</p>
69
+ </div>
70
+ <div class="arrow" aria-hidden="true">&rarr;</div>
71
+ <div class="beat">
72
+ <span class="n">2</span>
73
+ <h3>You watch it</h3>
74
+ <div class="thing"><b>demo.mp4</b><span>a still for every scene</span><span>a written record of what happened</span></div>
75
+ <p>It lands in this window, newest on top. <b>Download</b> for the file, <b>Show in folder</b> for the rest.</p>
76
+ </div>
77
+ <div class="arrow" aria-hidden="true">&rarr;</div>
78
+ <div class="beat">
79
+ <span class="n">3</span>
80
+ <h3>You change your mind</h3>
81
+ <div class="say">No zooms. Hide the cursor. Square for a post.</div>
82
+ <p>In plain words, and most of it comes back in seconds &mdash; the recording is already made. Say it once and Retake keeps it for the project.</p>
83
+ </div>
84
+ </div>
85
+ <p class="loop"><span>&#8634;</span> And when your app changes, you run the same file again. That is the whole idea: the demo outlives the video.</p>
50
86
 
51
- <section>
52
- <h3>2 · Judge the take</h3>
53
- <p>The video lands here — newest on top, one still per scene under it. <b>Download</b> for the file, <b>Show in folder</b> for everything else — the stills, the proof log, the manifest.</p>
54
- </section>
87
+ <hr>
55
88
 
56
89
  <section>
57
- <h3>3 · Direct it like a videographer</h3>
58
- <p>Say what you want changed, in plain words:</p>
90
+ <h3>When you want to know it is right</h3>
91
+ <p>A recording is finished when it records. Everything below is optional, run
92
+ afterwards on a take you already have, and none of it changes the video.</p>
93
+ <p>Ask your agent in plain words. It has all of these.</p>
59
94
  <ul>
60
- <li>“No zooms — keep the camera still.”</li>
61
- <li>“Hide the cursor.” · “Shorter captions.” · “No captions.”</li>
62
- <li>“Hold the ending longer.” · “Cut the first two seconds.”</li>
63
- <li>“Make it square for a post.” · “Vertical for a reel.”</li>
95
+ <li><b>Does it look right?</b> Write what should be visible on a scene —
96
+ <i>“the board shows two items”</i> — and every later recording answers it.</li>
97
+ <li><b>What did nobody think to check?</b> Every frame gets read for the ten
98
+ ways a screen goes wrong: text cut off, things overlapping, writing you
99
+ cannot read, a spinner still spinning, a dev badge left in shot.</li>
100
+ <li><b>Does it add up?</b> What the demo typed against what came back. It found
101
+ a quote showing one price for two flights that way.</li>
102
+ <li><b>What happens off the happy path?</b> The button pressed twice, the page
103
+ reloaded mid-flight, the app with nothing in it yet.</li>
104
+ <li><b>Did that get fixed?</b> Say something is wrong once and every later take
105
+ answers it, with the six seconds of video that show it.</li>
64
106
  </ul>
65
- <p class="quiet">Say it once — Retake remembers it for the project. Most changes re-render in seconds without re-recording.</p>
107
+ <p class="quiet">Tell it what your product is for and these get much better —
108
+ otherwise they judge your app as a generic web page and anything you did on
109
+ purpose can come back as a mistake. Say it once; it is kept.</p>
110
+ <p class="quiet">They report, they do not block. On Retake's own help page the
111
+ frame check found eight of eight faults deliberately planted in it and
112
+ complained about nothing on a clean recording, and that number is a floor
113
+ rather than a promise.</p>
66
114
  </section>
67
115
 
68
- <hr>
69
-
70
116
  <section>
71
117
  <h3>Where things live</h3>
72
118
  <p>Each demo is a small YAML file in <code>demos/</code> — that file <i>is</i> the demo. Videos land in <code>outputs/&lt;name&gt;/</code>: <code>demo.mp4</code>, <code>stills/</code>, and <code>proof-log.md</code> saying exactly what happened, step by step.</p>
package/dist/ui/server.js CHANGED
@@ -40,6 +40,7 @@ import { digest } from "../digest.js";
40
40
  import { readFlags } from "../ext/flags.js";
41
41
  import { findOrphans } from "../ext/heal.js";
42
42
  import { readChecks } from "../ext/checked.js";
43
+ import { productName } from "../ext/intent.js";
43
44
  import { running } from "../progress.js";
44
45
  import { MAX_BODY, checkRequest, clearToken, newToken, readBodyLimited, writeToken } from "./guard.js";
45
46
  import { startOffer, startApp, listeningPorts } from "../appserver.js";
@@ -201,14 +202,31 @@ function listDemos(project) {
201
202
  return [];
202
203
  const wanted = project?.trim() ? projectKey(project) : null;
203
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.
204
218
  const byUrl = new Map();
219
+ const namedByUrl = new Map();
205
220
  for (const f of fs.readdirSync(DEMOS).filter((x) => /\.ya?ml$/.test(x))) {
206
221
  const mm = cachedManifest(path.join(DEMOS, f));
207
- if (mm) {
208
- const asg = assignments[mm.name];
209
- if (asg && mm.url && !byUrl.has(mm.url))
210
- byUrl.set(mm.url, asg);
211
- }
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);
212
230
  }
213
231
  const listed = fs
214
232
  .readdirSync(DEMOS)
@@ -239,7 +257,10 @@ function listDemos(project) {
239
257
  // that points at the same app — otherwise an agent's new draft for
240
258
  // Capture lands in a second "localhost:3100" fold next to "capture".
241
259
  const assigned = assignments[name] ?? (url ? byUrl.get(url) : undefined);
242
- const group = assigned ? path.basename(assigned) : shortGroup(url);
260
+ // A product that has said what it is called is filed under that name.
261
+ // Grouping by URL gave folders like "localhost:3200" — true, and no help
262
+ // to anybody looking for their charter demos.
263
+ const group = assigned ? path.basename(assigned) : (productName(DEMOS, name) ?? (url ? namedByUrl.get(url) : undefined) ?? shortGroup(url));
243
264
  // Heal a folder whose run was killed mid-recording: the previous take is
244
265
  // in its stash and nothing has put it back yet. The window must never
245
266
  // show someone an empty demo when their recording is safe on disk.
@@ -276,7 +297,7 @@ function orphanedRecordings(known) {
276
297
  // rows that look like failed demos and are not demos at all.
277
298
  .filter((o) => o.rendered && !o.fragment && !known.includes(o.name))
278
299
  .map((o) => ({
279
- name: o.name, file: "", title: o.title, url: o.url, group: shortGroup(o.url),
300
+ name: o.name, file: "", title: o.title, url: o.url, group: productName(DEMOS, o.name) ?? shortGroup(o.url),
280
301
  valid: true, settings: {},
281
302
  lastTake: o.finishedAt ? { finishedAt: o.finishedAt, ok: true, partial: null, duration: o.seconds ?? 0 } : null,
282
303
  needsRecord: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "retake-demos",
3
- "version": "0.12.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,83 @@
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
+ },
18
+ {
19
+ "version": "0.13.1",
20
+ "date": "2026-08-28",
21
+ "headline": "The help page explains the checks, and the watcher stops saying something untrue",
22
+ "notes": [
23
+ "The How it works page inside Retake never mentioned any of the checks. It now explains them in a paragraph each, and says plainly that a recording is finished when it records — everything else is optional and runs afterwards on a take you already have.",
24
+ "Fixed: the workspace summary told you a demo had never been checked when that demo carried seventeen written expectations, all of which had just been answered. It only says that now when there is genuinely not one."
25
+ ]
26
+ },
27
+ {
28
+ "version": "0.13.0",
29
+ "date": "2026-08-28",
30
+ "headline": "You can now measure how much the picture checks are worth",
31
+ "notes": [
32
+ "New: Retake can test its own eyesight. It takes a demo that works, records it once for each way a screen can go wrong — text cut off, things overlapping, writing you can't read, content off the edge, something drawn twice, a stuck spinner, a broken image, a developer badge left in shot — and once more with nothing wrong at all. Then it counts what the check found.",
33
+ "The first measurement, on Retake's own help page: all eight seeded faults found, and nothing at all reported on the clean recording.",
34
+ "It says what that number is and is not. The faults were written by somebody who knew what the check looks for, so it is a floor for trust rather than a score, and a fault nobody thought to seed is still unmeasured.",
35
+ "It also refuses to fool itself: if a seeded fault never actually appeared on screen, the run says so instead of blaming the check. That happened three times while this was being built, and each time it looked exactly like a miss."
36
+ ]
37
+ },
38
+ {
39
+ "version": "0.12.5",
40
+ "date": "2026-08-28",
41
+ "headline": "How Retake works, shown rather than written",
42
+ "notes": [
43
+ "The How it works page opened with three numbered articles you had to read. It now shows the loop: you say it, you watch it, you change your mind — and when your app changes you run the same file again. Same words, arranged so the shape is visible before you read a sentence.",
44
+ "On the website, the three products Retake was built inside now show a frame from the actual recording instead of only describing one. The third says plainly that its walkthrough belongs to the client and is not mine to publish, which is the honest version of a case study."
45
+ ]
46
+ },
47
+ {
48
+ "version": "0.12.4",
49
+ "date": "2026-08-28",
50
+ "headline": "Your demos get filed under your product's name",
51
+ "notes": [
52
+ "The window used to file demos under whatever it could work out from the address — so real products ended up in folders called localhost:3200 and localhost:4990. True, and no help to anyone looking for their charter demos.",
53
+ "Name your product in its note and that name becomes its folder. One thing to write, two things fixed: the checks stop judging your app as a generic web page, and your library reads like your work instead of a list of ports.",
54
+ "What's new now opens on the newest release, with a way to the rest — instead of every release you have ever had, all at once.",
55
+ "On the website: the five bugs a recording found now sit directly under the first video instead of most of the way down the page, and two sections that explained the same thing twice are one."
56
+ ]
57
+ },
58
+ {
59
+ "version": "0.12.3",
60
+ "date": "2026-08-28",
61
+ "headline": "A finished recording now looks finished",
62
+ "notes": [
63
+ "Fixed: a completed recording was quiet grey text while a failed optional check was a bold red mark — so a demo that recorded perfectly read as broken. Every recording now says what it is, next to its name: complete, partial, brisk, or steps failed.",
64
+ "The optional checks have moved into their own area, headed and explained. They are things you can run against a finished recording, and the window now looks like that instead of like four missing parts.",
65
+ "On a phone: each check gets its own line instead of breaking into fragments, the buttons along the top stop wrapping inside themselves, and Use current frame is a proper full-width button rather than three wrapped lines.",
66
+ "On the website: the button that says it copies the setup prompt now copies it. It used to scroll you five thousand pixels down the page to a different button that did the copying.",
67
+ "And two sentences on that page have been corrected. They claimed watching a recording checks everything at once. Retake's own evidence says otherwise — one pass can miss something a second pass finds — so the page now says what is true: a recording puts the whole screen in front of you, the optional checks surface things nobody wrote down, and you decide what matters."
68
+ ]
69
+ },
70
+ {
71
+ "version": "0.12.2",
72
+ "date": "2026-08-28",
73
+ "headline": "A security fix, and the starter demo now passes its own check",
74
+ "notes": [
75
+ "Fixed, and please update: naming a demo when writing your product note could write the file outside your demos folder. Any name is now refused unless it is an ordinary demo name.",
76
+ "Fixed: `retake dry` failed on `scroll to: bottom` — it looked for something on the page called \"bottom\" instead of understanding it as the end of the page. The starter demo that ships with a new workspace uses it, so a new person's very first check failed on a demo that records perfectly.",
77
+ "The check that tells you whether something you flagged is fixed now reads your product note too, like the others already did.",
78
+ "Reading your product note is now part of the written order of work, not a footnote — it comes before anything looks at a frame."
79
+ ]
80
+ },
4
81
  {
5
82
  "version": "0.12.1",
6
83
  "date": "2026-08-28",
package/skill/SKILL.md CHANGED
@@ -49,20 +49,24 @@ say so in one line and stop.
49
49
  selectors, logins under `auth.setup` with `${ENV}` placeholders and
50
50
  `secret: true`, `reducedMotion: true` if things animate forever, scrolls
51
51
  before anything below the fold.
52
- 5. **`dry`** — ALWAYS before `run`. Seconds, no camera, strict; failures
52
+ 5. **`intent`** — read it. One call, and it is what stops the checks judging
53
+ this app as a generic web page. If there is nothing there and the person
54
+ has told you what their product is, write it; if they have not, say so
55
+ when you report, and never invent one.
56
+ 6. **`dry`** — ALWAYS before `run`. Seconds, no camera, strict; failures
53
57
  include what was on screen. Fix and repeat until it passes. Never record
54
58
  a manifest whose dry run fails.
55
- 6. **While iterating, record at `--preset draft`** (CLI) — same layout as the
59
+ 7. **While iterating, record at `--preset draft`** (CLI) — same layout as the
56
60
  final at a quarter of the pixels, so a take costs ~real time instead of
57
61
  real time plus an encoder. Switch to a post preset only for the take you
58
62
  intend to keep.
59
- 7. **`run` with `preview=true`**, then read the `receipts` AND `look` at
63
+ 8. **`run` with `preview=true`**, then read the `receipts` AND `look` at
60
64
  the take. Receipts say what happened; `look` shows how it looks — one
61
65
  image per scene. Judge it like a viewer would: is the thing the caption
62
66
  names actually on screen? Anything in shot that shouldn't be (dev
63
67
  badges, leftover data, cut-off text)? Wrong story or failed step →
64
68
  `edit`, run again. More than four rounds → ask the person.
65
- 8. **Before the expensive take, if it is expensive, show them the draft.**
69
+ 9. **Before the expensive take, if it is expensive, show them the draft.**
66
70
  `run` prints an estimate before capture. If it said this is a long take
67
71
  (over ~2.5 minutes), do not go straight to full quality: `ask` them,
68
72
  naming the demo and pointing at the draft in the Retake window, and say
@@ -79,7 +83,7 @@ say so in one line and stop.
79
83
  - **Nobody there to ask?** Never block and never guess. Leave the draft,
80
84
  `done` with what you have and what it would cost, and let them start
81
85
  the expensive one when they are back.
82
- 9. When they say go: `run` with `preview=false` once (full quality), then
86
+ 10. When they say go: `run` with `preview=false` once (full quality), then
83
87
  `done` with one sentence. Outputs: `demo.mp4`, `stills/` (one PNG per
84
88
  scene), `proof-log.md`.
85
89
 
@@ -130,6 +134,18 @@ Three things about reading what it says:
130
134
  - **A clean sweep is a floor, not a ceiling.** A closed checklist cannot find
131
135
  what is not on it. It catches the ten things that go wrong in every app; it
132
136
  does not certify the frame.
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.
133
149
  - **One pass is not reliable, and this is measured.** On a frame with a real
134
150
  overlap on it, three consecutive sweeps missed it and a fourth found it. The
135
151
  judge is not deterministic. So a frame that has already been looked at is
@@ -163,6 +179,12 @@ left in the navigation of a customer-facing recording.
163
179
  Same number of findings. Better findings.
164
180
 
165
181
  - `intent` with no text **reads** it. Do this before you report anything.
182
+ - **The first heading names the product, and that name becomes its folder in
183
+ the window.** Without one the window files demos by URL — real workspaces
184
+ end up with folders called `localhost:3200` and `localhost:4990`, which is
185
+ accurate and no help to anybody looking for their charter demos. Naming the
186
+ product once, for the checks, names its folder too. That part is your job:
187
+ the person should not have to tidy up after you.
166
188
  - `intent` with text **writes** it — the first time the person explains what
167
189
  their product does, who uses it, or why something unusual is on purpose.
168
190
  Use `demo` to write it for one app; a workspace with three products cannot
@@ -209,6 +231,7 @@ These are separate questions. Running the wrong one and reporting it as
209
231
  | What happens just off the happy path? | `destroy` | minutes | flags, you judge |
210
232
  | Did the things they flagged get fixed? | `fixed` | ~13s each | yes — exit 3 |
211
233
  | What is this product FOR? | `intent` | instant | context, not a check |
234
+ | How much is a `sweep` finding worth here? | `calibrate` | ~10 min, nine takes | a measurement, not a check |
212
235
  | What keeps going wrong across every demo? | `notes` | instant | no |
213
236
 
214
237
  `dry` really does click and fill — at full speed, with short timeouts, no