3 Commits

Author SHA1 Message Date
prosolis
045ec19b92 Petals: 2D sprite blossoms instead of shader blobs
The procedural WebGL petals read as faint smudges. Replace with a 2D
canvas that pre-renders real notched sakura petal sprites (tip→base
gradient, soft central sheen, transparent edges) and blits falling,
rotating, swaying instances. Count scales with viewport; pauses when
hidden; skipped under prefers-reduced-motion.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 07:47:13 -07:00
prosolis
f6fa73b17b Petals: saturated blossom tints + glossy shading
The pale pastel tints washed out to grey over the cream canvas. Use
saturated cherry-blossom colors, add a luminous centre + soft base
shadow per petal, and render at full opacity so they read as colorful.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 07:41:53 -07:00
prosolis
8cf78130bf Merge: ambient WebGL petals + cute notification sounds
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 07:34:46 -07:00
2 changed files with 137 additions and 204 deletions

View File

@@ -1,118 +1,84 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
// PetalFall is the cozy ambient layer: a full-window WebGL canvas that drifts a // PetalFall is the cozy ambient layer: a fixed, non-interactive 2D canvas that
// handful of soft flower petals down the page. It sits behind everything, // drifts soft cherry-blossom petals down the page. Each petal is a real
// ignores pointer events, and stays sparse so it reads as "occasional petals on // petal-shaped sprite (notched sakura silhouette with a gentle gradient and
// a breeze" rather than confetti. Honors prefers-reduced-motion by rendering // soft alpha), pre-rendered once per color and then blitted many times with
// nothing. No external libraries — a single tiny shader program animates every // rotation + sway. It sits behind the chrome, ignores pointer events, pauses
// petal entirely on the GPU, so the CPU just spins a clock. // when the tab is hidden, and renders nothing for prefers-reduced-motion.
// Each petal is a quad whose corners we expand in the vertex shader. We pack one // Saturated-but-soft blossom colors. Each petal fades from a light, near-white
// quad as 6 vertices; per-vertex we send the corner offset (which corner) plus // tip to its color at the base, so it reads as a real petal rather than a blob.
// the petal's static randomness (lane, speed, size, sway, tint, spin). const PALETTE: { r: number; g: number; b: number }[] = [
const PETAL_COUNT = 26 { r: 255, g: 150, b: 190 }, // rose pink
{ r: 255, g: 184, b: 205 }, // pale blossom
// Soft Petal-palette tints (rose / blush / lavender), as RGB 0..1. { r: 255, g: 158, b: 130 }, // warm peach
const TINTS: [number, number, number][] = [ { r: 209, g: 160, b: 255 }, // lavender
[0.91, 0.63, 0.75], // accent rose #E8A0BF { r: 255, g: 122, b: 172 }, // deep rose
[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 = ` const SPRITE_PX = 160 // off-screen render resolution per petal sprite
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() { function rgba(c: { r: number; g: number; b: number }, a: number): string {
v_uv = a_corner + 0.5; return `rgba(${Math.round(c.r)}, ${Math.round(c.g)}, ${Math.round(c.b)}, ${a})`
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);
} }
` 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 }
const FRAG = ` }
precision mediump float; function scale(c: { r: number; g: number; b: number }, t: number) {
varying vec2 v_uv; return { r: c.r * t, g: c.g * t, b: c.b * t }
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() { // Draw one petal sprite: a notched sakura silhouette filled with a tip→base
float m = petalMask(v_uv); // gradient and a faint central highlight, on a transparent canvas.
if (m < 0.01) discard; function makeSprite(color: { r: number; g: number; b: number }): HTMLCanvasElement {
int idx = int(v_tint + 0.5); const s = SPRITE_PX
vec3 col = u_tints[0]; const cv = document.createElement('canvas')
if (idx == 1) col = u_tints[1]; cv.width = s
else if (idx == 2) col = u_tints[2]; cv.height = s
else if (idx == 3) col = u_tints[3]; const g = cv.getContext('2d')!
else if (idx == 4) col = u_tints[4]; g.translate(s / 2, s / 2)
gl_FragColor = vec4(col, m * v_alpha * 0.55);
}
`
function compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader | null { const hw = s * 0.3 // half width
const sh = gl.createShader(type) const hh = s * 0.44 // half height
if (!sh) return null
gl.shaderSource(sh, src) g.beginPath()
gl.compileShader(sh) g.moveTo(0, hh) // pointed base
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { g.bezierCurveTo(hw, hh * 0.42, hw * 0.92, -hh * 0.52, hw * 0.2, -hh * 0.92) // right side → near tip
console.error('petal shader compile failed', gl.getShaderInfoLog(sh)) g.quadraticCurveTo(0, -hh * 0.72, -hw * 0.2, -hh * 0.92) // the sakura notch
gl.deleteShader(sh) g.bezierCurveTo(-hw * 0.92, -hh * 0.52, -hw, hh * 0.42, 0, hh) // left side → base
return null g.closePath()
}
return sh 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
} }
// Deterministic-ish pseudo random (we can't use Math.random at module scope for interface Petal {
// SSR safety, but here in an effect it's fine; kept simple). baseX: number
function rand(): number { y: number
return Math.random() 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() { export function PetalFall() {
@@ -124,120 +90,90 @@ export function PetalFall() {
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
if (reduce) return // respect users who don't want ambient motion if (reduce) return // respect users who don't want ambient motion
const canvas = canvasRef.current const el = canvasRef.current
if (!canvas) return if (!el) return
const gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: false, antialias: true }) const c2d = el.getContext('2d')
if (!gl) return // no WebGL — silently skip the effect if (!c2d) return
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. // Non-null aliases so the render closures keep the narrowed types.
const view = canvas const canvas = el
const glc = gl const ctx = c2d
const sprites = PALETTE.map(makeSprite)
const dpr = Math.min(window.devicePixelRatio || 1, 2) 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)
const spawn = (initial: boolean): Petal => ({
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() { function resize() {
const w = Math.floor(window.innerWidth * dpr) W = window.innerWidth
const h = Math.floor(window.innerHeight * dpr) H = window.innerHeight
if (view.width !== w || view.height !== h) { canvas.width = Math.floor(W * dpr)
view.width = w canvas.height = Math.floor(H * dpr)
view.height = h canvas.style.width = `${W}px`
} canvas.style.height = `${H}px`
glc.viewport(0, 0, view.width, view.height) ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
glc.uniform2f(uRes, view.width, view.height)
} }
resize() resize()
window.addEventListener('resize', resize) window.addEventListener('resize', resize)
let raf = 0 let raf = 0
let running = true let running = true
const start = performance.now() let last = performance.now()
function frame() {
function frame(now: number) {
if (!running) return if (!running) return
const t = (performance.now() - start) / 1000 const dt = Math.min(0.05, (now - last) / 1000) // clamp big gaps (tab wake)
glc.clearColor(0, 0, 0, 0) last = now
glc.clear(glc.COLOR_BUFFER_BIT) const t = now / 1000
glc.uniform1f(uTime, t) ctx.clearRect(0, 0, W, H)
glc.drawArrays(glc.TRIANGLES, 0, PETAL_COUNT * 6)
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
}
ctx.save()
ctx.translate(x, p.y)
ctx.rotate(p.angle)
ctx.globalAlpha = p.alpha
ctx.drawImage(sprites[p.sprite], -p.size / 2, -p.size / 2, p.size, p.size)
ctx.restore()
}
raf = requestAnimationFrame(frame) raf = requestAnimationFrame(frame)
} }
// Pause when the tab is hidden to save the battery.
const onVisibility = () => { const onVisibility = () => {
if (document.hidden) { if (document.hidden) {
running = false running = false
cancelAnimationFrame(raf) cancelAnimationFrame(raf)
} else if (!running) { } else if (!running) {
running = true running = true
last = performance.now()
raf = requestAnimationFrame(frame) raf = requestAnimationFrame(frame)
} }
} }
@@ -249,10 +185,7 @@ export function PetalFall() {
cancelAnimationFrame(raf) cancelAnimationFrame(raf)
window.removeEventListener('resize', resize) window.removeEventListener('resize', resize)
document.removeEventListener('visibilitychange', onVisibility) document.removeEventListener('visibilitychange', onVisibility)
gl.deleteBuffer(buf) petals = []
gl.deleteProgram(prog)
gl.deleteShader(vs)
gl.deleteShader(fs)
} }
}, []) }, [])

View File

@@ -388,7 +388,7 @@ button, a, input {
the writing. It hides itself in print and is skipped entirely for users who the writing. It hides itself in print and is skipped entirely for users who
prefer reduced motion (the component renders nothing in that case). */ prefer reduced motion (the component renders nothing in that case). */
.petal-fall { .petal-fall {
opacity: 0.85; opacity: 1;
} }
/* Honor reduced-motion globally: still the looping ambient animations and drop /* Honor reduced-motion globally: still the looping ambient animations and drop