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>
This commit is contained in:
1 parent
9c22a85134
commit
54d13d7472
15 files changed
+628
-59
No files matched your search
@@ -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
|
`true` means on; only `false` turns every celebration off, big and small. See `celebrationsOn` in
|
||||||
`Fireworks.tsx`.
|
`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`)
|
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
|
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.
|
interface default as of the owner's 2026-10-17 instruction; explicit saved Relaxed is preserved.
|
||||||
+26
-2
@@ -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.
|
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
|
- **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 →
|
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
|
Appearance → Motion & celebrations → Fireworks), read through `CelebrationsContext` in
|
||||||
tick animation (wash, pop, check) stays either way: it is feedback, not celebration.
|
`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
|
### Other pages
|
||||||
|
|
||||||
|
|||||||
+10
-6
@@ -192,9 +192,9 @@ the cut are still counted in the header, still on the timeline, and one tap away
|
|||||||
|
|
||||||
**F3 — Mark completed.**
|
**F3 — Mark completed.**
|
||||||
Done writes durable progress and a metadata snapshot immediately. A confirmation remains for about
|
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;
|
0.9 seconds (1.5 until 2026-10-20, which read as unresponsive) before the row fades/collapses into
|
||||||
Archive Restore remains available afterwards. A failed write is disclosed and never presented as
|
Archive. Undo is available for eight seconds; Archive Restore remains available afterwards. A failed
|
||||||
saved. Reduced motion removes the fade.
|
write is disclosed and never presented as saved. Reduced motion removes the fade.
|
||||||
|
|
||||||
**F4 — Settings and visibility.**
|
**F4 — Settings and visibility.**
|
||||||
Settings has named pages: General, Your games, Appearance, Lists, Region & time, Data & sources,
|
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
|
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
|
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
|
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)
|
Routines. Numeric routines use native step-one inputs. **Motion & celebrations** holds three cards.
|
||||||
covers the fireworks: a small burst for finishing one daily, routine or event, and the grand one for
|
**Reduce motion** (off by default, and saying what it does) stops every animation whatever the
|
||||||
finishing every daily or every routine in a game's group. Off means none at all, big or small.
|
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 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
|
The app is a lit instrument panel and that is what it should be on first sight, but it is also read
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { PinnedRoutine } from "./components/redesign/PinnedRoutine.tsx";
|
|||||||
import { DisplayTimeContext } from "./state/displayTime.ts";
|
import { DisplayTimeContext } from "./state/displayTime.ts";
|
||||||
import {
|
import {
|
||||||
CelebrationsContext,
|
CelebrationsContext,
|
||||||
|
shineOn,
|
||||||
Fireworks,
|
Fireworks,
|
||||||
celebrationsOn,
|
celebrationsOn,
|
||||||
} from "./components/Fireworks.tsx";
|
} from "./components/Fireworks.tsx";
|
||||||
@@ -65,7 +66,10 @@ import { GameMetaProvider } from "./state/gameMeta.tsx";
|
|||||||
import { useAppUpdate } from "./state/useAppUpdate.ts";
|
import { useAppUpdate } from "./state/useAppUpdate.ts";
|
||||||
import { ENTRIES, NOTES, newestId, unseen } from "../shared/changelog.ts";
|
import { ENTRIES, NOTES, newestId, unseen } from "../shared/changelog.ts";
|
||||||
import {
|
import {
|
||||||
|
ARCHIVE_COLLAPSE_MS,
|
||||||
|
ARCHIVE_HOLD_MS,
|
||||||
ChecklistEvent,
|
ChecklistEvent,
|
||||||
|
leavingMarks,
|
||||||
LedgerColumns,
|
LedgerColumns,
|
||||||
UrgencyLegend,
|
UrgencyLegend,
|
||||||
NextDeadline,
|
NextDeadline,
|
||||||
@@ -478,11 +482,11 @@ function ReaderContent({
|
|||||||
if (!transition) return;
|
if (!transition) return;
|
||||||
const t = setTimeout(
|
const t = setTimeout(
|
||||||
() => setTransition((t) => (t ? { ...t, phase: "fade" } : null)),
|
() => setTransition((t) => (t ? { ...t, phase: "fade" } : null)),
|
||||||
1500,
|
ARCHIVE_HOLD_MS,
|
||||||
);
|
);
|
||||||
const collapse = setTimeout(
|
const collapse = setTimeout(
|
||||||
() => setTransition((t) => (t ? { ...t, phase: "hidden" } : null)),
|
() => setTransition((t) => (t ? { ...t, phase: "hidden" } : null)),
|
||||||
1750,
|
ARCHIVE_COLLAPSE_MS,
|
||||||
);
|
);
|
||||||
const undo = setTimeout(() => setTransition(null), 8000);
|
const undo = setTimeout(() => setTransition(null), 8000);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -620,14 +624,7 @@ function ReaderContent({
|
|||||||
const key = pairKey(row.accountId, row.event.id),
|
const key = pairKey(row.accountId, row.event.id),
|
||||||
p = progress(row);
|
p = progress(row);
|
||||||
return (
|
return (
|
||||||
<li
|
<li key={key} {...leavingMarks(transition, key, shineOn(state.prefs))}>
|
||||||
key={key}
|
|
||||||
className={
|
|
||||||
transition?.key === key && transition.phase === "fade"
|
|
||||||
? "archive-fading"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ChecklistEvent
|
<ChecklistEvent
|
||||||
row={row}
|
row={row}
|
||||||
now={now}
|
now={now}
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ export function celebrationsOn(prefs: Record<string, unknown>): boolean {
|
|||||||
return prefs.celebrations !== false;
|
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. */
|
/** The reader's celebrations switch, provided once at the app's root. */
|
||||||
export const CelebrationsContext = createContext(true);
|
export const CelebrationsContext = createContext(true);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { timelineLook } from "./TimelinePage.tsx";
|
|||||||
import { ViewCards } from "./ViewCards.tsx";
|
import { ViewCards } from "./ViewCards.tsx";
|
||||||
import { focusBarShown } from "./FocusPicker.tsx";
|
import { focusBarShown } from "./FocusPicker.tsx";
|
||||||
import { URGENCY_STEPS, urgencyChoice } from "../../state/appearance.ts";
|
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. */
|
/** The live legend's words for each step. */
|
||||||
const URGENCY_LABEL = {
|
const URGENCY_LABEL = {
|
||||||
@@ -32,6 +32,8 @@ export function AppearancePage({
|
|||||||
const p = state.prefs,
|
const p = state.prefs,
|
||||||
patch = (patch: Record<string, unknown>) =>
|
patch = (patch: Record<string, unknown>) =>
|
||||||
void onAction({ type: "prefs", patch, ...actionMeta() });
|
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)
|
const hidden = Array.isArray(p.hiddenGames)
|
||||||
? (p.hiddenGames as string[])
|
? (p.hiddenGames as string[])
|
||||||
: [];
|
: [];
|
||||||
@@ -354,21 +356,44 @@ export function AppearancePage({
|
|||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
<section className="settings-group">
|
<section
|
||||||
<h2>Celebrations</h2>
|
className="settings-group motion-cards"
|
||||||
<p className="help">
|
style={{ "--g": "var(--color-near)" } as CSSProperties}
|
||||||
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.
|
<h2>Motion & celebrations</h2>
|
||||||
</p>
|
<div className="comp" role="group" aria-label="Motion and celebrations">
|
||||||
<label>
|
<MotionCard
|
||||||
<input
|
mark="data-reduce-motion"
|
||||||
type="checkbox"
|
title="Reduce motion"
|
||||||
data-celebrations
|
on={still}
|
||||||
checked={celebrationsOn(p)}
|
onToggle={(on) => patch({ reduceMotion: on })}
|
||||||
onChange={(e) => patch({ celebrations: e.target.checked })}
|
lines={[
|
||||||
|
"Stops pops, sweeps, slides and fireworks, whatever your device says.",
|
||||||
|
"Your device's setting applies either way",
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
Show fireworks
|
{/* Locked, not cleared: the saved choices come back with motion. */}
|
||||||
</label>
|
<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>
|
||||||
<section className="settings-group checklist-settings">
|
<section className="settings-group checklist-settings">
|
||||||
<h2>Checklist</h2>
|
<h2>Checklist</h2>
|
||||||
@@ -393,3 +418,46 @@ export function AppearancePage({
|
|||||||
</section>
|
</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>
|
</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({
|
export function ChecklistEvent({
|
||||||
row,
|
row,
|
||||||
now,
|
now,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { freshness } from "../../../shared/feed.ts";
|
import { freshness } from "../../../shared/feed.ts";
|
||||||
import type { CodesFeed, RedeemCode } from "../../../shared/codes.ts";
|
import type { CodesFeed, RedeemCode } from "../../../shared/codes.ts";
|
||||||
import { REDEEM } from "../../../shared/redeem.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 { actionMeta } from "../../state/reader/actions.ts";
|
||||||
import { formatInZone } from "../../state/displayTime.ts";
|
import { formatInZone } from "../../state/displayTime.ts";
|
||||||
import type { ActionHandler } from "./GameSettings.tsx";
|
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({
|
export function CodesPage({
|
||||||
game,
|
game,
|
||||||
feed,
|
feed,
|
||||||
@@ -34,8 +55,41 @@ export function CodesPage({
|
|||||||
accounts.map((a) => a.id),
|
accounts.map((a) => a.id),
|
||||||
state.codeRedeemed,
|
state.codeRedeemed,
|
||||||
);
|
);
|
||||||
const open = codes.filter((c) => !done(c)),
|
// Only the reader's own ticks animate: a code already redeemed, or one that
|
||||||
closed = codes.filter(done);
|
// 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 redeem = REDEEM[game as GameId];
|
||||||
const unseen = feed ? newCodes(feed, state.codeSeen[game] ?? [], game) : [];
|
const unseen = feed ? newCodes(feed, state.codeSeen[game] ?? [], game) : [];
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -49,7 +103,12 @@ export function CodesPage({
|
|||||||
});
|
});
|
||||||
}, [game, feed, onAction]);
|
}, [game, feed, onAction]);
|
||||||
const row = (c: RedeemCode) => (
|
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>
|
<div>
|
||||||
<code>{c.code}</code>
|
<code>{c.code}</code>
|
||||||
<button
|
<button
|
||||||
@@ -86,19 +145,40 @@ export function CodesPage({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{accounts.map((a) => (
|
{accounts.map((a) => (
|
||||||
<label key={a.id}>
|
<label key={a.id} data-fresh={fresh.has(a.id + c.id) || undefined}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={Boolean(state.codeRedeemed[a.id]?.[c.id])}
|
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({
|
void onAction({
|
||||||
type: "redeem",
|
type: "redeem",
|
||||||
accountId: a.id,
|
accountId: a.id,
|
||||||
codeId: c.id,
|
codeId: c.id,
|
||||||
redeemed: e.target.checked,
|
redeemed,
|
||||||
...actionMeta(),
|
...actionMeta(),
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
/>
|
/>
|
||||||
Redeemed
|
Redeemed
|
||||||
{/* The server only when there is more than one to tell apart. */}
|
{/* The server only when there is more than one to tell apart. */}
|
||||||
@@ -153,7 +233,12 @@ export function CodesPage({
|
|||||||
{open.length ? (
|
{open.length ? (
|
||||||
open.map(row)
|
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 && (
|
{closed.length > 0 && (
|
||||||
<section className="codes-used">
|
<section className="codes-used">
|
||||||
|
|||||||
+112
-12
@@ -4268,6 +4268,86 @@ dialog.archive-detail {
|
|||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
text-decoration-color: var(--color-muted);
|
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 {
|
.game-settings .new-code {
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -4292,13 +4372,13 @@ dialog.archive-detail {
|
|||||||
color: var(--color-faint);
|
color: var(--color-faint);
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
.game-settings .comp {
|
:is(.game-settings, .motion-cards) .comp {
|
||||||
margin: 14px 0 8px;
|
margin: 14px 0 8px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.game-settings .comp button {
|
:is(.game-settings, .motion-cards) .comp button {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -4308,22 +4388,22 @@ dialog.archive-detail {
|
|||||||
border: 1px solid var(--rule-strong);
|
border: 1px solid var(--rule-strong);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
.game-settings .comp button:hover {
|
:is(.game-settings, .motion-cards) .comp button:hover {
|
||||||
border-color: var(--color-muted);
|
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);
|
background: color-mix(in srgb, var(--g) 14%, transparent);
|
||||||
border-color: color-mix(in srgb, var(--g) 70%, 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-style: dashed;
|
||||||
border-color: color-mix(in srgb, var(--g) 70%, transparent);
|
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. */
|
/* 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;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
.game-settings .comp .cbx {
|
:is(.game-settings, .motion-cards) .comp .cbx {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 12px;
|
top: 12px;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
@@ -4334,37 +4414,57 @@ dialog.archive-detail {
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
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);
|
background: var(--g);
|
||||||
border-color: 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: "";
|
content: "";
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: var(--g);
|
background: var(--g);
|
||||||
}
|
}
|
||||||
.game-settings .comp .cbx svg {
|
:is(.game-settings, .motion-cards) .comp .cbx svg {
|
||||||
width: 11px;
|
width: 11px;
|
||||||
height: 11px;
|
height: 11px;
|
||||||
stroke: var(--color-ground);
|
stroke: var(--color-ground);
|
||||||
stroke-width: 3;
|
stroke-width: 3;
|
||||||
fill: none;
|
fill: none;
|
||||||
}
|
}
|
||||||
.game-settings .comp .ln {
|
:is(.game-settings, .motion-cards) .comp .ln {
|
||||||
display: block;
|
display: block;
|
||||||
padding-right: 22px;
|
padding-right: 22px;
|
||||||
font: 600 17px/1.1 var(--font-display);
|
font: 600 17px/1.1 var(--font-display);
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
color: var(--color-ink);
|
color: var(--color-ink);
|
||||||
}
|
}
|
||||||
.game-settings .comp .ls {
|
:is(.game-settings, .motion-cards) .comp .ls {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
color: var(--color-faint);
|
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 {
|
.game-settings .compnote {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
|||||||
@@ -192,6 +192,18 @@ export function urgencyTokens(
|
|||||||
]),
|
]),
|
||||||
) as Record<UrgencyStep, string>;
|
) 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(
|
export function applyAppearance(
|
||||||
prefs: Record<string, unknown>,
|
prefs: Record<string, unknown>,
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
@@ -211,4 +223,8 @@ export function applyAppearance(
|
|||||||
prefs.interface === "relaxed" ? "relaxed" : "structured";
|
prefs.interface === "relaxed" ? "relaxed" : "structured";
|
||||||
doc.documentElement.dataset.density =
|
doc.documentElement.dataset.density =
|
||||||
prefs.density === "compact" ? "compact" : "standard";
|
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";
|
||||||
}
|
}
|
||||||
@@ -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. */
|
/* Horizontal scrollers keep their gutter but hide the bar chrome. */
|
||||||
.scroll-x {
|
.scroll-x {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|||||||
@@ -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);
|
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(
|
const s = migrateLegacy(
|
||||||
{},
|
{},
|
||||||
[],
|
[],
|
||||||
@@ -44,13 +44,15 @@ test("Appearance offers a Celebrations switch, on by default", () => {
|
|||||||
const on = render(
|
const on = render(
|
||||||
<AppearancePage state={s} onAction={async () => ({ ok: true })} />,
|
<AppearancePage state={s} onAction={async () => ({ ok: true })} />,
|
||||||
);
|
);
|
||||||
expect(on).toContain("Celebrations");
|
expect(on).toContain("Motion & celebrations");
|
||||||
expect(on).toMatch(
|
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;
|
s.prefs.celebrations = false;
|
||||||
const off = render(
|
const off = render(
|
||||||
<AppearancePage state={s} onAction={async () => ({ ok: true })} />,
|
<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"/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
@@ -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("Redeemed or expired");
|
||||||
expect(html).toContain("Confirmed expired in game, 2026-10-20.");
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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 & 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);
|
||||||
|
});
|
||||||
@@ -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}`);
|
for (const n of [2, 3, 4]) expect(html).toContain(`Event ${n}`);
|
||||||
expect(html).not.toContain("Event 5");
|
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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user