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
@@ -90,6 +90,7 @@ src/
|
|||||||
ProgressControls status, effort, note (F12)
|
ProgressControls status, effort, note (F12)
|
||||||
Dailies.tsx today's strip of repeating jobs
|
Dailies.tsx today's strip of repeating jobs
|
||||||
DailyChecklist one repeating event's whole run
|
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)
|
Controls.tsx games, region, export/import(F4, F5, F6)
|
||||||
Welcome.tsx first-run game picker (F8)
|
Welcome.tsx first-run game picker (F8)
|
||||||
Toast.tsx undo an ignore
|
Toast.tsx undo an ignore
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { dailiesId, dayKey, msUntilReset, streakOf } from "../../shared/daily.ts";
|
import { dailiesId, dayKey, msUntilReset, streakOf } from "../../shared/daily.ts";
|
||||||
import { gameMeta } from "../../shared/games.ts";
|
import { gameMeta } from "../../shared/games.ts";
|
||||||
import type { GachaEvent, GameId, Region } from "../../shared/schema.ts";
|
import type { GachaEvent, GameId, Region } from "../../shared/schema.ts";
|
||||||
import { formatRemaining } from "../../shared/time.ts";
|
import { formatRemaining } from "../../shared/time.ts";
|
||||||
|
import { Fireworks } from "./Fireworks.tsx";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chores no source publishes.
|
* The chores no source publishes.
|
||||||
@@ -55,6 +57,29 @@ export function Dailies({
|
|||||||
const items = [...chores, ...repeating];
|
const items = [...chores, ...repeating];
|
||||||
const total = items.length;
|
const total = items.length;
|
||||||
const complete = items.filter((i) => daysFor(i.key).includes(i.today)).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;
|
if (total === 0) return null;
|
||||||
|
|
||||||
@@ -64,8 +89,10 @@ export function Dailies({
|
|||||||
const mixed = new Set(items.map((i) => i.resetsIn)).size > 1;
|
const mixed = new Set(items.map((i) => i.resetsIn)).size > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="border-b border-hairline px-4 py-4">
|
<section className="relative border-b border-hairline px-4 py-4">
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
{burst > 0 && <Fireworks key={burst} />}
|
||||||
|
|
||||||
|
<div className="relative flex items-baseline justify-between gap-3">
|
||||||
<h2 className="eyebrow">
|
<h2 className="eyebrow">
|
||||||
Today's dailies · {complete}/{total}
|
Today's dailies · {complete}/{total}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -75,7 +102,7 @@ export function Dailies({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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) => (
|
{chores.map((chore) => (
|
||||||
<li key={chore.key}>
|
<li key={chore.key}>
|
||||||
<TickChip
|
<TickChip
|
||||||
@@ -105,8 +132,8 @@ export function Dailies({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p className="mt-2 text-[0.6875rem] leading-relaxed text-faint">
|
<p className="relative mt-2 text-[0.6875rem] leading-relaxed text-faint">
|
||||||
{complete === total
|
{allDone
|
||||||
? "All done. Nothing else expires tonight."
|
? "All done. Nothing else expires tonight."
|
||||||
: `${waiting(total - complete)} still waiting on you today.`}
|
: `${waiting(total - complete)} still waiting on you today.`}
|
||||||
</p>
|
</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. */
|
/* Focus ring: visible, and never removed. */
|
||||||
:where(a, button, [tabindex]):focus-visible {
|
:where(a, button, [tabindex]):focus-visible {
|
||||||
outline: 2px solid var(--color-near);
|
outline: 2px solid var(--color-near);
|
||||||
|
|||||||
Reference in New Issue
Block a user