diff --git a/frontend/src/components/PlanchetteBoard.tsx b/frontend/src/components/PlanchetteBoard.tsx index 67daf7e..60f3d9e 100644 --- a/frontend/src/components/PlanchetteBoard.tsx +++ b/frontend/src/components/PlanchetteBoard.tsx @@ -96,6 +96,15 @@ export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) { let initialized = false const smoke: Smoke[] = [] + // Periodic, deliberate visit to GOODBYE — distinct from the ambient + // wander/letter-brushing above: every 2-5 minutes (randomized so + // multiple open tabs don't all drift there in lockstep), the planchette + // glides down and rests on GOODBYE for a few seconds before resuming + // normal idle wander. `t`-relative (seconds since mount), not wall-clock. + const randomBetween = (min: number, max: number) => min + Math.random() * (max - min) + let nextGoodbyeAt = randomBetween(120, 300) + let goodbyeRestUntil = 0 + // Spirit knock: an AudioContext created lazily on the first gesture // (autoplay policy); a soft tick sounds as each letter is dwelled upon. let tickCtx: AudioContext | null = null @@ -162,17 +171,36 @@ export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) { lastKnock = knockKey // --- target: spelled letter, else idle wander --- - // Wander radius is tied directly to the outer letter ring's own - // radius/squash (computeLayout's `arc('ABCDEFGHIJKLM', ...)` call) so - // the drift genuinely reaches the board's edges — not a fixed - // fraction of the canvas that happens to undershoot the ring. - const outerRingRadius = Math.min(w * 0.4, h * 0.52) + // Wander bounds are tied directly to the board's actual fixed + // waypoints — YES/NO (the outermost, topmost points) and the number + // row (the lowest) — rather than a derived "ring radius", so idle + // drift genuinely covers the full interactive board, corners + // included, not just the letter belt. + const wanderCx = (layout.yes.x + layout.no.x) / 2 + const wanderXRange = (layout.no.x - layout.yes.x) / 2 + const wanderCy = (layout.yes.y + layout.numbers.get('5')!.y) / 2 + const wanderYRange = (layout.numbers.get('5')!.y - layout.yes.y) / 2 let target: Point | null = null + let restingAtGoodbye = false if (snap.current) target = letterTarget(snap.current) + if (!target) { + if (t >= nextGoodbyeAt && goodbyeRestUntil === 0) { + // Arrived at the scheduled moment — begin resting on GOODBYE. + goodbyeRestUntil = t + randomBetween(3, 5) + } + if (goodbyeRestUntil > 0 && t < goodbyeRestUntil) { + target = layout.goodbye + restingAtGoodbye = true + } else if (goodbyeRestUntil > 0) { + // Rest just ended — clear it and schedule the next visit. + goodbyeRestUntil = 0 + nextGoodbyeAt = t + randomBetween(120, 300) + } + } if (!target) { target = { - x: layout.center.x + wander(t, 1.3) * outerRingRadius * 0.94, - y: layout.center.y + wander(t * 0.83, 4.1) * outerRingRadius * 0.72 * 0.94, + x: wanderCx + wander(t, 1.3) * wanderXRange * 0.96, + y: wanderCy + wander(t * 0.83, 4.1) * wanderYRange * 0.96, } } const speed = snap.current ? 0.16 : 0.03 @@ -253,8 +281,20 @@ export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) { ctx.fillText('YES', layout.yes.x, layout.yes.y) ctx.fillText('NO', layout.no.x, layout.no.y) ctx.font = `600 ${Math.max(12, h * 0.03)}px "IBM Plex Mono", monospace` - ctx.fillStyle = `hsla(${hueDeg} 85% 78% / 0.65)` - ctx.fillText('G O O D B Y E', layout.goodbye.x, layout.goodbye.y) + if (restingAtGoodbye) { + // Same restrained glow language as an ambiently-brushed letter — + // the planchette pausing here is a beat, not an actual "goodbye" + // action; nothing about session state changes. + ctx.save() + ctx.shadowColor = accent + ctx.shadowBlur = 12 + ctx.fillStyle = accent + ctx.fillText('G O O D B Y E', layout.goodbye.x, layout.goodbye.y) + ctx.restore() + } else { + ctx.fillStyle = `hsla(${hueDeg} 85% 78% / 0.65)` + ctx.fillText('G O O D B Y E', layout.goodbye.x, layout.goodbye.y) + } // Letters + numbers. const hovered = snap.current ?? brushed