Compare commits
5 Commits
companion-
...
045ec19b92
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
045ec19b92 | ||
|
|
f6fa73b17b | ||
|
|
8cf78130bf | ||
|
|
2487e73551 | ||
|
|
0a1ea225dd |
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 { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
|
||||
import { useVersionWatch } from './hooks/useVersionWatch'
|
||||
import { PetalFall } from './effects/PetalFall'
|
||||
import { playSuggestionSound } from './audio/sounds'
|
||||
|
||||
export default function App() {
|
||||
const updateAvailable = useVersionWatch()
|
||||
@@ -309,8 +311,34 @@ export default function App() {
|
||||
[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 (
|
||||
<div className="flex h-full flex-col">
|
||||
<PetalFall />
|
||||
<header
|
||||
onMouseDown={handleChromeDown}
|
||||
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,
|
||||
} from './tips'
|
||||
import { analyzeProse } from './prose'
|
||||
import { playSound } from '../../audio/sounds'
|
||||
|
||||
// The kitten's expression. Maps to a Lottie animation when assets are present,
|
||||
// otherwise to an emoji placeholder (see PetalCompanion).
|
||||
@@ -95,6 +96,11 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text
|
||||
lastProactive.current = t
|
||||
}
|
||||
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)
|
||||
setMood(opts?.celebrate ? 'celebrate' : 'talking')
|
||||
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 type { SaveStatus } from '../../hooks/useAutoSave'
|
||||
import { StatsPanel } from './StatsPanel'
|
||||
import { SoundToggle } from './SoundToggle'
|
||||
|
||||
interface Props {
|
||||
wordCount: number
|
||||
@@ -120,6 +121,9 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing, llmD
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
<SoundToggle />
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
200
web/src/effects/PetalFall.tsx
Normal file
200
web/src/effects/PetalFall.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
// 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
|
||||
]
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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() {
|
||||
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 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
|
||||
|
||||
const sprites = 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)
|
||||
|
||||
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() {
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 = []
|
||||
}
|
||||
}, [])
|
||||
|
||||
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
|
||||
path — it uses the browser's own fonts, so CJK renders correctly with no
|
||||
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: 1;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
.petal-fall,
|
||||
.petal-no-print,
|
||||
.petal-sidebar,
|
||||
.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