Ambient WebGL petals + cute notification sounds

Add a fixed, GPU-driven petal layer that drifts soft blossoms over the
page (sparse, translucent, paused when hidden, skipped under
prefers-reduced-motion). Add a synthesized cute sound palette — bubble
pop, water droplet, soft bell, sparkle, cheer — generated as embedded
WAV assets (scripts/gen_sounds.py) and played through a quiet Web Audio
bus with soft-clipping and a persisted mute toggle in the status bar.

Sounds fire per suggestion type when fresh advice arrives (staggered,
old pending advice stays silent) and on companion bubbles by tone.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 07:34:23 -07:00
parent 0a1ea225dd
commit 2487e73551
14 changed files with 706 additions and 0 deletions

View File

@@ -0,0 +1,267 @@
import { useEffect, useRef } from 'react'
// PetalFall is the cozy ambient layer: a full-window WebGL canvas that drifts a
// handful of soft flower petals down the page. It sits behind everything,
// ignores pointer events, and stays sparse so it reads as "occasional petals on
// a breeze" rather than confetti. Honors prefers-reduced-motion by rendering
// nothing. No external libraries — a single tiny shader program animates every
// petal entirely on the GPU, so the CPU just spins a clock.
// Each petal is a quad whose corners we expand in the vertex shader. We pack one
// quad as 6 vertices; per-vertex we send the corner offset (which corner) plus
// the petal's static randomness (lane, speed, size, sway, tint, spin).
const PETAL_COUNT = 26
// Soft Petal-palette tints (rose / blush / lavender), as RGB 0..1.
const TINTS: [number, number, number][] = [
[0.91, 0.63, 0.75], // accent rose #E8A0BF
[1.0, 0.94, 0.96], // near-white blush
[0.96, 0.72, 0.63], // peach #F4B8A0
[0.77, 0.71, 0.91], // lavender #C5B4E8
[0.85, 0.55, 0.69], // deeper rose
]
const VERT = `
precision mediump float;
attribute vec2 a_corner; // quad corner in [-0.5,0.5]
attribute vec4 a_rand; // x: lane(0..1), y: speed, z: size, w: phase
attribute vec4 a_rand2; // x: swayAmp, y: swayFreq, z: spin, w: tintIdx
uniform float u_time;
uniform vec2 u_res;
varying vec2 v_uv;
varying float v_tint;
varying float v_alpha;
void main() {
v_uv = a_corner + 0.5;
v_tint = a_rand2.w;
float lane = a_rand.x;
float speed = a_rand.y;
float size = a_rand.z;
float phase = a_rand.w;
// Vertical fall: loop 0..1 over time, offset per petal so they don't sync.
float fall = fract(u_time * speed + phase);
// Map to clip space: start above the top (1.15) fall to below bottom (-1.15).
float y = 1.15 - fall * 2.3;
// Horizontal: a base lane plus a gentle sway as it descends.
float sway = a_rand2.x * sin(u_time * a_rand2.y + phase * 6.2831);
float x = (lane * 2.0 - 1.0) + sway;
// Spin the quad over time.
float ang = u_time * a_rand2.z + phase * 6.2831;
float c = cos(ang), s = sin(ang);
vec2 corner = a_corner * size;
// Keep petals visually square despite aspect ratio.
corner.x *= u_res.y / u_res.x;
vec2 rot = vec2(corner.x * c - corner.y * s, corner.x * s + corner.y * c);
// Fade in near the top and out near the bottom so they don't pop.
v_alpha = smoothstep(0.0, 0.12, fall) * (1.0 - smoothstep(0.86, 1.0, fall));
gl_Position = vec4(x + rot.x, y + rot.y, 0.0, 1.0);
}
`
const FRAG = `
precision mediump float;
varying vec2 v_uv;
varying float v_tint;
varying float v_alpha;
uniform vec3 u_tints[5];
// A soft petal/teardrop alpha mask centered in the quad.
float petalMask(vec2 uv) {
vec2 p = uv - vec2(0.5);
// Teardrop: pinch the top, round the bottom.
float r = length(vec2(p.x * 1.65, p.y * 1.15 + 0.12));
float body = smoothstep(0.5, 0.16, r);
// A subtle crease down the middle for a petal feel.
float crease = 1.0 - 0.18 * exp(-pow(p.x * 7.0, 2.0));
return body * crease;
}
void main() {
float m = petalMask(v_uv);
if (m < 0.01) discard;
int idx = int(v_tint + 0.5);
vec3 col = u_tints[0];
if (idx == 1) col = u_tints[1];
else if (idx == 2) col = u_tints[2];
else if (idx == 3) col = u_tints[3];
else if (idx == 4) col = u_tints[4];
gl_FragColor = vec4(col, m * v_alpha * 0.55);
}
`
function compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader | null {
const sh = gl.createShader(type)
if (!sh) return null
gl.shaderSource(sh, src)
gl.compileShader(sh)
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
console.error('petal shader compile failed', gl.getShaderInfoLog(sh))
gl.deleteShader(sh)
return null
}
return sh
}
// Deterministic-ish pseudo random (we can't use Math.random at module scope for
// SSR safety, but here in an effect it's fine; kept simple).
function rand(): number {
return Math.random()
}
export function PetalFall() {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const reduce =
typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
if (reduce) return // respect users who don't want ambient motion
const canvas = canvasRef.current
if (!canvas) return
const gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: false, antialias: true })
if (!gl) return // no WebGL — silently skip the effect
const vs = compile(gl, gl.VERTEX_SHADER, VERT)
const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG)
if (!vs || !fs) return
const prog = gl.createProgram()!
gl.attachShader(prog, vs)
gl.attachShader(prog, fs)
gl.linkProgram(prog)
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
console.error('petal program link failed', gl.getProgramInfoLog(prog))
return
}
gl.useProgram(prog)
// Build interleaved vertex data: 6 verts per petal, each with corner(2),
// rand(4), rand2(4) = 10 floats.
const FLOATS_PER_VERT = 10
const corners = [
[-0.5, -0.5],
[0.5, -0.5],
[-0.5, 0.5],
[-0.5, 0.5],
[0.5, -0.5],
[0.5, 0.5],
]
const data = new Float32Array(PETAL_COUNT * 6 * FLOATS_PER_VERT)
let o = 0
for (let i = 0; i < PETAL_COUNT; i++) {
const lane = rand()
const speed = 0.018 + rand() * 0.03 // slow, gentle fall
const size = 0.05 + rand() * 0.06
const phase = rand()
const swayAmp = 0.04 + rand() * 0.09
const swayFreq = 0.4 + rand() * 0.7
const spin = (rand() - 0.5) * 1.2
const tint = Math.floor(rand() * TINTS.length)
for (let c = 0; c < 6; c++) {
data[o++] = corners[c][0]
data[o++] = corners[c][1]
data[o++] = lane
data[o++] = speed
data[o++] = size
data[o++] = phase
data[o++] = swayAmp
data[o++] = swayFreq
data[o++] = spin
data[o++] = tint
}
}
const buf = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW)
const stride = FLOATS_PER_VERT * 4
const aCorner = gl.getAttribLocation(prog, 'a_corner')
const aRand = gl.getAttribLocation(prog, 'a_rand')
const aRand2 = gl.getAttribLocation(prog, 'a_rand2')
gl.enableVertexAttribArray(aCorner)
gl.vertexAttribPointer(aCorner, 2, gl.FLOAT, false, stride, 0)
gl.enableVertexAttribArray(aRand)
gl.vertexAttribPointer(aRand, 4, gl.FLOAT, false, stride, 2 * 4)
gl.enableVertexAttribArray(aRand2)
gl.vertexAttribPointer(aRand2, 4, gl.FLOAT, false, stride, 6 * 4)
const uTime = gl.getUniformLocation(prog, 'u_time')
const uRes = gl.getUniformLocation(prog, 'u_res')
const uTints = gl.getUniformLocation(prog, 'u_tints')
gl.uniform3fv(uTints, TINTS.flat())
gl.enable(gl.BLEND)
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
gl.disable(gl.DEPTH_TEST)
// Non-null aliases so the render closures keep the narrowed types.
const view = canvas
const glc = gl
const dpr = Math.min(window.devicePixelRatio || 1, 2)
function resize() {
const w = Math.floor(window.innerWidth * dpr)
const h = Math.floor(window.innerHeight * dpr)
if (view.width !== w || view.height !== h) {
view.width = w
view.height = h
}
glc.viewport(0, 0, view.width, view.height)
glc.uniform2f(uRes, view.width, view.height)
}
resize()
window.addEventListener('resize', resize)
let raf = 0
let running = true
const start = performance.now()
function frame() {
if (!running) return
const t = (performance.now() - start) / 1000
glc.clearColor(0, 0, 0, 0)
glc.clear(glc.COLOR_BUFFER_BIT)
glc.uniform1f(uTime, t)
glc.drawArrays(glc.TRIANGLES, 0, PETAL_COUNT * 6)
raf = requestAnimationFrame(frame)
}
// Pause when the tab is hidden to save the battery.
const onVisibility = () => {
if (document.hidden) {
running = false
cancelAnimationFrame(raf)
} else if (!running) {
running = true
raf = requestAnimationFrame(frame)
}
}
document.addEventListener('visibilitychange', onVisibility)
raf = requestAnimationFrame(frame)
return () => {
running = false
cancelAnimationFrame(raf)
window.removeEventListener('resize', resize)
document.removeEventListener('visibilitychange', onVisibility)
gl.deleteBuffer(buf)
gl.deleteProgram(prog)
gl.deleteShader(vs)
gl.deleteShader(fs)
}
}, [])
return (
<canvas
ref={canvasRef}
aria-hidden
className="petal-fall pointer-events-none fixed inset-0"
style={{ zIndex: 20 }}
/>
)
}