Merge: ambient WebGL petals + cute notification sounds

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

165
scripts/gen_sounds.py Normal file
View File

@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Generate Petal's cute UI sound palette as small WAV assets.
These are warm, rounded little sounds — bubble pops, water drops, soft bells —
played when suggestions and companion bubbles appear. We synthesize them here
(rather than ship someone else's clips) so they match the app's gentle aesthetic
exactly and stay tiny. Output lands in web/src/assets/sounds/ where Vite bundles
and fingerprints them into dist (and so into the embedded Go binary).
Run: python3 scripts/gen_sounds.py
"""
import math
import os
import struct
import wave
SR = 44100
OUT = os.path.join(os.path.dirname(__file__), "..", "web", "src", "assets", "sounds")
def env(n, attack, decay, total):
"""Smooth attack + exponential decay envelope over `total` seconds."""
a = max(1, int(attack * SR))
out = []
for i in range(n):
t = i / SR
if i < a:
amp = i / a
else:
amp = math.exp(-(t - attack) / decay)
out.append(amp)
return out
def sine(freq_at, n):
"""Sine with a per-sample frequency function freq_at(t)->Hz (phase-accurate)."""
out = []
phase = 0.0
for i in range(n):
t = i / SR
f = freq_at(t)
phase += 2 * math.pi * f / SR
out.append(math.sin(phase))
return out
def mix(*layers):
n = max(len(l) for l in layers)
out = [0.0] * n
for l in layers:
for i, v in enumerate(l):
out[i] += v
return out
def soft_clip(s):
return [math.tanh(v * 1.2) for v in s]
def normalize(s, peak=0.9):
m = max(1e-9, max(abs(v) for v in s))
g = peak / m
return [v * g for v in s]
def write_wav(name, samples):
samples = soft_clip(samples)
samples = normalize(samples, 0.85)
# 4ms fade-out tail so nothing clicks at the end.
tail = int(0.004 * SR)
for i in range(tail):
samples[-1 - i] *= i / tail
os.makedirs(OUT, exist_ok=True)
path = os.path.join(OUT, name)
with wave.open(path, "w") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(SR)
frames = b"".join(struct.pack("<h", int(max(-1, min(1, v)) * 32767)) for v in samples)
w.writeframes(frames)
print(f"wrote {name} ({len(samples)/SR*1000:.0f} ms, {len(frames)} bytes)")
def apply_env(sig, e):
return [s * a for s, a in zip(sig, e)]
def bubble_pop():
"""A cute bubble 'bloop' — pitch swoops up fast then a rounded body."""
dur = 0.16
n = int(dur * SR)
# Rising swoop is the signature of a bubble.
body = sine(lambda t: 360 + 720 * (1 - math.exp(-t * 38)), n)
body = apply_env(body, env(n, 0.004, 0.05, dur))
# A soft octave shimmer on top.
shimmer = sine(lambda t: 1080 + 400 * (1 - math.exp(-t * 38)), n)
shimmer = apply_env(shimmer, env(n, 0.003, 0.03, dur))
shimmer = [v * 0.25 for v in shimmer]
return mix(body, shimmer)
def droplet():
"""A clean little water-drop ping — high, downward, watery tail."""
dur = 0.22
n = int(dur * SR)
main = sine(lambda t: 1500 * math.exp(-t * 3.2) + 760, n)
main = apply_env(main, env(n, 0.003, 0.06, dur))
# Tiny resonant echo for a wet feel.
echo = sine(lambda t: 980, n)
echo = apply_env(echo, env(n, 0.05, 0.07, dur))
echo = [v * 0.2 for v in echo]
return mix(main, droop_delay(echo, 0.04))
def droop_delay(sig, delay_s):
d = int(delay_s * SR)
return [0.0] * d + sig
def bell(freq, dur, partials=((1, 1.0), (2.01, 0.5), (2.78, 0.28), (4.1, 0.12))):
"""A soft inharmonic bell tone (sum of slightly detuned partials)."""
n = int(dur * SR)
layers = []
for ratio, amp in partials:
s = sine(lambda t, f=freq * ratio: f, n)
s = apply_env(s, env(n, 0.005, dur * 0.45, dur))
layers.append([v * amp for v in s])
return mix(*layers)
def chime():
"""Two gentle bell notes a soft fifth apart."""
a = bell(784, 0.42) # G5
b = bell(1175, 0.40) # D6
b = droop_delay(b, 0.10)
return mix(a, [v * 0.85 for v in b])
def shimmer():
"""Three quick ascending sparkles — glockenspiel-ish."""
notes = [988, 1319, 1976] # B5, E6, B6
layers = []
for i, f in enumerate(notes):
s = bell(f, 0.30, partials=((1, 1.0), (2.7, 0.3), (5.1, 0.1)))
layers.append(droop_delay([v * 0.8 for v in s], 0.06 * i))
return mix(*layers)
def cheer():
"""A happy little major arpeggio C5-E5-G5-C6 on bells."""
notes = [523, 659, 784, 1047]
layers = []
for i, f in enumerate(notes):
s = bell(f, 0.36)
layers.append(droop_delay([v * 0.8 for v in s], 0.075 * i))
return mix(*layers)
if __name__ == "__main__":
write_wav("pop.wav", bubble_pop())
write_wav("droplet.wav", droplet())
write_wav("chime.wav", chime())
write_wav("shimmer.wav", shimmer())
write_wav("cheer.wav", cheer())
print("done →", os.path.normpath(OUT))

View File

@@ -13,6 +13,8 @@ import { StatusBar } from './components/StatusBar/StatusBar'
import { PetalCompanion } from './components/Companion/PetalCompanion' import { PetalCompanion } from './components/Companion/PetalCompanion'
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner' import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
import { useVersionWatch } from './hooks/useVersionWatch' import { useVersionWatch } from './hooks/useVersionWatch'
import { PetalFall } from './effects/PetalFall'
import { playSuggestionSound } from './audio/sounds'
export default function App() { export default function App() {
const updateAvailable = useVersionWatch() const updateAvailable = useVersionWatch()
@@ -309,8 +311,34 @@ export default function App() {
[removeSuggestion], [removeSuggestion],
) )
// Play a soft sound when freshly-checked suggestions arrive — one per distinct
// new type, lightly staggered so a batch reads as a little melody rather than
// a pile-up. We track which ids we've already chimed for, and only chime for
// recently-created suggestions so opening a doc with old pending advice stays
// silent (the existing set was created in a past session).
const chimedRef = useRef<Set<string>>(new Set())
useEffect(() => {
const fresh = suggestions.filter((s) => !chimedRef.current.has(s.id))
fresh.forEach((s) => chimedRef.current.add(s.id))
const justMade = fresh.filter(
(s) => Date.now() - new Date(s.created_at).getTime() < 12_000,
)
if (justMade.length === 0) return
const types = [...new Set(justMade.map((s) => s.type))].slice(0, 3)
const timers = types.map((t, i) =>
window.setTimeout(() => playSuggestionSound(t), i * 150),
)
return () => timers.forEach((id) => window.clearTimeout(id))
}, [suggestions])
// Forget chimed ids when switching documents so the set can't grow unbounded.
useEffect(() => {
chimedRef.current = new Set()
}, [currentDoc?.id])
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<PetalFall />
<header <header
onMouseDown={handleChromeDown} onMouseDown={handleChromeDown}
className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-5" className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-5"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

174
web/src/audio/sounds.ts Normal file
View File

@@ -0,0 +1,174 @@
// Petal's cute sound palette. The actual tones live as tiny WAV assets in
// ./assets (generated by scripts/gen_sounds.py — warm bubble pops, water drops,
// and soft bells tuned to match the app's gentle look). Vite fingerprints and
// bundles them into dist, so they ride along inside the single Go binary; here
// we just fetch + decode them once and play them through a Web Audio bus with a
// soft master volume. The whole thing is opt-out-able and the mute choice
// persists in localStorage so it survives reloads.
import popUrl from '../assets/sounds/pop.wav'
import dropletUrl from '../assets/sounds/droplet.wav'
import chimeUrl from '../assets/sounds/chime.wav'
import shimmerUrl from '../assets/sounds/shimmer.wav'
import cheerUrl from '../assets/sounds/cheer.wav'
const STORAGE_KEY = 'petal.sound'
// Master volume — deliberately gentle. These are background delights, not alerts.
const MASTER_GAIN = 0.5
export type SoundName =
| 'pop' // a soft bubble pop — the signature "something arrived"
| 'droplet' // a single rounded water-drop ping
| 'chime' // a gentle two-note bell
| 'shimmer' // three quick ascending sparkles
| 'cheer' // a happy little major arpeggio (accepts, milestones)
const SOURCES: Record<SoundName, string> = {
pop: popUrl,
droplet: dropletUrl,
chime: chimeUrl,
shimmer: shimmerUrl,
cheer: cheerUrl,
}
let ctx: AudioContext | null = null
let master: GainNode | null = null
const buffers = new Map<SoundName, AudioBuffer>()
let enabled = readEnabled()
const listeners = new Set<(on: boolean) => void>()
function readEnabled(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) !== 'off'
} catch {
return true
}
}
export function isSoundEnabled(): boolean {
return enabled
}
export function setSoundEnabled(on: boolean): void {
enabled = on
try {
localStorage.setItem(STORAGE_KEY, on ? 'on' : 'off')
} catch {
/* private mode — choice just won't persist */
}
listeners.forEach((fn) => fn(on))
// Touching the context on enable doubles as a user-gesture unlock + warm-up.
if (on) void ensureContext()
}
export function onSoundEnabledChange(fn: (on: boolean) => void): () => void {
listeners.add(fn)
return () => listeners.delete(fn)
}
// Lazily create the AudioContext (browsers require a user gesture before audio
// can play; the first play() after a click will succeed, earlier ones resume).
async function ensureContext(): Promise<AudioContext | null> {
if (typeof window === 'undefined') return null
const AC: typeof AudioContext | undefined =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (!AC) return null
if (!ctx) {
ctx = new AC()
master = ctx.createGain()
master.gain.value = MASTER_GAIN
// A gentle soft-clip so stacked sounds never crackle.
const shaper = ctx.createWaveShaper()
shaper.curve = softClipCurve()
master.connect(shaper)
shaper.connect(ctx.destination)
}
if (ctx.state === 'suspended') {
try {
await ctx.resume()
} catch {
/* will retry on the next gesture */
}
}
return ctx
}
function softClipCurve(): Float32Array<ArrayBuffer> {
const n = 1024
const curve = new Float32Array(new ArrayBuffer(n * 4))
for (let i = 0; i < n; i++) {
const x = (i / (n - 1)) * 2 - 1
curve[i] = Math.tanh(x * 1.2)
}
return curve
}
// Fetch + decode one sound, caching the decoded buffer for instant replay.
async function load(name: SoundName, audio: AudioContext): Promise<AudioBuffer | null> {
const cached = buffers.get(name)
if (cached) return cached
try {
const res = await fetch(SOURCES[name])
const bytes = await res.arrayBuffer()
const buf = await audio.decodeAudioData(bytes)
buffers.set(name, buf)
return buf
} catch {
return null // asset missing / decode failed — sound just won't play
}
}
// Warm the cache so the first real play is instant.
async function prime(): Promise<void> {
const audio = await ensureContext()
if (!audio) return
await Promise.all((Object.keys(SOURCES) as SoundName[]).map((n) => load(n, audio)))
}
// Fire a sound. No-op when muted or audio is unavailable. Never throws — a
// delight that fails should fail silently.
export function playSound(name: SoundName): void {
if (!enabled) return
void (async () => {
const audio = await ensureContext()
if (!audio || !master || audio.state !== 'running') return
const buf = await load(name, audio)
if (!buf) return
try {
const src = audio.createBufferSource()
src.buffer = buf
src.connect(master)
src.start()
} catch {
/* audio glitch — ignore */
}
})()
}
// Map an editor suggestion type to its sound, so each kind of help has its own
// little voice (grammar pops like a bubble, idioms chime, etc.).
const SUGGESTION_SOUND: Record<string, SoundName> = {
grammar: 'pop',
phrasing: 'droplet',
idiom: 'chime',
clarity: 'shimmer',
voice: 'droplet',
}
export function playSuggestionSound(type: string): void {
playSound(SUGGESTION_SOUND[type] ?? 'pop')
}
// Unlock + warm audio on the first user gesture so the very first sound isn't
// dropped by the browser's autoplay policy. Self-removing.
if (typeof window !== 'undefined') {
const unlock = () => {
void prime()
window.removeEventListener('pointerdown', unlock)
window.removeEventListener('keydown', unlock)
}
window.addEventListener('pointerdown', unlock)
window.addEventListener('keydown', unlock)
}

View File

@@ -12,6 +12,7 @@ import {
type Line, type Line,
} from './tips' } from './tips'
import { analyzeProse } from './prose' import { analyzeProse } from './prose'
import { playSound } from '../../audio/sounds'
// The kitten's expression. Maps to a Lottie animation when assets are present, // The kitten's expression. Maps to a Lottie animation when assets are present,
// otherwise to an emoji placeholder (see PetalCompanion). // otherwise to an emoji placeholder (see PetalCompanion).
@@ -95,6 +96,11 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text
lastProactive.current = t lastProactive.current = t
} }
sleeping.current = false sleeping.current = false
// A soft sound to match the bubble's mood — celebrations cheer, gentle
// cheers sparkle, break nudges chime, and tips give a little bubble pop.
playSound(
opts?.celebrate ? 'cheer' : b.tone === 'cheer' ? 'shimmer' : b.tone === 'break' ? 'chime' : 'pop',
)
setBubble(b) setBubble(b)
setMood(opts?.celebrate ? 'celebrate' : 'talking') setMood(opts?.celebrate ? 'celebrate' : 'talking')
clearTimeout(bubbleTimer.current) clearTimeout(bubbleTimer.current)

View File

@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react'
import { isSoundEnabled, onSoundEnabledChange, playSound, setSoundEnabled } from '../../audio/sounds'
// A tiny mute toggle for Petal's cute sounds, tucked at the right of the status
// bar. Bilingual tooltip (she reads Mandarin first), and a soft confirming pop
// when sounds are turned back on so the choice is audible.
export function SoundToggle() {
const [on, setOn] = useState(isSoundEnabled)
// Stay in sync if the setting is flipped elsewhere.
useEffect(() => onSoundEnabledChange(setOn), [])
const toggle = () => {
const next = !on
setSoundEnabled(next)
setOn(next)
if (next) playSound('pop') // a little hello when re-enabled
}
return (
<button
type="button"
onClick={toggle}
aria-pressed={on}
title={on ? '声音开 · Sounds on' : '声音关 · Sounds off'}
aria-label={on ? 'Mute Petal sounds' : 'Unmute Petal sounds'}
className="rounded-full px-1.5 py-0.5 transition-colors"
style={{ color: on ? 'var(--color-accent)' : 'var(--color-muted)', lineHeight: 1 }}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-accent-hover)')}
onMouseLeave={(e) =>
(e.currentTarget.style.color = on ? 'var(--color-accent)' : 'var(--color-muted)')
}
>
<span aria-hidden>{on ? '🔔' : '🔕'}</span>
</button>
)
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { SaveStatus } from '../../hooks/useAutoSave' import type { SaveStatus } from '../../hooks/useAutoSave'
import { StatsPanel } from './StatsPanel' import { StatsPanel } from './StatsPanel'
import { SoundToggle } from './SoundToggle'
interface Props { interface Props {
wordCount: number wordCount: number
@@ -120,6 +121,9 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing, llmD
</span> </span>
</> </>
)} )}
<div className="ml-auto">
<SoundToggle />
</div>
</footer> </footer>
) )
} }

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 }}
/>
)
}

View File

@@ -382,7 +382,31 @@ button, a, input {
only the title and the writing itself reach the page. This is Petal's PDF only the title and the writing itself reach the page. This is Petal's PDF
path — it uses the browser's own fonts, so CJK renders correctly with no path — it uses the browser's own fonts, so CJK renders correctly with no
server-side font embedding. */ server-side font embedding. */
/* --- Ambient falling petals -------------------------------------------------
The WebGL petal layer is a fixed, non-interactive overlay that drifts soft
blossoms over the page. Kept gentle (low opacity) so it never competes with
the writing. It hides itself in print and is skipped entirely for users who
prefer reduced motion (the component renders nothing in that case). */
.petal-fall {
opacity: 0.85;
}
/* Honor reduced-motion globally: still the looping ambient animations and drop
the petal layer. One-shot entrance/feedback animations are left intact. */
@media (prefers-reduced-motion: reduce) {
.petal-fall {
display: none !important;
}
.petal-bob,
.petal-bob-slow,
.petal-zzz,
.petal-checkpoint-dot {
animation: none !important;
}
}
@media print { @media print {
.petal-fall,
.petal-no-print, .petal-no-print,
.petal-sidebar, .petal-sidebar,
.petal-suggestion-card, .petal-suggestion-card,

1
web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />