Merge feat/petals-toggle: status-bar toggle to disable falling petals

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 17:27:25 -07:00
4 changed files with 90 additions and 3 deletions

View File

@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react'
import { isPetalsEnabled, onPetalsEnabledChange, setPetalsEnabled } from '../../effects/petals'
// A tiny toggle for Petal's ambient falling-blossom layer, sitting just left of
// the sound toggle in the status bar. Some people find the drifting petals
// distracting, so this turns them off entirely. Bilingual tooltip (she reads
// Mandarin first), and the choice persists across reloads.
export function PetalsToggle() {
const [on, setOn] = useState(isPetalsEnabled)
// Stay in sync if the setting is flipped elsewhere.
useEffect(() => onPetalsEnabledChange(setOn), [])
const toggle = () => {
const next = !on
setPetalsEnabled(next)
setOn(next)
}
return (
<button
type="button"
onClick={toggle}
aria-pressed={on}
title={on ? '花瓣开 · Petals on' : '花瓣关 · Petals off'}
aria-label={on ? 'Hide falling petals' : 'Show falling petals'}
className="flex items-center justify-center rounded-full px-2 py-1 text-xl leading-none 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 { Fragment, useEffect, useRef, useState } from 'react'
import type { SaveStatus } from '../../hooks/useAutoSave'
import { StatsPanel } from './StatsPanel'
import { PetalsToggle } from './PetalsToggle'
import { SoundToggle } from './SoundToggle'
interface Props {
@@ -145,7 +146,8 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing, coll
</span>
</>
)}
<div className="ml-auto">
<div className="ml-auto flex items-center">
<PetalsToggle />
<SoundToggle />
</div>
</footer>

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import { isPetalsEnabled, onPetalsEnabledChange } from './petals'
// 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
@@ -212,8 +213,19 @@ interface Petal {
export function PetalFall({ night = false }: { night?: boolean }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const [enabled, setEnabled] = useState(isPetalsEnabled)
// Let the status-bar toggle switch the ambient layer on/off live.
useEffect(() => onPetalsEnabledChange(setEnabled), [])
useEffect(() => {
if (!enabled) {
// User turned the ambient petals off — wipe any frame left on the canvas.
const el = canvasRef.current
el?.getContext('2d')?.clearRect(0, 0, el.width, el.height)
return
}
const reduce =
typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
@@ -347,7 +359,7 @@ export function PetalFall({ night = false }: { night?: boolean }) {
document.removeEventListener('visibilitychange', onVisibility)
petals = []
}
}, [night])
}, [night, enabled])
return (
<canvas

36
web/src/effects/petals.ts Normal file
View File

@@ -0,0 +1,36 @@
// The on/off preference for the ambient falling-petals layer (PetalFall). Some
// people find drifting petals distracting rather than cozy, so the whole effect
// is opt-out-able and the choice persists in localStorage so it survives reloads.
// Mirrors the tiny pub/sub used for the sound mute toggle (../audio/sounds).
const STORAGE_KEY = 'petal.petals'
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 isPetalsEnabled(): boolean {
return enabled
}
export function setPetalsEnabled(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))
}
export function onPetalsEnabledChange(fn: (on: boolean) => void): () => void {
listeners.add(fn)
return () => listeners.delete(fn)
}