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:
165
scripts/gen_sounds.py
Normal file
165
scripts/gen_sounds.py
Normal 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))
|
||||||
@@ -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"
|
||||||
|
|||||||
BIN
web/src/assets/sounds/cheer.wav
Normal file
BIN
web/src/assets/sounds/cheer.wav
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/chime.wav
Normal file
BIN
web/src/assets/sounds/chime.wav
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/droplet.wav
Normal file
BIN
web/src/assets/sounds/droplet.wav
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/pop.wav
Normal file
BIN
web/src/assets/sounds/pop.wav
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/shimmer.wav
Normal file
BIN
web/src/assets/sounds/shimmer.wav
Normal file
Binary file not shown.
174
web/src/audio/sounds.ts
Normal file
174
web/src/audio/sounds.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
37
web/src/components/StatusBar/SoundToggle.tsx
Normal file
37
web/src/components/StatusBar/SoundToggle.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
267
web/src/effects/PetalFall.tsx
Normal file
267
web/src/effects/PetalFall.tsx
Normal 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 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
1
web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
Reference in New Issue
Block a user