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:
co-authored by
Claude Opus 5.5
parent
9c22a85134
commit
54d13d7472
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 & 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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 & 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"/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
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