diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 21f17ee..2e27e1d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -90,6 +90,7 @@ src/ ProgressControls status, effort, note (F12) Dailies.tsx today's strip of repeating jobs DailyChecklist one repeating event's whole run + Fireworks.tsx the burst when the last daily lands Controls.tsx games, region, export/import(F4, F5, F6) Welcome.tsx first-run game picker (F8) Toast.tsx undo an ignore diff --git a/src/client/components/Dailies.tsx b/src/client/components/Dailies.tsx index e612f4e..e8424db 100644 --- a/src/client/components/Dailies.tsx +++ b/src/client/components/Dailies.tsx @@ -1,7 +1,9 @@ +import { useEffect, useRef, useState } from "react"; import { dailiesId, dayKey, msUntilReset, streakOf } from "../../shared/daily.ts"; import { gameMeta } from "../../shared/games.ts"; import type { GachaEvent, GameId, Region } from "../../shared/schema.ts"; import { formatRemaining } from "../../shared/time.ts"; +import { Fireworks } from "./Fireworks.tsx"; /** * The chores no source publishes. @@ -55,6 +57,29 @@ export function Dailies({ const items = [...chores, ...repeating]; const total = items.length; const complete = items.filter((i) => daysFor(i.key).includes(i.today)).length; + const allDone = total > 0 && complete === total; + + const [burst, setBurst] = useState(0); + // Null until the first render has been seen, so arriving at a page where + // everything is already ticked is not treated as having just finished it. + const previous = useRef<{ total: number; complete: number } | null>(null); + + useEffect(() => { + const was = previous.current; + previous.current = { total, complete }; + if (was === null || !allDone) return; + // Celebrate finishing the last one, and only that. The list also gets + // shorter when the reader marks a repeating event done, which can land on + // "all complete" without them having ticked anything — a burst there is the + // app congratulating them for filtering. + if (was.total === total && complete > was.complete) setBurst((n) => n + 1); + }, [total, complete, allDone]); + + useEffect(() => { + if (burst === 0) return; + const id = setTimeout(() => setBurst(0), 1400); + return () => clearTimeout(id); + }, [burst]); if (total === 0) return null; @@ -64,8 +89,10 @@ export function Dailies({ const mixed = new Set(items.map((i) => i.resetsIn)).size > 1; return ( -
-
+
+ {burst > 0 && } + +

Today's dailies · {complete}/{total}

@@ -75,7 +102,7 @@ export function Dailies({

-
    +
      {chores.map((chore) => (
    • -

      - {complete === total +

      + {allDone ? "All done. Nothing else expires tonight." : `${waiting(total - complete)} still waiting on you today.`}

      diff --git a/src/client/components/Fireworks.tsx b/src/client/components/Fireworks.tsx new file mode 100644 index 0000000..34fe57d --- /dev/null +++ b/src/client/components/Fireworks.tsx @@ -0,0 +1,61 @@ +/** + * A small burst, for the one moment in this app that is unambiguously good + * news: every daily ticked off before the reset. + * + * Deliberately tiny and deliberately silent — decorative only, `aria-hidden`, + * and gone in under a second. The text beside it already says "All done", which + * is what a screen reader gets; this is the same sentence said in colour. + * + * Fully off under `prefers-reduced-motion` (see styles.css). A celebration + * nobody asked for is exactly the kind of motion that setting exists to stop, + * and the state it celebrates is legible without it. + */ + +/** + * Fixed spark geometry rather than random: the burst looks the same every time, + * which makes it a piece of the interface rather than a surprise, and keeps it + * out of the "renders differently on every paint" category. + */ +const BURSTS: Array<{ x: number; y: number; delay: number }> = [ + { x: 18, y: 40, delay: 0 }, + { x: 52, y: 22, delay: 140 }, + { x: 82, y: 52, delay: 260 }, +]; + +const SPARKS = 9; + +/** The heat ramp, reused: the palette already means "this app", so it stays. */ +const COLORS = [ + "var(--color-near)", + "var(--color-soon)", + "var(--color-critical)", + "var(--color-ink)", +]; + +export function Fireworks() { + return ( +
      + {BURSTS.map((burst, b) => ( + + {Array.from({ length: SPARKS }, (_, i) => ( + + ))} + + ))} +
      + ); +} diff --git a/src/client/styles.css b/src/client/styles.css index a224251..ec36017 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -225,6 +225,58 @@ body { } } +/* ---- The daily burst ---------------------------------------------------- */ +/* + * Fires once, when the last daily of the day gets ticked. Purely decorative and + * pointer-transparent, so it never sits between the reader and a chip. + * + * The whole thing is inside `prefers-reduced-motion: no-preference` rather than + * being merely reduced by it: unlike the meter, nothing here carries meaning + * that would be lost, so the honest reduced version is nothing at all. + */ +.fireworks { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; + display: none; +} +.firework { + position: absolute; + display: block; + width: 0; + height: 0; +} +.spark { + position: absolute; + width: 3px; + height: 3px; + border-radius: 999px; +} + +@media (prefers-reduced-motion: no-preference) { + .fireworks { + display: block; + } + .spark { + animation: spark-fly 900ms cubic-bezier(0.15, 0.7, 0.35, 1) var(--delay, 0ms) + both; + } + @keyframes spark-fly { + from { + transform: rotate(var(--a)) translateX(0) scale(0.6); + opacity: 0; + } + 18% { + opacity: 1; + } + to { + transform: rotate(var(--a)) translateX(var(--d)) scale(1); + opacity: 0; + } + } +} + /* Focus ring: visible, and never removed. */ :where(a, button, [tabindex]):focus-visible { outline: 2px solid var(--color-near);