#!/usr/bin/env node /* * check-rail-climax.cjs — enforce the Rail ↔ climax hand-off (PIPELINE.md). * * node check-rail-climax.cjs * * The embed climax is a PROMOTED word — lifted out of the rail into the hero * layer. It must NEVER also be revealed in the rail. This gate loads index.html * (the climax) and rail.html (the verbatim rail) in headless Chromium, finds the * climax's on-screen window + its word(s), then checks whether the rail reveals * any of those same words DURING that window. If it does, the promoted word is * duplicated on screen → exit 2 (the render aborts). Standard mode only. * * Conservative by design: only a CONFIRMED duplicate fails. Anything we can't * determine (no rail.html, no puppeteer, timeline never registers, no climax) * exits 0 — infra problems never block a render. */ const path = require("path"); const fs = require("fs"); const os = require("os"); const HF_ROOTS = [ process.env.HYPERFRAMES_ROOT, path.resolve(__dirname, "../../.."), path.join(os.homedir(), "Downloads", "hyperframes"), ].filter(Boolean); function findInBun(root, pkg, sub) { const cands = [path.join(root, "node_modules", pkg)]; const bunDir = path.join(root, "node_modules", ".bun"); try { if (fs.existsSync(bunDir)) for (const d of fs.readdirSync(bunDir)) if (d.startsWith(pkg + "@")) cands.push(path.join(bunDir, d, "node_modules", pkg)); } catch { /* ignore */ } for (const c of cands) { const p = sub ? path.join(c, sub) : c; if (fs.existsSync(p)) return p; } return null; } let puppeteer = null, gsapSource = null; for (const root of HF_ROOTS) { if (!puppeteer) { const p = findInBun(root, "puppeteer"); if (p) { try { puppeteer = require(p); } catch {} } } if (!gsapSource) { const g = findInBun(root, "gsap", path.join("dist", "gsap.min.js")); if (g) gsapSource = fs.readFileSync(g, "utf8"); } } const norm = (s) => String(s) .toLowerCase() .replace(/[^a-z0-9]/g, ""); const toks = (s) => String(s) .split(/\s+/) .map(norm) .filter((t) => t.length >= 2); function ok(msg) { if (msg) console.log(msg); process.exit(0); } // can't-determine / pass → never blocks function fail(msg) { console.error(msg); process.exit(2); } async function newPage(browser, W, H) { const page = await browser.newPage(); await page.setViewport({ width: W, height: H, deviceScaleFactor: 1 }); if (gsapSource) { await page.evaluateOnNewDocument(gsapSource); await page.setRequestInterception(true); page.on("request", (req) => { const u = req.url(); if (req.resourceType() === "script" && /gsap/i.test(u) && /^https?:/i.test(u)) req.abort(); else req.continue(); }); } return page; } async function load(page, file) { await page.goto(`file://${file}`, { waitUntil: "load", timeout: 15000 }); const start = Date.now(); while (Date.now() - start < 12000) { if (await page.evaluate(() => !!(window.__timelines && window.__timelines.main))) { try { await page.evaluate(async () => { await document.fonts.ready; }); } catch {} return true; } await new Promise((r) => setTimeout(r, 150)); } return false; } // visible text of a selector at time t, by seeking the page's main timeline. // Visibility must be EFFECTIVE (own opacity × every ancestor's): a rail line that // fades out as a CONTAINER leaves its word spans at opacity:1 — checking only the // span's own style false-positives on text that is actually invisible. async function visibleAt(page, t, selector, childSel) { return page.evaluate( (t, selector, childSel) => { const tl = window.__timelines.main; tl.seek(t); void document.body.offsetHeight; const eff = (el) => { let o = 1, n = el; while (n && n.nodeType === 1) { const cs = getComputedStyle(n); if (cs.display === "none" || cs.visibility === "hidden") return 0; o *= +cs.opacity; n = n.parentElement; } return o; }; const out = []; for (const el of document.querySelectorAll(selector)) { if (eff(el) < 0.05) continue; const kids = childSel ? [...el.querySelectorAll(childSel)] : []; if (kids.length) { for (const k of kids) { if (eff(k) > 0.05) out.push(k.textContent); } } else if ((el.textContent || "").trim()) out.push(el.textContent); } return out.join(" ").trim(); }, t, selector, childSel, ); } async function main() { const project = path.resolve(process.argv[2] || ""); if (!process.argv[2]) ok("[rail-climax] usage: check-rail-climax.cjs "); const indexPath = path.join(project, "index.html"); const railPath = path.join(project, "rail.html"); if (!fs.existsSync(railPath) || !fs.existsSync(indexPath)) ok("[rail-climax] no rail.html+index.html — not Standard, skipping"); if (!puppeteer) ok("[rail-climax] puppeteer unavailable — skipping (set HYPERFRAMES_ROOT)"); const exe = process.platform === "darwin" ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" : "/usr/bin/google-chrome"; let browser; try { browser = await puppeteer.launch({ headless: "new", executablePath: fs.existsSync(exe) ? exe : undefined, args: ["--disable-web-security", "--allow-file-access-from-files", "--disable-dev-shm-usage"], }); } catch (e) { ok(`[rail-climax] could not launch Chromium — skipping (${e.message})`); } try { // index.html → climax window + climax word tokens const ip = await newPage(browser, 1920, 1080); if (!(await load(ip, indexPath))) ok("[rail-climax] index timeline never registered — skipping"); const dur = +(await ip.evaluate(() => { const r = document.querySelector("#root") || document.querySelector("[data-duration]"); return (r && r.dataset && r.dataset.duration) || 0; })) || 30; const STEP = 0.1; const climaxTokens = new Set(); let winIn = null, winOut = null; for (let t = 0; t <= dur + 0.001; t += STEP) { const txt = await visibleAt(ip, +t.toFixed(2), ".climax", "span,.w"); if (txt) { toks(txt).forEach((w) => climaxTokens.add(w)); if (winIn === null) winIn = t; winOut = t; } } if (!climaxTokens.size || winIn === null) ok("[rail-climax] no visible climax found in index.html — skipping"); // rail.html → words visible during [winIn, winOut] const rp = await newPage(browser, 1920, 1080); if (!(await load(rp, railPath))) ok("[rail-climax] rail timeline never registered — skipping"); const hits = new Map(); // token -> first t seen for (let t = Math.max(0, winIn - STEP); t <= winOut + 0.001; t += STEP) { const railToks = new Set(toks(await visibleAt(rp, +t.toFixed(2), ".w", null))); for (const w of climaxTokens) if (railToks.has(w) && !hits.has(w)) hits.set(w, +t.toFixed(2)); } if (hits.size) { const list = [...hits.entries()] .map(([w, t]) => `"${w}" (rail shows it at t=${t}s)`) .join(", "); fail( `[rail-climax] ✗ DUPLICATE PROMOTED WORD: the climax word(s) ${[...climaxTokens].map((w) => `"${w}"`).join(", ")} ` + `are on screen during the climax window [${winIn.toFixed(2)}–${winOut.toFixed(2)}s], ` + `and the rail ALSO reveals: ${list}.\n` + ` The promoted word must be handed off, never duplicated — see PIPELINE.md "Rail ↔ climax hand-off":\n` + ` freeze the rail before the promoted word, let the climax carry it, hold the climax across the rail's\n` + ` page-flip, and exit it at the end of the thought. The rail must never reveal the promoted word.`, ); } console.log( `[rail-climax] ✓ ok — promoted word(s) ${[...climaxTokens].map((w) => `"${w}"`).join(", ")} not duplicated in the rail (window ${winIn.toFixed(2)}–${winOut.toFixed(2)}s)`, ); } finally { await Promise.race([browser.close().catch(() => {}), new Promise((r) => setTimeout(r, 8000))]); } } main() .then(() => process.exit(0)) .catch((e) => { console.error(`[rail-climax] (skipped — ${e.message})`); process.exit(0); });