From beb8ba4a6a906eed9556fb88395c1b22d393f400 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 27 Jul 2026 21:44:05 -0700 Subject: [PATCH] Keep No Paint completion in place --- lith/Caddyfile | 6 ++ lith/server.mjs | 5 ++ .../aesthetic.computer/disks/nopaint.mjs | 90 +++++++++++++++---- tests/browser/nopaint-journey.test.mjs | 9 +- 4 files changed, 91 insertions(+), 19 deletions(-) diff --git a/lith/Caddyfile b/lith/Caddyfile index 300239eec5..fac7cf1eeb 100644 --- a/lith/Caddyfile +++ b/lith/Caddyfile @@ -45,6 +45,12 @@ header @cacheable Cache-Control "public, max-age=3600, stale-while-revalidate=86400" header @cacheable ?Access-Control-Allow-Origin * # set-if-absent — see @code note + # These are database-backed resolvers, not static object URLs. In + # particular, do not cache the brief 404 window while a new #code and its + # object-store upload converge. The redirect target remains cacheable. + @mediaLookup path /media/paintings/* /media/tapes/* + header @mediaLookup Cache-Control "no-cache, no-store, must-revalidate" + # Service workers must always revalidate — cached sw.js delays rollout of # bumped CACHE_NAME, pinning clients to stale module caches. @serviceworker path /sw.js /firebase-messaging-sw.js diff --git a/lith/server.mjs b/lith/server.mjs index 609257b54e..96a54d7d47 100644 --- a/lith/server.mjs +++ b/lith/server.mjs @@ -1132,6 +1132,7 @@ app.all("/media/*rest", async (req, res) => { return res.redirect(302, `https://${bucket}.sfo3.digitaloceanspaces.com/${key}`); } } catch {} + res.set("Cache-Control", "no-store, max-age=0"); return res.status(404).send("Tape not found"); } @@ -1158,6 +1159,10 @@ app.all("/media/*rest", async (req, res) => { return res.redirect(302, `https://${bucket}.sfo3.digitaloceanspaces.com/${key}`); } } catch {} + // A freshly uploaded object and its Mongo record become visible on + // separate network hops. Never let Cloudflare turn that brief race into a + // cached one-hour failure for a valid new #code. + res.set("Cache-Control", "no-store, max-age=0"); return res.status(404).send("Painting not found"); } diff --git a/system/public/aesthetic.computer/disks/nopaint.mjs b/system/public/aesthetic.computer/disks/nopaint.mjs index 3a3d6b14a2..a1a0934c9c 100644 --- a/system/public/aesthetic.computer/disks/nopaint.mjs +++ b/system/public/aesthetic.computer/disks/nopaint.mjs @@ -30,9 +30,14 @@ let lastDownload = null; let testApi = null; let testChannel = null; let archiveOrigin = null; +let testMode = false; let paintingResolution = null; let finishMode = false; let doneCount = 0; +let completionBusy = false; +let completionProgress = 0; +let completionCode = null; +let completionError = null; let paintingDragPaused = false; let paintingPressed = false; let hoveredDecision = null; @@ -529,6 +534,13 @@ function testSnapshot() { saveCount, finishMode, doneCount, + completion: { + busy: completionBusy, + progress: completionProgress, + code: completionCode, + error: completionError, + stayedInNoPaint: true, + }, audio: { ready: [...cueSamples.keys()], brushReady: [...brushCueSamples.keys()], @@ -576,6 +588,7 @@ function installTestHook(debug) { const explicitlyTesting = initialNavigationURL()?.searchParams.has("test"); const windowDebug = typeof window !== "undefined" && window.acDEBUG; if (!debug && !explicitlyTesting && !windowDebug) return; + testMode = true; if (typeof BroadcastChannel !== "undefined") { testChannel?.close(); @@ -624,6 +637,10 @@ function boot({ colon, debug, hud, net, num, params, screen, store, system, ui, saveCount = 0; finishMode = false; doneCount = 0; + completionBusy = false; + completionProgress = 0; + completionCode = null; + completionError = null; paintingDragPaused = false; paintingPressed = false; hoveredDecision = null; @@ -658,6 +675,7 @@ function boot({ colon, debug, hud, net, num, params, screen, store, system, ui, store["painting:resolution-lock"] = true; store.persist("painting:resolution-lock", "local:db"); testApi = { ...api, hud, net, screen, store, system }; + testMode = false; api.cursor?.("none"); stateBeforePause = "proposing"; @@ -879,7 +897,11 @@ function paint($) { const definition = proposalDefinition(proposal.kind); $.ink(18).box(bar, "fill"); $.ink(255, 180).write( - definition?.label || proposal.kind, + completionBusy + ? `Uploading ${Math.round(completionProgress * 100)}%` + : completionCode + ? `#${completionCode}` + : completionError || definition?.label || proposal.kind, { x: stage.x + 8, y: stage.y + 8 }, ); @@ -899,6 +921,53 @@ function isAny(e, names) { return names.some((name) => e.is(name)); } +async function completePainting($) { + if (completionBusy || completionCode) return; + playCue($, "done"); + doneCount += 1; + completionBusy = true; + completionProgress = 0; + completionError = null; + $.needsPaint(); + publishTestState(); + + if (testMode) { + completionCode = "test"; + completionProgress = 1; + completionBusy = false; + $.needsPaint(); + publishTestState(); + return; + } + + try { + const painting = { + width: $.system.painting.width, + height: $.system.painting.height, + pixels: $.system.painting.pixels, + ...($.store["painting:tags"] ? { tags: $.store["painting:tags"] } : {}), + }; + const filename = `painting-${$.num.timestamp()}.png`; + const data = await $.upload(filename, painting, (progress) => { + completionProgress = Math.max(0, Math.min(1, Number(progress) || 0)); + $.needsPaint(); + publishTestState(); + }); + if (!data?.code) throw new Error("Painting upload completed without a code"); + completionCode = data.code; + completionProgress = 1; + $.system.painting.code = data.code; + $.store["painting:code"] = data.code; + $.store.persist?.("painting:code", "local:db"); + } catch (error) { + completionError = error?.message || "Upload failed"; + } finally { + completionBusy = false; + $.needsPaint(); + publishTestState(); + } +} + function xboxButtonPush(e) { const match = e.name?.match(/^gamepad:\d+:button:(\d+):push$/); return match ? Number(match[1]) : null; @@ -974,32 +1043,19 @@ function act($) { } if (finishMode) { + if (completionBusy) return; backButton.btn.act(e, { down: () => playCue($, "button-down"), push: leaveFinishMode, }); doneButton.btn.act(e, { down: () => playCue($, "done-down"), - push: () => { - playCue($, "done"); - doneCount += 1; - publishTestState(); - // `done` is a Prompt command, not a piece. Autorun the canonical - // completion pipeline so it uploads and yields `painting#CODE`. - if (!initialNavigationURL()?.searchParams.has("test")) { - $.jump("prompt~done~!autorun"); - } - }, + push: () => completePainting($), }); // Xbox parity: A/Right confirms Done; B/X/Left goes Back; Y/View toggles // the same completion surface as tapping the painting. if (xboxAction === "done") { - playCue($, "done"); - doneCount += 1; - publishTestState(); - if (!initialNavigationURL()?.searchParams.has("test")) { - $.jump("prompt~done~!autorun"); - } + completePainting($); return; } if (xboxAction === "back") { diff --git a/tests/browser/nopaint-journey.test.mjs b/tests/browser/nopaint-journey.test.mjs index cc2077447e..c38aac9e7a 100644 --- a/tests/browser/nopaint-journey.test.mjs +++ b/tests/browser/nopaint-journey.test.mjs @@ -382,9 +382,14 @@ try { rect.x + (done.x + done.w / 2) * scaleX, rect.y + (done.y + done.h / 2) * scaleY, ); - await ac.wait(250); + await ac.page.waitForFunction( + () => window.__acNoPaintTest?.()?.completion?.code === "test", + { timeout: 3000 }, + ); const completed = await ac.nopaintState(); - expect(completed?.doneCount === 1, "Done invokes the canonical prompt completion command"); + expect(completed?.doneCount === 1, "Done invokes one in-place completion transaction"); + expect(completed?.completion?.code === "test" && completed?.completion?.stayedInNoPaint === true, + "Done yields a #code without leaving the No Paint shim"); }); await scenario("An archive record can become the starting painting", async (expect) => { -- 2.51.2