From a7e5d7ee9e16f9f257fc15e60a63790c09461b0e Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 27 Jul 2026 18:26:33 -0700 Subject: [PATCH] Make painting tap reveal canonical Done action --- .../aesthetic.computer/disks/nopaint.mjs | 68 +++++++++++++------ tests/browser/ac-harness.mjs | 7 +- tests/browser/nopaint-journey.test.mjs | 60 +++++++++++----- 3 files changed, 98 insertions(+), 37 deletions(-) diff --git a/system/public/aesthetic.computer/disks/nopaint.mjs b/system/public/aesthetic.computer/disks/nopaint.mjs index 9d3003ffc..2bf7d2fd3 100644 --- a/system/public/aesthetic.computer/disks/nopaint.mjs +++ b/system/public/aesthetic.computer/disks/nopaint.mjs @@ -25,6 +25,8 @@ let testApi = null; let testChannel = null; let archiveOrigin = null; let paintingResolution = null; +let finishMode = false; +let doneCount = 0; function initialNavigationURL() { if (typeof window === "undefined") return null; @@ -37,7 +39,7 @@ function initialNavigationURL() { } let noButton; let paintButton; -let saveButton; +let doneButton; function transition(next) { if (!NOPAINT_LOOP_STATES.includes(next)) { @@ -73,8 +75,7 @@ function positionButtons(screen) { 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 available = screen.width - margin * 2 - gap; const noWidth = Math.floor(available * 0.38); const paintWidth = available - noWidth; const buttonY = bar.y + statusHeight; @@ -91,11 +92,14 @@ function positionButtons(screen) { x: margin + noWidth + gap, y: buttonY, w: paintWidth, h: decisionHeight, }); - saveButton.reposition({ - left: margin + noWidth + gap + paintWidth + gap, - top: buttonY + Math.floor((decisionHeight - saveButton.height) / 2), - screen, - }); + const done = doneButton?.btn || doneButton; + if (done) { + done.box ||= {}; + Object.assign(done.box, { + x: margin, y: buttonY, + w: available, h: decisionHeight, + }); + } } function paintDecisionButton($, button, label, light = false) { @@ -281,13 +285,13 @@ function testSnapshot() { operation: proposal?.kind || null, decisions: decisions.map((decision) => ({ ...decision })), saveCount, + finishMode, + doneCount, lastDownload, ready: Boolean(proposal && testApi?.system?.nopaint?.buffer), - controls: { - no: controlBox(noButton), - paint: controlBox(paintButton), - save: controlBox(saveButton), - }, + controls: finishMode + ? { done: controlBox(doneButton) } + : { no: controlBox(noButton), paint: controlBox(paintButton) }, layout: layout ? { paintingViewport: { ...layout.stage }, paintingResolution: { ...paintingResolution }, @@ -355,6 +359,8 @@ function boot({ colon, debug, hud, net, num, params, screen, store, system, ui, cameraFeed = null; decisions = []; saveCount = 0; + finishMode = false; + doneCount = 0; lastDownload = null; archiveOrigin = archiveId ? { type: "nopaint-archive", @@ -381,7 +387,7 @@ function boot({ colon, debug, hud, net, num, params, screen, store, system, ui, noButton = new ui.TextButton("No"); paintButton = new ui.TextButton("Paint"); - saveButton = new ui.TextButton("Save"); + doneButton = new ui.TextButton("Done"); positionButtons(screen); hud.label("No Paint — No [N] / Paint [Enter]"); @@ -564,9 +570,12 @@ function paint($) { ); positionButtons($.screen); - 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]]); + if (finishMode) { + doneButton.paint($, [[245, 245, 245], [210, 210, 210], [15, 15, 15]]); + } else { + noButton.paint($, [[10, 10, 10], [80, 80, 80], [255, 255, 255]]); + paintButton.paint($, [[245, 245, 245], [210, 210, 210], [15, 15, 15]]); + } return loopState === "proposing"; } @@ -578,10 +587,30 @@ function isAny(e, names) { function act($) { const { event: e } = $; + if (finishMode) { + doneButton.btn.act(e, () => { + doneCount += 1; + publishTestState(); + if (!initialNavigationURL()?.searchParams.has("test")) $.jump("done"); + }); + return; + } + noButton.btn.act(e, () => discardProposal($)); paintButton.btn.act(e, () => commitProposal($)); - saveButton.btn.act(e, () => savePainting($)); - + const stage = interfaceLayout($.screen).stage; + if ( + e.is("lift:1") && + e.x >= stage.x && e.x <= stage.x + stage.w && + e.y >= stage.y && e.y <= stage.y + stage.h + ) { + finishMode = true; + stateBeforePause = loopState; + transition("paused"); + $.needsPaint(); + publishTestState(); + return; + } if (isAny(e, [ "keyboard:down:n", "keyboard:down:escape", @@ -599,7 +628,6 @@ function act($) { ])) commitProposal($); if (e.is("keyboard:down:space")) togglePaused($); - if (e.is("keyboard:down:s")) savePainting($); } // The conductor makes decisions explicitly; generic pointer-lift baking must diff --git a/tests/browser/ac-harness.mjs b/tests/browser/ac-harness.mjs index 3a9344f76..05d8ef4ec 100644 --- a/tests/browser/ac-harness.mjs +++ b/tests/browser/ac-harness.mjs @@ -29,7 +29,12 @@ export const CONFIG = { slowMo: parseInt(process.env.AC_SLOWMO || "0", 10) || 0, shotDir: process.env.AC_SHOT_DIR || join(HERE, "__screens__"), viewportWidth: parseInt(process.env.AC_VIEWPORT_WIDTH || "1200", 10), - viewportHeight: parseInt(process.env.AC_VIEWPORT_HEIGHT || "900", 10), + // A headed content viewport must leave room for macOS + Chrome chrome. + // Blueberry's 881 px display cannot physically show a 900 px page. + viewportHeight: parseInt( + process.env.AC_VIEWPORT_HEIGHT || (process.env.AC_HEADED === "1" ? "720" : "900"), + 10, + ), }; function percentile(sorted, fraction) { diff --git a/tests/browser/nopaint-journey.test.mjs b/tests/browser/nopaint-journey.test.mjs index 865bc0ee5..cc92ef6e2 100644 --- a/tests/browser/nopaint-journey.test.mjs +++ b/tests/browser/nopaint-journey.test.mjs @@ -51,9 +51,9 @@ try { 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] + [state?.controls?.no, state?.controls?.paint] .every((box) => box && box.w > 0 && box.h > 0), - "No, Paint, and Save expose visible control rectangles", + "No and Paint expose visible control rectangles", ); const controls = Object.values(state?.controls || {}); const stageBottom = state?.layout?.paintingViewport?.y + state?.layout?.paintingViewport?.h; @@ -87,7 +87,7 @@ try { const { paintingViewport: stage, controlBar: bar } = state.layout; const resolution = state.layout.paintingResolution; const screenResolution = state.layout.screenResolution; - const { no, paint, save } = state.controls; + const { no, paint } = state.controls; const canvasRect = await ac.page.evaluate(() => { const canvas = document.querySelector("canvas"); if (!canvas) return null; @@ -108,11 +108,18 @@ try { `${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), + expect([no, paint].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`); + expect(no.x + no.w <= paint.x && paint.x + paint.w <= bar.x + bar.w, + `${viewport.label}: No and Paint do not overlap`); } + // Synthetic viewport probes may exceed the filming display. Return to the + // harness's screen-safe size before taking any more native Frame receipts. + await ac.page.setViewport({ + width: CONFIG.viewportWidth, + height: CONFIG.viewportHeight, + }); + await ac.wait(350); }); await scenario("No rejects without changing the accepted painting", async (expect) => { @@ -195,17 +202,38 @@ try { } }); - await scenario("Save uses the AC painting export path", async (expect) => { - await ac.press("KeyS"); - const downloads = await ac.waitForDownload(); - const state = await ac.nopaintState(); - await receipt("05-saved"); - expect(state?.saveCount === 1, `one save was requested (got ${state?.saveCount})`); - expect(state?.lastDownload?.endsWith(".png"), `download is named as PNG (${state?.lastDownload})`); - expect( - downloads.some((name) => name.endsWith(".png")) || state?.lastDownload?.endsWith(".png"), - "the browser received a PNG download or native-share export request", + await scenario("The painting is the gateway to the canonical Done command", async (expect) => { + const before = await ac.nopaintState(); + const rect = await ac.page.evaluate(() => Array.from(document.querySelectorAll("canvas")) + .map((canvas) => { + const box = canvas.getBoundingClientRect(); + return { x: box.x, y: box.y, width: box.width, height: box.height }; + }) + .filter((box) => box.width > 0 && box.height > 0) + .sort((a, b) => b.width * b.height - a.width * a.height)[0] || null); + expect(rect !== null, "a visible AC canvas receives the painting tap"); + const screen = before.layout.screenResolution; + const stage = before.layout.paintingViewport; + const scaleX = rect.width / screen.width; + const scaleY = rect.height / screen.height; + await ac.page.mouse.click( + rect.x + (stage.x + stage.w / 2) * scaleX, + rect.y + (stage.y + stage.h / 2) * scaleY, ); + await ac.wait(250); + const finishing = await ac.nopaintState(); + await receipt("05-done-ready"); + expect(finishing?.finishMode === true, "tapping the painting enters completion mode"); + expect(Object.keys(finishing?.controls || {}).join(",") === "done", "Done is the only visible action"); + + const done = finishing.controls.done; + await ac.page.mouse.click( + rect.x + (done.x + done.w / 2) * scaleX, + rect.y + (done.y + done.h / 2) * scaleY, + ); + await ac.wait(250); + const completed = await ac.nopaintState(); + expect(completed?.doneCount === 1, "Done invokes the canonical prompt completion command"); }); await scenario("An archive record can become the starting painting", async (expect) => { -- 2.51.2