retake-demos 0.12.1 → 0.13.1

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,18 @@ 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: the
239
+ seeds were written by somebody who has read the checklist, so recall is an
240
+ upper bound.
229
241
  - `retake intent` — what the product IS, in the builder's words: what it does,
230
242
  who uses it, and what about it is deliberate. Every visual check reads it.
231
243
  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 } 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,43 @@ 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
+ for (const v of plan.variants) {
403
+ const dir = path.join(plan.root, v.name);
404
+ say(`\n${v.name}${v.defect ? ` — ${v.defect.looks}` : ""}`);
405
+ try {
406
+ const take = await record(v.manifest, { outDir: dir, manifestDir: loaded.dir, headed: false, log: () => { } });
407
+ 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
+ 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)" : ""}`);
414
+ }
415
+ 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)}`);
418
+ }
419
+ }
420
+ for (const l of report(results))
421
+ 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");
423
+ });
385
424
  program
386
425
  .command("intent")
387
426
  .description("what this product IS — the context every visual check is missing without it")
package/dist/dryrun.js CHANGED
@@ -260,8 +260,13 @@ 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
+ if (step.to && step.to !== "top" && step.to !== "bottom")
265
270
  await page.locator(step.to).first().boundingBox({ timeout: short });
266
271
  break;
267
272
  case "upload":
@@ -0,0 +1,257 @@
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
+ /**
49
+ * Deliberately mechanical. Every one of these is visible to a person in one
50
+ * glance, which is the bar: if a human cannot see it in the frame, a miss is
51
+ * not evidence of anything.
52
+ *
53
+ * And every one targets ORDINARY TEXT — p, li, headings — rather than a
54
+ * specific element. Three seeds failed here before that rule existed: they
55
+ * changed parts of the page the demo's frames never showed, so the run
56
+ * reported a miss when nothing had been asked of the check. A calibration
57
+ * harness that can silently measure nothing is worse than none, because its
58
+ * number looks the same either way.
59
+ */
60
+ export const DEFECTS = [
61
+ {
62
+ name: "clipped-text",
63
+ proof: "[...document.querySelectorAll('p,li,h1,h2,h3')].some(e => e.scrollHeight > e.clientHeight + 4)",
64
+ expect: "CLIPPED",
65
+ looks: "headings cut off mid-letter by a box too short for them",
66
+ css: "h1,h2,h3,li,p{max-height:14px!important;overflow:hidden!important;display:block!important}",
67
+ },
68
+ {
69
+ name: "overlapping-text",
70
+ proof: "[...document.querySelectorAll('p,li')].some(e => parseFloat(getComputedStyle(e).marginBottom) < -10)",
71
+ expect: "OVERLAP",
72
+ looks: "paragraphs sitting on top of each other",
73
+ css: "p,li{margin-bottom:-26px!important;position:relative!important;z-index:2!important}",
74
+ },
75
+ {
76
+ name: "unreadable-contrast",
77
+ proof: "getComputedStyle(document.body).color.replace(/\\\\s/g,'') === 'rgb(233,235,228)'",
78
+ expect: "CONTRAST",
79
+ looks: "body text almost the same colour as the page behind it",
80
+ css: "body,p,li,span{color:#e9ebe4!important}",
81
+ },
82
+ {
83
+ name: "runs-off-the-edge",
84
+ proof: 'document.documentElement.scrollWidth > document.documentElement.clientWidth + 40',
85
+ expect: "CUT OFF",
86
+ looks: "content pushed off the right-hand side of the frame",
87
+ css: "main,body>*{margin-left:62%!important;white-space:nowrap!important}",
88
+ },
89
+ {
90
+ name: "doubled-heading",
91
+ proof: "!!document.querySelector('[data-retake-dup]')",
92
+ expect: "DOUBLED",
93
+ looks: "every heading rendered twice, one under the other",
94
+ // The first version targeted h2 and used attr(data-x), which is empty —
95
+ // so nothing appeared and the run looked like a MISS. A seed that fails
96
+ // silently is indistinguishable from a check that failed, which is why
97
+ // `landed` below exists.
98
+ // Twice wrong before this: CSS pseudo-content on headings, which produced
99
+ // nothing the first time and nothing IN FRAME the second. A seed that is
100
+ // not plainly visible in the frames being judged measures nothing, and the
101
+ // temptation to keep tuning it until the number improves is exactly how a
102
+ // calibration harness starts lying. So it clones the real node instead.
103
+ css: "",
104
+ 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); } }",
105
+ },
106
+ {
107
+ name: "stuck-spinner",
108
+ proof: "!!document.getElementById('retake-seed-spinner')",
109
+ expect: "UNFINISHED",
110
+ looks: "a loading spinner sitting on the page that never resolves",
111
+ css: "",
112
+ 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);",
113
+ },
114
+ {
115
+ name: "broken-image",
116
+ proof: "!!document.getElementById('retake-seed-img')",
117
+ expect: "BROKEN MEDIA",
118
+ looks: "an image that failed to load, showing its alt text instead",
119
+ css: "",
120
+ 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);",
121
+ },
122
+ {
123
+ name: "debug-badge",
124
+ proof: "!!document.getElementById('retake-seed-badge')",
125
+ expect: "NOT FOR THE CAMERA",
126
+ looks: "a development badge left in the corner of the screen",
127
+ css: "",
128
+ 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);",
129
+ },
130
+ ];
131
+ /** Where calibration writes. Never near a real take. */
132
+ export const lab = (outRoot, demo) => path.join(outRoot, ".calibrate", demo);
133
+ /**
134
+ * A control and one variant per defect, as ordinary manifests.
135
+ *
136
+ * The control matters more than any single variant: without a run where
137
+ * nothing is wrong, a low false-positive rate cannot be claimed at all.
138
+ */
139
+ export function planCalibration(m, outRoot, opts = {}) {
140
+ const root = lab(outRoot, m.name);
141
+ fs.mkdirSync(root, { recursive: true });
142
+ const want = opts.only?.length ? DEFECTS.filter((d) => opts.only.includes(d.name)) : DEFECTS;
143
+ const variants = [];
144
+ const write = (name, defect) => {
145
+ const clone = JSON.parse(JSON.stringify(m));
146
+ clone.name = `${m.name}-${name}`.slice(0, 60);
147
+ if (defect) {
148
+ // Before anything is recorded, so every frame carries the fault.
149
+ clone.setup = [
150
+ ...(clone.setup ?? []),
151
+ // A stylesheet in <head> survives re-render; the elements below may not.
152
+ ...(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);}` }] : []),
153
+ // Re-applied on a timer, because a React app hydrates and re-renders
154
+ // after the seed runs and takes anything appended to the body with it.
155
+ // Capture did exactly that: the element passed its own proof at seed
156
+ // time and was gone from every recorded frame, which looked identical
157
+ // to the check missing it.
158
+ ...(defect.js ? [{ action: "evaluate", script: `{ const seed = () => { try { ${defect.js} } catch {} }; seed(); setInterval(() => { if (!(${defect.proof})) seed(); }, 250); }` }] : []),
159
+ // The seed proves itself, in the page, before anything is recorded.
160
+ // A seed that did not render must fail the take rather than produce
161
+ // frames that look exactly like a check missing something.
162
+ { action: "wait", ms: 1200 },
163
+ // Checked after the app has had time to hydrate and re-render, not at
164
+ // the instant of injection — that was the difference between a seed
165
+ // that exists and a seed that is in the picture.
166
+ { action: "evaluate", script: `{ if (!(${defect.proof})) throw new Error("the ${defect.name} seed did not take on this app"); }` },
167
+ { action: "wait", ms: 300 },
168
+ ];
169
+ }
170
+ const file = path.join(root, `${name}.yaml`);
171
+ fs.writeFileSync(file, [
172
+ defect ? `# ${defect.name} — ${defect.looks}.` : "# control — nothing is wrong with this one.",
173
+ `# Generated by \`retake calibrate ${m.name}\`. Recording it seeds the fault for real.`,
174
+ "",
175
+ YAML.stringify(clone),
176
+ ].join("\n"));
177
+ return { name, defect, file, manifest: clone };
178
+ };
179
+ variants.push(write("control", null));
180
+ for (const d of want)
181
+ variants.push(write(d.name, d));
182
+ return { root, variants };
183
+ }
184
+ /**
185
+ * Did the seed actually take?
186
+ *
187
+ * A variant whose frames are byte-identical to the control's is a variant
188
+ * where nothing was injected — the CSS missed, the selector was wrong, the
189
+ * page changed. Counting that as a MISS blames the check for the harness's
190
+ * mistake, and it happened on the very first run: a doubled-heading seed that
191
+ * targeted the wrong element reported 7 of 8 when the honest answer was 7 of
192
+ * 7 and one seed that never landed.
193
+ */
194
+ /**
195
+ * @deprecated Seeds prove themselves in the page now — see `Defect.proof`.
196
+ *
197
+ * Kept only as a record of why: this compares a variant's frames to the
198
+ * control's, which cannot work on an app whose content changes between runs.
199
+ * Capture's board carries timestamps and counts, so every variant differed
200
+ * from its control whether or not anything had been injected, and this
201
+ * reported eight seeds as landed when at least one plainly had not.
202
+ */
203
+ export function landed(controlDir, variantDir) {
204
+ // NOTE what this does and does not prove: that the frames differ from the
205
+ // control's, so SOMETHING was injected. It cannot prove the fault is
206
+ // plainly visible in them. A seed that changes the page off-screen passes
207
+ // here and still measures nothing — which happened, twice, before the
208
+ // doubled-heading seed was rewritten to clone a real node.
209
+ const shots = (d) => {
210
+ try {
211
+ return fs.readdirSync(path.join(d, "stills")).sort()
212
+ .map((f) => createHash("sha1").update(fs.readFileSync(path.join(d, "stills", f))).digest("hex"));
213
+ }
214
+ catch {
215
+ return [];
216
+ }
217
+ };
218
+ const a = shots(controlDir), b = shots(variantDir);
219
+ if (!a.length || !b.length)
220
+ return true; // cannot tell; do not accuse the harness
221
+ return a.join() !== b.join();
222
+ }
223
+ /** Recall, false positives, and the sentence that says what they mean. */
224
+ export function report(rs) {
225
+ const all = rs.filter((r) => r.defect);
226
+ const seeded = all.filter((r) => r.seeded);
227
+ const unseeded = all.filter((r) => !r.seeded);
228
+ const control = rs.find((r) => !r.defect);
229
+ const found = seeded.filter((r) => r.found).length;
230
+ const out = [];
231
+ out.push("");
232
+ out.push(`RECALL ${found} of ${seeded.length} defects that actually appeared were found`);
233
+ 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")}`);
235
+ }
236
+ if (unseeded.length) {
237
+ out.push("");
238
+ out.push(`NOT MEASURED ${unseeded.length} seed(s) produced no visible change, so nothing was asked of the check:`);
239
+ for (const r of unseeded)
240
+ out.push(` — ${r.variant.padEnd(20)} the injection did not land. Fix the seed, not the checklist.`);
241
+ }
242
+ out.push("");
243
+ if (control) {
244
+ out.push(control.kinds.length
245
+ ? `FALSE POSITIVES ${control.kinds.length} finding(s) on the control, where nothing is wrong: ${control.kinds.join(", ")}`
246
+ : "FALSE POSITIVES none on the control");
247
+ }
248
+ else {
249
+ out.push("FALSE POSITIVES not measured — no control was run, so no rate can be claimed");
250
+ }
251
+ const noise = seeded.reduce((n, r) => n + r.other, 0);
252
+ if (noise)
253
+ out.push(` and ${noise} extra finding(s) across the variants, beyond the fault seeded in each`);
254
+ 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.");
256
+ return out;
257
+ }
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,21 @@ 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
+ 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() },
713
+ annotations: READ_ONLY,
714
+ }, async ({ name, run }) => {
715
+ if (!safe(name))
716
+ return text(`no demo "${name}"`);
717
+ 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
+ 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.`);
722
+ const r = JSON.parse(fs.readFileSync(file, "utf8"));
723
+ return text([...describeCalibration(r.results), "", `measured ${r.at}`].join("\n"));
724
+ });
709
725
  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
726
  await tell(`Done: ${summary}`);
711
727
  if (UI && !SESSION)
package/dist/ui/chat.html CHANGED
@@ -17,12 +17,21 @@
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,12 +88,31 @@
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
+ .rhint { text-transform:none; letter-spacing:0; font-size:12px; opacity:.85; }
109
+ .checked { margin-top:8px; font-size:12.5px; color:var(--muted); display:flex; gap:14px; flex-wrap:wrap; align-items:baseline; }
110
+ /* One per row on a phone: four of these side by side break into fragments
111
+ that read as unrelated words. */
112
+ @media (max-width: 700px) {
113
+ .checked { display:grid; grid-template-columns:1fr; gap:6px; }
114
+ .checked .c { justify-content:flex-start; }
115
+ }
81
116
  .checked .c { display:inline-flex; gap:5px; align-items:baseline; }
82
117
  .checked .c b { font-weight:500; color:var(--ink); }
83
118
  .checked .c.none b { color:var(--muted); }
@@ -238,6 +273,7 @@
238
273
  <video id="vid" controls playsinline></video>
239
274
  <div class="under">
240
275
  <span class="vtitle" id="vidName"></span>
276
+ <span class="vstate" id="vidState"></span>
241
277
  <span class="vmeta" id="vidMeta"></span>
242
278
  <span class="sp"></span>
243
279
  <select id="vidSpeed" title="Playback speed — preview now, and Download gets this speed">
@@ -256,7 +292,10 @@
256
292
  </div>
257
293
  <div id="flagRows"></div>
258
294
  </div>
259
- <div class="checked" id="checked"></div>
295
+ <div class="review" id="review" style="display:none">
296
+ <div class="rhead">Optional review<span class="rhint">checks you can run against this recording — it is complete without them</span></div>
297
+ <div class="checked" id="checked"></div>
298
+ </div>
260
299
  <div class="frames">
261
300
  <div class="fhead">
262
301
  <span class="lab">Poster & frames</span>
@@ -335,19 +374,28 @@ $('newsBtn').onclick = async () => {
335
374
  const fmt = (d) => { const x = new Date(d + 'T00:00:00'); return isNaN(x) ? '' : x.toLocaleDateString(undefined, { month: 'long', day: 'numeric', year: 'numeric' }); };
336
375
  const veil = document.createElement('div');
337
376
  veil.className = 'veil';
338
- veil.innerHTML = '<div class="modal wide"><h3>What\'s new</h3>' +
339
- '<div class="scroll">' + rs.map((r, i) =>
377
+ // The newest release, and a way to the rest. Every release at once made the
378
+ // one thing that changed today compete with everything that ever changed.
379
+ let showAll = false;
380
+ const body = () => (showAll ? rs : rs.slice(0, 1)).map((r, i) =>
340
381
  '<div class="rel' + (i ? '' : ' latest') + '">' +
341
382
  '<div class="relhead"><b>' + esc(r.headline) + '</b><span class="relmeta">' + esc(r.version) + (r.date ? ' · ' + fmt(r.date) : '') + '</span></div>' +
342
383
  '<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>';
384
+ '</div>').join('');
385
+ const render = () => {
386
+ veil.innerHTML = '<div class="modal wide"><h3>What\'s new</h3><div class="scroll">' + body() + '</div>' +
387
+ '<div class="row" style="margin-top:18px">' +
388
+ (showAll || rs.length < 2 ? '' : '<button class="btn" id="newsAll">All ' + rs.length + ' releases</button>') +
389
+ '<span class="sp"></span><button class="btn" id="newsClose">Close</button></div></div>';
390
+ const all = veil.querySelector('#newsAll');
391
+ if (all) all.onclick = () => { showAll = true; render(); };
392
+ veil.querySelector('#newsClose').onclick = () => veil.remove();
393
+ };
394
+ render();
345
395
  document.body.appendChild(veil);
346
396
  localStorage.setItem(SEEN_KEY, rs[0].version);
347
397
  $('newsBtn').querySelector('.ndot')?.remove();
348
- const shut = () => veil.remove();
349
- veil.querySelector('#newsClose').onclick = shut;
350
- veil.onclick = (e) => { if (e.target === veil) shut(); };
398
+ veil.onclick = (e) => { if (e.target === veil) veil.remove(); };
351
399
  document.addEventListener('keydown', function esc2(e) { if (e.key === 'Escape') { shut(); document.removeEventListener('keydown', esc2); } });
352
400
  };
353
401
 
@@ -430,6 +478,12 @@ async function pin(name) {
430
478
  const t = d.take;
431
479
  const failed = (t.timeline || []).find((e) => e.ok === false);
432
480
  $('vidName').textContent = name;
481
+ // What the RECORDING did — separate from anything optional that looked at it.
482
+ 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.'; }
484
+ 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
+ else { stateEl.className = 'vstate done'; stateEl.textContent = 'Recording complete'; stateEl.title = 'The take is sound: every step passed and the artifacts check out.'; }
433
487
  $('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
488
  $('vidMeta').title = failed?.screen ? 'On screen at that moment: ' + failed.screen : '';
435
489
  $('vidDl').href = `/out/${name}/demo.mp4${q}`; $('vidDl').setAttribute('download', name + '.mp4');
@@ -484,7 +538,8 @@ function drawChecked(checks, take) {
484
538
  const now = take && take.finishedAt;
485
539
  // Labelled, because four bare dashes read as four things missing. They are
486
540
  // 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]) => {
541
+ $('review').style.display = '';
542
+ box.innerHTML = CHECKS.map(([key, label, title]) => {
488
543
  const r = checks && checks[key];
489
544
  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
545
  const stale = !now || r.takeFinishedAt !== now;
@@ -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";
@@ -239,7 +240,10 @@ function listDemos(project) {
239
240
  // that points at the same app — otherwise an agent's new draft for
240
241
  // Capture lands in a second "localhost:3100" fold next to "capture".
241
242
  const assigned = assignments[name] ?? (url ? byUrl.get(url) : undefined);
242
- const group = assigned ? path.basename(assigned) : shortGroup(url);
243
+ // A product that has said what it is called is filed under that name.
244
+ // Grouping by URL gave folders like "localhost:3200" — true, and no help
245
+ // to anybody looking for their charter demos.
246
+ const group = assigned ? path.basename(assigned) : (productName(DEMOS, name) ?? shortGroup(url));
243
247
  // Heal a folder whose run was killed mid-recording: the previous take is
244
248
  // in its stash and nothing has put it back yet. The window must never
245
249
  // show someone an empty demo when their recording is safe on disk.
@@ -276,7 +280,7 @@ function orphanedRecordings(known) {
276
280
  // rows that look like failed demos and are not demos at all.
277
281
  .filter((o) => o.rendered && !o.fragment && !known.includes(o.name))
278
282
  .map((o) => ({
279
- name: o.name, file: "", title: o.title, url: o.url, group: shortGroup(o.url),
283
+ name: o.name, file: "", title: o.title, url: o.url, group: productName(DEMOS, o.name) ?? shortGroup(o.url),
280
284
  valid: true, settings: {},
281
285
  lastTake: o.finishedAt ? { finishedAt: o.finishedAt, ok: true, partial: null, duration: o.seconds ?? 0 } : null,
282
286
  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.1",
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,69 @@
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.1",
6
+ "date": "2026-08-28",
7
+ "headline": "The help page explains the checks, and the watcher stops saying something untrue",
8
+ "notes": [
9
+ "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.",
10
+ "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."
11
+ ]
12
+ },
13
+ {
14
+ "version": "0.13.0",
15
+ "date": "2026-08-28",
16
+ "headline": "You can now measure how much the picture checks are worth",
17
+ "notes": [
18
+ "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.",
19
+ "The first measurement, on Retake's own help page: all eight seeded faults found, and nothing at all reported on the clean recording.",
20
+ "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.",
21
+ "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."
22
+ ]
23
+ },
24
+ {
25
+ "version": "0.12.5",
26
+ "date": "2026-08-28",
27
+ "headline": "How Retake works, shown rather than written",
28
+ "notes": [
29
+ "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.",
30
+ "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."
31
+ ]
32
+ },
33
+ {
34
+ "version": "0.12.4",
35
+ "date": "2026-08-28",
36
+ "headline": "Your demos get filed under your product's name",
37
+ "notes": [
38
+ "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.",
39
+ "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.",
40
+ "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.",
41
+ "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."
42
+ ]
43
+ },
44
+ {
45
+ "version": "0.12.3",
46
+ "date": "2026-08-28",
47
+ "headline": "A finished recording now looks finished",
48
+ "notes": [
49
+ "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.",
50
+ "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.",
51
+ "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.",
52
+ "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.",
53
+ "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."
54
+ ]
55
+ },
56
+ {
57
+ "version": "0.12.2",
58
+ "date": "2026-08-28",
59
+ "headline": "A security fix, and the starter demo now passes its own check",
60
+ "notes": [
61
+ "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.",
62
+ "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.",
63
+ "The check that tells you whether something you flagged is fixed now reads your product note too, like the others already did.",
64
+ "Reading your product note is now part of the written order of work, not a footnote — it comes before anything looks at a frame."
65
+ ]
66
+ },
4
67
  {
5
68
  "version": "0.12.1",
6
69
  "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,13 @@ 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 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.
133
144
  - **One pass is not reliable, and this is measured.** On a frame with a real
134
145
  overlap on it, three consecutive sweeps missed it and a fourth found it. The
135
146
  judge is not deterministic. So a frame that has already been looked at is
@@ -163,6 +174,12 @@ left in the navigation of a customer-facing recording.
163
174
  Same number of findings. Better findings.
164
175
 
165
176
  - `intent` with no text **reads** it. Do this before you report anything.
177
+ - **The first heading names the product, and that name becomes its folder in
178
+ the window.** Without one the window files demos by URL — real workspaces
179
+ end up with folders called `localhost:3200` and `localhost:4990`, which is
180
+ accurate and no help to anybody looking for their charter demos. Naming the
181
+ product once, for the checks, names its folder too. That part is your job:
182
+ the person should not have to tidy up after you.
166
183
  - `intent` with text **writes** it — the first time the person explains what
167
184
  their product does, who uses it, or why something unusual is on purpose.
168
185
  Use `demo` to write it for one app; a workspace with three products cannot
@@ -209,6 +226,7 @@ These are separate questions. Running the wrong one and reporting it as
209
226
  | What happens just off the happy path? | `destroy` | minutes | flags, you judge |
210
227
  | Did the things they flagged get fixed? | `fixed` | ~13s each | yes — exit 3 |
211
228
  | What is this product FOR? | `intent` | instant | context, not a check |
229
+ | How much is a `sweep` finding worth here? | `calibrate` | ~10 min, nine takes | a measurement, not a check |
212
230
  | What keeps going wrong across every demo? | `notes` | instant | no |
213
231
 
214
232
  `dry` really does click and fill — at full speed, with short timeouts, no