From ccd399a6aeae036e796bf836300c909e48499acd Mon Sep 17 00:00:00 2001 From: Joshua Date: Mon, 20 Jul 2026 22:33:36 +0100 Subject: [PATCH] feat(web): crossfade mascot GIF states with Motion Replace the canvas ImageDecoder renderer with native GIFs crossfaded via motion/react AnimatePresence. Idle <-> build/plan state changes now fade over ~280ms instead of hard-cutting. All three sources render in one fixed 84x96 box (object-contain, bottom-anchored) so the mascot no longer resizes between states; a per-source SCALE knob is left for in-browser fine-tuning. Honors prefers-reduced-motion with an instant swap. Net -163 lines. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/package.json | 1 + apps/web/src/components/chat/ChatMascot.tsx | 244 ++++---------------- bun.lock | 9 + 3 files changed, 50 insertions(+), 204 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index a03a7587a20..01fbe73df11 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -39,6 +39,7 @@ "effect": "catalog:", "lexical": "^0.41.0", "lucide-react": "^0.564.0", + "motion": "^12.42.2", "react": "19.2.6", "react-dom": "19.2.6", "react-markdown": "^10.1.0", diff --git a/apps/web/src/components/chat/ChatMascot.tsx b/apps/web/src/components/chat/ChatMascot.tsx index 63dc30078e1..0db5150832e 100644 --- a/apps/web/src/components/chat/ChatMascot.tsx +++ b/apps/web/src/components/chat/ChatMascot.tsx @@ -1,13 +1,15 @@ -import { useEffect, useRef, type RefObject } from "react"; +import { useEffect, useState, type RefObject } from "react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { isMascotTyping } from "./chatMascot.logic"; -// Mode-aware pixel-ghost mascot that perches above the composer. Three states: -// idle -> /gits-idle.gif (loops whenever the user is not typing) +// Mode-aware pixel-ghost mascot that perches above the composer. Three states, +// each a native GIF the browser plays; Motion crossfades between them on change. +// idle -> /gits-idle.gif (whenever the user is not typing) // typing, build -> /gits-mascot-loop.gif (the keyboard ghost) // typing, plan -> /gits-plan-anim.gif (the planning animation) -// Sources decode lazily on mount; any that fail fall back to the baked-in 36x36 -// pixel sprite. Frames draw contain-fitted (sources have mixed aspect ratios). -// The bob + glow come from CSS (.gits-mascot), running only while typing. +// All three render in one fixed 84x96 box, contain-fit + bottom-anchored, so the +// box never resizes between states. The bob + glow come from CSS (.gits-mascot), +// running only while typing. export type MascotMode = "build" | "plan"; @@ -18,107 +20,16 @@ const SOURCES = { } as const; type SourceKey = keyof typeof SOURCES; -// Decode box: 2x the 84x96 canvas. Full-res frames (484x552 x97 for the build -// ghost) would hold ~100MB of bitmaps for an 84px-wide indicator. -const DECODE_W = 168; -const DECODE_H = 192; -// 36-row sprite sampled from the reference art. E=outline, W=body, g=shade. -const GMAP = [ - "..............EEEEEEEE..............", - "............EEWWWWWWWEEE............", - ".........EEEWWWWWWWWWWWEE...........", - "........EEWWWWWWWWWWWWWWgE..........", - ".......EEWWWWWWWWWWWWWWWWgE.........", - ".......EgWWWWWWWWWWWWWWWWWEE........", - "......EEWWWWWWWWWWWWWWgEWWWE........", - "......EEWWWWWWWWWWWWWEEWWWWE........", - "......EgWgggggWWWWWgEEEEgWWEE.......", - "......EWWEEEEEWWWWWggggggWWWE.......", - "......EWWWgEEWWWWWWWEgWWWWWWE.......", - "......EWWgEgggWWWWWWWEWWWWWWE.......", - "......EWWEWWEWWWgEWWgEWWWWWgE.......", - "......EWWWWWgEWgEEEgEgWWWWWEE.......", - "......EWWWWWWEEEgWEEgWWWWWWWEEE.....", - "......EgWWWWWWWWWWWWWWWWWWWWWWgE....", - "......EEWWWWWWWWWWWWWWWWWWWWWWWgE...", - ".......EgWWWWWWWWWWWWWWWWWWWWWWgE...", - ".....EEEgWWWWWWWWWWWWWWWgEEEEEE.....", - "....EgWWWWWWWWWWWWWWWWWWEEEEEE......", - "...EgWWWWWWWWWWWWWWWWWWWE...........", - "...EWWWWWEWWWWWWWWWWWWWgE...........", - "...EEgggEEgWWWWWWWWWWWWEE...........", - "....EEEEEgEWWWWWWWWWWWgE............", - ".........EgWWWWWWWWWWgE.............", - ".........EWWWWWWWWWWEE..............", - ".........EWWWWWWWggEE...............", - ".........EWWWWWWgEEEE...............", - ".........EgWWWWgEE..................", - ".........EEWWWWgE...................", - "..........EgWWWEE...................", - "..........EEggWgE...................", - "...........EEEgggE..................", - ".............EEggE..................", - "..............EEEE..................", - "..............EEE...................", -] as const; -const GCOL: Record = { W: "#eef1f8", g: "#aeb6d8", E: "#0e1120" }; -const CELL = 2; +// The source GIFs bake the ghost at different internal scales (idle 512², build +// 484×552 with a keyboard, plan 256²), so contain-fit alone leaves the ghost body +// reading larger/smaller between states. Per-source scale evens that out. +// ponytail: eyeball knobs — nudge in-browser until the ghost holds its size. +const SCALE: Record = { idle: 1, build: 1, plan: 1 }; -type GifFrame = { bitmap: ImageBitmap; durationMs: number }; -type FrameSets = Partial>; - -function drawSprite(canvas: HTMLCanvasElement): void { - const ctx = canvas.getContext("2d"); - if (!ctx) return; - ctx.clearRect(0, 0, canvas.width, canvas.height); - const cols = GMAP[0].length; - const rows = GMAP.length; - const ox = Math.round((canvas.width - cols * CELL) / 2); - const oy = Math.round((canvas.height - rows * CELL) / 2); - for (let y = 0; y < rows; y++) { - const line = GMAP[y]; - if (!line) continue; - for (let x = 0; x < cols; x++) { - const color = GCOL[line[x] ?? "."]; - if (!color) continue; - ctx.fillStyle = color; - ctx.fillRect(ox + x * CELL, oy + y * CELL, CELL, CELL); - } - } -} - -async function loadGifFrames(url: string): Promise { - try { - const response = await fetch(url); - if (!response.ok) return null; - const data = await response.arrayBuffer(); - const decoderCtor = ( - window as unknown as { ImageDecoder?: new (init: { data: ArrayBuffer; type: string }) => any } - ).ImageDecoder; - if (!decoderCtor) return null; - const decoder = new decoderCtor({ data, type: "image/gif" }); - await decoder.tracks.ready; - const count: number = decoder.tracks.selectedTrack?.frameCount ?? 0; - if (count <= 0) return null; - const frames: GifFrame[] = []; - for (let i = 0; i < count; i++) { - const { image } = await decoder.decode({ frameIndex: i }); - // Contain-fit into the decode box, preserving each source's aspect ratio. - const scale = Math.min(DECODE_W / image.displayWidth, DECODE_H / image.displayHeight); - frames.push({ - bitmap: await createImageBitmap(image, { - resizeWidth: Math.max(1, Math.round(image.displayWidth * scale)), - resizeHeight: Math.max(1, Math.round(image.displayHeight * scale)), - }), - durationMs: Math.max(20, (image.duration ?? 40_000) / 1000), - }); - image.close(); - } - return frames.length > 0 ? frames : null; - } catch { - return null; - } -} +// isMascotTyping owns how long a keystroke keeps the typing state alive; poll a +// bit faster than that window so the state swap feels prompt without a rAF loop. +const POLL_MS = 150; +const CROSSFADE_S = 0.28; export function ChatMascot({ typingRef, @@ -127,113 +38,38 @@ export function ChatMascot({ typingRef: RefObject; mode: MascotMode; }) { - const canvasRef = useRef(null); - const containerRef = useRef(null); - const modeRef = useRef(mode); - modeRef.current = mode; + const [typing, setTyping] = useState(false); + const reduceMotion = useReducedMotion(); useEffect(() => { - const canvas = canvasRef.current; - const container = containerRef.current; - if (!canvas || !container) return; - - drawSprite(canvas); - - let stopped = false; - const frameSets: FrameSets = {}; - let activeSource: SourceKey | null = null; - let frameIndex = 0; - let accumMs = 0; - let prevTs = 0; - let raf = 0; - - for (const key of Object.keys(SOURCES) as SourceKey[]) { - void loadGifFrames(SOURCES[key]).then((loaded) => { - if (!stopped && loaded) frameSets[key] = loaded; - }); - } - - const drawFrame = (frame: GifFrame) => { - const ctx = canvas.getContext("2d"); - if (!ctx) return; - ctx.clearRect(0, 0, canvas.width, canvas.height); - // Contain-centered: sources have mixed aspect ratios (square idle/plan, - // tall keyboard ghost), all anchored to the bottom of the canvas. - const scale = Math.min( - canvas.width / frame.bitmap.width, - canvas.height / frame.bitmap.height, - ); - const w = frame.bitmap.width * scale; - const h = frame.bitmap.height * scale; - ctx.drawImage(frame.bitmap, (canvas.width - w) / 2, canvas.height - h, w, h); - }; - - const tick = (ts: number) => { - if (stopped) return; - const typing = isMascotTyping(typingRef.current ?? 0, Date.now()); - container.style.animationPlayState = typing ? "running" : "paused"; - const wanted: SourceKey = typing ? modeRef.current : "idle"; - const frames = frameSets[wanted]; - if (frames) { - if (activeSource !== wanted) { - activeSource = wanted; - frameIndex = 0; - accumMs = 0; - prevTs = ts; - drawFrame(frames[0]!); - } - if (!prevTs) prevTs = ts; - accumMs += ts - prevTs; - let moved = false; - let current = frames[frameIndex]; - while (current && accumMs >= current.durationMs) { - accumMs -= current.durationMs; - frameIndex = (frameIndex + 1) % frames.length; - current = frames[frameIndex]; - moved = true; - } - if (moved && current) drawFrame(current); - } - prevTs = ts; - raf = requestAnimationFrame(tick); - }; - - // The loop only runs while the tab is visible; the visibility listener - // restarts it. Idle now animates too (the idle GIF), so unlike the earlier - // typing-only loop this runs whenever the mascot is on screen. - const start = () => { - if (stopped || document.hidden) return; - cancelAnimationFrame(raf); - prevTs = 0; - raf = requestAnimationFrame(tick); - }; - const onVisibility = () => { - if (document.hidden) { - cancelAnimationFrame(raf); - } else { - start(); - } - }; - document.addEventListener("visibilitychange", onVisibility); - start(); - - return () => { - stopped = true; - document.removeEventListener("visibilitychange", onVisibility); - cancelAnimationFrame(raf); - for (const frames of Object.values(frameSets)) { - for (const frame of frames ?? []) frame.bitmap.close(); - } - }; + const poll = () => setTyping(isMascotTyping(typingRef.current ?? 0, Date.now())); + poll(); + const id = setInterval(poll, POLL_MS); + return () => clearInterval(id); }, [typingRef]); + const active: SourceKey = typing ? mode : "idle"; + return ( ); } diff --git a/bun.lock b/bun.lock index 6f727dca0d5..5a79572b0c0 100644 --- a/bun.lock +++ b/bun.lock @@ -177,6 +177,7 @@ "effect": "catalog:", "lexical": "^0.41.0", "lucide-react": "^0.564.0", + "motion": "^12.42.2", "react": "19.2.6", "react-dom": "19.2.6", "react-markdown": "^10.1.0", @@ -2066,6 +2067,8 @@ "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + "framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="], + "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], @@ -2596,6 +2599,12 @@ "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + "motion": ["motion@12.42.2", "", { "dependencies": { "framer-motion": "^12.42.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q=="], + + "motion-dom": ["motion-dom@12.42.2", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA=="], + + "motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],