diff --git a/skill/reference/new-work.md b/skill/reference/new-work.md
index b1b1bae0d..cc567537d 100644
--- a/skill/reference/new-work.md
+++ b/skill/reference/new-work.md
@@ -106,7 +106,7 @@ Then, in order, each closed by `node {{scripts_path}}/build-phase.mjs advance` (
3. **hero.** Build only the first viewport, at the comp's own dimensions, plates first: place every plate at its spec box (`object-fit: cover`, an `
`, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, advance once so the gate reads the material, then lay the semantic layer over the plates from the spec's palette and boxes and advance again. The gate first refuses while any plate is unreferenced by the source, then runs `comp-diff.mjs`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no region missing. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run.
4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system.
5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered.
-6. **responsive.** The other viewports. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame.
+6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame.
### Code-led
diff --git a/skill/scripts/build-phase.mjs b/skill/scripts/build-phase.mjs
index 623201f55..0830e0c61 100644
--- a/skill/scripts/build-phase.mjs
+++ b/skill/scripts/build-phase.mjs
@@ -48,8 +48,12 @@
* .impeccable/review/hero-repro.png exists and comp-diff overall
* >= HERO_MIN (default 0.72) with no region `missing`. The
* score, the report path, and the attempt count are recorded.
- * sections / motion / responsive -> no mechanical gate; advancing records
- * the moment, and the finish reviewer reads the timeline.
+ * responsive -> .impeccable/review/desktop.png and mobile.png exist, and
+ * the desktop capture still scores >= RESPONSIVE_MIN against
+ * the comp with no region missing: a first viewport that only
+ * holds at the comp's exact width is not built.
+ * sections / motion -> no mechanical gate; advancing records the moment,
+ * and the finish reviewer reads the timeline.
*
* Exit codes: 0 ok / advanced, 2 gate failed (state unchanged, reasons
* printed), 1 usage.
@@ -73,6 +77,7 @@ export const STATE_PATH = path.join(BUILD_DIR, 'state.json');
export const PHASES = ['comps', 'spec', 'plates', 'hero', 'sections', 'motion', 'responsive', 'review'];
export const MOCKS_DIR = path.join('.impeccable', 'mocks');
export const HERO_MIN = 0.72;
+export const RESPONSIVE_MIN = 0.65;
export const PLATE_MIN = 0.4;
export const PLATE_STRUCTURE_MIN = 0.4;
export const HERO_REPRO = path.join('.impeccable', 'review', 'hero-repro.png');
@@ -376,7 +381,34 @@ function hashFile(file) {
} catch { return null; }
}
-const GATES = { comps: gateComps, spec: gateSpec, plates: gatePlates, hero: gateHero };
+/**
+ * Responsive gate: the desktop capture (whatever common width the build
+ * used, 1440 typically) must still read as the comp. A first viewport that
+ * only holds at the comp's exact width and collapses to one column 96px
+ * narrower passed every earlier gate in the first simulated round.
+ */
+export function gateResponsive(state, { specPath = SPEC_PATH, min = RESPONSIVE_MIN, outDir = path.join('.impeccable', 'review', 'diff', 'desktop') } = {}) {
+ const desktop = path.join('.impeccable', 'review', 'desktop.png');
+ const mobile = path.join('.impeccable', 'review', 'mobile.png');
+ const reasons = [];
+ if (!fs.existsSync(desktop)) reasons.push(`no ${desktop}: capture the page at a common desktop width (1440 wide, full page) into that path`);
+ if (!fs.existsSync(mobile)) reasons.push(`no ${mobile}: capture the page at 390 wide, full page, into that path`);
+ if (reasons.length) return { ok: false, reasons };
+ const script = path.join(HERE, 'comp-diff.mjs');
+ const args = [script, '--comp', state.comp, '--build', desktop, '--out-dir', outDir, '--label', 'desktop', '--json'];
+ if (loadSpec(specPath)) args.push('--spec', specPath);
+ const res = spawnSync(process.execPath, args, { encoding: 'utf8' });
+ let report;
+ try { report = JSON.parse(res.stdout); } catch { return { ok: false, reasons: [`comp-diff failed on ${desktop}: ${res.stderr || res.stdout}`] }; }
+ const missing = report.regions.filter((r) => r.verdict === 'missing');
+ const contradictedDirection = report.regions.filter((r) => r.verdict === 'contradicted' && (r.kind === 'plate' || r.kind === 'image' || r.kind === 'text'));
+ if (report.overall < min) reasons.push(`the desktop capture (${report.buildSize}) scores ${(report.overall * 100).toFixed(0)}% against the comp, under ${(min * 100).toFixed(0)}%: the first viewport does not survive a common desktop width. The hero passed at ${state.breakpoint || 'the comp size'}; the layout must hold from ~1280 up, not only at the comp's exact width (grid columns in fr / minmax, not fixed px that overflow and wrap).`);
+ for (const r of missing) reasons.push(`at desktop width, region ${r.id} is missing`);
+ for (const r of contradictedDirection) reasons.push(`at desktop width, region ${r.id} (${r.kind}) is contradicted (structure ${(r.score.structure * 100).toFixed(0)}%)`);
+ return { ok: reasons.length === 0, reasons, summary: `desktop ${(report.overall * 100).toFixed(0)}% (${report.verdict})`, score: report.overall, sideBySide: report.files ? report.files.sideBySide : null };
+}
+
+const GATES = { comps: gateComps, spec: gateSpec, plates: gatePlates, hero: gateHero, responsive: gateResponsive };
// ---- transitions -----------------------------------------------------------
@@ -431,7 +463,7 @@ export function nextInstruction(state) {
case 'hero': return `Build only the first viewport at ${state.breakpoint || 'the comp size'}, plates first: place every plate at its spec box (comp-spec.mjs --print lists boxes as percentages of the viewport) with object-fit: cover before writing a line of text or a control, capture into ${HERO_REPRO}, and advance once so the gate reads the material; then lay the semantic layer (text, controls, rules) over the plates from the spec's palette and boxes, capture, advance. When it fails, open the region crops it lists first, in order, then fix; do not build past the hero until it passes.`;
case 'sections': return 'Build the remaining sections inside the spec system (same corner language, rules, and palette; nothing the comp does not show). Then build-phase.mjs advance.';
case 'motion': return 'Add the signature interaction, reveals, and motion. Then build-phase.mjs advance.';
- case 'responsive': return 'Build the other viewports (mobile first if the surface is mobile). Capture desktop.png and mobile.png into .impeccable/review/. Then build-phase.mjs advance.';
+ case 'responsive': return 'Build the other viewports (mobile first if the surface is mobile). The first viewport must hold at common desktop widths (1280 to 1600), not only at the comp\'s exact size: fluid columns, no fixed-px grid that wraps 96px narrower. Capture desktop.png (1440 wide, full page) and mobile.png (390 wide, full page) into .impeccable/review/; the gate diffs desktop.png against the comp. Then build-phase.mjs advance.';
case 'review': return 'Spawn the finish reviewer with the state file, the hero diff report, and the captures; record its disposition with build-phase.mjs finish --disposition .';
default: return '';
}
diff --git a/tests/build-phase.test.mjs b/tests/build-phase.test.mjs
index 55ff666bb..bb5ae5ba6 100644
--- a/tests/build-phase.test.mjs
+++ b/tests/build-phase.test.mjs
@@ -250,8 +250,18 @@ describe('build-phase state machine (CLI)', () => {
assert.equal(res.status, 0, res.stdout);
assert.match(res.stdout, new RegExp(`ADVANCED ${from}`));
}
- let res = run(PHASE_SCRIPT, ['advance', '--force', '--reason', 'single-file delivery needs CSS'], dir);
- assert.equal(res.status, 0, 'responsive has no gate, so force is moot');
+ // responsive gate: needs desktop.png + mobile.png, and desktop must read as the comp
+ let res = run(PHASE_SCRIPT, ['advance'], dir);
+ assert.equal(res.status, 2);
+ assert.match(res.stdout, /no \.impeccable\/review\/desktop\.png/);
+ const comp = makeComp();
+ const wide = createImage(1440, 900, [240, 237, 226, 255]);
+ blit(wide, resize(comp, 1440, 900), 0, 0);
+ fs.writeFileSync(path.join(dir, '.impeccable', 'review', 'desktop.png'), encodePng(wide));
+ fs.writeFileSync(path.join(dir, '.impeccable', 'review', 'mobile.png'), encodePng(resize(comp, 390, 600)));
+ res = run(PHASE_SCRIPT, ['advance'], dir);
+ assert.equal(res.status, 0, res.stdout);
+ assert.match(res.stdout, /ADVANCED responsive -> review/);
res = run(PHASE_SCRIPT, ['finish', '--disposition', 'fix'], dir);
assert.equal(res.status, 0);
assert.match(res.stdout, /finish fix/);