From 00a4fd5783d993a6cb142882c7715508955fd0e1 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 13 Jul 2026 22:33:59 -0700 Subject: [PATCH] =?UTF-8?q?cancelok:=20the=20pad=20gets=20its=20own=20surf?= =?UTF-8?q?ace=20=E2=80=94=20like=20a=20nopaint=20brush=20buffer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bar was painted OVER the candidate, so the bottom 46px of every pad was covered, and worse: the pad normalized taps against the full screen height, so part of its XY range lived under a button and could never be reached. You were judging a clipped instrument. Now there are two surfaces. The pad paints into its own buffer and is told that buffer IS its screen — so its world is genuinely that size, not merely cropped. Events are clamped to the stage before they're handed over. Then we composite. The subtle part, and it cost a broken render to find: the pad's `screen` must BE the stage buffer, not a copy of the real screen with a shorter height. Per-pixel pads (vex, molten) write straight into `screen.pixels` and never touch page() — handing them a fake screen still pointing at the real pixel array made them scribble into the wrong buffer at the wrong stride. `vex` came out as grey scanlines instead of plasma. It renders identically to standalone now. The cron runs --no-ship: the machine can make things unattended, but it can't decide anyone else has to look at them. Co-Authored-By: Claude Opus 4.8 (1M context) --- cancelok/cron.fish | 13 +++-- .../aesthetic.computer/disks/cancelok.mjs | 54 ++++++++++++++++--- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/cancelok/cron.fish b/cancelok/cron.fish index 9ec324e96..60be5e695 100644 --- a/cancelok/cron.fish +++ b/cancelok/cron.fish @@ -1,8 +1,15 @@ #!/usr/bin/env fish # cron.fish — install (or remove) the hourly cancelok loop on this Mac. # -# One pad an hour, generated, certified, and shipped while you're not looking. -# By morning there's a queue, and emptying it is a minute of thumbs. +# One pad an hour, generated and certified while you're not looking. By morning +# there's a queue, and emptying it is a minute of thumbs. +# +# The cron does NOT publish. It runs --no-ship, so a pad it makes waits in the +# local queue until you've seen it and said so. Nothing reaches other people on +# a timer — the machine can make things unattended, but it can't decide that +# anyone else should have to look at them. To publish one you kept: +# +# node cancelok/ship.mjs "" # → ^pads, commit, push, deploy # # fish cancelok/cron.fish install # fish cancelok/cron.fish status @@ -33,7 +40,7 @@ switch "$CMD" /bin/sh -lc - cd $REPO; curl -sf -o /dev/null http://localhost:8899/ || (node marketing/av-reels/bin/serve-local.mjs & sleep 3); node cancelok/loop.mjs --tries 2 + cd $REPO; curl -sf -o /dev/null http://localhost:8899/ || (node marketing/av-reels/bin/serve-local.mjs & sleep 3); node cancelok/loop.mjs --tries 2 --no-ship EnvironmentVariables PATH$REALPATH diff --git a/system/public/aesthetic.computer/disks/cancelok.mjs b/system/public/aesthetic.computer/disks/cancelok.mjs index 7af251384..c8a17a145 100644 --- a/system/public/aesthetic.computer/disks/cancelok.mjs +++ b/system/public/aesthetic.computer/disks/cancelok.mjs @@ -38,6 +38,7 @@ let error = ""; let deck = []; // [{ code, name }] — the candidates, in order let at = 0; // which one we're on let host = null; // the live candidate module +let stage = null; // the candidate's own surface — everything above the bar let hostFailed = false; // it threw on load or on frame — a machine-gate fail let verdicts = []; // [{ code, verdict, dwellMs, taps, failed, at }] @@ -115,7 +116,8 @@ async function mount(api) { // rewrites static imports. Fetched as a real URL, the pad's own // `../lib/pads.mjs` resolves on its own. const mod = await import(`${ORIGIN}/aesthetic.computer/disks/${code}.mjs`); - mod.boot?.(api); + stage = null; // a new candidate gets a fresh surface + mod.boot?.(staged(api)); // it must be born knowing its real size host = mod; } catch (e) { hostFailed = true; @@ -194,10 +196,32 @@ function boot(api) { }); } +// The pad's stage: everything above the bar. This is the ONLY size the candidate +// is ever told about. +const stageH = (h) => Math.max(1, h - BAR_H); + +// The candidate's surface, the way a nopaint brush gets a buffer instead of the +// painting itself. Two surfaces, never one. +function ensureStage(api) { + const w = api.screen.width; + const h = stageH(api.screen.height); + if (!stage || stage.width !== w || stage.height !== h) + stage = api.painting(w, h, (p) => p.wipe(0, 0, 0)); + return stage; +} + +// The pad's `screen` IS the stage buffer — not a copy of the real screen with a +// shorter height. That distinction is the whole bug: per-pixel pads (vex, molten) +// write straight into `screen.pixels` and never touch page(), so handing them a +// fake screen that still pointed at the REAL pixel array made them scribble into +// the wrong buffer at the wrong stride. Give them the surface itself and both +// paths — immediate-mode draws and raw pixel pokes — land in the same place. +const staged = (api) => ({ ...api, screen: ensureStage(api) }); + function sim(api) { if (state === "judging" && host && !hostFailed) { try { - host.sim?.(api); + host.sim?.(staged(api)); } catch (e) { hostFailed = true; // it ran, then died. still a verdict. error = String(e?.message || e); @@ -224,15 +248,21 @@ function paint(api) { if (state === "done") return summary(api); - // The candidate paints itself, full-bleed. If it can't, we say so plainly — - // a broken candidate still deserves its three seconds and its verdict. + // The candidate paints into ITS OWN surface, then we composite. The pad can't + // scribble on the bar, and the bar isn't covering anything the pad meant you + // to see. if (host && !hostFailed) { + const surface = ensureStage(api); try { - host.paint?.(api); + api.page(surface); + host.paint?.(staged(api)); } catch (e) { hostFailed = true; error = String(e?.message || e); + } finally { + api.page(screen); // whatever happened in there, we get the screen back. } + if (!hostFailed) api.paste(surface, 0, 0); } if (hostFailed || !host) { wipe(20, 6, 10); @@ -350,11 +380,21 @@ function act(api) { if (e.is("keyboard:down:arrowright") || e.is("keyboard:down:enter")) return advance(api, "ok"); - // Everything else is the candidate's — it's an instrument, so play it. + // Everything else is the candidate's — it's an instrument, so play it. The + // event is clamped to the stage before it's handed over, so a drag that wanders + // down over the bar still reads as the bottom edge of the pad's world and never + // as a coordinate it has no pixels for. if (!fromBar && host && !hostFailed) { if (e.is("touch")) taps += 1; + const ph = stageH(h); + const onStage = { + ...staged(api), + event: Object.assign(Object.create(Object.getPrototypeOf(e)), e, { + y: Math.max(0, Math.min(ph - 1, e.y)), + }), + }; try { - host.act?.(api); + host.act?.(onStage); } catch (err) { hostFailed = true; error = String(err?.message || err); -- 2.51.2