5 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
prosolis
2487e73551 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
2026-06-26 07:34:23 -07:00
prosolis
0a1ea225dd Merge: companion rules-based writing advice + tests 2026-06-26 07:16:45 -07:00
14 changed files with 639 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 { 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"

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

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

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

View File

@@ -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
View File

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