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 +12 -0
- package/dist/cli.js +39 -0
- package/dist/dryrun.js +6 -1
- package/dist/ext/calibrate.js +257 -0
- package/dist/ext/flags.js +8 -1
- package/dist/ext/intent.js +53 -5
- package/dist/ext/notes.js +15 -4
- package/dist/operator/tools.js +17 -1
- package/dist/ui/chat.html +66 -11
- package/dist/ui/guide.html +66 -20
- package/dist/ui/server.js +6 -2
- package/package.json +1 -1
- package/site/releases.json +63 -0
- package/skill/SKILL.md +23 -5
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) {
|
package/dist/ext/intent.js
CHANGED
|
@@ -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 = `#
|
|
111
|
+
export const TEMPLATE = `# Name this product
|
|
67
112
|
|
|
68
|
-
<!--
|
|
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
|
-
/**
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/dist/operator/tools.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
339
|
-
|
|
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('')
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/ui/guide.html
CHANGED
|
@@ -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
|
-
<
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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">→</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">→</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 — 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>↺</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
|
-
<
|
|
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>
|
|
58
|
-
<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
|
|
61
|
-
|
|
62
|
-
<li
|
|
63
|
-
|
|
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">
|
|
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/<name>/</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
|
-
|
|
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
package/site/releases.json
CHANGED
|
@@ -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. **`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|