Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions docs/media/gate-demo-narrow-transcript.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,13 @@ proofloop gate: running check "npm test" -> npm test
> tiny-app@1.0.0 test
> node test.js

proofloop gate: check "npm test" FAILED (2640ms, exit 1)
proofloop gate: check "npm test" FAILED (876ms, exit 1)
proofloop gate: FAILED
state: ~/tiny-app\.proofloop\gate-state.json
ts: 2026-10-16T15:09:56.875Z
ts: 2026-10-18T03:45:01.655Z
source: npm-test-fallback
checks:
- [FAIL] npm test (2640ms)
- [FAIL] npm test (876ms)

node:assert:150
throw new AssertionError(obj);
Expand Down Expand Up @@ -70,11 +70,11 @@ proofloop gate: running check "npm test" -> npm test
> node test.js

test ok: add(2,3)=5
proofloop gate: check "npm test" PASSED (2760ms, exit 0)
proofloop gate: check "npm test" PASSED (926ms, exit 0)
proofloop gate: PASSED
state: ~/tiny-app\.proofloop\gate-state.json
ts: 2026-10-16T15:10:00.090Z
ts: 2026-10-18T03:45:02.726Z
source: npm-test-fallback
checks:
- [pass] npm test (2760ms)
- [pass] npm test (926ms)

Binary file modified docs/media/gate-demo-narrow.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
60 changes: 60 additions & 0 deletions scripts/narrow-wrap.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* The 34-column wrap behind `node scripts/record-gate-demo.mjs --narrow`,
* kept apart from the recorder so tests can feed it lines without running
* Playwright or ffmpeg.
*/
export const COLS = 34;
export const IND = " ";

// Greedy word wrap. A soft break drops the spaces at the break (kept in `join`),
// a token longer than a row is split (join ""), so rows can always be re-joined exactly.
export function wrapLine(s) {
if (s.length <= COLS) return [{ text: s, join: "" }];
const rows = [];
let cur = "";
let pend = "";
let join = "";
const flush = (next) => {
rows.push({ text: cur, join });
cur = IND;
join = next;
};
for (const tok of s.match(/ +|[^ ]+/g)) {
if (tok[0] === " ") {
pend += tok;
continue;
}
if (cur.length + pend.length + tok.length <= COLS) {
cur += pend + tok;
pend = "";
continue;
}
if (cur.trim()) flush(pend);
else cur += pend;
pend = "";
let w = tok;
while (cur.length + w.length > COLS) {
const room = COLS - cur.length;
cur += w.slice(0, room);
w = w.slice(room);
flush("");
}
cur += w;
}
rows.push({ text: cur + pend, join });
return rows;
}

/**
* Every wrapped row, whitespace-only and leading-indent rows included, must fit
* the column budget; a wider row would be clipped by the canvas. wrapLine does
* not guarantee this on its own: a 40-space line, trailing spaces, or an indent
* wider than a row each come back over-long, so this check is the guard.
*/
export function assertRowsFit(rows, src) {
for (const row of rows) {
if (!(row.length <= COLS)) {
throw new Error(`wrapped row is ${row.length} columns, over ${COLS}: ${JSON.stringify(row)} (from ${JSON.stringify(src)})`);
}
}
}
75 changes: 21 additions & 54 deletions scripts/record-gate-demo.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const { chromium } = await import("playwright").catch(() => {

/**
* Phone variant: node scripts/record-gate-demo.mjs --narrow
* Same real runs, replayed on a 648x900 canvas at 32px so the text is still
* Same real runs, replayed at 32px on a 648 px wide canvas, as tall as its fullest frame, so the text is still
* readable when GitHub scales the image to a ~324 css px phone column (cap
* height about 10 css px; the 960px clip lands near 3). Lines are wrapped at
* 34 columns on word boundaries (a token longer than a row is split), never
Expand All @@ -50,6 +50,7 @@ const { chromium } = await import("playwright").catch(() => {
* the fresh run it replays; the wide clip and its transcript are not touched).
* NARROW_WRAP_JSON=<file> also dumps the wrapped rows for an external check.
*/
import { COLS, IND, wrapLine, assertRowsFit } from "./narrow-wrap.mjs";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const NARROW = process.argv.includes("--narrow");
const OUT_GIF = path.join(ROOT, NARROW ? "docs/media/gate-demo-narrow.gif" : "docs/media/gate-demo.gif");
Expand Down Expand Up @@ -112,47 +113,7 @@ push(gatePass);
await fs.mkdir(path.dirname(OUT_TXT), { recursive: true });
await fs.writeFile(OUT_TXT, lines.map((l) => l.s).join("\n") + "\n");

// --- 3b. Phone variant (--narrow): wrap, replay on a 648x900 canvas, build the GIF, stop.
const COLS = 34;
const IND = " ";
// Greedy word wrap. A soft break drops the spaces at the break (kept in `join`),
// a token longer than a row is split (join ""), so rows can always be re-joined exactly.
function wrapLine(s) {
if (s.length <= COLS) return [{ text: s, join: "" }];
const rows = [];
let cur = "";
let pend = "";
let join = "";
const flush = (next) => {
rows.push({ text: cur, join });
cur = IND;
join = next;
};
for (const tok of s.match(/ +|[^ ]+/g)) {
if (tok[0] === " ") {
pend += tok;
continue;
}
if (cur.length + pend.length + tok.length <= COLS) {
cur += pend + tok;
pend = "";
continue;
}
if (cur.trim()) flush(pend);
else cur += pend;
pend = "";
let w = tok;
while (cur.length + w.length > COLS) {
const room = COLS - cur.length;
cur += w.slice(0, room);
w = w.slice(room);
flush("");
}
cur += w;
}
rows.push({ text: cur + pend, join });
return rows;
}
// --- 3b. Phone variant (--narrow): wrap, replay on a canvas fitted to its tallest frame, build the GIF, stop.
if (NARROW) {
const esc = (x) => x.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const rows = [];
Expand All @@ -164,6 +125,7 @@ if (NARROW) {
const n = (x) => x.split(word).length - 1;
if (n(l.s) !== w.reduce((a, r) => a + n(r.text), 0)) throw new Error(`${word} split across rows: ${JSON.stringify(l.s)}`);
}
assertRowsFit(w.map((r) => r.text), l.s); // every row.length <= COLS, whitespace-only and indented rows included
for (const r of w) rows.push({ ...r, src, t: l.t, verdict: /^proofloop gate: (FAILED|PASSED)$/.test(l.s) });
}
if (process.env.NARROW_WRAP_JSON) {
Expand All @@ -172,8 +134,22 @@ if (NARROW) {
JSON.stringify({ cols: COLS, indent: IND.length, lines: lines.map((l) => l.s), rows }, null, 1),
);
}
const W = 648, H = 900, FONT = 32, PITCH = 45, PAD = 24;
const visible = Math.floor((H - 2 * PAD) / PITCH);
// 3 rows per frame, but a frame always ends on a verdict row; verdict frames and the last frame are held.
const PER_FRAME = 3, FRAME_S = 0.36, HOLD_S = 2.4;
const marks = [];
let since = 0;
rows.forEach((r, i) => {
if (++since === PER_FRAME || r.verdict || i === rows.length - 1) {
marks.push(i + 1);
since = 0;
}
});
// The terminal scrolls: at most VISIBLE rows show at once. The canvas is exactly as tall as the
// fullest frame (plus padding), so a full window leaves no blank band; frames still filling up are shorter than the canvas.
const W = 648, FONT = 32, PITCH = 34, PAD = 12, VISIBLE = 12;
const visible = Math.min(VISIBLE, rows.length);
const tallest = Math.max(...marks.map((n) => Math.min(n, visible)));
const H = 2 * PAD + tallest * PITCH;
const rowHtml = (r) => {
const cls = r.t + (/FAILED/.test(r.text) ? " fail" : "") + (/PASSED/.test(r.text) ? " pass" : "");
return `<div class="ln ${cls}">${esc(r.text) || "&nbsp;"}</div>`;
Expand All @@ -196,16 +172,6 @@ if (NARROW) {
el.style.display = i < n && i >= n - ${visible} ? "block" : "none";
});
</script>`;
// 3 rows per frame, but a frame always ends on a verdict row; verdict frames and the last frame are held.
const PER_FRAME = 3, FRAME_S = 0.36, HOLD_S = 2.4;
const marks = [];
let since = 0;
rows.forEach((r, i) => {
if (++since === PER_FRAME || r.verdict || i === rows.length - 1) {
marks.push(i + 1);
since = 0;
}
});
const frameDir = await fs.mkdtemp(path.join(os.tmpdir(), "gate-frames-"));
const browser = await chromium.launch();
const pg = await browser.newPage({ viewport: { width: W, height: H } });
Expand All @@ -221,6 +187,7 @@ if (NARROW) {
const list = ["ffconcat version 1.0"];
for (const [k, n] of marks.entries()) {
await pg.evaluate((m) => window.reveal(m), n);
if (await pg.evaluate(() => term.scrollHeight > term.clientHeight)) throw new Error(`frame ${k}: rows are taller than the canvas`);
const f = `f${String(k).padStart(4, "0")}.png`;
await pg.screenshot({ path: path.join(frameDir, f) });
const hold = rows[n - 1].verdict || k === marks.length - 1;
Expand Down
43 changes: 43 additions & 0 deletions tests/narrowWrap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
// @ts-expect-error plain .mjs shared with scripts/record-gate-demo.mjs
import { COLS, wrapLine, assertRowsFit } from "../scripts/narrow-wrap.mjs";

/**
* The scenario: a reader on a phone opens the README, where the narrow gate-demo
* GIF is shown at about half its pixel width. A row wider than 34 columns would be
* clipped by the canvas, so the last characters of a real command or verdict would
* silently go missing from the clip. The recorder wraps every transcript line at
* 34 columns and must refuse to render if any row, including a whitespace-only or
* deeply indented one, is wider than that.
*/
const rowsOf = (line: string): string[] => wrapLine(line).map((r: { text: string }) => r.text);

describe("narrow gate-demo wrap: every row fits 34 columns", () => {
it("accepts transcript-shaped lines: prose, a path, a long unbroken token, a short indent", () => {
const lines = [
"$ node dist/cli.js gate # the gate decides whether \u201cdone\u201d is true",
" state: ~/tiny-app\\.proofloop\\gate-state.json",
" at Object.<anonymous> (~/tiny-app\\test.js:3:8)",
" " + "y".repeat(60),
"",
" ",
];
for (const line of lines) expect(() => assertRowsFit(rowsOf(line), line)).not.toThrow();
});

// Negative controls: wrapLine really does return these over-long (so the assertion is
// load-bearing, not decorative), and assertRowsFit must throw on every one of them.
it.each([
["a whitespace-only line", " ".repeat(40)],
["trailing spaces after text", "x".repeat(10) + " ".repeat(40)],
["a leading indent wider than a row", " ".repeat(36) + "x".repeat(40)],
])("throws on %s", (_name, line) => {
const rows = rowsOf(line);
expect(Math.max(...rows.map((r) => r.length))).toBeGreaterThan(COLS);
expect(() => assertRowsFit(rows, line)).toThrow(/over 34/);
});

it("throws on a hand-made over-long row", () => {
expect(() => assertRowsFit(["x".repeat(COLS + 1)], "synthetic")).toThrow(/35 columns, over 34/);
});
});
Loading