From fd09282d8d45c7fa0d6846e405f5ea454eeaee53 Mon Sep 17 00:00:00 2001 From: rockachopa Date: Mon, 19 Oct 2026 12:35:11 +0000 Subject: [PATCH 1/3] fix(release): paint demo captions and touch rings on camera and verify the pixels The demo injected its caption CSS with page.addStyleTag(), which writes into the current document only. The first page.reload() in the recording stripped that stylesheet, so every later caption rendered as unstyled body text clipped at the bottom of the frame and every touch indicator was invisible, while the caption receipt still listed each string and the release gate accepted it. - caption and touch nodes now carry inline styles, which no reload can remove - caption() proves each caption is a fixed bar at the top of the viewport, on top of what is beneath it, fully inside the frame, and that the caption band pixels change; the recording fails rather than shipping a caption nobody can read - the receipt records per-caption evidence and build_release.py requires every required caption, including the release delta, to be pixel-verified - the demo photo is a legible synthetic test card instead of a 1x1 white pixel that rendered on camera as an empty box - tests assert the inline styling and the pixel gate --- scripts/record_release_demo.mjs | 51 +++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/scripts/record_release_demo.mjs b/scripts/record_release_demo.mjs index 4fc778a..233e350 100644 --- a/scripts/record_release_demo.mjs +++ b/scripts/record_release_demo.mjs @@ -113,21 +113,51 @@ await page.route('**/api/agent/chat', route => { await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' }); await page.evaluate(() => localStorage.clear()); await page.reload({ waitUntil: 'networkidle' }); -await page.addStyleTag({ content: ` - #demo-caption{position:fixed;left:16px;right:16px;top:14px;z-index:20000;background:rgba(40,33,29,.94);color:#fff;padding:11px 14px;border-radius:14px;font:800 12px/1.25 system-ui;letter-spacing:.02em;text-align:center;box-shadow:0 8px 24px rgba(0,0,0,.2)} - #demo-touch{position:fixed;z-index:20001;width:48px;height:48px;border:4px solid #fff;background:rgba(245,201,91,.55);box-shadow:0 0 0 7px rgba(21,125,120,.28);border-radius:50%;pointer-events:none;transform:translate(-50%,-50%)} -` }); +const captionEvidence = []; +// Pixel proof that a caption actually painted: if the caption band looks identical with and without +// the node, the caption did not reach the screen even though the string was pushed. +async function captionBand() { + return page.screenshot({ clip: { x: 0, y: 0, width: 405, height: 68 } }); +} async function caption(text, duration = 1400) { captionsShown.push(text); + const blankBand = await captionBand().catch(() => null); await page.evaluate(text => { document.querySelector('#demo-caption')?.remove(); const node = document.createElement('div'); node.id = 'demo-caption'; node.textContent = text; + // Inline styles, not a stylesheet: a stylesheet belongs to one document and the demo reloads the + // page, which silently stripped caption styling and left every later caption clipped body text. + node.style.cssText = 'position:fixed;left:16px;right:16px;top:14px;z-index:20000;' + + 'background:rgba(40,33,29,.94);color:#fff;padding:11px 14px;border-radius:14px;' + + 'font:800 12px/1.25 system-ui;letter-spacing:.02em;text-align:center;' + + 'box-shadow:0 8px 24px rgba(0,0,0,.2)'; document.body.append(node); node.animate([{ opacity: 0, transform: 'translateY(-8px)' }, { opacity: 1, transform: 'translateY(0)' }], { duration: 260, fill: 'forwards' }); }, text); + await sleep(320); + // Structural proof: a fixed bar at the top of the viewport, on top of whatever is beneath it, fully + // inside the frame. A caption that lost its stylesheet sits unstyled at the end of the body instead. + const painted = await page.evaluate(() => { + const node = document.querySelector('#demo-caption'); + if (!node) return { ok: false, reason: 'caption node missing' }; + const rect = node.getBoundingClientRect(); + const style = getComputedStyle(node); + const topmost = document.elementFromPoint(Math.round(rect.left + rect.width / 2), Math.round(rect.top + rect.height / 2)); + return { + ok: style.position === 'fixed' && style.zIndex === '20000' && rect.top <= 40 && rect.height >= 24 + && rect.width > 200 && rect.bottom <= window.innerHeight && (topmost === node || (topmost && node.contains(topmost))), + top: Math.round(rect.top), height: Math.round(rect.height), position: style.position, + zIndex: style.zIndex, background: style.backgroundColor, topmost: topmost === node, + }; + }); + const shownBand = await captionBand().catch(() => null); + const bandChanged = Boolean(blankBand && shownBand && !blankBand.equals(shownBand)); + if (!painted.ok) throw new Error(`Caption is not an on-screen bar: ${text} ${JSON.stringify(painted)}`); + if (!bandChanged) throw new Error(`Caption painted nothing into the caption band: ${text}`); + captionEvidence.push({ text, visible: true, position: painted.position, top: painted.top, height: painted.height, bandChanged }); await sleep(duration); // A caption must not outlive its stated duration. It used to stay on screen until the next one // replaced it, so a view change left the previous screen's claim hanging over a different screen — @@ -150,6 +180,9 @@ async function tap(selector, after = 650) { document.querySelector('#demo-touch')?.remove(); const ring = document.createElement('div'); ring.id = 'demo-touch'; + ring.style.cssText = 'position:fixed;z-index:20001;width:48px;height:48px;border:4px solid #fff;' + + 'background:rgba(245,201,91,.55);box-shadow:0 0 0 7px rgba(21,125,120,.28);border-radius:50%;' + + 'pointer-events:none;transform:translate(-50%,-50%)'; ring.style.left = `${x}px`; ring.style.top = `${y}px`; document.body.append(ring); @@ -170,6 +203,9 @@ async function indicate(selector) { document.querySelector('#demo-touch')?.remove(); const ring = document.createElement('div'); ring.id = 'demo-touch'; + ring.style.cssText = 'position:fixed;z-index:20001;width:48px;height:48px;border:4px solid #fff;' + + 'background:rgba(245,201,91,.55);box-shadow:0 0 0 7px rgba(21,125,120,.28);border-radius:50%;' + + 'pointer-events:none;transform:translate(-50%,-50%)'; ring.style.left = `${x}px`; ring.style.top = `${y}px`; document.body.append(ring); @@ -379,7 +415,9 @@ await page.reload({ waitUntil: 'networkidle' }); // states that a photo exists without rendering it, the image appears only when asked for, removing // it keeps every confirmed field, and delete-all takes the ledger and every photo together. The // only image here is a synthetic 1x1 PNG constant written by this script; no real photo is used. -const SYNTHETIC_PHOTO = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII='; +// A 160x120 synthetic test card (checkerboard + 'SYNTHETIC DEMO PHOTO'), so the reveal is legibly +// an image on camera. It carries no photograph of any person or specimen. +const SYNTHETIC_PHOTO = 'data:image/png;base64,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'; const photoDay = (id, daysAgo, bristolType = 4) => ({ ...sampleDay(id, daysAgo, bristolType), photoDataUrl: SYNTHETIC_PHOTO }); const storedLedger = async () => { const key = await page.evaluate(() => `timmy:${document.querySelector('meta[name="timmy-base-path"]')?.content || '/'}:ledger-v1`); @@ -568,7 +606,8 @@ if (expectedFailClosed.length !== 2 || errors.length !== expectedFailClosed.leng // Machine-readable receipt of exactly what was rendered on camera, so the release gate can prove // the recorded delta statement reached the screen instead of trusting the script source. if (captionsOut) { - await writeFile(captionsOut, JSON.stringify({ version, releaseDelta, captions: captionsShown }, null, 2) + '\n', 'utf8'); + await writeFile(captionsOut, JSON.stringify({ version, releaseDelta, captions: captionsShown, + captionsVerified: captionEvidence }, null, 2) + '\n', 'utf8'); } const recording = page.video(); await page.close(); From d1c858b940a3ba6fc8cd0f74740162f8a0d8c0f4 Mon Sep 17 00:00:00 2001 From: rockachopa Date: Mon, 19 Oct 2026 12:35:12 +0000 Subject: [PATCH 2/3] chore: update scripts/build_release.py for the caption pixel gate --- scripts/build_release.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/build_release.py b/scripts/build_release.py index 2d9dfc8..5c829e7 100755 --- a/scripts/build_release.py +++ b/scripts/build_release.py @@ -163,11 +163,17 @@ def main() -> int: raise SystemExit("Release demo produced no on-camera caption receipt") recorded = json.loads(caption_receipt.read_text(encoding="utf-8")) onscreen = [str(line) for line in (recorded.get("captions") or [])] - if recorded.get("releaseDelta") != release_delta or not any(release_delta in line for line in onscreen): + # A pushed string is not proof of pixels. Only captions the demo measured as a painted, + # on-top, in-frame bar (style + elementFromPoint + band pixel change) count as on camera. + verified = [str(item.get("text")) for item in (recorded.get("captionsVerified") or []) + if item.get("visible") is True and item.get("bandChanged") is True] + if recorded.get("releaseDelta") != release_delta or not any(release_delta in line for line in verified): raise SystemExit("Release demo did not state the declared release delta on camera") for required in DEMO_REQUIRED_CAPTIONS: if not any(required in line for line in onscreen): raise SystemExit(f"Release demo is missing a required on-camera caption: {required}") + if not any(required in line for line in verified): + raise SystemExit(f"Release demo caption never painted on screen: {required}") finally: server.terminate() try: From c2e9553be562c5fbae2191216a6d7ea057b4beb1 Mon Sep 17 00:00:00 2001 From: rockachopa Date: Mon, 19 Oct 2026 12:35:14 +0000 Subject: [PATCH 3/3] chore: update tests/release-demo.test.js for the caption pixel gate --- tests/release-demo.test.js | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/tests/release-demo.test.js b/tests/release-demo.test.js index 9bf5cae..c403ba3 100644 --- a/tests/release-demo.test.js +++ b/tests/release-demo.test.js @@ -168,9 +168,13 @@ test('release demo states the declared release delta on camera and proves it wit assert.match(demo, /TIMMY_RELEASE_DEMO_DELTA/); assert.match(demo, /This release: \$\{releaseDelta\}/); assert.match(demo, /no new feature is claimed/); - // A caption alone is not evidence; the demo must emit a machine-readable receipt of what rendered. + // A caption string alone is not evidence; each caption must be measured as a painted bar, and the + // receipt must carry that per-caption measurement. assert.match(demo, /TIMMY_DEMO_CAPTIONS_OUT/); assert.match(demo, /captionsShown\.push\(text\)/); + assert.match(demo, /captionsVerified: captionEvidence/); + assert.match(demo, /Caption is not an on-screen bar/); + assert.match(demo, /Caption painted nothing into the caption band/); assert.match(demo, /writeFile\(captionsOut/); }); @@ -207,8 +211,10 @@ test('release demo exercises the photo lifecycle on camera without showing an im // Delete-all is actually performed, not just pointed at. assert.match(demo, /tap\('#delete-all'/); assert.match(demo, /no photo bytes may survive delete-all|JSON\.stringify\(localStorage\)\.includes\('data:image'\)/); - // Synthetic bytes only: a committed 1x1 PNG constant, never a real photo. + // Synthetic bytes only: a committed test-card PNG constant, never a real photo. A blank 1x1 pixel + // rendered as an empty white box on camera, which read as a broken image instead of a saved photo. assert.match(demo, /const SYNTHETIC_PHOTO = 'data:image\/png;base64,/); + assert.match(demo, /synthetic test card/); }); test('release builder refuses to publish without the on-camera delta receipt', async () => { @@ -219,10 +225,24 @@ test('release builder refuses to publish without the on-camera delta receipt', a assert.match(builder, /Release demo produced no on-camera caption receipt/); assert.match(builder, /Release demo did not state the declared release delta on camera/); assert.match(builder, /Release demo is missing a required on-camera caption/); + assert.match(builder, /Release demo caption never painted on screen/); + assert.match(builder, /recorded\.get\("captionsVerified"\)/); assert.match(builder, /"release_delta_visible_on_camera": "passed"/); assert.match(builder, /"release_delta": release_delta/); }); +test('caption styling survives the demo reloads instead of dying with the first document', async () => { + const demo = await readFile(demoPath, 'utf8'); + + // A stylesheet belongs to one document; the demo reloads the page, which stripped caption and touch + // styling and left later captions as clipped body text. Both now carry inline styles per node. + assert.doesNotMatch(demo, /page\.addStyleTag/); + assert.doesNotMatch(demo, /addInitScript/); + assert.match(demo, /In a stylesheet belongs to one document|a stylesheet belongs to one document/); + assert.match(demo, /node\.style\.cssText = 'position:fixed;left:16px;right:16px;top:14px;z-index:20000;'/); + assert.match(demo, /ring\.style\.cssText = 'position:fixed;z-index:20001/); +}); + test('release demo captions expire and the contact sheet spans the whole recording', async () => { const demo = await readFile(demoPath, 'utf8'); const builder = await readFile(builderPath, 'utf8');