Add a status-bar toggle to disable the falling petals
Some people find the drifting ambient petals distracting rather than cozy, so make the whole PetalFall layer opt-out-able from a 🌸 toggle sitting just left of the sound toggle in the status bar. Mirrors the existing sound-mute pattern: a tiny localStorage-backed pub/sub (effects/petals.ts) drives a PetalsToggle twin of SoundToggle. PetalFall subscribes to the setting, stops animating and wipes the canvas when off, and the choice persists across reloads. Works in both day (petals) and night (stars) modes. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
37
web/src/components/StatusBar/PetalsToggle.tsx
Normal file
37
web/src/components/StatusBar/PetalsToggle.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Fragment, useEffect, useRef, useState } from 'react'
|
import { Fragment, 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 { PetalsToggle } from './PetalsToggle'
|
||||||
import { SoundToggle } from './SoundToggle'
|
import { SoundToggle } from './SoundToggle'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -145,7 +146,8 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing, coll
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto">
|
<div className="ml-auto flex items-center">
|
||||||
|
<PetalsToggle />
|
||||||
<SoundToggle />
|
<SoundToggle />
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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 }) {
|
export function PetalFall({ night = false }: { night?: boolean }) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
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(() => {
|
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 =
|
const reduce =
|
||||||
typeof window !== 'undefined' &&
|
typeof window !== 'undefined' &&
|
||||||
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||||
@@ -347,7 +359,7 @@ export function PetalFall({ night = false }: { night?: boolean }) {
|
|||||||
document.removeEventListener('visibilitychange', onVisibility)
|
document.removeEventListener('visibilitychange', onVisibility)
|
||||||
petals = []
|
petals = []
|
||||||
}
|
}
|
||||||
}, [night])
|
}, [night, enabled])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
|
|||||||
36
web/src/effects/petals.ts
Normal file
36
web/src/effects/petals.ts
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user