import { useEffect, useRef, useState } from 'react' import { isPetalsEnabled, onPetalsEnabledChange } from './petals' // PetalFall is the cozy ambient layer: a fixed, non-interactive 2D canvas that // drifts soft cherry-blossom petals down the page. Each petal is a real // petal-shaped sprite (notched sakura silhouette with a gentle gradient and // soft alpha), pre-rendered once per color and then blitted many times with // rotation + sway. It sits behind the chrome, ignores pointer events, pauses // when the tab is hidden, and renders nothing for prefers-reduced-motion. // Saturated-but-soft blossom colors. Each petal fades from a light, near-white // tip to its color at the base, so it reads as a real petal rather than a blob. const PALETTE: { r: number; g: number; b: number }[] = [ { r: 255, g: 150, b: 190 }, // rose pink { r: 255, g: 184, b: 205 }, // pale blossom { r: 255, g: 158, b: 130 }, // warm peach { r: 209, g: 160, b: 255 }, // lavender { r: 255, g: 122, b: 172 }, // deep rose ] // Night-mode sparkle palette: soft starlight tones for the small twinkle accents // scattered between the chunky cartoon stars (moonlit white, warm gold). const STAR_PALETTE: { r: number; g: number; b: number }[] = [ { r: 255, g: 255, b: 255 }, // white { r: 255, g: 240, b: 200 }, // warm gold ] // Chunky cartoon-star colors — bold, candy-bright fills à la a Mario power star // or a Kirby warp star. Each is drawn with a glossy gradient + a puffy outline. const CARTOON_COLORS: { r: number; g: number; b: number }[] = [ { r: 255, g: 201, b: 56 }, // golden power-star { r: 255, g: 150, b: 190 }, // bubblegum pink { r: 130, g: 195, b: 255 }, // sky blue { r: 150, g: 225, b: 190 }, // mint { r: 200, g: 175, b: 255 }, // lavender ] const SPRITE_PX = 160 // off-screen render resolution per petal sprite function rgba(c: { r: number; g: number; b: number }, a: number): string { return `rgba(${Math.round(c.r)}, ${Math.round(c.g)}, ${Math.round(c.b)}, ${a})` } function mixWhite(c: { r: number; g: number; b: number }, t: number) { return { r: c.r + (255 - c.r) * t, g: c.g + (255 - c.g) * t, b: c.b + (255 - c.b) * t } } function scale(c: { r: number; g: number; b: number }, t: number) { return { r: c.r * t, g: c.g * t, b: c.b * t } } // Draw one petal sprite: a notched sakura silhouette filled with a tip→base // gradient and a faint central highlight, on a transparent canvas. function makeSprite(color: { r: number; g: number; b: number }): HTMLCanvasElement { const s = SPRITE_PX const cv = document.createElement('canvas') cv.width = s cv.height = s const g = cv.getContext('2d')! g.translate(s / 2, s / 2) const hw = s * 0.3 // half width const hh = s * 0.44 // half height g.beginPath() g.moveTo(0, hh) // pointed base g.bezierCurveTo(hw, hh * 0.42, hw * 0.92, -hh * 0.52, hw * 0.2, -hh * 0.92) // right side → near tip g.quadraticCurveTo(0, -hh * 0.72, -hw * 0.2, -hh * 0.92) // the sakura notch g.bezierCurveTo(-hw * 0.92, -hh * 0.52, -hw, hh * 0.42, 0, hh) // left side → base g.closePath() const grad = g.createLinearGradient(0, -hh, 0, hh) grad.addColorStop(0, rgba(mixWhite(color, 0.55), 0.78)) // light, soft tip grad.addColorStop(0.45, rgba(color, 0.88)) grad.addColorStop(1, rgba(scale(color, 0.8), 0.95)) // deeper base g.fillStyle = grad g.fill() // A soft white sheen down the centre for a delicate, glossy feel. const sheen = g.createLinearGradient(-hw * 0.3, 0, hw * 0.3, 0) sheen.addColorStop(0, 'rgba(255,255,255,0)') sheen.addColorStop(0.5, 'rgba(255,255,255,0.28)') sheen.addColorStop(1, 'rgba(255,255,255,0)') g.fillStyle = sheen g.fill() return cv } // Draw one star sprite: a soft radial glow with a crisp four-point sparkle on // top, filled brightest at the core so it reads as a twinkling star. function makeStarSprite(color: { r: number; g: number; b: number }): HTMLCanvasElement { const s = SPRITE_PX const cv = document.createElement('canvas') cv.width = s cv.height = s const g = cv.getContext('2d')! g.translate(s / 2, s / 2) // Soft halo behind the sparkle. const glow = g.createRadialGradient(0, 0, 0, 0, 0, s * 0.5) glow.addColorStop(0, rgba(mixWhite(color, 0.5), 0.5)) glow.addColorStop(0.35, rgba(color, 0.16)) glow.addColorStop(1, rgba(color, 0)) g.fillStyle = glow g.beginPath() g.arc(0, 0, s * 0.5, 0, Math.PI * 2) g.fill() // Four-point sparkle: alternate a long outer radius with a small inner one so // the points pinch in sharply. const R = s * 0.46 const r = s * 0.08 g.beginPath() for (let i = 0; i < 8; i++) { const ang = (i * Math.PI) / 4 - Math.PI / 2 const rad = i % 2 === 0 ? R : r const x = Math.cos(ang) * rad const y = Math.sin(ang) * rad if (i === 0) g.moveTo(x, y) else g.lineTo(x, y) } g.closePath() const core = g.createRadialGradient(0, 0, 0, 0, 0, R) core.addColorStop(0, rgba(mixWhite(color, 0.7), 0.98)) core.addColorStop(0.5, rgba(color, 0.9)) core.addColorStop(1, rgba(color, 0.2)) g.fillStyle = core g.fill() return cv } // Draw one chunky cartoon star — a fat five-point star with a glossy radial // fill, a soft outer glow so it pops off the dark sky, a puffy rounded outline // (round line joins → Kirby-ish soft points), and a little corner shine. Reads // like a game power-up rather than a delicate sparkle. function makeCartoonStar(base: { r: number; g: number; b: number }): HTMLCanvasElement { const s = SPRITE_PX const cv = document.createElement('canvas') cv.width = s cv.height = s const g = cv.getContext('2d')! g.translate(s / 2, s / 2) const R = s * 0.4 // outer point radius const r = R * 0.46 // inner radius — small ⇒ chunky, defined points // Soft outer glow so the star separates from the night background. const glow = g.createRadialGradient(0, 0, R * 0.3, 0, 0, R * 1.35) glow.addColorStop(0, rgba(base, 0.35)) glow.addColorStop(1, rgba(base, 0)) g.fillStyle = glow g.beginPath() g.arc(0, 0, R * 1.35, 0, Math.PI * 2) g.fill() // Five-point star path (start at the top point). const star = () => { g.beginPath() for (let i = 0; i < 10; i++) { const ang = -Math.PI / 2 + (i * Math.PI) / 5 const rad = i % 2 === 0 ? R : r const x = Math.cos(ang) * rad const y = Math.sin(ang) * rad if (i === 0) g.moveTo(x, y) else g.lineTo(x, y) } g.closePath() } // Candy fill: bright near the top-left, deepening toward the lower edge. const fill = g.createRadialGradient(-R * 0.25, -R * 0.3, R * 0.1, 0, 0, R * 1.1) fill.addColorStop(0, rgba(mixWhite(base, 0.7), 1)) fill.addColorStop(0.5, rgba(base, 1)) fill.addColorStop(1, rgba(scale(base, 0.82), 1)) star() g.fillStyle = fill g.fill() // Puffy outline in a deeper shade of the same hue (round joins soften the // points so it feels hand-drawn, not spiky). g.lineJoin = 'round' g.lineCap = 'round' g.lineWidth = s * 0.05 g.strokeStyle = rgba(scale(base, 0.5), 0.95) star() g.stroke() // Glossy shine: a small soft white blob up in the top-left lobe. const shine = g.createRadialGradient(-R * 0.22, -R * 0.32, 0, -R * 0.22, -R * 0.32, R * 0.3) shine.addColorStop(0, 'rgba(255,255,255,0.85)') shine.addColorStop(1, 'rgba(255,255,255,0)') g.fillStyle = shine g.beginPath() g.ellipse(-R * 0.22, -R * 0.32, R * 0.26, R * 0.18, -0.5, 0, Math.PI * 2) g.fill() return cv } interface Petal { baseX: number y: number size: number vy: number // fall speed (px/s) angle: number spin: number // rad/s swayAmp: number swayFreq: number swayPhase: number alpha: number sprite: number } export function PetalFall({ night = false }: { night?: boolean }) { const canvasRef = useRef(null) const [enabled, setEnabled] = useState(isPetalsEnabled) // Let the status-bar toggle switch the ambient layer on/off live. useEffect(() => onPetalsEnabledChange(setEnabled), []) useEffect(() => { if (!enabled) { // User turned the ambient petals off — wipe any frame left on the canvas. const el = canvasRef.current el?.getContext('2d')?.clearRect(0, 0, el.width, el.height) return } const reduce = typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches if (reduce) return // respect users who don't want ambient motion const el = canvasRef.current if (!el) return const c2d = el.getContext('2d') if (!c2d) return // Non-null aliases so the render closures keep the narrowed types. const canvas = el const ctx = c2d // Night sky = chunky cartoon stars (the stars of the show) with a couple of // small twinkle sparkles mixed in for depth. Random sprite pick weights it // ~70% chunky stars. const sprites = night ? [...CARTOON_COLORS.map(makeCartoonStar), ...STAR_PALETTE.map(makeStarSprite)] : PALETTE.map(makeSprite) const dpr = Math.min(window.devicePixelRatio || 1, 2) let W = window.innerWidth let H = window.innerHeight // Scale petal count to the viewport so it stays sparse and gentle. const count = Math.round(Math.min(46, Math.max(16, (W * H) / 46000))) const rnd = (a: number, b: number) => a + Math.random() * (b - a) // Stars are smaller, drift down a touch slower, sway less, and barely spin // (they twinkle in place instead of tumbling like petals). const spawn = (initial: boolean): Petal => night ? { baseX: rnd(0, W), y: initial ? rnd(-H, H) : rnd(-60, -20), size: rnd(13, 32), vy: rnd(14, 36), angle: rnd(0, Math.PI * 2), // Every star visibly spins — random direction, and a guaranteed // minimum speed (a 5-point star is symmetric every 72°, so a slow // rate reads as motionless). Varied so no two turn quite alike. spin: (Math.random() < 0.5 ? -1 : 1) * rnd(0.5, 2.2), swayAmp: 0, // stars fall straight down — no petal-like drift swayFreq: rnd(0.4, 1.1), // still drives the twinkle shimmer below swayPhase: rnd(0, Math.PI * 2), alpha: rnd(0.55, 0.9), sprite: Math.floor(Math.random() * sprites.length), } : { baseX: rnd(0, W), y: initial ? rnd(-H, H) : rnd(-60, -20), size: rnd(16, 38), vy: rnd(22, 52), angle: rnd(0, Math.PI * 2), spin: rnd(-0.9, 0.9), swayAmp: rnd(14, 42), swayFreq: rnd(0.3, 0.85), swayPhase: rnd(0, Math.PI * 2), alpha: rnd(0.5, 0.82), sprite: Math.floor(Math.random() * sprites.length), } let petals = Array.from({ length: count }, () => spawn(true)) function resize() { W = window.innerWidth H = window.innerHeight canvas.width = Math.floor(W * dpr) canvas.height = Math.floor(H * dpr) canvas.style.width = `${W}px` canvas.style.height = `${H}px` ctx.setTransform(dpr, 0, 0, dpr, 0, 0) } resize() window.addEventListener('resize', resize) let raf = 0 let running = true let last = performance.now() function frame(now: number) { if (!running) return const dt = Math.min(0.05, (now - last) / 1000) // clamp big gaps (tab wake) last = now const t = now / 1000 ctx.clearRect(0, 0, W, H) for (const p of petals) { p.y += p.vy * dt p.angle += p.spin * dt const x = p.baseX + p.swayAmp * Math.sin(t * p.swayFreq + p.swayPhase) // Recycle once fully past the bottom. if (p.y - p.size > H) { Object.assign(p, spawn(false)) continue } // Stars shimmer gently (shallow pulse so the chunky ones glow rather // than blink); petals hold a steady alpha. const alpha = night ? p.alpha * (0.8 + 0.2 * Math.sin(t * p.swayFreq * 2.2 + p.swayPhase)) : p.alpha ctx.save() ctx.translate(x, p.y) ctx.rotate(p.angle) ctx.globalAlpha = alpha ctx.drawImage(sprites[p.sprite], -p.size / 2, -p.size / 2, p.size, p.size) ctx.restore() } raf = requestAnimationFrame(frame) } const onVisibility = () => { if (document.hidden) { running = false cancelAnimationFrame(raf) } else if (!running) { running = true last = performance.now() raf = requestAnimationFrame(frame) } } document.addEventListener('visibilitychange', onVisibility) raf = requestAnimationFrame(frame) return () => { running = false cancelAnimationFrame(raf) window.removeEventListener('resize', resize) document.removeEventListener('visibilitychange', onVisibility) petals = [] } }, [night, enabled]) return ( ) }