From 013e38c9570b70bd1c6e325eca7fe407173d073d Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Tue, 14 Jul 2026 05:48:02 +0000 Subject: [PATCH] cancelok: no buttons — swipe, and let attention be the vote Your session recorded NOTHING and I didn't notice, which is the worst bug in the thing so far. net.userRequest awaits authorize(), and authorize() never settles when you're logged out — so the POST hung forever, the .catch never fired, and a whole sitting of verdicts vanished. Silently, because I wrote .catch(() => {}). Now: always a plain fetch, token attached only if it arrives within 1.2s, and any verdict the server refuses is COUNTED AND PAINTED ON SCREEN. Never again. The buttons are gone. The pad is the whole screen. Swipe up from the bottom edge for the next one, down from the top edge to go back — thin edge bands, so the instrument keeps its whole face. The verdict is now a side effect of using it: linger or play and it's an ok, flick past in two seconds and it's a cancel. The two signals never left; you just cast them with attention instead of a thumb, and attention was the scarce thing all along. The name shows for a moment and fades, because a label that never leaves stops being read. Sessions are tracked (one id per sitting) and so is REAL fps on the REAL device. That last one matters: the headless gate can only prove a pad runs in a datacenter. A pad that stutters on the machine it's being judged on has already been cancelled by physics, and no amount of taste can save it — GET /api/cancelok/sessions to see where a run went slow. Coming back to a pad is now the strongest keep signal there is, and a revisit can't be undone by the flick that preceded it. Co-Authored-By: Claude Opus 4.8 (1M context) --- cancelok/sink.mjs | 4 ++++ system/netlify/functions/cancelok.mjs | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ system/public/aesthetic.computer/disks/cancelok.mjs | 469 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ 3 file(s) changed, 271 insertion(s)(+), 282 deletion(s)(-) diff --git a/cancelok/sink.mjs b/cancelok/sink.mjs --- a/cancelok/sink.mjs +++ b/cancelok/sink.mjs @@ -58,6 +58,10 @@ code: v.code, verdict: v.verdict, dwellMs: v.dwellMs ?? 0, taps: v.taps ?? 0, + fps: v.fps ?? 0, // real fps on a real device — the gate can't see this + fpsMin: v.fpsMin ?? 0, + revisit: v.revisit ?? 0, + session: v.session ?? null, failed: !!v.failed, at: new Date().toISOString(), }; diff --git a/system/netlify/functions/cancelok.mjs b/system/netlify/functions/cancelok.mjs --- a/system/netlify/functions/cancelok.mjs +++ b/system/netlify/functions/cancelok.mjs @@ -28,6 +28,49 @@ // What YOU have already judged. The piece uses this to deal you a deck of // things you haven't seen — being asked the same question twice is the // fastest way to make someone stop answering honestly. + // Sessions, newest first. A session is a sitting: what you were shown, in + // what order, how long you stayed, what your hands did, and what the frame + // rate was while you decided. The ok/cancel column alone can't tell you that + // someone bailed on pad 12 because the whole run had gone slow. + if (who === "sessions") { + const rows = await verdicts.find({}).sort({ when: -1 }).limit(400).toArray(); + const by = new Map(); + for (const r of rows) { + const key = r.session || "—"; + if (!by.has(key)) + by.set(key, { + session: key, + handle: r.handle || null, + when: r.when, + pads: [], + ok: 0, + cancel: 0, + taps: 0, + }); + const s = by.get(key); + s.pads.push({ + code: r.code, + verdict: r.verdict, + dwellMs: r.dwellMs, + taps: r.taps, + fps: r.fps, + fpsMin: r.fpsMin, + }); + s[r.verdict === "ok" ? "ok" : "cancel"] += 1; + s.taps += r.taps || 0; + if (r.when < s.when) s.when = r.when; + } + await database.disconnect(); + return respond(200, { + sessions: [...by.values()].map((s) => ({ + ...s, + pads: s.pads.reverse(), // back into the order you actually saw them + judged: s.pads.length, + slowest: Math.min(...s.pads.map((p) => p.fpsMin || 999)), + })), + }); + } + if (who === "mine") { let mine = []; try { @@ -57,6 +100,9 @@ } // The tallies. `taps` sums because being played is the signal nobody was // asked for — a pad with 3 oks and 40 taps beats one with 5 oks and none. + // dwell and fps come along because they're the two things that explain WHY: + // a pad nobody stays with, and a pad nobody CAN stay with, look identical in + // the ok/cancel column and are completely different problems. const tally = await verdicts .aggregate([ { @@ -65,7 +111,12 @@ _id: "$code", ok: { $sum: { $cond: [{ $eq: ["$verdict", "ok"] }, 1, 0] } }, cancel: { $sum: { $cond: [{ $eq: ["$verdict", "cancel"] }, 1, 0] } }, taps: { $sum: "$taps" }, + dwell: { $avg: "$dwellMs" }, + fps: { $avg: "$fps" }, + worstFps: { $min: "$fpsMin" }, + revisits: { $sum: "$revisit" }, judges: { $addToSet: "$handle" }, + sessions: { $addToSet: "$session" }, }, }, { $sort: { ok: -1 } }, @@ -79,7 +130,12 @@ code: t._id, ok: t.ok, cancel: t.cancel, taps: t.taps, + dwellMs: Math.round(t.dwell || 0), + fps: Math.round(t.fps || 0), + worstFps: Math.round(t.worstFps || 0), + revisits: t.revisits || 0, judges: t.judges.filter(Boolean).length, + sessions: t.sessions.filter(Boolean).length, })), }); } @@ -121,21 +177,33 @@ code, verdict, dwellMs: Number(body.dwellMs) || 0, taps: Number(body.taps) || 0, + // Real frame rate, on the machine a real person was holding. The headless + // gate can only prove a pad runs in a datacenter; this is whether it ran for + // YOU. A pad that stutters on the hardware it's judged on has already been + // cancelled by physics, and no amount of taste can save it. + fps: Number(body.fps) || 0, + fpsMin: Number(body.fpsMin) || 0, + w: Number(body.w) || 0, + h: Number(body.h) || 0, + revisit: Number(body.revisit) || 0, // coming BACK is the strongest keep there is + session: String(body.session || "").slice(0, 40), failed: Boolean(body.failed), when: new Date(), user: attributed ? user.sub : null, handle: attributed ? handle : null, }; + await verdicts.createIndex({ session: 1 }); + if (attributed) { // One verdict per person per pad. Changing your mind should REPLACE, not // stack — otherwise whoever judges most often wins, and that's turnout, - // not taste. - await verdicts.updateOne( - { user: user.sub, code }, - { $set: row }, - { upsert: true }, - ); + // not taste. But keep the BEST of the two: if you came back and stayed, that + // supersedes the time you flicked past it, not the other way around. + const prior = await verdicts.findOne({ user: user.sub, code }); + const better = !prior || row.verdict === "ok" || prior.verdict !== "ok"; + if (better) + await verdicts.updateOne({ user: user.sub, code }, { $set: row }, { upsert: true }); } else { await verdicts.insertOne(row); } diff --git a/system/public/aesthetic.computer/disks/cancelok.mjs b/system/public/aesthetic.computer/disks/cancelok.mjs --- a/system/public/aesthetic.computer/disks/cancelok.mjs +++ b/system/public/aesthetic.computer/disks/cancelok.mjs @@ -1,123 +1,150 @@ // cancelok, 26.07.14 -// The judging surface. A candidate runs for real — full-bleed, playable, not a -// screenshot — and you answer it with one of two buttons: `not` or `ok`. That's -// the whole instrument. Everything downstream (what gets kept, what the next -// generation is told to make) is built out of those two signals plus what your -// hands did while you watched. +// A machine makes instruments. You decide which ones live. There is no button +// for it — you decide by staying. // -// The name is the two buttons, the way nopaint's was: "Press Paint if you like -// what you see or No if you don't." Same gesture, one medium over — the judgment -// happens AFTER the thing exists, and it's one bit wide. +// The whole screen is the pad, full-bleed and playable. Swipe up from the bottom +// edge for the next one; swipe down from the top edge to go back. That's the +// entire interface, and the verdict is a side effect of using it: linger and play +// and it's an `ok`, flick past it in two seconds and it's a `cancel`. The two +// signals never went away — you just cast them with your attention instead of +// your thumb, and attention is the thing that was actually scarce. // -// A candidate is HOSTED, not linked. We import the pad module and drive its -// lifecycle ourselves (boot → sim/paint/act), then draw the verdict bar on top. -// That works because lib/pads.mjs is a session singleton whose config is -// re-asserted by each pad's own boot — so hosting a pad is just calling it. -// The upshot: you can still PLAY the pad with the same taps you'd use anywhere -// else, and the verdict bar is the only screen real estate we take from it. +// Descended from nopaint: "Press Paint if you like what you see or No if you +// don't." Make it first, judge it after. Except now judging isn't a press either. +// +// The candidate is HOSTED, not linked: we import a pad's module and drive its +// lifecycle ourselves. lib/pads.mjs is a session singleton whose config each pad +// re-asserts in its own boot, so hosting one is just calling it. Only the thin +// edge bands are ours; every other pixel belongs to the instrument. + +const EDGE = 40; // the swipe bands, top and bottom — a home-indicator's worth +const SWIPE = 30; // travel before a drag becomes a page turn +const LINGER = 6000; // stay this long and you meant it +const NAME_MS = 2600; // the name fades — it's a label, not furniture -const BAR_H = 46; // the verdict bar — the only pixels the pad doesn't get -const CANCEL = [255, 70, 80]; const OK = [90, 255, 150]; +const NO = [255, 70, 80]; -// The disk worker's real origin (port included — dev lives on one). const ORIGIN = typeof location !== "undefined" && location.origin && location.origin !== "null" ? location.origin : "https://aesthetic.computer"; +const LOCAL = /localhost|127\.0\.0\.1/.test(ORIGIN); +const API = LOCAL ? "http://localhost:8901/verdict" : `${ORIGIN}/api/cancelok`; -// Where verdicts go and where the queue comes from. On a laptop that's the -// loop's own sink (a file); in production it's the api, and the verdict is -// signed so it carries your handle. -const LOCAL = /localhost|127\.0\.0\.1/.test(ORIGIN); -const SINK = LOCAL ? "http://localhost:8901" : `${ORIGIN}/api/cancelok`; +// One id for the whole sitting, so a session reads as a session: what you were +// shown, in what order, how long you stayed, what you did with your hands. +const SESSION = `${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`; -let state = "loading"; // loading | judging | done | error +let state = "loading"; // loading | judging | error 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 deck = []; +let at = 0; +let host = null; +let hostFailed = false; -let verdicts = []; // [{ code, verdict, dwellMs, taps, failed, at }] -let shownAt = 0; // when this candidate came up (for dwell) -let taps = 0; // how many times you played it — the usage signal -let judge = null; // the handle whose taste this is (null = anonymous) -let replaying = false; // you've judged them all — this is a second pass -let armed = null; // "cancel" | "ok" — a press waiting on its lift -let fromBar = false; // did this gesture start in the bar? (route the whole drag) +let judge = null; // your handle, or null for anon +let shownAt = 0; // when this pad came up +let taps = 0; // times you played it +let seen = {}; // code → how many times you've come back to it +let sent = 0; // verdicts the server took +let lost = 0; // verdicts it didn't — shown, because silence is how I lost yours + +// Real fps, on a real device, measured while a real person watches. The headless +// gate can only certify that a pad runs on a Mac in a datacenter; THIS is whether +// it ran for you. A pad that stutters on the machine it's judged on has already +// been cancelled by the hardware. +let frames = 0; +let fpsAt = 0; +let fps = 0; +let fpsMin = 999; + +let drag = null; // { from, y0, moved } — a gesture that began in an edge band +let flash = 0; // the last verdict, briefly, as a colored edge +let flashOk = false; const now = () => Date.now(); const current = () => deck[at]; -// Deal a deck. The loop's queue comes first — those are the candidates nobody -// has seen yet, and they're the whole reason to be here. A bag is the fallback, -// so `cancelok` still works with no loop running (and `cancelok:^pads` judges -// the 47 that already shipped). async function load(api) { const asked = (api.params?.[0] || "").replace(/^\^/, "").toLowerCase(); - - if (!asked && LOCAL) { - const q = await fetch(`${SINK}/queue`) - .then((r) => (r.ok ? r.json() : null)) - .catch(() => null); // no sink running is a normal state, not an error - const items = (typeof q === "string" ? JSON.parse(q) : q)?.items || []; - if (items.length) { - deck = items.map((i) => ({ code: i.code, name: i.trait || i.name || i.code })); - return; - } - } - - const bagName = asked || "pads"; const res = await fetch("/aesthetic.computer/bags.json"); if (!res.ok) throw new Error("bags.json HTTP " + res.status); - const bag = (await res.json()).bags?.[bagName]; - if (!bag) throw new Error("no bag named ^" + bagName); + const bag = (await res.json()).bags?.[asked || "pads"]; + if (!bag) throw new Error("no bag named ^" + asked); deck = (bag.items || []) .filter((it) => it.type === "piece") .map((it) => ({ code: it.code, name: it.name || it.code })); - if (!deck.length) throw new Error("^" + bagName + " holds no pieces"); + if (!deck.length) throw new Error("that bag holds no pieces"); + deck.reverse(); // newest first — the fresh ones are the point +} - // Don't ask a question you already have the answer to. The deck is what YOU - // haven't judged — newest first, since the freshly-made ones are the point. - // Once you've judged everything, we deal the whole thing again rather than - // leaving you with an empty screen; a second pass is allowed to change its mind. - // Signed, because "what have I judged" is a question only you can ask about - // yourself. userRequest throws when logged out, and a logged-out visitor has - // judged nothing — so the throw IS the answer. - if (!LOCAL) { - const mine = await api.net - ?.userRequest?.("GET", "/api/cancelok/mine") - .catch(() => null); - const seen = new Set(mine?.judged || []); - if (seen.size) { - const fresh = deck.filter((d) => !seen.has(d.code)); - if (fresh.length) deck = fresh; - else replaying = true; // judged them all — deal again, you may differ. - } +// Send it. ALWAYS a plain fetch, with the token attached only if one shows up in +// time. `net.userRequest` looked right and was a trap: it awaits authorize(), +// which never settles when you're logged out — so the request hung forever, the +// catch never ran, and a whole session of verdicts vanished without a sound. +// Never again: a failure to send is counted and painted on screen. +async function send(api, row) { + const headers = { "content-type": "application/json" }; + try { + const token = await Promise.race([ + api.authorize?.() ?? Promise.resolve(null), + new Promise((r) => setTimeout(() => r(null), 1200)), // logged out = silence + ]); + if (token) headers.Authorization = `Bearer ${token}`; + } catch { + // no token. you still count, you're just anonymous. + } + try { + const res = await fetch(API, { method: "POST", headers, body: JSON.stringify(row) }); + if (!res.ok) throw new Error("HTTP " + res.status); + sent += 1; + } catch { + lost += 1; } - deck.reverse(); // the newest pads sit at the end of the bag — show them first. +} + +// You're leaving a pad. Whatever you did while you were here IS the verdict — +// nobody asked you a question, so nobody had to be honest. +function leaving(api) { + const c = current(); + if (!c || !shownAt) return; + const dwellMs = now() - shownAt; + const kept = taps > 0 || dwellMs >= LINGER; + flash = 1; + flashOk = kept; + send(api, { + session: SESSION, + code: c.code, + verdict: kept ? "ok" : "cancel", + dwellMs, + taps, + fps: Math.round(fps), + fpsMin: fpsMin === 999 ? 0 : Math.round(fpsMin), + revisit: seen[c.code] || 0, + failed: hostFailed, + w: api.screen.width, + h: api.screen.height, + }); } -// Bring a candidate up. A candidate that won't even load is not a crash — it's -// a verdict the machine already made for you, so we show it as failed and let -// you confirm with the same tap. async function mount(api) { host = null; hostFailed = false; taps = 0; shownAt = now(); + frames = 0; + fpsAt = 0; + fps = 0; + fpsMin = 999; const { code } = current(); + seen[code] = (seen[code] || 0) + 1; try { - // A fully-qualified URL, because AC runs a disk as a BLOB module: it has no - // base to resolve `./x.mjs` or even `/x.mjs` against, and updateCode only - // rewrites static imports. Fetched as a real URL, the pad's own - // `../lib/pads.mjs` resolves on its own. + // Origin-qualified: a disk runs as a blob module, so it has no base URL to + // resolve `./x.mjs` against, and updateCode only rewrites static imports. const mod = await import(`${ORIGIN}/aesthetic.computer/disks/${code}.mjs`); - stage = null; // a new candidate gets a fresh surface - mod.boot?.(staged(api)); // it must be born knowing its real size + mod.boot?.(api); host = mod; } catch (e) { hostFailed = true; @@ -125,65 +152,17 @@ error = String(e?.message || e); } } -function record(api, verdict) { - const c = current(); - const v = { - code: c.code, - verdict, - dwellMs: now() - shownAt, - taps, - failed: hostFailed, - at: now(), - }; - verdicts.push(v); - - // Fire and forget, always. A verdict is worth more than its confirmation, and - // nothing about judging should ever wait on a socket — the next pad is already - // playing. - if (LOCAL) { - fetch(`${SINK}/verdict`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(v), - }).catch(() => {}); - } else { - // Signed, so the verdict has a name on it. A logged-out visitor still counts - // — userRequest throws when there's no token, and an anonymous tap is real - // usage, so we fall back rather than drop it. - api.net - ?.userRequest?.("POST", "/api/cancelok", v) - .catch(() => - fetch("/api/cancelok", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(v), - }).catch(() => {}), - ); - } -} - -function advance(api, verdict) { - record(api, verdict); - at += 1; - if (at >= deck.length) { - state = "done"; - save(api); - return; - } +// Turn the page. Going BACK is not a neutral act — you returned to something, and +// coming back is the strongest keep signal in the whole system. +function turn(api, dir) { + leaving(api); + at = (at + dir + deck.length) % deck.length; mount(api); } -function save({ store }) { - store["cancelok:verdicts"] = verdicts; - store.persist?.("cancelok:verdicts", "local:db"); -} - function boot(api) { api.wipe(6, 6, 10); - api.hud?.label?.("cancelok"); - // Your handle goes on every verdict you cast, so it belongs on screen. An - // unhandled visitor still judges — their taps are real — they just do it - // anonymously, and they should know that before they start. + api.hud?.label?.(""); judge = api.handle?.() || null; load(api) .then(() => { @@ -196,37 +175,16 @@ error = String(e?.message || e); }); } -// 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?.(staged(api)); + host.sim?.(api); } catch (e) { - hostFailed = true; // it ran, then died. still a verdict. + hostFailed = true; error = String(e?.message || e); } } + if (flash > 0) flash -= 0.04; } function paint(api) { @@ -239,162 +197,115 @@ wipe(6, 6, 10); ink(150).write("dealing…", { center: "xy" }); return; } - if (state === "error") { wipe(6, 6, 10); - ink(...CANCEL).write(error, { center: "x", y: h / 2 - 4 }); + ink(...NO).write(error, { center: "x", y: h / 2 - 4 }); return; } - if (state === "done") return summary(api); + // Frame cadence — the honest one, on the machine a person is actually holding. + const t = performance?.now?.() ?? now(); + if (fpsAt) { + const dt = t - fpsAt; + if (dt > 0 && dt < 1000) { + const inst = 1000 / dt; + fps = fps ? fps + (inst - fps) * 0.1 : inst; + if (++frames > 30 && fps < fpsMin) fpsMin = fps; // ignore the boot stumble + } + } + fpsAt = t; - // 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. + // The pad, full-bleed. It gets every pixel — nothing of ours sits on top of it + // except a name that fades and a number nobody has to read. if (host && !hostFailed) { - const surface = ensureStage(api); try { - api.page(surface); - host.paint?.(staged(api)); + host.paint?.(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); - ink(...CANCEL).write("didn't run", { center: "x", y: h / 2 - 10 }); - ink(120).write(error.slice(0, 48), { center: "x", y: h / 2 + 2 }); + wipe(18, 6, 10); + ink(...NO).write("didn't run", { center: "x", y: h / 2 - 10 }); + ink(110).write(String(error).slice(0, 44), { center: "x", y: h / 2 + 2 }); } - bar(api, w, h); + chrome(api, w, h); } -// The verdict bar: two halves, the whole width. Cancel is left because that's -// where the thumb rests and the reflex is to keep going, not to keep the thing. -function bar({ ink, write, box }, w, h) { - const y = h - BAR_H; - const half = w / 2; +// Everything of ours. It should feel like almost nothing. +function chrome({ ink, write, box, screen }, w, h) { const c = current(); - - ink(0, 0, 0, 170).box(0, y, w, BAR_H); - ink(40, 40, 50).box(0, y, w, 1); - ink(40, 40, 50).box(half, y, 1, BAR_H); - - const lit = (side) => (armed === side ? 255 : 90); - ink(...CANCEL, lit("cancel")).box(0, y + 1, half, BAR_H - 1); - ink(...OK, lit("ok")).box(half + 1, y + 1, half - 1, BAR_H - 1); + const age = now() - shownAt; - // Each word centered in its own half. The halves are equal even though the - // words aren't — the two answers carry the same weight. - const mid = y + BAR_H / 2 - 4; - const mark = (word, x) => - ink(armed === word ? 0 : 255).write(word, { x: x - word.length * 3, y: mid }); - mark("cancel", half / 2); - mark("ok", half + half / 2); - - // Progress + name, top-left, shadowed so it survives whatever the pad paints. - const tag = `${at + 1}/${deck.length} ${c.name}`; - ink(0, 0, 0, 180).write(tag, { x: 7, y: 7 }); - ink(230).write(tag, { x: 6, y: 6 }); - - // Whose taste this is. Anonymous is a real answer, so say it rather than - // leaving the corner empty and the question open. - const who = judge || "anon"; - ink(0, 0, 0, 180).write(who, { x: 7, y: y - 11 }); - ink(judge ? [120, 220, 255] : [110, 110, 120]).write(who, { x: 6, y: y - 12 }); - - // The usage signal, shown back to you — taps are the thing you did without - // being asked, and they're worth as much as the verdict. - if (taps > 0) { - const t = `${taps} ▸`; - ink(0, 0, 0, 180).write(t, { x: w - 6 - t.length * 6 + 1, y: 7 }); - ink(255, 220, 120).write(t, { x: w - 6 - t.length * 6, y: 6 }); + // The name — the one thing worth saying out loud, and only for a moment. A + // label that never leaves stops being read. + if (age < NAME_MS) { + const a = Math.min(1, Math.max(0, (NAME_MS - age) / 700)); + const label = c.name; + ink(0, 0, 0, 150 * a).write(label, { x: 7, y: 8 }); + ink(240, 240, 255, 255 * a).write(label, { x: 6, y: 7 }); + const n = `${at + 1}/${deck.length}`; + ink(120, 120, 140, 200 * a).write(n, { x: w - 6 - n.length * 6, y: 7 }); } -} -function summary({ wipe, ink, write, screen }) { - wipe(6, 6, 10); - const kept = verdicts.filter((v) => v.verdict === "ok"); - const w = screen.width; + // The last verdict, as a breath of color at the edge you swiped from. You + // never pressed anything, so this is the only acknowledgement there is. + if (flash > 0) { + const [r, g, b] = flashOk ? OK : NO; + ink(r, g, b, 120 * flash).box(0, h - 3, w, 3); + } - ink(230).write(`${kept.length} ok · ${verdicts.length - kept.length} not`, { - x: 8, - y: 10, - }); - ink(90).write("saved — tap to judge again", { x: 8, y: 24 }); + // The swipe hint, only while you're touching an edge — furniture the rest of + // the time would just be in the pad's way. + if (drag) { + const up = drag.from === "bottom"; + ink(255, 255, 255, 60).box(0, up ? h - 2 : 0, w, 2); + } - let y = 46; - for (const v of verdicts) { - if (y > screen.height - 12) break; - const on = v.verdict === "ok"; - ink(...(on ? OK : [90, 70, 80])).write(on ? "ok" : "cancel", { x: 8, y }); - ink(on ? 230 : 110).write(v.code, { x: 62, y }); - const meta = `${Math.round(v.dwellMs / 100) / 10}s${v.taps ? " · " + v.taps + "▸" : ""}`; - ink(70).write(meta, { x: w - 8 - meta.length * 6, y }); - y += 12; + // Verdicts the server refused. This is here because the first version of this + // piece lost an entire session in total silence, and I'm not doing that again. + if (lost > 0) { + const m = `${lost} unsent`; + ink(0, 0, 0, 160).write(m, { x: w - 5 - m.length * 6, y: h - 11 }); + ink(...NO).write(m, { x: w - 6 - m.length * 6, y: h - 12 }); } } function act(api) { const { event: e } = api; - if (state === "error") return; - - if (state === "done") { - if (e.is("lift")) { - at = 0; - verdicts = []; - state = "judging"; - mount(api); - } - return; - } if (state !== "judging") return; const h = api.screen.height; - const w = api.screen.width; - const inBar = (y) => y >= h - BAR_H; - // Route by where the gesture STARTED, so a drag out of the bar doesn't play - // the pad and a drag out of the pad doesn't cast a verdict. + // A gesture that starts in an edge band is ours; everything else is the pad's. + // The bands are thin on purpose — the instrument keeps its whole face. if (e.is("touch")) { - fromBar = inBar(e.y); - if (fromBar) armed = e.x < w / 2 ? "cancel" : "ok"; + if (e.y >= h - EDGE) drag = { from: "bottom", y0: e.y }; + else if (e.y <= EDGE) drag = { from: "top", y0: e.y }; + else drag = null; } - if (e.is("lift") && fromBar) { - const still = armed && inBar(e.y) && (e.x < w / 2) === (armed === "cancel"); - const verdict = armed; - armed = null; - fromBar = false; - if (still) return advance(api, verdict); - return; + if (drag && e.is("lift")) { + const moved = drag.y0 - e.y; // positive = swiped up + const d = drag.from; + drag = null; + if (d === "bottom" && moved > SWIPE) return turn(api, +1); // up → next + if (d === "top" && -moved > SWIPE) return turn(api, -1); // down → back + return; // a tap on the edge is not a verdict. Say nothing. } - // Keys — the same two signals, for judging a long deck without leaving home. - if (e.is("keyboard:down:arrowleft") || e.is("keyboard:down:escape")) - return advance(api, "cancel"); - if (e.is("keyboard:down:arrowright") || e.is("keyboard:down:enter")) - return advance(api, "ok"); + // Keys, for judging a long deck without leaving home. + if (e.is("keyboard:down:arrowdown") || e.is("keyboard:down:space")) + return turn(api, +1); + if (e.is("keyboard:down:arrowup")) return turn(api, -1); - // 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) { + // Everything else belongs to the instrument. Play it — that's the whole vote. + if (!drag && 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?.(onStage); + host.act?.(api); } catch (err) { hostFailed = true; error = String(err?.message || err); @@ -402,4 +313,10 @@ } } } -export { boot, sim, paint, act }; +// The last pad you were on still deserves its verdict — leaving the piece is +// leaving it. +function leave(api) { + if (state === "judging") leaving(api); +} + +export { boot, sim, paint, act, leave }; -- tangled.sh