Files
gacha-event-tracker/src/client/components/Fireworks.tsx
T
Lucas WintherandClaude Opus 5.5 54d13d7472 feat: shine a code redeemed or an event completed on its way out
A code redeemed on its last server sweeps in the game's colour and greys
in place for 0.8 s, then lands in Redeemed or expired. A completed event
greys and shines while its Moved to Archive notice holds, now 0.9 s
rather than 1.5 s, which read as unresponsive; an ignored one only greys.

Settings → Appearance gains Motion & celebrations as three What to track
cards: Reduce motion (stops every animation and the fireworks, adding to
the device's own setting), Fireworks, and Shine. Reduce motion locks the
other two off without forgetting them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 04:26:50 +02:00

116 lines
3.8 KiB
TypeScript

import { createContext, useContext } from "react";
/**
* A small burst, for finishing something: a daily ticked, a routine met, an
* event marked completed. Finishing a whole section gets the grand version.
*
* 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.
*/
/**
* Whether the reader wants celebrations at all (Settings → Appearance). Off
* means none, big or small; absent means on.
*/
export function celebrationsOn(prefs: Record<string, unknown>): boolean {
return prefs.celebrations !== false;
}
/**
* Whether a redeemed code or a completed event shines on its way out
* (Settings → Appearance → Celebrations). Separate from fireworks; absent
* means on.
*/
export function shineOn(prefs: Record<string, unknown>): boolean {
return prefs.shine !== false;
}
/** The reader's celebrations switch, provided once at the app's root. */
export const CelebrationsContext = createContext(true);
/**
* 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)",
];
/**
* The grand version, for finishing a whole section (every daily, or every
* routine in a game's group) rather than one item: more bursts across the
* section, wider and longer, in waves. Same fixed geometry rule as above.
*/
const GRAND_BURSTS: Array<{ x: number; y: number; delay: number }> = [
{ x: 12, y: 30, delay: 0 },
{ x: 50, y: 18, delay: 120 },
{ x: 86, y: 34, delay: 220 },
{ x: 30, y: 62, delay: 380 },
{ x: 70, y: 66, delay: 480 },
{ x: 20, y: 86, delay: 640 },
{ x: 80, y: 84, delay: 720 },
];
const GRAND_SPARKS = 14;
export function Fireworks({
grand = false,
hue,
}: {
/** The section-complete celebration rather than the single burst. */
grand?: boolean;
/** A game's colour to mix into the sparks. */
hue?: string | undefined;
} = {}) {
const on = useContext(CelebrationsContext);
if (!on) return null;
const bursts = grand ? GRAND_BURSTS : BURSTS,
sparks = grand ? GRAND_SPARKS : SPARKS,
colors = hue ? [hue, ...COLORS] : COLORS;
return (
<div aria-hidden className={`fireworks${grand ? " grand" : ""}`}>
{bursts.map((burst, b) => (
<span
key={b}
className="firework"
style={{ left: `${burst.x}%`, top: `${burst.y}%` }}
>
{Array.from({ length: sparks }, (_, i) => (
<span
key={i}
className="spark"
style={{
["--a" as string]: `${(360 / sparks) * i + b * 13}deg`,
// Uneven reach, so it reads as a burst rather than a wheel.
["--d" as string]: grand
? `${34 + ((i * 11) % 34)}px`
: `${16 + ((i * 7) % 13)}px`,
["--delay" as string]: `${burst.delay}ms`,
background:
colors[(i + b) % colors.length] ?? "var(--color-ink)",
}}
/>
))}
</span>
))}
</div>
);
}