Merge feat/petals-toggle: status-bar toggle to disable falling petals
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