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
This commit is contained in:
1 parent
0e4dde5f81
commit
fd09282d8d
1 file changed
+45
-6
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user