feat(daily): a small burst when the last daily lands
The one moment in this app that is unambiguously good news, and it read the same as any other tick. Fires only on a tick that completes the set — the list also shortens when the reader filters, and a burst there would be congratulating them for filtering. Nothing on mount either, so arriving at an already-finished day is not treated as having just finished it. Decorative, aria-hidden and under a second; the "All done" text beside it is what a screen reader gets. Fully off under prefers-reduced-motion rather than merely reduced — nothing here carries meaning, so the honest reduced version is nothing at all. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a0d0faf231
commit
c3e9b9064d
@@ -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 (
|
||||
<section className="border-b border-hairline px-4 py-4">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<section className="relative border-b border-hairline px-4 py-4">
|
||||
{burst > 0 && <Fireworks key={burst} />}
|
||||
|
||||
<div className="relative flex items-baseline justify-between gap-3">
|
||||
<h2 className="eyebrow">
|
||||
Today's dailies · {complete}/{total}
|
||||
</h2>
|
||||
@@ -75,7 +102,7 @@ export function Dailies({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="mt-2.5 flex flex-wrap gap-1.5">
|
||||
<ul className="relative mt-2.5 flex flex-wrap gap-1.5">
|
||||
{chores.map((chore) => (
|
||||
<li key={chore.key}>
|
||||
<TickChip
|
||||
@@ -105,8 +132,8 @@ export function Dailies({
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mt-2 text-[0.6875rem] leading-relaxed text-faint">
|
||||
{complete === total
|
||||
<p className="relative mt-2 text-[0.6875rem] leading-relaxed text-faint">
|
||||
{allDone
|
||||
? "All done. Nothing else expires tonight."
|
||||
: `${waiting(total - complete)} still waiting on you today.`}
|
||||
</p>
|
||||
|
||||
@@ -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 (
|
||||
<div aria-hidden className="fireworks">
|
||||
{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]: `${16 + ((i * 7) % 13)}px`,
|
||||
["--delay" as string]: `${burst.delay}ms`,
|
||||
background: COLORS[(i + b) % COLORS.length] ?? "var(--color-ink)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user