diff --git a/journeys/phone-board.mjs b/journeys/phone-board.mjs
new file mode 100644
index 0000000..14a1701
--- /dev/null
+++ b/journeys/phone-board.mjs
@@ -0,0 +1,24 @@
+// Nonpaid proof that a board of phone screens at 1280 passes the render check refine runs: three 390px phone screens in one row on a
+// 1280 page raise no phone-sized finding, while one lone phone canvas centred on the desktop still does. Needs Chrome or Chromium.
+import assert from 'node:assert/strict';
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { renderCheck } from '../src/render-check.mjs';
+const out = process.env.DONE_DONE_EVIDENCE_ROOT ?? process.argv[2];
+if (!out) throw Error('Give an evidence folder');
+mkdirSync(out, { recursive: true });
+const page = (body, css) => `
${body}`;
+const screens = (n) => Array.from({ length: n }, (_, i) => `Step ${i + 1}
Phone screen ${i + 1} of ${n}
`).join('');
+const phone = /is still phone-sized/;
+const check = renderCheck();
+if (check.drawing === false) throw Error('Chrome or Chromium is needed for this journey');
+const run = async (name, html) => { const errors = await check.verify(html); writeFileSync(join(out, `${name}.html`), html); writeFileSync(join(out, `${name}.errors.json`), JSON.stringify(errors, null, 2)); return errors; };
+try {
+ const board = await run('board-3-phones', page(screens(3), 'display:flex;gap:24px;justify-content:center;align-items:flex-start'));
+ assert.equal(board.filter((e) => phone.test(e.message)).length, 0, 'a board of three phone screens in a row is not phone-sized');
+ const lone = await run('lone-phone', page(screens(1), 'display:flex;justify-content:center'));
+ assert.ok(lone.some((e) => phone.test(e.message)), 'a lone phone canvas on the desktop is still refused');
+ writeFileSync(join(out, 'summary.json'), JSON.stringify({ status: check.status, board_errors: board.map((e) => e.message), lone_phone_phone_sized_findings: lone.filter((e) => phone.test(e.message)).length }, null, 2));
+ assert.equal(check.status, 'checked');
+ console.log('board of 3 phone screens at 1280: no phone-sized finding; lone phone canvas: refused');
+} finally { check.close(); }
diff --git a/src/make.mjs b/src/make.mjs
index a571379..83932ec 100644
--- a/src/make.mjs
+++ b/src/make.mjs
@@ -42,7 +42,7 @@ const STATIC = 'Host export contract (first-draft; overrides JSX/runtime guidanc
const LANDING = /\blanding[\s-]?pages?\b/i;
const LANDING_BRIEF = (clear) => 'Landing page brief (the prompt asks for a landing page): write a short landing page that makes a visitor want the product, not a documentation site. Read the source for what the product does, who it is for and how to start, then write the page in your own words. At most about 6 sections, in this order: ' + (clear ? 'a hero (one promise in plain words, one primary action), proof straight after it (two to four cases or results from the source, or plausible placeholders), the value (about three benefit blocks, one idea each), how it works in three steps, install' : 'a hero (one promise in plain words, one primary action), the value (about three benefit blocks, one idea each), how it works in three steps, one proof block (a real number, result or quote from the source), install') + ' (the one command a user runs first and one sentence; no flags, alternatives or setup detail) and a closing call to action. Keep the whole page about 6,000 px tall at 390, or less. Space: every heading is followed by 16px or more before its text, and sections are 64px or more apart, the closing call to action included, so no heading ever sits flush on the paragraph under it. Do not mirror the source\'s headings or order, and leave out its doc paths, file names, maintainer commands, flags, config keys, release notes and other reference material. One screen, not a multi-screen flow, unless the prompt asks for screens: no data-oc-screen, no form-state screens, no setup wizard, no reference pages.';
// Every screen's phone and desktop layouts, in both themes, are checked before acceptance.
-const outputRules = (dark) => `Output rules: deliver every screen in ONE file with a phone layout at 390x844 and a desktop layout at 1280x800. The whole file AND each data-oc-screen section in isolation are rendered at both sizes in light and dark before acceptance. Keep screen sections as full-width siblings, never use different screens as desktop grid columns: put responsive columns INSIDE each screen. Every screen must remain visible and usable when all other screens are hidden. Use CSS media queries for real desktop/phone layouts, not a fixed-width phone canvas stretched into desktop space. (1) Both sizes and themes must fit without sideways scroll: wrap every pre and table in a container with overflow-x:auto, write grid columns as minmax(0,1fr) instead of 1fr, give every grid and flex child min-width:0, and let long text wrap. ${dark ? '(2) The style you were given is dark-first, so the page is dark by default, in light and in dark: a near-black page background with light text on :root, no light palette, and color-scheme: dark; never a light page background.' : '(2) It must have a real light theme and a real dark theme, both chosen by prefers-color-scheme: :root holds the light palette (a light page background, dark text) and a @media (prefers-color-scheme: dark) block overrides every colour custom property with a dark palette (a dark background, light text); also set color-scheme: light dark. Never write one dark palette for both: in light the page background is light.'}`;
+const outputRules = (dark) => `Output rules: deliver every screen in ONE file with a phone layout at 390x844 and a desktop layout at 1280x800. The whole file AND each data-oc-screen section in isolation are rendered at both sizes in light and dark before acceptance. Keep screen sections as full-width siblings, never use different screens as desktop grid columns: put responsive columns INSIDE each screen (except when the brief asks for a board of phone screens side by side: then each screen stays a 390px frame in one row). Every screen must remain visible and usable when all other screens are hidden. Use CSS media queries for real desktop/phone layouts, not a fixed-width phone canvas stretched into desktop space. (1) Both sizes and themes must fit without sideways scroll: wrap every pre and table in a container with overflow-x:auto, write grid columns as minmax(0,1fr) instead of 1fr, give every grid and flex child min-width:0, and let long text wrap. ${dark ? '(2) The style you were given is dark-first, so the page is dark by default, in light and in dark: a near-black page background with light text on :root, no light palette, and color-scheme: dark; never a light page background.' : '(2) It must have a real light theme and a real dark theme, both chosen by prefers-color-scheme: :root holds the light palette (a light page background, dark text) and a @media (prefers-color-scheme: dark) block overrides every colour custom property with a dark palette (a dark background, light text); also set color-scheme: light dark. Never write one dark palette for both: in light the page background is light.'}`;
// script: the page keeps its inline script and handlers (an option's own motion), under LIVE_POLICY; a script with a src and a javascript: URL are still refused.
export function staticExport(html, { script = false } = {}) {
diff --git a/src/render-check.mjs b/src/render-check.mjs
index d94ff8d..6ac1b84 100644
--- a/src/render-check.mjs
+++ b/src/render-check.mjs
@@ -414,6 +414,8 @@ export class Renderer {
const lightness = await this.eval(c, `(${pageLightness.toString()})()`);
const shot = await c.send('Page.captureScreenshot', { format: 'png' });
const layout = screen === null ? null : await this.eval(c, `(()=>{const e=document.querySelectorAll('[data-oc-screen]')[${screen}],r=e.getBoundingClientRect();return {width:r.width,height:r.height,visible:getComputedStyle(e).visibility!=='hidden'}})()`);
+ // A board: the whole page lays two or more phone-wide screens in one row, which is a design to review, not a phone canvas stretched over the desktop.
+ const board = screen === null ? await this.eval(c, `(()=>{const r=[...document.querySelectorAll('[data-oc-screen]')].map(e=>e.getBoundingClientRect()).filter(r=>r.width>0&&r.width<=390);return r.filter(a=>r.some(b=>b!==a&&Math.abs(a.top-b.top)<=2&&(a.right<=b.left+1||b.right<=a.left+1))).length>=2})()`) : false;
const visible = screen === null || (layout.width > 0 && layout.height > 0 && layout.visible);
const covered = (this.redesign && screen !== null) || (this.pageChecks && screen === null) ? await this.eval(c, `(${coveredText.toString()})()`) : [];
const stray = this.pageChecks && screen === null ? await this.eval(c, `(${strayClose.toString()})()`) : [];
@@ -422,7 +424,7 @@ export class Renderer {
const human = this.humanSide && screen === null ? await this.eval(c, `(${humanSidePage.toString()})(${CARD_RUN}, ${HUES}, ${JSON.stringify(TITLE_WORDS)})`) : []; // the human-side checks are the whole page's too
const scene = this.flow && screen === null ? await this.eval(c, `(${flowScene.toString()})()`) : []; // the flow's scenes are measured across the screens, as the person sees them together
const shell = this.flow && screen === null ? await this.eval(c, `(${flowShell.toString()})()`) : []; // so are its step labels and the actions on its last screen
- return { measured, sig, lightness, visible, covered, stray, faint, clarity, human, scene, shell, screenWidth: layout?.width, png: Buffer.from(shot.data, 'base64') };
+ return { measured, sig, lightness, visible, covered, stray, faint, clarity, human, scene, shell, screenWidth: layout?.width, board, png: Buffer.from(shot.data, 'base64') };
}
async eval(c, expression) {
@@ -662,13 +664,14 @@ export class Renderer {
}).toString()})(${JSON.stringify(redesignPages)})`);
errors.push(...findings.map(message=>({source:'redesign',message})));
}
+ const boards = new Set(shots.filter((s) => s.screen === null && s.width === 1280 && s.light.board && s.dark.board).map(() => 1280));
const said = new Set(); // the same clarity finding in the other theme is one finding
for (const { width, height, screen, light, dark } of shots) {
const context = `${screen === null ? 'the page' : `screen ${JSON.stringify(screen)}`} at ${width}x${height}`;
for (const [theme, shot] of Object.entries({ light, dark })) {
if (!shot.visible) errors.push({ source: 'render', message: `${context} in ${theme} has no visible layout. Each screen must work independently on phone and desktop. Then call done again.` });
- // ponytail: reject phone-sized outer boxes; visual review still decides desktop quality.
- if (width === 1280 && screen !== null && shot.visible && shot.screenWidth <= 390) errors.push({ source: 'render', message: `${context} in ${theme} is still phone-sized (${Math.round(shot.screenWidth)}px). Let each outer screen use the desktop width; put responsive columns inside it, not between screens. Then call done again.` });
+ // ponytail: reject phone-sized outer boxes; visual review still decides desktop quality. A board of phone frames side by side at 1280 is the exception.
+ if (width === 1280 && screen !== null && !boards.has(width) && shot.visible && shot.screenWidth <= 390) errors.push({ source: 'render', message: `${context} in ${theme} is still phone-sized (${Math.round(shot.screenWidth)}px). Let each outer screen use the desktop width; put responsive columns inside it, not between screens. Then call done again.` });
for (const { message } of shot.clarity) if (!said.has(message) && said.add(message)) errors.push({ source: 'clarity', message: `clarity over flash, ${context}: ${message}. Then call done again.` });
for (const message of shot.scene ?? []) if (!said.has(message) && said.add(message)) errors.push({ source: 'flow', message: `${context}: ${message}. Make the illustration part of the screen's own surface: let it run to the screen's left and right edges on the screen's own ground, or give it a soft edge that fades into that ground with a mask (mask-image: radial-gradient(...)), and take away the hard rim: no border, outline, frame, mat, drop shadow, oval or rounded clip. Then call done again.` });
for (const message of shot.shell ?? []) if (!said.has(message) && said.add(message)) errors.push({ source: 'flow', message: `${context}: ${message}. Then call done again.` });