From 86e6c741a3cbe7febd487664f717b181010d29d9 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 27 Jul 2026 21:02:36 -0700 Subject: [PATCH] Make No Paint a compatible brush surfer --- docs/nopaint-brush-proposal-contract.md | 51 +++++++++ docs/nopaint-construct-audio-spec.md | 6 + docs/nopaint-construct-button-spec.md | 103 ++++++++++++++++-- spec/nopaint-brush-proposal-spec.mjs | 29 +++++ .../public/aesthetic.computer/disks/line.mjs | 60 +++++++++- .../aesthetic.computer/disks/nopaint.mjs | 98 ++++++++++++++--- tests/browser/nopaint-journey.test.mjs | 11 ++ 7 files changed, 331 insertions(+), 27 deletions(-) create mode 100644 docs/nopaint-brush-proposal-contract.md create mode 100644 spec/nopaint-brush-proposal-spec.mjs diff --git a/docs/nopaint-brush-proposal-contract.md b/docs/nopaint-brush-proposal-contract.md new file mode 100644 index 000000000..c55e6873c --- /dev/null +++ b/docs/nopaint-brush-proposal-contract.md @@ -0,0 +1,51 @@ +# No Paint compatible brush proposal contract + +No Paint is a constrained piece/brush surfer. A compatible AC brush owns the +parameter schema, seeded score generation, and proposal rendering used by the +surfer. The conductor owns selection, No/Paint decisions, persistence, and the +accepted-painting composite. + +Compatibility is explicit and allowlisted. Exporting an ordinary `meta()` or +using `system = "nopaint"` does not automatically make a stateful brush safe to +run as a proposal. + +## Version 1 contract + +`line.mjs` is the first implementation and exports `nopaintProposal`: + +- `version`: contract version. +- `slug` and `label`: stable identity and human HUD label. +- `compatible: true`: explicit opt-in. +- `parameters`: machine-readable bounds and types. +- `generate({ random, width, height, base })`: returns a frozen, deterministic + score containing explicit params, colon modifiers, geometry, and duration. +- `render(api, score, frame)`: renders only into the active proposal buffer. + +The frozen score records the invocation that produced the pixels: + +```js +{ + brush: { + slug: "line", + params: [r, g, b, alpha], + colon: [thickness], + parameters: { thickness, alpha, pointCount, durationFrames } + }, + points: [{ x, y }, ...] +} +``` + +## Safety invariants + +1. Selection comes only from the conductor's static compatibility allowlist. +2. Generation uses the session's seeded PRNG; ambient randomness is forbidden. +3. Every generated coordinate and parameter stays within the brush-owned + schema. +4. Rendering cannot persist, broadcast, bake, or mutate the accepted painting. +5. No clears the proposal buffer. Paint alone composites and persists it. +6. Stateful legacy brushes require an adapter or a brush-owned proposal export; + `meta()` prose is not executable capability metadata. + +The existing Robo synthetic-pen pipeline remains the migration path for older +pointer-driven brushes. It must be isolated per proposal and must never invoke +their normal bake/broadcast path before Paint. diff --git a/docs/nopaint-construct-audio-spec.md b/docs/nopaint-construct-audio-spec.md index 69f609518..f1362fe66 100644 --- a/docs/nopaint-construct-audio-spec.md +++ b/docs/nopaint-construct-audio-spec.md @@ -53,6 +53,12 @@ is replaced, the piece pauses, completion mode opens, the seed changes, or the piece exits. Resuming restarts the current proposal's cue. One-shot decision and primitive samples end naturally. +No Paint 3.0 adds a held-decision inspection state: while No or Paint remains +down, proposal-frame stepping stops and the active proposal sample is reduced +to `sampleSpeed: 0.18` for a scratch-like slowdown. Sliding between decisions +preserves the hold. Release chooses the destination and the next proposal begins +at normal speed; cancellation restores the current proposal to speed `1`. + ## Brush and tool cues Primitive starts are `box - start`, `line - start`, `triangle - start`, and the diff --git a/docs/nopaint-construct-button-spec.md b/docs/nopaint-construct-button-spec.md index 3945cc502..1d22b73b4 100644 --- a/docs/nopaint-construct-button-spec.md +++ b/docs/nopaint-construct-button-spec.md @@ -190,20 +190,43 @@ presentation hit polygons only; pointer anchoring must use the origins above. Cursor behavior is centralized in Buttons sheet `project[6][38]`: -- Nudge start/stop is events around `.1.8.8.9-.11`. Nudge makes Cursor visible - and starts its animation; desktop NoNudge selects `No Nudge` at +- Nudge start/stop is events around `.1.8.8.9-.11`. Event 5 calls + `StartAnim(fromBeginning=true)` at `project.6.38.1.8.8.9.8.1.7.0` when + `DesktopMode=true`. This starts whichever animation is current; the later + every-tick decision at events 64-68 selects `Nudging`. Desktop NoNudge event + 11 selects `No Nudge` with + `SetAnim(name, fromBeginning=true)` at `project.6.38.1.8.8.11.8.0.7.0`. - Normal desktop restoration selects `Normal` at - `project.6.38.1.8.8.36.7.2`, `.40.7.0`, and related release/reset branches. + `project.6.38.1.8.8.36.7.2` (mouse release, a button was carried, target is + enabled) and `.40.7.0` (the Else branch while not nudging). Both restart the + selected animation from frame zero. - Passive or carried button targeting selects `Over Button` at `project.6.38.1.8.8.39.8.0.8.1.7.0` and `.41.7.0`; nudge selection occurs at `.39.8.0.8.0.8.0.7.0`. These serialized constants resolve to `618=Nudging`, `619=Over Button`, `613=Normal`. -- Event 43 (`project.6.38.1.8.8.43`) advances/starts the hand animation for - touch activity. Event 44 (`.44`) sets Cursor position to current Mouse X/Y. - Its children set frame 0/4 according to pointer/button state, hide on - incompatible touch mode, and re-show/reset the sprite when appropriate. -- Event 45 (`.45`) applies the current cursor color and opacity treatment. +- Event 72 (`project.6.38.1.8.8.41`) selects and restarts `Over Button` when + Mouse is over an enabled Button in desktop mode, not peeking, and the target + semantic value is No, Paint, or PlayMode. This is name selection, not frame + cycling. +- Mouse OnObjectClicked event 51 (`project.6.38.1.8.8.34`) runs for an enabled + Button and, in action order, sets `AnyButtonPressed=true`, Cursor visible, + opacity 100, then the canonical packed color. It does not select an animation; + the subsequent state-selection events do that. +- Event 74 (`project.6.38.1.8.8.43`) is `WagCursorTimer` while the current + animation name is `Over Button`; it explicitly sets animation frame **0**. +- Event 75 and its descendants (`project.6.38.1.8.8.44`) are the cursor + position/wag/visibility loop, not events 43-45 in Construct's displayed event + numbering. Event 76, desktop mode, sets position to + `(floor(Mouse.X), floor(Mouse.Y))` at `.44.8.0.7.0`. Event 78 sets frame **2** + when horizontal displacement from `MouseLastX` is greater than 1 + (`.44.8.0.8.0.8.0.7.0`); sibling Else event 79 sets frame **1** for the + opposite horizontal direction threshold (`.44.8.0.8.0.8.1.7.0`). Event 81 + hides the cursor when its bounds fail the exported on-canvas test. Event 83 + re-shows it after pointer movement and restores opacity/color. +- Event 86 (`project.6.38.1.8.8.45`) is `HideCursorTimer` while no button is + carried: it changes the color to expression 625 and opacity to 25. It does + not select an animation. - PainterStoriesIndex hides Cursor at `project.6.41.1.1.7.32`; transition sheet hides it at `project.6.42.1.1.7.0`; Index moves it to the proper UI layer and toggles visibility at `project.6.43.1.10.8.5.8.3`; Splash performs @@ -215,6 +238,70 @@ its sprite position from the Construct-space mouse coordinates every tick, use nudge modes, and restore `Normal` on unhover/release. Preserve each frame's per-frame origin or the hand visibly jumps while waving. +### Explicit cursor transition table + +Construct animation frames are **zero-based**. `SetAnimFrame(0)` means the first +frame, and the runtime floors/clamps the requested index. Every cursor +`SetAnim` action shown here passes `fromBeginning=true`; selection therefore +immediately resets to frame 0 even though `Normal` and `Over Button` have speed +zero. + +| prior/input state | condition and path | ordered cursor actions | resulting state | +|---|---|---|---| +| nudge begins, desktop | event 5, `.9.8.1` | `StartAnim(true)` | current animation restarts; events 64-68 subsequently select the nudge/hand state | +| enabled Button clicked | event 51, `.34` | `SetVisible(true)`, `SetOpacity(100)`, `SetDefaultColor(-281492157629439)` | visible full-strength cursor; animation name unchanged until later selection | +| desktop NoNudge | event 11, `.11.8.0` | `SetAnim("No Nudge", true)` | one-frame No Nudge visual, animation speed 20 is immaterial | +| mouse release after carried button | event 56, `.36` | `SetAnim("Normal", true)` | Normal frame 0 | +| nudge mode chosen | events 64/65/66/67, `.39.8.0.8.0.8.0` | `SetAnim("Nudging", true)` | looping frames 0/1 at speed 5 | +| same decision's Else | event 68, `.39.8.0.8.1` | `SetAnim("Over Button", true)` | hand frame 0 | +| no applicable button/nudge | event 70, `.40` | `SetAnim("Normal", true)` | Normal frame 0 | +| over enabled No/Paint/PlayMode Button | event 72, `.41` | `SetAnim("Over Button", true)` | hand frame 0 | +| wag timer fires while Over Button | event 74, `.43` | `SetAnimFrame(0)` | neutral hand pose | +| desktop pointer loop | event 76, `.44.8.0` | `SetPos(floor(Mouse.X), floor(Mouse.Y))` | hotspot follows pointer | +| horizontal movement one direction | event 78, `.44.8.0.8.0.8.0` | `SetAnimFrame(2)` | directional wag pose 2 | +| horizontal movement opposite direction | event 79, `.44.8.0.8.0.8.1` | `SetAnimFrame(1)` | directional wag pose 1 | +| cursor bounds outside accepted canvas test | event 81, `.44.8.0.8.1` | `SetVisible(false)` | hidden | +| pointer moved since last mouse sample | event 83, `.44.8.0.8.2.8.0` | `SetVisible(true)`, `SetOpacity(100)`, `SetDefaultColor(-281492157629439)` | visible, fully opaque, canonical packed color | +| hide timer, no carried button | event 86, `.45` | `SetDefaultColor(-140746078815231)`, `SetOpacity(25)` | dimmed/tinted idle cursor | + +The packed color constants above are the exact evaluated values of expression +611 (`-281492157629439`, hexadecimal `-0x10004001003ff`) and expression 625 +(`-140746078815231`, hexadecimal `-0x8002000803ff`). They should be decoded with +Construct's packed-color helper if exact channel values are needed; do not treat +them as CSS integers. Expression 103 is opacity 100, 613 is `Normal`, 618 is +`Nudging`, 619 is `Over Button`, 621 is `WagCursorTimer`, and 624 is +`HideCursorTimer`. + +Events 43-45 in the displayed Construct numbering are unrelated to the cursor: +event 43 ends a touch text-cursor drag, while events 44-50 implement Peek +dragging. Their proximity in the serialized sheet is easy to misread; the +cursor's displayed events begin at 51 and resume at 56, 64, 70, 72, 74-86. + +## Animation selection versus frame selection + +The export does not imply that every spritesheet should auto-cycle. + +- `SetAnim(name, fromBeginning)` selects a named animation. With + `fromBeginning=true`, it resets to zero-based frame 0. The authored animation's + speed and loop flags then control whether it advances. +- `SetAnimFrame(index)` chooses a specific zero-based pose. It does not mean + “play the sheet.” The cursor hand uses only frames 0, 1, and 2 of the + seven-frame `Over Button` animation in these events; the other authored poses + are retained assets, not evidence for automatic playback. +- `StartAnim(fromBeginning)` resumes/starts the currently selected animation; + it does not select a different animation name. +- Cursor `Normal` and `Over Button` have speed 0 and non-looping flags, so all + motion is event-selected. `Nudging` is speed 5 and looping; `No Nudge` is + marked looping but contains one frame. +- Pressed feedback generally uses separate logical replacement objects + (`NoPressed`, `PaintPressed`, `SavePressed`, `BackPressed`, and the other + `*Pressed` types). Core button events show/hide those objects or select their + frames. They are not alternate frames of the invisible generic Button. +- Consequently, a faithful importer should model atlas frame rectangles, + per-frame origins, named-animation speed/loop metadata, explicit frame + selection, and separate replacement-object visibility as distinct concepts. + Never infer cycling merely because multiple frames share a spritesheet. + ## Fidelity checklist 1. Keep `Button` hit regions invisible and drive them by `For` plus the three diff --git a/spec/nopaint-brush-proposal-spec.mjs b/spec/nopaint-brush-proposal-spec.mjs new file mode 100644 index 000000000..b707e8f71 --- /dev/null +++ b/spec/nopaint-brush-proposal-spec.mjs @@ -0,0 +1,29 @@ +import { nopaintProposal as lineProposal } from "../system/public/aesthetic.computer/disks/line.mjs"; +import { + makeProposal, + seededRandom, + seedFrom, +} from "../system/public/aesthetic.computer/lib/nopaint-proposals.mjs"; + +describe("No Paint compatible brush proposals", () => { + it("lets Line own a deterministic, bounded parameter score", () => { + const make = () => { + const random = seededRandom(seedFrom("line-test-3")); + const base = makeProposal(random, 596, 446); + expect(base.kind).toBe("line"); + return lineProposal.generate({ random, width: 596, height: 446, base }); + }; + + const first = make(); + const second = make(); + expect(first).toEqual(second); + expect(first.brush.slug).toBe("line"); + expect(first.brush.colon).toEqual([String(first.thickness)]); + expect(first.thickness).toBeGreaterThanOrEqual(1); + expect(first.thickness).toBeLessThanOrEqual(50); + expect(first.color[3]).toBeGreaterThanOrEqual(24); + expect(first.color[3]).toBeLessThanOrEqual(192); + expect(first.points.every(({ x, y }) => + x >= 0 && x < 596 && y >= 0 && y < 446)).toBeTrue(); + }); +}); diff --git a/system/public/aesthetic.computer/disks/line.mjs b/system/public/aesthetic.computer/disks/line.mjs index d99f70f0b..9f493dec5 100644 --- a/system/public/aesthetic.computer/disks/line.mjs +++ b/system/public/aesthetic.computer/disks/line.mjs @@ -254,6 +254,64 @@ function bake() { strokeToBake?.(); } +// Machine-readable No Paint proposal contract. This lives beside the real +// brush so the surfer does not maintain a second imitation of Line. +const nopaintProposal = Object.freeze({ + version: 1, + slug: "line", + label: "Line", + compatible: true, + parameters: Object.freeze({ + color: Object.freeze({ type: "rgba", alpha: [24, 192] }), + thickness: Object.freeze({ type: "integer", min: 1, max: 50 }), + points: Object.freeze({ type: "path", min: 4, max: 18 }), + durationFrames: Object.freeze({ type: "integer", min: 24, max: 120 }), + }), + generate({ random, width, height, base }) { + const pointCount = 4 + Math.floor(random() * 15); + const start = { x: base.x, y: base.y }; + const end = { + x: Math.min(width - 1, base.x + base.w), + y: Math.min(height - 1, base.y + base.h), + }; + const points = Array.from({ length: pointCount }, (_, index) => { + const t = pointCount === 1 ? 0 : index / (pointCount - 1); + const sway = Math.sin(t * Math.PI * (1 + Math.floor(random() * 4))) * base.drift; + return Object.freeze({ + x: Math.max(0, Math.min(width - 1, + Math.round(start.x + (end.x - start.x) * t + sway))), + y: Math.max(0, Math.min(height - 1, + Math.round(start.y + (end.y - start.y) * t - sway * 0.65))), + }); + }); + const thickness = 1 + Math.floor(random() * 50); + const alpha = 24 + Math.floor(random() * 169); + const color = Object.freeze([base.color[0], base.color[1], base.color[2], alpha]); + const durationFrames = 24 + Math.floor(random() * 97); + return Object.freeze({ + ...base, + color, + thickness, + points: Object.freeze(points), + brush: Object.freeze({ + slug: "line", + params: Object.freeze(color.map(String)), + colon: Object.freeze([String(thickness)]), + parameters: Object.freeze({ thickness, alpha, pointCount, durationFrames }), + }), + }); + }, + render({ ink }, score, frame) { + const duration = score.brush.parameters.durationFrames; + const visible = Math.max(2, Math.ceil(score.points.length * Math.min(1, frame / duration))); + for (let index = 1; index < visible; index += 1) { + const from = score.points[index - 1]; + const to = score.points[index]; + ink(score.color).line(from.x, from.y, to.x, to.y, score.thickness); + } + }, +}); + function meta() { return { title: "Line", @@ -265,7 +323,7 @@ function meta() { }; } -export { boot, act, paint, bake, meta, system }; +export { boot, act, paint, bake, meta, nopaintProposal, system }; // ── Helpers ── diff --git a/system/public/aesthetic.computer/disks/nopaint.mjs b/system/public/aesthetic.computer/disks/nopaint.mjs index 17cb1e6ba..7a7068b49 100644 --- a/system/public/aesthetic.computer/disks/nopaint.mjs +++ b/system/public/aesthetic.computer/disks/nopaint.mjs @@ -10,6 +10,11 @@ import { seededRandom, seedFrom, } from "../lib/nopaint-proposals.mjs"; +import { nopaintProposal as lineProposal } from "./line.mjs"; + +const COMPATIBLE_BRUSHES = Object.freeze(new Map([ + [lineProposal.slug, lineProposal], +])); let loopState = "choosing"; let stateBeforePause = "proposing"; @@ -32,6 +37,8 @@ let paintingDragPaused = false; let hoveredDecision = null; let cursorSheet = null; let cursorPoint = null; +let cursorFrame = 0; +let cursorWagFrames = 0; const cueSamples = new Map(); let cueEvents = []; @@ -69,6 +76,7 @@ const brushCueSamples = new Map(); let brushCueProposal = 0; let activeBrushSound = null; let activeBrushKind = null; +let decisionHeld = false; // Exact frame rectangles and origins from Construct's Cursor object (ID 90). // The animated hand is the original `Over Button` sequence in cursor-sheet0. @@ -139,6 +147,17 @@ function stopBrushCue() { publishTestState(); } +function setDecisionHeld(held) { + if (decisionHeld === held) return; + decisionHeld = held; + activeBrushSound?.update?.({ sampleSpeed: held ? 0.18 : 1 }); + cueEvents.push({ + name: held ? "brush-scratch-slow" : "brush-scratch-resume", + path: "lifecycle", + }); + publishTestState(); +} + function resumeBrushCue(api) { brushCueProposal = 0; if (proposal?.kind) playBrushCue(api, proposal.kind); @@ -290,8 +309,7 @@ function paintDecisionButton($, button, label, light = false) { function paintOriginalCursor($) { if (!cursorSheet || !cursorPoint) return; const frames = hoveredDecision ? CURSOR_FRAMES.hand : CURSOR_FRAMES.normal; - const cadence = hoveredDecision ? 4 : 18; - const frame = frames[Math.floor(proposalFrame / cadence) % frames.length]; + const frame = frames[Math.min(cursorFrame, frames.length - 1)]; const scale = Math.max(1, Math.round(Math.min($.screen.width, $.screen.height) / 300)); $.paste( { @@ -315,16 +333,26 @@ function clearProposal({ flatten, needsPaint, page, screen, system }) { function chooseProposal(api) { stopBrushCue(); + decisionHeld = false; transition("choosing"); const resolution = paintingResolution || { width: api.system.painting.width, height: api.system.painting.height, }; - proposal = makeProposal( + const baseProposal = makeProposal( random, resolution.width, resolution.height, ); + const compatibleBrush = COMPATIBLE_BRUSHES.get(baseProposal.kind); + proposal = compatibleBrush + ? compatibleBrush.generate({ + random, + width: resolution.width, + height: resolution.height, + base: baseProposal, + }) + : baseProposal; proposalNumber += 1; proposalFrame = 0; transition("proposing"); @@ -483,6 +511,12 @@ function testSnapshot() { proposalNumber, proposalFrame, operation: proposal?.kind || null, + brush: proposal?.brush ? { + slug: proposal.brush.slug, + params: [...proposal.brush.params], + colon: [...proposal.brush.colon], + parameters: { ...proposal.brush.parameters }, + } : null, decisions: decisions.map((decision) => ({ ...decision })), saveCount, finishMode, @@ -491,6 +525,7 @@ function testSnapshot() { ready: [...cueSamples.keys()], brushReady: [...brushCueSamples.keys()], activeBrush: activeBrushKind, + decisionHeld, hovered: hoveredDecision, events: cueEvents.map((event) => ({ ...event })), }, @@ -498,6 +533,7 @@ function testSnapshot() { ready: Boolean(cursorSheet), visible: Boolean(cursorPoint), animation: hoveredDecision ? "Over Button" : "Normal", + frame: cursorFrame, }, lastDownload, ready: Boolean(proposal && testApi?.system?.nopaint?.buffer), @@ -579,10 +615,13 @@ function boot({ colon, debug, hud, net, num, params, screen, store, system, ui, hoveredDecision = null; cursorSheet = null; cursorPoint = null; + cursorFrame = 0; + cursorWagFrames = 0; cueEvents = []; brushCueProposal = 0; activeBrushSound = null; activeBrushKind = null; + decisionHeld = false; lastDownload = null; archiveOrigin = archiveId ? { type: "nopaint-archive", @@ -655,7 +694,14 @@ function boot({ colon, debug, hud, net, num, params, screen, store, system, ui, // 🧮 Sim function sim({ needsPaint }) { - if (loopState === "proposing") { + if (cursorWagFrames > 0) { + cursorWagFrames -= 1; + if (cursorWagFrames === 0 && cursorFrame !== 0) { + cursorFrame = 0; + needsPaint(); + } + } + if (loopState === "proposing" && !decisionHeld) { proposalFrame += 1; needsPaint(); if (proposalFrame % 12 === 0) publishTestState(); @@ -700,13 +746,7 @@ function renderProposal($) { true, ); } else if (proposal.kind === "line") { - ink(color).line( - proposal.x - drift, - proposal.y + drift, - proposal.x + proposal.w + drift, - proposal.y + proposal.h - drift, - proposal.thickness, - ); + lineProposal.render($, proposal, proposalFrame); } else if (proposal.kind === "softy") { proposal.points.slice(0, 12).forEach((point, index) => { const breathe = 1 + Math.sin(phase + index * 0.7) * 0.18; @@ -819,12 +859,11 @@ function paint($) { $.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(18).box(bar, "fill"); $.ink(255, 180).write( - `No Paint ${NOPAINT_VERSION} · ${definition?.label || proposal.kind} ${proposalNumber}${paused} · seed ${sessionSeed}`, - { x: 8, y: status.y + 5 }, + definition?.label || proposal.kind, + { x: stage.x + 8, y: stage.y + 8 }, ); positionButtons($.screen); @@ -846,7 +885,9 @@ function isAny(e, names) { // 🎪 Act — every input surface reaches the same two decision functions. function act($) { const { event: e } = $; + let cursorDeltaX = 0; if (e.device === "mouse" && Number.isFinite(e.x) && Number.isFinite(e.y)) { + cursorDeltaX = cursorPoint ? e.x - cursorPoint.x : 0; cursorPoint = { x: e.x, y: e.y }; } else if (e.device === "touch") { cursorPoint = null; @@ -874,6 +915,7 @@ function act($) { : null; if (target !== hoveredDecision) { hoveredDecision = target; + cursorFrame = 0; if (target === "painting") { cueEvents.push({ name: "painting-hover", @@ -884,6 +926,14 @@ function act($) { playCue($, "rollover"); } } + if (target && Math.abs(cursorDeltaX) > 1) { + // Construct events 78/79 explicitly select directional hand poses; + // WagCursorTimer later restores frame zero. These are logical states, + // not a seven-frame playback loop. + cursorFrame = cursorDeltaX > 0 ? 2 : 1; + cursorWagFrames = 6; + $.needsPaint(); + } } if (finishMode) { @@ -922,13 +972,25 @@ function act($) { } noButton.btn.act(e, { - down: () => playCue($, "no-down"), - cancel: () => playCue($, "back"), + down: () => { + playCue($, "no-down"); + setDecisionHeld(true); + }, + cancel: () => { + playCue($, "back"); + setDecisionHeld(false); + }, push: () => discardProposal($), }); paintButton.btn.act(e, { - down: () => playCue($, "paint-down"), - cancel: () => playCue($, "back"), + down: () => { + playCue($, "paint-down"); + setDecisionHeld(true); + }, + cancel: () => { + playCue($, "back"); + setDecisionHeld(false); + }, push: () => commitProposal($), }); if ( diff --git a/tests/browser/nopaint-journey.test.mjs b/tests/browser/nopaint-journey.test.mjs index 5f4876620..9a1a56345 100644 --- a/tests/browser/nopaint-journey.test.mjs +++ b/tests/browser/nopaint-journey.test.mjs @@ -193,12 +193,21 @@ try { const paint = point(before.controls.paint); await ac.page.mouse.move(no.x, no.y); await ac.page.mouse.down(); + await ac.wait(100); + const held = await ac.nopaintState(); + await ac.wait(250); + const stillHeld = await ac.nopaintState(); + expect(held?.audio?.decisionHeld === true, "holding No enters the scratch-slow state"); + expect(stillHeld?.proposalFrame === held?.proposalFrame, "holding a decision pauses brush stepping"); + expect(stillHeld?.audio?.events?.some(({ name }) => name === "brush-scratch-slow"), + "holding records the brush playback slowdown"); await ac.page.mouse.move(paint.x, paint.y, { steps: 12 }); await ac.page.mouse.up(); await ac.wait(250); const after = await ac.nopaintState(); expect(after?.proposalNumber === before.proposalNumber + 1, "release chooses the slid-to button"); expect(after?.decisions?.at(-1)?.decision === "paint", "sliding No → Paint commits Paint"); + expect(after?.audio?.decisionHeld === false, "release resumes normal proposal playback"); const recentCues = after?.audio?.events?.slice(-6).map(({ name }) => name) || []; expect(recentCues.includes("no-down"), "hold begins with the No press cue"); expect(recentCues.includes("paint-down") || recentCues.includes("rollover"), "crossing announces Paint"); @@ -226,6 +235,8 @@ try { const paintingHovered = await ac.nopaintState(); expect(paintingHovered.cursor?.ready === true, "the original cursor remains active across painting hover transitions"); + expect([0, 1, 2].includes(paintingHovered.cursor?.frame), + "cursor uses Construct's logical frame replacements rather than atlas cycling"); }); await scenario("Pause freezes and resumes the live proposal", async (expect) => { -- 2.51.2