diff --git a/captutor/screenplays/nopaint-first-painting.mjs b/captutor/screenplays/nopaint-first-painting.mjs --- a/captutor/screenplays/nopaint-first-painting.mjs +++ b/captutor/screenplays/nopaint-first-painting.mjs @@ -50,7 +50,7 @@ ], }, setup: async ({ cdp }) => { - await cdp.nav(`${AC_URL}/nopaint?seed=nopaint-e2e-v1&test=1`); + await cdp.nav(`${AC_URL}/nopaint?seed=nopaint-perf-v1&test=1`); await cdp.waitFor("typeof window.__acNoPaintTest === 'function'"); await cdp.waitFor("window.__acNoPaintTest().state === 'proposing'"); }, diff --git a/system/public/aesthetic.computer/disks/nopaint.mjs b/system/public/aesthetic.computer/disks/nopaint.mjs --- a/system/public/aesthetic.computer/disks/nopaint.mjs +++ b/system/public/aesthetic.computer/disks/nopaint.mjs @@ -24,6 +24,7 @@ let lastDownload = null; let testApi = null; let testChannel = null; let archiveOrigin = null; +let paintingResolution = null; function initialNavigationURL() { if (typeof window === "undefined") return null; @@ -45,24 +46,56 @@ } loopState = next; } +function interfaceLayout(screen) { + const barHeight = Math.max(96, Math.floor(screen.height * 0.18)); + const statusHeight = Math.max(22, Math.floor(barHeight * 0.22)); + const available = { x: 0, y: 0, w: screen.width, h: screen.height - barHeight }; + const source = paintingResolution || { width: available.w, height: available.h }; + const scale = Math.min(available.w / source.width, available.h / source.height); + const viewport = { + x: Math.floor((available.w - source.width * scale) / 2), + y: Math.floor((available.h - source.height * scale) / 2), + w: Math.floor(source.width * scale), + h: Math.floor(source.height * scale), + }; + return { + stage: viewport, + bar: { x: 0, y: screen.height - barHeight, w: screen.width, h: barHeight }, + statusHeight, + scale, + }; +} + function positionButtons(screen) { - // The recovered instrument makes the two words architectural: No occupies - // the upper field and Paint anchors the lower field. Keep those proportions - // across landscape and portrait surfaces instead of shrinking both choices - // into ordinary adjacent toolbar buttons. - const noWidth = Math.max(120, Math.floor(screen.width * 0.48)); - const paintWidth = Math.max(150, Math.floor(screen.width * 0.62)); - const decisionHeight = Math.max(44, Math.floor(screen.height * 0.13)); - Object.assign(noButton.box, { - x: Math.floor((screen.width - noWidth) / 2), y: Math.floor(screen.height * 0.05), + // The recovered instrument keeps the decision pair together along the + // bottom edge: No on the left, Paint larger on the right. They are the + // architecture of the surface, not ordinary toolbar buttons. + const { bar, statusHeight } = interfaceLayout(screen); + const gap = Math.max(4, Math.floor(screen.width * 0.006)); + const margin = Math.max(6, Math.floor(screen.width * 0.008)); + const saveWidth = Math.max(72, Math.floor(screen.width * 0.12)); + const available = screen.width - margin * 2 - gap * 2 - saveWidth; + const noWidth = Math.floor(available * 0.38); + const paintWidth = available - noWidth; + const buttonY = bar.y + statusHeight; + const decisionHeight = bar.h - statusHeight - margin; + const no = noButton.btn || noButton; + const paint = paintButton.btn || paintButton; + no.box ||= {}; + paint.box ||= {}; + Object.assign(no.box, { + x: margin, y: buttonY, w: noWidth, h: decisionHeight, }); - Object.assign(paintButton.box, { - x: Math.floor((screen.width - paintWidth) / 2), - y: screen.height - decisionHeight - Math.floor(screen.height * 0.04), + Object.assign(paint.box, { + x: margin + noWidth + gap, y: buttonY, w: paintWidth, h: decisionHeight, }); - saveButton.reposition({ right: 8, top: 8, screen }); + saveButton.reposition({ + left: margin + noWidth + gap + paintWidth + gap, + top: buttonY + Math.floor((decisionHeight - saveButton.height) / 2), + screen, + }); } function paintDecisionButton($, button, label, light = false) { @@ -90,10 +123,14 @@ } function chooseProposal(api) { transition("choosing"); + const resolution = paintingResolution || { + width: api.system.painting.width, + height: api.system.painting.height, + }; proposal = makeProposal( random, - api.system.painting.width, - api.system.painting.height, + resolution.width, + resolution.height, ); proposalNumber += 1; proposalFrame = 0; @@ -211,7 +248,9 @@ lastDownload = `nopaint-${num.timestamp()}.png`; download(lastDownload, system.painting, { scale: 2, cropToScreen: true, - sharing: canShare, + // Browser journeys need a deterministic downloaded artifact; native share + // sheets detach the page/iframe and are covered by their own integration. + sharing: canShare && !initialNavigationURL()?.searchParams.has("test"), }); publishTestState(); } @@ -232,6 +271,7 @@ const controlBox = (control) => { const box = control?.box || control?.btn?.box; return box ? { x: box.x, y: box.y, w: box.w, h: box.h } : null; }; + const layout = testApi?.screen ? interfaceLayout(testApi.screen) : null; return { version: NOPAINT_VERSION, state: loopState, @@ -248,6 +288,15 @@ no: controlBox(noButton), paint: controlBox(paintButton), save: controlBox(saveButton), }, + layout: layout ? { + paintingViewport: { ...layout.stage }, + paintingResolution: { ...paintingResolution }, + screenResolution: { + width: testApi.screen.width, + height: testApi.screen.height, + }, + controlBar: { ...layout.bar }, + } : null, paintingFingerprint: paintingFingerprint(testApi?.system?.painting), origin: archiveOrigin ? { ...archiveOrigin } : null, }; @@ -286,7 +335,7 @@ } } // 🥾 Boot -function boot({ colon, debug, hud, net, num, params, screen, system, ui, ...api }) { +function boot({ colon, debug, hud, net, num, params, screen, store, system, ui, ...api }) { // The runtime may rewrite the visible route before the piece boots. The // Navigation Timing entry retains the original tutorial/test URL. const urlSeed = initialNavigationURL()?.searchParams.get("seed") || null; @@ -319,11 +368,19 @@ id: freshFromId, record: `https://nopaint.art/${freshFromId}`, action: "rejected-as-start", } : null; - testApi = { ...api, hud, net, screen, system }; + // AC's painting contract: the initial canvas establishes this painting's + // pixel resolution. Window changes after boot only alter presentation. + paintingResolution = { + width: system.painting.width, + height: system.painting.height, + }; + store["painting:resolution-lock"] = true; + store.persist("painting:resolution-lock", "local:db"); + testApi = { ...api, hud, net, screen, store, system }; stateBeforePause = "proposing"; - noButton = new ui.Button({ x: 0, y: 0, w: 1, h: 1 }); - paintButton = new ui.Button({ x: 0, y: 0, w: 1, h: 1 }); + noButton = new ui.TextButton("No"); + paintButton = new ui.TextButton("Paint"); saveButton = new ui.TextButton("Save"); positionButtons(screen); @@ -492,17 +549,23 @@ if (!proposal || !$.system.nopaint.buffer) return false; renderProposal($); $.system.nopaint.needsPresent = true; + const { bar, stage, scale } = interfaceLayout($.screen); + // Present the entire fixed-resolution painting above the controls. The + // viewport may fit/letterbox responsively, but its pixels never reflow. + $.wipe(18); + $.paste($.system.painting, stage.x, stage.y, scale); + $.paste($.system.nopaint.buffer, stage.x, stage.y, scale); const paused = loopState === "paused" ? " · paused" : ""; const definition = proposalDefinition(proposal.kind); - $.ink(255).write( - `${definition?.label || proposal.kind} ${proposalNumber}${paused}`, - { x: 8, y: 8 }, + $.ink(18).box(bar, "fill"); + $.ink(255, 180).write( + `No Paint ${NOPAINT_VERSION} · ${definition?.label || proposal.kind} ${proposalNumber}${paused} · seed ${sessionSeed}`, + { x: 8, y: bar.y + 5 }, ); - $.ink(255, 150).write(`No Paint ${NOPAINT_VERSION} · seed ${sessionSeed}`, { x: 8, y: 20 }); positionButtons($.screen); - paintDecisionButton($, noButton, "No"); - paintDecisionButton($, paintButton, "Paint", true); + noButton.paint($, [[10, 10, 10], [80, 80, 80], [255, 255, 255]]); + paintButton.paint($, [[245, 245, 245], [210, 210, 210], [15, 15, 15]]); saveButton.paint($, [[20, 20, 20], [180, 180, 180], [255, 255, 255]]); return loopState === "proposing"; } @@ -515,8 +578,8 @@ // 🎪 Act — every input surface reaches the same two decision functions. function act($) { const { event: e } = $; - noButton.act(e, () => discardProposal($)); - paintButton.act(e, () => commitProposal($)); + noButton.btn.act(e, () => discardProposal($)); + paintButton.btn.act(e, () => commitProposal($)); saveButton.btn.act(e, () => savePainting($)); if (isAny(e, [ diff --git a/system/public/aesthetic.computer/lib/nopaint-proposals.mjs b/system/public/aesthetic.computer/lib/nopaint-proposals.mjs --- a/system/public/aesthetic.computer/lib/nopaint-proposals.mjs +++ b/system/public/aesthetic.computer/lib/nopaint-proposals.mjs @@ -43,7 +43,8 @@ Object.freeze({ name: "walker", label: "Walker", family: "authored", weight: 0.2 }), Object.freeze({ name: "banner", label: "Banner", family: "authored", weight: 1 }), Object.freeze({ name: "wafer", label: "Wafer", family: "authored", weight: 1 }), Object.freeze({ name: "wipe", weight: 1 }), - Object.freeze({ name: "camera", weight: 1 }), + // Camera is implemented but deliberately absent from random proposals. + // Device access belongs to an explicit camera-tool action, never page load. ]); export function proposalDefinition(name, catalog = NOPAINT_PROPOSAL_CATALOG) { diff --git a/system/tests/nopaint-proposals.test.mjs b/system/tests/nopaint-proposals.test.mjs --- a/system/tests/nopaint-proposals.test.mjs +++ b/system/tests/nopaint-proposals.test.mjs @@ -26,7 +26,8 @@ test("the first native catalog retains the recovered duplicate Box weight", () => { const rect = NOPAINT_PROPOSAL_CATALOG.find(({ name }) => name === "rect"); assert.equal(rect.weight, 2); assert.equal(NOPAINT_VERSION, "3.0"); - assert.equal(NOPAINT_PROPOSAL_CATALOG.length, 11); + assert.equal(NOPAINT_PROPOSAL_CATALOG.length, 10); + assert.equal(NOPAINT_PROPOSAL_CATALOG.some(({ name }) => name === "camera"), false); assert.equal(NOPAINT_PROPOSAL_CATALOG.find(({ name }) => name === "softy").weight, 1.5); assert.equal(NOPAINT_PROPOSAL_CATALOG.find(({ name }) => name === "walker").weight, 0.2); }); @@ -130,6 +131,7 @@ ui: { TextButton }, }; nopaintPiece.boot(common); + assert.equal(store["painting:resolution-lock"], true); nopaintPiece.act({ ...common, event: { is: (name) => name === "keyboard:down:enter" }, @@ -137,7 +139,7 @@ }); assert.deepEqual([...painting.pixels], new Array(16).fill(90)); assert.equal(undoCount, 1); - assert.equal(persistCount, 2); + assert.equal(persistCount, 3); assert.deepEqual(store["nopaint:session"], { version: "3.0", seed: store["nopaint:session"].seed, @@ -157,7 +159,7 @@ }); assert.deepEqual([...painting.pixels], new Array(16).fill(90)); assert.equal(undoCount, 1); - assert.equal(persistCount, 3); + assert.equal(persistCount, 4); assert.deepEqual(store["nopaint:session"].decisions, [ { number: 1, diff --git a/tests/browser/ac-harness.mjs b/tests/browser/ac-harness.mjs --- a/tests/browser/ac-harness.mjs +++ b/tests/browser/ac-harness.mjs @@ -101,6 +101,8 @@ try { await this.page.waitForFunction( () => typeof window.__acPromptTest === "function" || + (typeof window.__acNoPaintTest === "function" && + window.__acNoPaintTest()?.ready === true) || window.acBOOT_START_TIME, { timeout: 20000 }, ); diff --git a/tests/browser/nopaint-journey.test.mjs b/tests/browser/nopaint-journey.test.mjs --- a/tests/browser/nopaint-journey.test.mjs +++ b/tests/browser/nopaint-journey.test.mjs @@ -22,7 +22,18 @@ } try { await scenario("No Paint 3.0 boots a reproducible first proposal", async (expect) => { - await ac.boot("nopaint?seed=nopaint-e2e-v1&test=1"); + await ac.boot("nopaint?seed=nopaint-perf-v1&test=1"); + // The AC front door intentionally waits for a first human gesture. A + // center-stage tap activates the piece without touching the decision bar. + if (!(await ac.nopaintState())?.ready) { + const viewport = ac.page.viewport(); + await ac.page.mouse.click(viewport.width / 2, viewport.height / 2); + await ac.page.waitForFunction( + () => window.__acNoPaintTest?.()?.ready === true, + { timeout: 20000 }, + ); + await ac.wait(500); + } const state = await ac.nopaintState(); await receipt("01-first-proposal"); if (!state) { @@ -37,13 +48,71 @@ expect(state !== null, "No Paint test contract is installed"); expect(state?.version === "3.0", `version is 3.0 (got ${state?.version})`); expect(state?.state === "proposing", `state is proposing (got ${state?.state})`); expect(state?.proposalNumber === 1, "first proposal is numbered 1"); - expect(state?.operation === "camera", `seed begins with Camera (got ${state?.operation})`); + expect(state?.operation === "banner", `seed begins with Banner, not Camera (got ${state?.operation})`); expect(state?.ready === true, "proposal buffer reports ready"); expect( [state?.controls?.no, state?.controls?.paint, state?.controls?.save] .every((box) => box && box.w > 0 && box.h > 0), "No, Paint, and Save expose visible control rectangles", ); + const controls = Object.values(state?.controls || {}); + const stageBottom = state?.layout?.paintingViewport?.y + state?.layout?.paintingViewport?.h; + expect( + controls.every((box) => box.y >= stageBottom), + "all controls share the below-painting control bar", + ); + expect(state?.controls?.no?.y === state?.controls?.paint?.y, "No and Paint align in one bottom row"); + expect( + state?.layout?.controlBar?.x === 0 && + state?.layout?.controlBar?.w >= + state?.layout?.paintingViewport?.x + state?.layout?.paintingViewport?.w, + "control bar spans the full width below the painting", + ); + }); + + await scenario("The first load locks painting resolution while its presentation responds", async (expect) => { + const initial = await ac.nopaintState(); + const lockedResolution = initial.layout.paintingResolution; + const acceptedFingerprint = initial.paintingFingerprint; + const viewports = [ + { width: 390, height: 844, label: "phone portrait" }, + { width: 844, height: 390, label: "short landscape" }, + { width: 1024, height: 768, label: "tablet landscape" }, + { width: 1200, height: 900, label: "desktop 4:3" }, + ]; + for (const viewport of viewports) { + await ac.page.setViewport({ width: viewport.width, height: viewport.height }); + await new Promise((resolve) => setTimeout(resolve, 350)); + const state = await ac.nopaintState(); + const { paintingViewport: stage, controlBar: bar } = state.layout; + const resolution = state.layout.paintingResolution; + const screenResolution = state.layout.screenResolution; + const { no, paint, save } = state.controls; + const canvasRect = await ac.page.evaluate(() => { + const canvas = document.querySelector("canvas"); + if (!canvas) return null; + const rect = canvas.getBoundingClientRect(); + return { x: rect.x, y: rect.y, w: rect.width, h: rect.height }; + }); + expect( + resolution.width === lockedResolution.width && resolution.height === lockedResolution.height, + `${viewport.label}: logical painting remains ${lockedResolution.width}×${lockedResolution.height}`, + ); + expect(state.paintingFingerprint === acceptedFingerprint, + `${viewport.label}: resize does not mutate accepted pixels`); + expect(stage.w > 0 && stage.h > 0 && stage.x >= 0 && stage.y >= 0, + `${viewport.label}: fitted painting viewport is visible`); + expect(stage.x + stage.w <= viewport.width && stage.y + stage.h <= bar.y, + `${viewport.label}: full painting fits above controls`); + expect(bar.x === 0 && bar.w === screenResolution.width, + `${viewport.label}: control bar spans the fixed AC surface`); + expect(canvasRect && canvasRect.w <= viewport.width && canvasRect.h <= viewport.height, + `${viewport.label}: AC fits the fixed surface within the browser`); + expect([no, paint, save].every((box) => box.y >= bar.y && box.y + box.h <= bar.y + bar.h), + `${viewport.label}: controls stay inside bar`); + expect(no.x + no.w <= paint.x && paint.x + paint.w <= save.x, + `${viewport.label}: No, Paint, and Save do not overlap`); + } }); await scenario("No rejects without changing the accepted painting", async (expect) => {