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 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-20 04:26:50 +02:00
co-authored by Claude Opus 5.5
parent 9c22a85134
commit 54d13d7472
15 changed files with 628 additions and 59 deletions
+7
View File
@@ -832,6 +832,13 @@ means the reader has not taken this tour: the app moves them to `interface: "rel
`true` means on; only `false` turns every celebration off, big and small. See `celebrationsOn` in
`Fireworks.tsx`.
`prefs.shine` (2026-10-20) is its neighbour for the sweep across a code just redeemed or an event
just completed: absent or `true` is on, only `false` is off (`shineOn` in `Fireworks.tsx`).
`prefs.reduceMotion` (2026-10-20) is Appearance → Motion & celebrations → Reduce motion: only `true`
reduces motion, and it adds to the device's `prefers-reduced-motion` rather than replacing it
(`motionReduced` in `state/appearance.ts`). Neither changes an ID or a key; an older build ignores
both.
The fidelity correction adds `prefs.routineGroup` (`reset`, the absent-value default, or `game`)
for the Routines page's grouping. It changes no routine IDs or placement keys. Structured is the
interface default as of the owner's 2026-10-17 instruction; explicit saved Relaxed is preserved.
+26 -2
View File
@@ -626,8 +626,32 @@ legend on the same baseline as "Next: …".
fires the small burst in the "Moved to Archive" notice; ignoring one fires nothing.
- **When it fires.** Only an action taken now that completes the item or section, never arriving
complete. Reduced motion removes all of it, and so does `prefs.celebrations: false` (Settings →
Appearance → Celebrations), read through `CelebrationsContext` in `Fireworks.tsx`. The row's own
tick animation (wash, pop, check) stays either way: it is feedback, not celebration.
Appearance → Motion & celebrations → Fireworks), read through `CelebrationsContext` in
`Fireworks.tsx`. The row's own tick animation (wash, pop, check) stays either way: it is feedback,
not celebration.
- **Shine (2026-10-20, owner).** Something leaving the list shines first: the dailies' light sweep
(held bright until it has crossed, as `code-sweep`, since a single row is too thin for one that
fades as it travels) and ring, in the game's colour. A code redeemed on its last server shines in
place for 0.8 s (the sweep 640 ms, the glow 560 ms), fades, and lands in "Redeemed or expired";
ticking one of two servers only pops that box, and the code's text greys to 45% while it shines
(its Redeemed boxes stay full strength). A completed event's text greys the same while its "Moved
to Archive" notice holds, and the row shines; an ignored one greys without the shine. No fireworks
on a code: redeeming is a chore cleared, not a finish, and six in a row would be noise. Settings →
Appearance → Motion & celebrations → Shine (`prefs.shine: false`) turns the sweep off; the greying
stays, since it says where the row is going. Only the reader's own action shines, never a row
arriving.
- **Reduce motion (2026-10-20, owner).** Settings → Appearance → Motion & celebrations.
`prefs.reduceMotion: true` sets `data-motion="reduce"` on the root, and `styles.css` stops every
animation and transition and hides the fireworks. Off, it follows the device's
`prefers-reduced-motion`: a stylesheet cannot undo the device's request, so the switch can only
add to it, and its card says so. While it is on, the Fireworks and Shine cards show off, dimmed
and unpressable, with "Off while Reduce motion is on"; the saved choices underneath are kept, so
they come back as they were.
- **Motion & celebrations cards (2026-10-20, owner, mockup B of three).** The three switches are a
game's What to track cards (`.comp`, shared with `.game-settings` through `:is()`), three across
and one per row on phones, Reduce motion first: each explanation inside its own card, rather than
checkboxes with help text above and below them. The owner chose it over rows with switches (a
control type the app does not otherwise use) and nested checkboxes.
### Other pages
+10 -6
View File
@@ -192,9 +192,9 @@ the cut are still counted in the header, still on the timeline, and one tap away
**F3 — Mark completed.**
Done writes durable progress and a metadata snapshot immediately. A confirmation remains for about
1.5 seconds before the row fades/collapses into Archive. Undo is available for eight seconds;
Archive Restore remains available afterwards. A failed write is disclosed and never presented as
saved. Reduced motion removes the fade.
0.9 seconds (1.5 until 2026-10-20, which read as unresponsive) before the row fades/collapses into
Archive. Undo is available for eight seconds; Archive Restore remains available afterwards. A failed
write is disclosed and never presented as saved. Reduced motion removes the fade.
**F4 — Settings and visibility.**
Settings has named pages: General, Your games, Appearance, Lists, Region & time, Data & sources,
@@ -482,9 +482,13 @@ scheme are independent. Custom stores ground/surface/text/accent separately for
rendered colours derive readable contrast without overwriting the saved choices. Per-game colours
have a picker, hex field, preview and reset. Checklist dailies can occupy a separate direct-tick panel
or grouped routines, with collapsed/expanded/always-expanded policies; these controls also live in
Routines. Numeric routines use native step-one inputs. A **Celebrations** switch (on by default)
covers the fireworks: a small burst for finishing one daily, routine or event, and the grand one for
finishing every daily or every routine in a game's group. Off means none at all, big or small.
Routines. Numeric routines use native step-one inputs. **Motion & celebrations** holds three cards.
**Reduce motion** (off by default, and saying what it does) stops every animation whatever the
device says; off, the device's own setting still applies. **Fireworks** (on by default) covers a
small burst for finishing one daily, routine or event, and the grand one for finishing every daily
or every routine in a game's group; off means none at all, big or small. **Shine** (on by default)
covers the sweep across a code just redeemed or an event just completed. While Reduce motion is on,
Fireworks and Shine are locked off without forgetting their setting.
The earlier theme rationale below still applies to both interfaces.
The app is a lit instrument panel and that is what it should be on first sight, but it is also read
+7 -10
View File
@@ -9,6 +9,7 @@ import { PinnedRoutine } from "./components/redesign/PinnedRoutine.tsx";
import { DisplayTimeContext } from "./state/displayTime.ts";
import {
CelebrationsContext,
shineOn,
Fireworks,
celebrationsOn,
} from "./components/Fireworks.tsx";
@@ -65,7 +66,10 @@ import { GameMetaProvider } from "./state/gameMeta.tsx";
import { useAppUpdate } from "./state/useAppUpdate.ts";
import { ENTRIES, NOTES, newestId, unseen } from "../shared/changelog.ts";
import {
ARCHIVE_COLLAPSE_MS,
ARCHIVE_HOLD_MS,
ChecklistEvent,
leavingMarks,
LedgerColumns,
UrgencyLegend,
NextDeadline,
@@ -478,11 +482,11 @@ function ReaderContent({
if (!transition) return;
const t = setTimeout(
() => setTransition((t) => (t ? { ...t, phase: "fade" } : null)),
1500,
ARCHIVE_HOLD_MS,
);
const collapse = setTimeout(
() => setTransition((t) => (t ? { ...t, phase: "hidden" } : null)),
1750,
ARCHIVE_COLLAPSE_MS,
);
const undo = setTimeout(() => setTransition(null), 8000);
return () => {
@@ -620,14 +624,7 @@ function ReaderContent({
const key = pairKey(row.accountId, row.event.id),
p = progress(row);
return (
<li
key={key}
className={
transition?.key === key && transition.phase === "fade"
? "archive-fading"
: ""
}
>
<li key={key} {...leavingMarks(transition, key, shineOn(state.prefs))}>
<ChecklistEvent
row={row}
now={now}
+9
View File
@@ -21,6 +21,15 @@ 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);
@@ -9,7 +9,7 @@ import { timelineLook } from "./TimelinePage.tsx";
import { ViewCards } from "./ViewCards.tsx";
import { focusBarShown } from "./FocusPicker.tsx";
import { URGENCY_STEPS, urgencyChoice } from "../../state/appearance.ts";
import { celebrationsOn } from "../Fireworks.tsx";
import { celebrationsOn, shineOn } from "../Fireworks.tsx";
/** The live legend's words for each step. */
const URGENCY_LABEL = {
@@ -32,6 +32,8 @@ export function AppearancePage({
const p = state.prefs,
patch = (patch: Record<string, unknown>) =>
void onAction({ type: "prefs", patch, ...actionMeta() });
// Reduce motion holds fireworks and shine off: both are nothing but motion.
const still = p.reduceMotion === true;
const hidden = Array.isArray(p.hiddenGames)
? (p.hiddenGames as string[])
: [];
@@ -354,21 +356,44 @@ export function AppearancePage({
</label>
))}
</section>
<section className="settings-group">
<h2>Celebrations</h2>
<p className="help">
Fireworks when you finish a daily, a routine or an event, and a bigger
show when you finish all your dailies or a game's routines.
</p>
<label>
<input
type="checkbox"
data-celebrations
checked={celebrationsOn(p)}
onChange={(e) => patch({ celebrations: e.target.checked })}
<section
className="settings-group motion-cards"
style={{ "--g": "var(--color-near)" } as CSSProperties}
>
<h2>Motion &amp; celebrations</h2>
<div className="comp" role="group" aria-label="Motion and celebrations">
<MotionCard
mark="data-reduce-motion"
title="Reduce motion"
on={still}
onToggle={(on) => patch({ reduceMotion: on })}
lines={[
"Stops pops, sweeps, slides and fireworks, whatever your device says.",
"Your device's setting applies either way",
]}
/>
Show fireworks
</label>
{/* Locked, not cleared: the saved choices come back with motion. */}
<MotionCard
mark="data-celebrations"
title="Fireworks"
on={!still && celebrationsOn(p)}
locked={still}
onToggle={(on) => patch({ celebrations: on })}
lines={[
"For finishing a daily, routine or event, and a bigger show for a whole set.",
]}
/>
<MotionCard
mark="data-shine-switch"
title="Shine"
on={!still && shineOn(p)}
locked={still}
onToggle={(on) => patch({ shine: on })}
lines={[
"A sweep of the game's colour across a code you redeem or an event you complete.",
]}
/>
</div>
</section>
<section className="settings-group checklist-settings">
<h2>Checklist</h2>
@@ -393,3 +418,46 @@ export function AppearancePage({
</section>
);
}
/** One of the Motion & celebrations cards: a game's What to track card. */
function MotionCard({
mark,
title,
on,
locked = false,
onToggle,
lines,
}: {
mark: string;
title: string;
on: boolean;
locked?: boolean;
onToggle: (on: boolean) => void;
lines: string[];
}) {
return (
<button
type="button"
{...{ [mark]: true }}
aria-pressed={on}
data-st={on ? "on" : "off"}
disabled={locked}
onClick={() => onToggle(!on)}
>
<span className="cbx" aria-hidden="true">
{on && (
<svg viewBox="0 0 24 24">
<path d="M5 12.5l4.5 4.5L19 7" />
</svg>
)}
</span>
<span className="ln">{title}</span>
{lines.map((line) => (
<span className="ls" key={line}>
{line}
</span>
))}
{locked && <span className="ls">Off while Reduce motion is on</span>}
</button>
);
}
@@ -50,6 +50,32 @@ export function UrgencyLegend() {
</div>
);
}
/**
* How long a completed or ignored row holds, greyed with its "Moved to Archive"
* notice, before it fades (2026-10-20, owner: 1.5 s read as unresponsive), and
* when it is gone once the 250 ms collapse has run.
*/
export const ARCHIVE_HOLD_MS = 900,
ARCHIVE_COLLAPSE_MS = ARCHIVE_HOLD_MS + 250;
/**
* The marks on a checklist row on its way to the Archive. While it holds, its
* text greys; a completed one also gets the codes' shine, an ignored one does
* not. Then it fades and collapses.
*/
export function leavingMarks(
transition: { key: string; label: string; phase: string } | null,
key: string,
shine = true,
) {
const leaving = transition?.key === key ? transition : null;
return {
className: leaving?.phase === "fade" ? "archive-fading" : "",
"data-leaving": (leaving && leaving.phase !== "hidden") || undefined,
"data-shine":
(shine && leaving?.phase === "hold" && leaving.label === "Completed") ||
undefined,
};
}
export function ChecklistEvent({
row,
now,
+95 -10
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { freshness } from "../../../shared/feed.ts";
import type { CodesFeed, RedeemCode } from "../../../shared/codes.ts";
import { REDEEM } from "../../../shared/redeem.ts";
@@ -8,6 +8,27 @@ import { codeExpired, codeUsed, newCodes } from "../../state/reader/codes.ts";
import { actionMeta } from "../../state/reader/actions.ts";
import { formatInZone } from "../../state/displayTime.ts";
import type { ActionHandler } from "./GameSettings.tsx";
import { shineOn } from "../Fireworks.tsx";
import { motionReduced } from "../../state/appearance.ts";
// A redeemed code shines where it was ticked, then lands among the redeemed.
const SHINE_MS = 800,
LAND_MS = 360;
/**
* The open list and the redeemed-or-expired list. A code still shining from
* the tick that redeemed it holds its place in the open list, not greyed yet.
*/
export function codeLanes<C extends { id: string }>(
codes: readonly C[],
done: (c: C) => boolean,
shining: ReadonlySet<string>,
) {
const used = (c: C) => done(c) && !shining.has(c.id);
return {
open: codes.filter((c) => !used(c)),
closed: codes.filter(used),
used,
};
}
export function CodesPage({
game,
feed,
@@ -34,8 +55,41 @@ export function CodesPage({
accounts.map((a) => a.id),
state.codeRedeemed,
);
const open = codes.filter((c) => !done(c)),
closed = codes.filter(done);
// Only the reader's own ticks animate: a code already redeemed, or one that
// expires by itself, never plays on arrival.
const [shining, setShining] = useState<ReadonlySet<string>>(new Set());
const [landing, setLanding] = useState<ReadonlySet<string>>(new Set());
const [fresh, setFresh] = useState<ReadonlySet<string>>(new Set());
const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
useEffect(() => () => timers.current.forEach(clearTimeout), []);
const settle = (id: string) => {
clearTimeout(timers.current.get(id));
timers.current.delete(id);
};
const without = (id: string) => (set: ReadonlySet<string>) => {
const next = new Set(set);
next.delete(id);
return next;
};
const shine = (id: string) => {
settle(id);
setShining((set) => new Set(set).add(id));
timers.current.set(
id,
setTimeout(() => {
setShining(without(id));
setLanding((set) => new Set(set).add(id));
timers.current.set(
id,
setTimeout(() => {
timers.current.delete(id);
setLanding(without(id));
}, LAND_MS),
);
}, SHINE_MS),
);
};
const { open, closed, used } = codeLanes(codes, done, shining);
const redeem = REDEEM[game as GameId];
const unseen = feed ? newCodes(feed, state.codeSeen[game] ?? [], game) : [];
useEffect(() => {
@@ -49,7 +103,12 @@ export function CodesPage({
});
}, [game, feed, onAction]);
const row = (c: RedeemCode) => (
<article className={done(c) ? "code-row used" : "code-row"} key={c.id}>
<article
className={used(c) ? "code-row used" : "code-row"}
key={c.id}
data-shine={shining.has(c.id) || undefined}
data-landing={landing.has(c.id) || undefined}
>
<div>
<code>{c.code}</code>
<button
@@ -86,19 +145,40 @@ export function CodesPage({
</div>
<div>
{accounts.map((a) => (
<label key={a.id}>
<label key={a.id} data-fresh={fresh.has(a.id + c.id) || undefined}>
<input
type="checkbox"
checked={Boolean(state.codeRedeemed[a.id]?.[c.id])}
onChange={(e) =>
onChange={(e) => {
const redeemed = e.target.checked;
setFresh((set) =>
redeemed
? new Set(set).add(a.id + c.id)
: without(a.id + c.id)(set),
);
if (!redeemed) {
settle(c.id);
setShining(without(c.id));
setLanding(without(c.id));
} else if (
!codeExpired(c, now) &&
// Without the shine, or holding still, it moves straight down.
shineOn(state.prefs) &&
!motionReduced(state.prefs) &&
// This tick is the last server still to redeem it.
accounts.every(
(o) => o.id === a.id || state.codeRedeemed[o.id]?.[c.id],
)
)
shine(c.id);
void onAction({
type: "redeem",
accountId: a.id,
codeId: c.id,
redeemed: e.target.checked,
redeemed,
...actionMeta(),
})
}
});
}}
/>
Redeemed
{/* The server only when there is more than one to tell apart. */}
@@ -153,7 +233,12 @@ export function CodesPage({
{open.length ? (
open.map(row)
) : (
<p>Every code here is redeemed or expired.</p>
<p
className="codes-clear"
data-fresh={landing.size > 0 || undefined}
>
Every code here is redeemed or expired.
</p>
)}
{closed.length > 0 && (
<section className="codes-used">
+112 -12
View File
@@ -4268,6 +4268,86 @@ dialog.archive-detail {
text-decoration: line-through;
text-decoration-color: var(--color-muted);
}
/* A checklist event on its way to the Archive greys its text while it holds,
so it reads as leaving; the "Moved to Archive" line under it stays legible. */
.event-list > li[data-leaving] > button > span {
opacity: 0.45;
}
/* A code shining on its way to Redeemed greys the same way; its Redeemed
boxes stay full strength, so the tick that sent it is still plain. */
.code-row[data-shine] > div:first-child > * {
opacity: 0.45;
}
/* A code just redeemed, or an event just completed: the dailies' sweep and
ring cross the row in the game's colour. A code then fades out and lands
among the redeemed; an event fades as the Archive takes it. Only the
reader's own action sets these, and reduced motion skips the code's hold. */
@media (prefers-reduced-motion: no-preference) {
.event-list > li > button > span,
.code-row > div:first-child > * {
transition: opacity 300ms ease-out;
}
.code-row[data-shine],
.event-list > li[data-shine] > button {
position: relative;
animation: daily-glow 560ms ease-out;
}
.code-row[data-shine] {
animation:
daily-glow 560ms ease-out,
code-leave 210ms ease-in 590ms forwards;
}
.code-row[data-shine]::after,
.event-list > li[data-shine] > button::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(
100deg,
transparent 30%,
color-mix(in srgb, var(--g, var(--color-ink)) 38%, transparent) 50%,
transparent 70%
);
background-size: 250% 100%;
animation: code-sweep 640ms ease-out both;
}
.code-row[data-landing] {
animation: code-land 360ms ease-out;
}
.code-row label[data-fresh] input {
animation: daily-pop 420ms cubic-bezier(0.3, 1.6, 0.5, 1);
}
.codes-clear[data-fresh] {
animation: daily-rise 360ms ease-out;
}
/* The dailies' sweep, held bright until it has crossed: a single row is
too thin for a band that fades as it travels. */
@keyframes code-sweep {
from {
background-position: 100% 0;
}
75% {
opacity: 1;
}
to {
background-position: 0% 0;
opacity: 0;
}
}
@keyframes code-leave {
to {
opacity: 0;
transform: translateY(6px);
}
}
@keyframes code-land {
from {
opacity: 0;
transform: translateY(-6px);
}
}
}
.game-settings .new-code {
margin-left: 6px;
font-size: 11px;
@@ -4292,13 +4372,13 @@ dialog.archive-detail {
color: var(--color-faint);
letter-spacing: 0;
}
.game-settings .comp {
:is(.game-settings, .motion-cards) .comp {
margin: 14px 0 8px;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.game-settings .comp button {
:is(.game-settings, .motion-cards) .comp button {
position: relative;
display: flex;
flex-direction: column;
@@ -4308,22 +4388,22 @@ dialog.archive-detail {
border: 1px solid var(--rule-strong);
border-radius: 3px;
}
.game-settings .comp button:hover {
:is(.game-settings, .motion-cards) .comp button:hover {
border-color: var(--color-muted);
}
.game-settings .comp button[data-st="on"] {
:is(.game-settings, .motion-cards) .comp button[data-st="on"] {
background: color-mix(in srgb, var(--g) 14%, transparent);
border-color: color-mix(in srgb, var(--g) 70%, transparent);
}
.game-settings .comp button[data-st="some"] {
:is(.game-settings, .motion-cards) .comp button[data-st="some"] {
border-style: dashed;
border-color: color-mix(in srgb, var(--g) 70%, transparent);
}
/* Nothing built in yet: dimmed like the mockup, but still a choice that is remembered. */
.game-settings .comp button[data-empty="true"]:not([data-st="on"]) {
:is(.game-settings, .motion-cards) .comp button[data-empty="true"]:not([data-st="on"]) {
opacity: 0.55;
}
.game-settings .comp .cbx {
:is(.game-settings, .motion-cards) .comp .cbx {
position: absolute;
top: 12px;
right: 12px;
@@ -4334,37 +4414,57 @@ dialog.archive-detail {
display: grid;
place-items: center;
}
.game-settings .comp button[data-st="on"] .cbx {
:is(.game-settings, .motion-cards) .comp button[data-st="on"] .cbx {
background: var(--g);
border-color: var(--g);
}
.game-settings .comp button[data-st="some"] .cbx::after {
:is(.game-settings, .motion-cards) .comp button[data-st="some"] .cbx::after {
content: "";
width: 8px;
height: 2px;
background: var(--g);
}
.game-settings .comp .cbx svg {
:is(.game-settings, .motion-cards) .comp .cbx svg {
width: 11px;
height: 11px;
stroke: var(--color-ground);
stroke-width: 3;
fill: none;
}
.game-settings .comp .ln {
:is(.game-settings, .motion-cards) .comp .ln {
display: block;
padding-right: 22px;
font: 600 17px/1.1 var(--font-display);
letter-spacing: 0.02em;
color: var(--color-ink);
}
.game-settings .comp .ls {
:is(.game-settings, .motion-cards) .comp .ls {
display: block;
margin-top: 3px;
font-size: 11.5px;
line-height: 1.3;
color: var(--color-faint);
}
/* Settings → Appearance → Motion & celebrations: the same cards, three across.
A card Reduce motion holds off is dimmed and cannot be pressed. */
.motion-cards {
--rule-strong: color-mix(in srgb, var(--color-ink) 22%, var(--color-surface));
}
.motion-cards .comp {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.motion-cards .comp button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.motion-cards .comp button:disabled:hover {
border-color: var(--rule-strong);
}
@media (max-width: 700px) {
.motion-cards .comp {
grid-template-columns: 1fr;
}
}
.game-settings .compnote {
display: flex;
gap: 14px;
+16
View File
@@ -192,6 +192,18 @@ export function urgencyTokens(
]),
) as Record<UrgencyStep, string>;
}
/**
* Whether to hold still: the reader's Reduce motion switch, or the device's
* own setting. The switch can only add to the device's choice, since a
* stylesheet cannot undo `prefers-reduced-motion`.
*/
export function motionReduced(prefs: Record<string, unknown>): boolean {
return (
prefs.reduceMotion === true ||
(typeof matchMedia === "function" &&
matchMedia("(prefers-reduced-motion: reduce)").matches)
);
}
export function applyAppearance(
prefs: Record<string, unknown>,
theme: Theme,
@@ -211,4 +223,8 @@ export function applyAppearance(
prefs.interface === "relaxed" ? "relaxed" : "structured";
doc.documentElement.dataset.density =
prefs.density === "compact" ? "compact" : "standard";
// styles.css stops every animation under "reduce"; "system" leaves it to
// the device's prefers-reduced-motion.
doc.documentElement.dataset.motion =
prefs.reduceMotion === true ? "reduce" : "system";
}
+15
View File
@@ -475,6 +475,21 @@ body {
}
}
/* Settings → Appearance → Reduce motion: the reader asks for what the
device's prefers-reduced-motion asks for, whatever the device says. Every
animation and transition stops, and the fireworks, which are nothing but
motion, are not drawn at all. */
[data-motion="reduce"] *,
[data-motion="reduce"] *::before,
[data-motion="reduce"] *::after {
animation: none !important;
transition: none !important;
scroll-behavior: auto !important;
}
[data-motion="reduce"] .fireworks {
display: none !important;
}
/* Horizontal scrollers keep their gutter but hide the bar chrome. */
.scroll-x {
overflow-x: auto;
+6 -4
View File
@@ -34,7 +34,7 @@ test("the item that finishes a section gets the big celebration, not the small b
expect(finishesSection(3, 3)).toBe(false);
});
test("Appearance offers a Celebrations switch, on by default", () => {
test("Appearance offers a Fireworks card, on by default", () => {
const s = migrateLegacy(
{},
[],
@@ -44,13 +44,15 @@ test("Appearance offers a Celebrations switch, on by default", () => {
const on = render(
<AppearancePage state={s} onAction={async () => ({ ok: true })} />,
);
expect(on).toContain("Celebrations");
expect(on).toContain("Motion &amp; celebrations");
expect(on).toMatch(
/<input type="checkbox" data-celebrations="true" checked=""/,
/<button[^>]*data-celebrations="true"[^>]*aria-pressed="true"|<button[^>]*aria-pressed="true"[^>]*data-celebrations="true"/,
);
s.prefs.celebrations = false;
const off = render(
<AppearancePage state={s} onAction={async () => ({ ok: true })} />,
);
expect(off).not.toMatch(/data-celebrations="true" checked=""/);
expect(off).toMatch(
/<button[^>]*aria-pressed="false"[^>]*data-celebrations="true"|<button[^>]*data-celebrations="true"[^>]*aria-pressed="false"/,
);
});
+63
View File
@@ -129,3 +129,66 @@ test("a code confirmed dead in game says so, in the redeemed-or-expired section"
expect(html).toContain("Redeemed or expired");
expect(html).toContain("Confirmed expired in game, 2026-10-20.");
});
import { codeLanes } from "../src/client/components/redesign/CodesPage.tsx";
test("a code just redeemed shines in the open list before it moves, then lands below", () => {
const codes = ["a", "b", "c"].map((id) => ({ id }));
const done = (c: { id: string }) => c.id !== "a";
// "b" was just redeemed: it holds in the open list while it shines.
const shining = codeLanes(codes, done, new Set(["b"]));
expect(shining.open.map((c) => c.id)).toEqual(["a", "b"]);
expect(shining.closed.map((c) => c.id)).toEqual(["c"]);
expect(shining.used(codes[1]!)).toBe(false);
// Then it lands among the redeemed, greyed like the rest.
const landed = codeLanes(codes, done, new Set());
expect(landed.open.map((c) => c.id)).toEqual(["a"]);
expect(landed.closed.map((c) => c.id)).toEqual(["b", "c"]);
expect(landed.used(codes[1]!)).toBe(true);
});
test("an opened Codes tab plays nothing: no row shines or lands on arrival", () => {
const at = "2026-10-18T12:00:00.000Z",
s = migrateLegacy({}, ["genshin"], at, () => "account:one");
s.codeRedeemed["account:one"] = { done: at };
const code = (id: string) => ({
id,
game: "genshin" as const,
code: id.toUpperCase(),
reward: null,
expiresAt: null,
firstObservedAt: at,
sourceUrl: "https://example.com",
});
const html = render(
<CodesPage
game="genshin"
state={s}
onAction={async () => ({ ok: true })}
feed={{
schemaVersion: 1,
sources: [],
codes: [code("open"), code("done")],
}}
/>,
);
expect(html).toContain("Redeemed or expired");
expect(html).not.toContain("data-shine");
expect(html).not.toContain("data-landing");
expect(html).not.toContain("data-fresh");
});
test("a shining code greys its text, as a completed event does on its way out", async () => {
const css = await Bun.file(
new URL("../src/client/redesign.css", import.meta.url),
).text();
const opacity = (selector: RegExp) =>
new RegExp(
selector.source + String.raw`\s*\{[^}]*opacity:\s*([\d.]+)`,
"s",
).exec(css)?.[1];
const event = opacity(/\.event-list > li\[data-leaving\] > button > span/);
expect(event).toBe("0.45");
expect(opacity(/\.code-row\[data-shine\] > div:first-child > \*/)).toBe(
event,
);
});
+111
View File
@@ -0,0 +1,111 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup as render } from "react-dom/server";
import { shineOn } from "../src/client/components/Fireworks.tsx";
import { AppearancePage } from "../src/client/components/redesign/AppearancePage.tsx";
import { leavingMarks } from "../src/client/components/redesign/ChecklistEvent.tsx";
import {
applyAppearance,
motionReduced,
} from "../src/client/state/appearance.ts";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
const state = () =>
migrateLegacy({}, [], "2026-10-20T12:00:00.000Z", () => "account:test");
// The opening tag of one of the Motion & celebrations cards.
const card = (html: string, name: string) =>
new RegExp(`<button[^>]*${name}="true"[^>]*>`).exec(html)?.[0] ?? "";
const page = (prefs: Record<string, unknown>) => {
const s = state();
Object.assign(s.prefs, prefs);
return render(
<AppearancePage state={s} onAction={async () => ({ ok: true })} />,
);
};
test("reduced motion follows the device unless the reader turns it on", () => {
// No browser here, so the device asks for nothing.
expect(motionReduced({})).toBe(false);
expect(motionReduced({ reduceMotion: false })).toBe(false);
expect(motionReduced({ reduceMotion: true })).toBe(true);
});
test("the reduced motion switch marks the page, so the stylesheet can stop animations", () => {
const doc = {
documentElement: {
dataset: {} as Record<string, string>,
style: { setProperty() {} },
},
};
applyAppearance({ reduceMotion: true }, "dark", doc as unknown as Document);
expect(doc.documentElement.dataset.motion).toBe("reduce");
applyAppearance({}, "dark", doc as unknown as Document);
expect(doc.documentElement.dataset.motion).toBe("system");
});
test("the stylesheet stops every animation and the fireworks when the page is marked", async () => {
const css = await Bun.file(
new URL("../src/client/styles.css", import.meta.url),
).text();
expect(css).toMatch(
/\[data-motion="reduce"\][^{]*\*[^{]*\{[^}]*animation: none !important/s,
);
expect(css).toMatch(
/\[data-motion="reduce"\] \.fireworks\s*\{[^}]*display: none !important/s,
);
});
test("Appearance has a Reduce motion switch that says what it does, off by default", () => {
const off = page({});
expect(off).toContain("Reduce motion");
expect(off).toContain("your device");
expect(card(off, "data-reduce-motion")).toContain('aria-pressed="false"');
expect(card(page({ reduceMotion: true }), "data-reduce-motion")).toContain(
'aria-pressed="true"',
);
// One section of cards, Reduce motion first.
expect(off).toContain("Motion &amp; celebrations");
expect(off.indexOf("data-reduce-motion")).toBeLessThan(
off.indexOf("data-celebrations"),
);
});
test("Celebrations has a Shine switch, on by default", () => {
expect(shineOn({})).toBe(true);
expect(shineOn({ shine: false })).toBe(false);
expect(card(page({}), "data-shine-switch")).toContain('aria-pressed="true"');
expect(card(page({ shine: false }), "data-shine-switch")).toContain(
'aria-pressed="false"',
);
});
test("with shine off, a completed event still greys on its way out but does not shine", () => {
const t = { key: "row", label: "Completed", phase: "hold" };
expect(leavingMarks(t, "row", false)).toEqual({
className: "",
"data-leaving": true,
"data-shine": undefined,
});
expect(leavingMarks(t, "row")["data-shine"]).toBe(true);
});
test("with Reduce motion on, fireworks and shine are locked off without losing the choice", () => {
const locked = page({ reduceMotion: true, celebrations: true, shine: true });
for (const name of ["data-celebrations", "data-shine-switch"]) {
expect(card(locked, name)).toContain('aria-pressed="false"');
expect(card(locked, name)).toContain('disabled=""');
}
expect(locked).toContain("Off while Reduce motion is on");
// Reduce motion off again: both come back as they were.
const back = page({ reduceMotion: false, celebrations: true, shine: false });
expect(card(back, "data-celebrations")).toContain('aria-pressed="true"');
expect(card(back, "data-shine-switch")).toContain('aria-pressed="false"');
expect(card(back, "data-celebrations")).not.toContain("disabled");
expect(card(back, "data-shine-switch")).not.toContain("disabled");
});
test("the Motion & celebrations cards draw their outline outside a game's settings", async () => {
const css = await Bun.file(
new URL("../src/client/redesign.css", import.meta.url),
).text();
expect(css).toMatch(/\.motion-cards\s*\{[^}]*--rule-strong:/s);
});
+42
View File
@@ -52,3 +52,45 @@ test("the next deadline lists the following three, as in the Round 6 rail", () =
for (const n of [2, 3, 4]) expect(html).toContain(`Event ${n}`);
expect(html).not.toContain("Event 5");
});
import { leavingMarks } from "../src/client/components/redesign/ChecklistEvent.tsx";
test("a completed event shines and greys while it holds, then fades; an ignored one only greys", () => {
const done = (phase: "hold" | "fade" | "hidden") => ({
key: "row",
label: "Completed",
phase,
});
expect(leavingMarks(done("hold"), "row")).toEqual({
className: "",
"data-leaving": true,
"data-shine": true,
});
expect(leavingMarks(done("fade"), "row")).toEqual({
className: "archive-fading",
"data-leaving": true,
"data-shine": undefined,
});
expect(
leavingMarks({ key: "row", label: "Ignored", phase: "hold" }, "row"),
).toEqual({ className: "", "data-leaving": true, "data-shine": undefined });
// Every other row, and every row with nothing moving, is untouched.
const still = {
className: "",
"data-leaving": undefined,
"data-shine": undefined,
};
expect(leavingMarks(done("hold"), "other")).toEqual(still);
expect(leavingMarks(null, "row")).toEqual(still);
});
import {
ARCHIVE_COLLAPSE_MS,
ARCHIVE_HOLD_MS,
} from "../src/client/components/redesign/ChecklistEvent.tsx";
test("a completed event holds under a second, long enough for its shine, then collapses", () => {
expect(ARCHIVE_HOLD_MS).toBe(900);
// The shine (a 640 ms sweep) finishes before the row starts to fade.
expect(ARCHIVE_HOLD_MS).toBeGreaterThan(640);
// The collapse waits out the 250 ms max-height transition.
expect(ARCHIVE_COLLAPSE_MS - ARCHIVE_HOLD_MS).toBe(250);
});