From 17e957fa0038e1633420baa6f1307d416cd63004 Mon Sep 17 00:00:00 2001 From: Jacob Cheatley Date: Tue, 22 Sep 2026 11:20:42 +1200 Subject: [PATCH 1/4] Elemental Showdown: lift the vote-casting helpers in the server test The Matchup helpers the Stats gate's tests build their crowd with are about to be what the unlock's tests need too, so they move out of that one describe and up to the file. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AVEtgjCoEbaNHrYfzyCRNH --- .../elemental-showdown.server.test.ts | 74 +++++++++---------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/src/projects/elemental-showdown/elemental-showdown.server.test.ts b/src/projects/elemental-showdown/elemental-showdown.server.test.ts index 060d432..4f94fb6 100644 --- a/src/projects/elemental-showdown/elemental-showdown.server.test.ts +++ b/src/projects/elemental-showdown/elemental-showdown.server.test.ts @@ -40,6 +40,43 @@ async function insertElement( const storedVotes = () => db.select().from(votes); +type Matchup = { elementLow: number; elementHigh: number }; + +const A_WEAK_WIN: VoteValue = 1; + +// Seven Elements give 21 Matchups, more than the Voter's own number, so each +// of their Votes below is on a Matchup of its own. +const ELEMENTS_FOR_THE_GATE = 7; + +async function insertMatchups(elementCount: number) { + const ids: number[] = []; + for (let at = 0; at < elementCount; at++) + ids.push(await insertElement(`element ${at}`)); + return ids.flatMap((elementLow, at) => + ids.slice(at + 1).map((elementHigh) => ({ elementLow, elementHigh })), + ); +} + +// The Voter takes a Matchup each; the rest of the crowd piles onto the first +// of them, one Voter apiece so that every Vote stands. +async function castVotes( + matchups: Matchup[], + { own, everyone }: { own: number; everyone: number }, +) { + const [crowded] = matchups; + if (!crowded) throw new Error("no Matchup to vote on"); + await db.insert(votes).values([ + ...matchups + .slice(0, own) + .map((matchup) => ({ voter: VOTER, ...matchup, value: A_WEAK_WIN })), + ...Array.from({ length: everyone - own }, () => ({ + voter: crypto.randomUUID(), + ...crowded, + value: A_WEAK_WIN, + })), + ]); +} + describe("castVote", () => { it("stores a Vote cast with the higher-id Element on top the way round the Matchup is kept, sign and all", async () => { const fire = await insertElement("fire"); @@ -281,43 +318,6 @@ describe("showdownAggregate", () => { }); describe("showdownStats", () => { - type Matchup = { elementLow: number; elementHigh: number }; - - const A_WEAK_WIN: VoteValue = 1; - - // Seven Elements give 21 Matchups, more than the Voter's own number, so each - // of their Votes below is on a Matchup of its own. - const ELEMENTS_FOR_THE_GATE = 7; - - async function insertMatchups(elementCount: number) { - const ids: number[] = []; - for (let at = 0; at < elementCount; at++) - ids.push(await insertElement(`element ${at}`)); - return ids.flatMap((elementLow, at) => - ids.slice(at + 1).map((elementHigh) => ({ elementLow, elementHigh })), - ); - } - - // The Voter takes a Matchup each; the rest of the crowd piles onto the first - // of them, one Voter apiece so that every Vote stands. - async function castVotes( - matchups: Matchup[], - { own, everyone }: { own: number; everyone: number }, - ) { - const [crowded] = matchups; - if (!crowded) throw new Error("no Matchup to vote on"); - await db.insert(votes).values([ - ...matchups - .slice(0, own) - .map((matchup) => ({ voter: VOTER, ...matchup, value: A_WEAK_WIN })), - ...Array.from({ length: everyone - own }, () => ({ - voter: crypto.randomUUID(), - ...crowded, - value: A_WEAK_WIN, - })), - ]); - } - // The gate is met exactly, so every test below reads the open Stats. async function unlockFor(elementCount: number) { const matchups = await insertMatchups(elementCount); From 3497b0ce27dd1db86d2305eac3530503f07b23d2 Mon Sep 17 00:00:00 2001 From: Jacob Cheatley Date: Tue, 22 Sep 2026 11:23:32 +1200 Subject: [PATCH 2/4] Elemental Showdown: the reveal knows the Vote that unlocked the Stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reveal of the one Vote that crosses both unlock numbers carries the Active Elements, which is what the unlock's wave flips a tile for; every other reveal carries none of them. Both numbers are counted fresh on the cast path rather than read from the cached aggregate, whose crowd count is up to a minute behind and so cannot say which Vote crossed. The insert reports whether it stored anything, so a repeat Vote — which moves neither count — never fires the unlock. The Stats' own gate still reads the cached count, so the unlocking cast drops that copy: the Voter who just watched the Stats open finds them open. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AVEtgjCoEbaNHrYfzyCRNH --- .../elemental-showdown.server.test.ts | 92 +++++++++++++++++++ .../elemental-showdown.server.ts | 51 ++++++++-- .../elemental-showdown/matchup-score.ts | 5 + .../showdown-aggregate.test.ts | 29 ++++-- .../elemental-showdown/showdown-aggregate.ts | 15 ++- 5 files changed, 173 insertions(+), 19 deletions(-) diff --git a/src/projects/elemental-showdown/elemental-showdown.server.test.ts b/src/projects/elemental-showdown/elemental-showdown.server.test.ts index 4f94fb6..e81952f 100644 --- a/src/projects/elemental-showdown/elemental-showdown.server.test.ts +++ b/src/projects/elemental-showdown/elemental-showdown.server.test.ts @@ -209,6 +209,98 @@ describe("castVote", () => { }); }); +describe("the Vote that unlocks the Stats", () => { + // Both numbers one Vote short, with a Matchup left for the Voter to cast the + // Vote that crosses them on. + async function oneVoteShortOfBoth() { + const matchups = await insertMatchups(ELEMENTS_FOR_THE_GATE); + await castVotes(matchups, { + own: OWN_VOTES_TO_UNLOCK - 1, + everyone: EVERY_VOTES_TO_UNLOCK - 1, + }); + return { + matchups, + unlocking: theVoteOn(matchups, OWN_VOTES_TO_UNLOCK - 1), + }; + } + + function theVoteOn(matchups: Matchup[], at: number) { + const unvoted = matchups[at]; + if (!unvoted) throw new Error(`no Matchup ${at} to vote on`); + return { + topElementId: unvoted.elementLow, + bottomElementId: unvoted.elementHigh, + value: A_WEAK_WIN, + }; + } + + it("carries the Active Elements the wave flips a tile for", async () => { + const { unlocking } = await oneVoteShortOfBoth(); + + const reveal = await castVote(VOTER, unlocking); + + expect(reveal.unlockedElements).toHaveLength(ELEMENTS_FOR_THE_GATE); + expect(reveal.unlockedElements?.[0]).toEqual({ + id: expect.any(Number), + name: "element 0", + emoji: "🔥", + colour: "#f2541b", + }); + }); + + it("says nothing of an unlock on the Vote before both numbers are met", async () => { + const matchups = await insertMatchups(ELEMENTS_FOR_THE_GATE); + await castVotes(matchups, { + own: OWN_VOTES_TO_UNLOCK - 2, + everyone: EVERY_VOTES_TO_UNLOCK - 2, + }); + + const reveal = await castVote( + VOTER, + theVoteOn(matchups, OWN_VOTES_TO_UNLOCK - 2), + ); + + expect(reveal.unlockedElements).toBeUndefined(); + }); + + it("says nothing of an unlock on the Vote after it", async () => { + const { matchups, unlocking } = await oneVoteShortOfBoth(); + await castVote(VOTER, unlocking); + + const reveal = await castVote( + VOTER, + theVoteOn(matchups, OWN_VOTES_TO_UNLOCK), + ); + + expect(reveal.unlockedElements).toBeUndefined(); + }); + + it("says nothing of an unlock on a repeat Vote that stored nothing", async () => { + const { unlocking } = await oneVoteShortOfBoth(); + await castVote(VOTER, unlocking); + + const reveal = await castVote(VOTER, unlocking); + + expect(reveal.unlockedElements).toBeUndefined(); + }); + + it("opens the Stats to the Voter the moment their unlocking Vote lands", async () => { + const { unlocking } = await oneVoteShortOfBoth(); + // The locked screen the Voter is reading holds the crowd's number for a + // minute, and their unlocking Vote arrives inside it. + const whileHeldMs = nextRead(); + expect(await showdownStats(VOTER, whileHeldMs)).toMatchObject({ + state: "locked", + }); + + await castVote(VOTER, unlocking); + + expect(await showdownStats(VOTER, whileHeldMs)).toMatchObject({ + state: "unlocked", + }); + }); +}); + // The limiter is one per Machine, so each test here brings an address of its // own rather than spending another test's cap. describe("castVoteFromAddress", () => { diff --git a/src/projects/elemental-showdown/elemental-showdown.server.ts b/src/projects/elemental-showdown/elemental-showdown.server.ts index c17946a..842a268 100644 --- a/src/projects/elemental-showdown/elemental-showdown.server.ts +++ b/src/projects/elemental-showdown/elemental-showdown.server.ts @@ -22,6 +22,7 @@ import { crowdCallsOf, isStatsUnlocked, type ShowdownStats, + statsElementOf, type UnlockCounts, } from "./showdown-stats"; import { type OwnVote, storiesOf } from "./showdown-stories"; @@ -48,7 +49,9 @@ async function loadAggregate(): Promise { // One copy per Machine: a suspended Machine wakes with an empty cache and a // second Machine holds its own, both of which the Project can live with. -export const showdownAggregate = cacheAggregate(loadAggregate); +const { readAggregate, forgetAggregate } = cacheAggregate(loadAggregate); + +export const showdownAggregate = readAggregate; // A visitor with no cookie has voted on nothing, so they are offered the whole // roster's Matchups. Their own Votes are read fresh every time: the cache would @@ -140,7 +143,9 @@ export async function castVote( eq(votes.elementHigh, elementHigh), ); - await db + // A repeat Vote stores nothing, so it leaves both counts where they were and + // cannot be the Vote that crosses them. + const inserted = await db .insert(votes) .values({ voter, @@ -150,11 +155,13 @@ export async function castVote( }) .onConflictDoNothing({ target: [votes.voter, votes.elementLow, votes.elementHigh], - }); + }) + .returning({ voter: votes.voter }); // The split is read fresh for this one Matchup, never from the cache the - // Stats read: the Voter's own Vote has to be in it. - const [tally, [stood]] = await Promise.all([ + // Stats read: the Voter's own Vote has to be in it. So are both unlock + // numbers, which the cached copy is up to a minute too old to decide on. + const [tally, [stood], [countsAfter]] = await Promise.all([ db .select({ value: votes.value, voteCount: count() }) .from(votes) @@ -164,16 +171,48 @@ export async function castVote( .select({ value: votes.value }) .from(votes) .where(and(thisMatchup, eq(votes.voter, voter))), + db + .select({ + everyVoteCount: count(), + ownVoteCount: + sql`count(*) filter (where ${votes.voter} = ${voter})`.mapWith( + Number, + ), + }) + .from(votes), ]); if (!stood) throw new Error( `the Vote on Matchup ${elementLow}-${elementHigh} was cast but is not there to read back`, ); + if (!countsAfter) + throw new Error("counting the Votes cast so far gave no row back"); const counts = noVotes(); for (const { value: stored, voteCount } of tally) counts[asShown(stored)] += voteCount; - return revealFor(counts, asShown(stood.value)); + const reveal = revealFor(counts, asShown(stood.value)); + + const countsBefore: UnlockCounts = + inserted.length === 1 + ? { + ownVoteCount: countsAfter.ownVoteCount - 1, + everyVoteCount: countsAfter.everyVoteCount - 1, + } + : countsAfter; + if (isStatsUnlocked(countsBefore) || !isStatsUnlocked(countsAfter)) + return reveal; + + // The Stats gate reads the cached count, which is a minute behind this Vote + // at worst: the Voter who has just watched them open must not be dropped + // onto the locked screen. + forgetAggregate(); + const roster = await db + .select() + .from(elements) + .where(eq(elements.isActive, true)) + .orderBy(elements.name); + return { ...reveal, unlockedElements: roster.map(statsElementOf) }; } // One cap per Machine, as the aggregate's cache is one copy per Machine. diff --git a/src/projects/elemental-showdown/matchup-score.ts b/src/projects/elemental-showdown/matchup-score.ts index b57d2c2..940d711 100644 --- a/src/projects/elemental-showdown/matchup-score.ts +++ b/src/projects/elemental-showdown/matchup-score.ts @@ -3,6 +3,7 @@ import { VOTE_VALUES, type VoteValue, } from "./showdown-schema"; +import type { StatsElement } from "./showdown-stats"; // What the crowd's Votes on one Matchup add up to. Nothing here may import // server code: the reveal, the Stats and the Stories all read these values in @@ -65,6 +66,10 @@ export type VoteReveal = { // Where the crowd's mean sits on the Vote scale, or null below the verdict // gate, where a handful of Votes is no crowd to stand against. crowdMean: number | null; + // The Active Elements the unlock's wave flips a tile for, here on the one + // Vote that opened the Stats to this Voter and on no other. Only the cast + // path can tell: it counts the Votes either side of this one. + unlockedElements?: StatsElement[]; }; // A Vote that was never stored: this address has cast more than the window diff --git a/src/projects/elemental-showdown/showdown-aggregate.test.ts b/src/projects/elemental-showdown/showdown-aggregate.test.ts index 9f3ad59..dc5d217 100644 --- a/src/projects/elemental-showdown/showdown-aggregate.test.ts +++ b/src/projects/elemental-showdown/showdown-aggregate.test.ts @@ -120,35 +120,46 @@ describe("cacheAggregate", () => { it("loads once however many times it is read inside the lifetime", async () => { const { load, loaded } = loadCounting(); - const aggregate = cacheAggregate(load); + const { readAggregate } = cacheAggregate(load); - await aggregate(1_000); - await aggregate(1_000 + AGGREGATE_LIFETIME_MS - 1); + await readAggregate(1_000); + await readAggregate(1_000 + AGGREGATE_LIFETIME_MS - 1); expect(loaded()).toBe(1); }); it("loads again on the first read after the lifetime is up", async () => { const { load, loaded } = loadCounting(); - const aggregate = cacheAggregate(load); + const { readAggregate } = cacheAggregate(load); - await aggregate(1_000); - await aggregate(1_000 + AGGREGATE_LIFETIME_MS); + await readAggregate(1_000); + await readAggregate(1_000 + AGGREGATE_LIFETIME_MS); + + expect(loaded()).toBe(2); + }); + + it("loads again on the next read once what it holds is forgotten", async () => { + const { load, loaded } = loadCounting(); + const { readAggregate, forgetAggregate } = cacheAggregate(load); + await readAggregate(1_000); + + forgetAggregate(); + await readAggregate(1_000); expect(loaded()).toBe(2); }); it("holds nothing when the load fails, so the next read tries again", async () => { let attempt = 0; - const aggregate = cacheAggregate(() => { + const { readAggregate } = cacheAggregate(() => { attempt++; return attempt === 1 ? Promise.reject(new Error("the database said no")) : Promise.resolve(aggregateOf({ elements: [fire], matchupSums: [] })); }); - await expect(aggregate(1_000)).rejects.toThrow("the database said no"); + await expect(readAggregate(1_000)).rejects.toThrow("the database said no"); - expect(await aggregate(1_000)).toMatchObject({ elements: [fire] }); + expect(await readAggregate(1_000)).toMatchObject({ elements: [fire] }); }); }); diff --git a/src/projects/elemental-showdown/showdown-aggregate.ts b/src/projects/elemental-showdown/showdown-aggregate.ts index bffddd4..8aae568 100644 --- a/src/projects/elemental-showdown/showdown-aggregate.ts +++ b/src/projects/elemental-showdown/showdown-aggregate.ts @@ -90,9 +90,16 @@ export const AGGREGATE_LIFETIME_MS = 60_000; // serves a stale read. export function cacheAggregate(load: () => Promise) { let held: { loadedAtMs: number; aggregate: ShowdownAggregate } | undefined; - return async (nowMs: number): Promise => { - if (!held || nowMs - held.loadedAtMs >= AGGREGATE_LIFETIME_MS) - held = { loadedAtMs: nowMs, aggregate: await load() }; - return held.aggregate; + return { + readAggregate: async (nowMs: number): Promise => { + if (!held || nowMs - held.loadedAtMs >= AGGREGATE_LIFETIME_MS) + held = { loadedAtMs: nowMs, aggregate: await load() }; + return held.aggregate; + }, + // A Vote whose own count has to be right this second, rather than within + // the minute the copy is worth: the next read pays for one more load. + forgetAggregate: () => { + held = undefined; + }, }; } From 586e86f6c3d0b3a3f0eb3c12695ab63ea6f477d3 Mon Sep 17 00:00:00 2001 From: Jacob Cheatley Date: Tue, 22 Sep 2026 11:26:52 +1200 Subject: [PATCH 3/4] Elemental Showdown: the unlock moment on the screen that earned it The reveal of an unlocking Vote is followed by the mosaic: a tile per Active Element flips to its own colour and emoji in a diagonal wave, the tilted stamp lands on it and the platform buzzes if it can, and then the Stats take the screen. A Voter who asks for less motion is taken straight there. The whole moment is one named constant long, so the component tests drive it on fake timers. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AVEtgjCoEbaNHrYfzyCRNH --- .../ElementalShowdown.test.tsx | 76 ++++++++++++- .../elemental-showdown/ElementalShowdown.tsx | 28 ++++- .../elemental-showdown/UnlockMoment.tsx | 100 ++++++++++++++++++ src/styles.css | 9 ++ 4 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 src/projects/elemental-showdown/UnlockMoment.tsx diff --git a/src/projects/elemental-showdown/ElementalShowdown.test.tsx b/src/projects/elemental-showdown/ElementalShowdown.test.tsx index 1bd507a..1fd7e7d 100644 --- a/src/projects/elemental-showdown/ElementalShowdown.test.tsx +++ b/src/projects/elemental-showdown/ElementalShowdown.test.tsx @@ -13,10 +13,17 @@ import type { NextMatchup } from "./matchup-selection"; import { REVEAL_LINGER_MS } from "./Reveal"; import type { ShowdownElement } from "./schema"; import type { RateWindow, VoteCast } from "./showdown-schema"; +import { statsElementOf } from "./showdown-stats"; +import { UNLOCK_MS } from "./UnlockMoment"; // The route's only contribution is the Matchup the loader drew and the two -// server functions, so the tests hand those in. -vi.mock("@tanstack/react-router", () => import("@/test/router-stub")); +// server functions, so the tests hand those in. `vi.mock` is hoisted above +// this declaration, so the factory has to read it lazily. +const navigate = vi.fn(); +vi.mock("@tanstack/react-router", async () => ({ + ...(await import("@/test/router-stub")), + useNavigate: () => navigate, +})); const element = ( id: number, @@ -89,7 +96,9 @@ const cast = (value: number) => ({ afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); vi.useRealTimers(); + navigate.mockClear(); }); describe("the Tug as a slider", () => { @@ -259,6 +268,69 @@ describe("after a Vote", () => { }); }); +describe("the Vote that unlocks the Stats", () => { + const THE_ROSTER = [fire, water, plant].map(statsElementOf); + + // The reveal is read out as ever; the moment comes after it, on the clock + // the draining bar runs on. + async function castTheUnlockingVote() { + vi.useFakeTimers(); + showdown({ + castVote: vi.fn(async () => + reveal({ unlockedElements: THE_ROSTER }), + ), + }); + fireEvent.click(pill()); + await act(async () => {}); + await act(async () => void vi.advanceTimersByTime(REVEAL_LINGER_MS)); + } + + it("flips a tile to every Element and stamps the screen before the Stats", async () => { + await castTheUnlockingVote(); + + expect(screen.getByText("stats unlocked")).toBeInTheDocument(); + for (const element of THE_ROSTER) + expect(screen.getByText(element.emoji)).toBeInTheDocument(); + expect(navigate).not.toHaveBeenCalled(); + + await act(async () => void vi.advanceTimersByTime(UNLOCK_MS)); + + expect(navigate).toHaveBeenCalledWith({ to: "/elemental-showdown/stats" }); + }); + + it("goes on showing Matchups when the Vote unlocked nothing", async () => { + vi.useFakeTimers(); + showdown(); + fireEvent.click(pill()); + await act(async () => {}); + + await act(async () => void vi.advanceTimersByTime(REVEAL_LINGER_MS)); + + expect(screen.getByText("plant")).toBeInTheDocument(); + expect(navigate).not.toHaveBeenCalled(); + }); + + it("skips the wave for a Voter who asks for less motion", async () => { + vi.stubGlobal("matchMedia", (media: string) => ({ + matches: media === "(prefers-reduced-motion: reduce)", + })); + + await castTheUnlockingVote(); + + expect(screen.queryByText("stats unlocked")).toBeNull(); + expect(navigate).toHaveBeenCalledWith({ to: "/elemental-showdown/stats" }); + }); + + it("opens the Stats on a browser with no Vibration API", async () => { + expect(navigator.vibrate).toBeUndefined(); + + await castTheUnlockingVote(); + await act(async () => void vi.advanceTimersByTime(UNLOCK_MS)); + + expect(navigate).toHaveBeenCalledWith({ to: "/elemental-showdown/stats" }); + }); +}); + describe("a Vote the address has no room left for", () => { it("says slow down and keeps the Matchup up", async () => { const user = userEvent.setup(); diff --git a/src/projects/elemental-showdown/ElementalShowdown.tsx b/src/projects/elemental-showdown/ElementalShowdown.tsx index 1303767..d3a3523 100644 --- a/src/projects/elemental-showdown/ElementalShowdown.tsx +++ b/src/projects/elemental-showdown/ElementalShowdown.tsx @@ -1,4 +1,4 @@ -import { Link } from "@tanstack/react-router"; +import { Link, useNavigate } from "@tanstack/react-router"; import { useCallback, useRef, useState } from "react"; import { INK, PAPER, textOn } from "./element-colour"; import type { VoteCastResult, VoteLimited, VoteReveal } from "./matchup-score"; @@ -6,7 +6,15 @@ import type { NextMatchup } from "./matchup-selection"; import { PrimaryLink } from "./PrimaryLink"; import { ShareButton } from "./ShareButton"; import type { VoteCast, VoteValue } from "./showdown-schema"; +import type { StatsElement } from "./showdown-stats"; import { Tug } from "./Tug"; +import { UnlockMoment } from "./UnlockMoment"; + +// The wave is motion a visitor can ask not to be shown; asked, the Stats take +// the screen at once instead. `matchMedia` belongs to a browser, and this is +// read only when a Vote lands in one. +const prefersReducedMotion = () => + window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true; // The whole screen: one Matchup at a time, in the Project's own full-height // layout rather than the Portfolio's shell. @@ -19,10 +27,13 @@ export function ElementalShowdown({ castVote: (options: { data: VoteCast }) => Promise; nextMatchup: () => Promise; }) { + const navigate = useNavigate(); const [matchup, setMatchup] = useState(shown); const [reveal, setReveal] = useState(null); const [limited, setLimited] = useState(null); const [votesCast, setVotesCast] = useState(0); + // The Elements of the moment being played, once a Vote has unlocked them. + const [unlocking, setUnlocking] = useState(null); // A ref for the guard: a second Enter can arrive before a re-render would // have told it the first Vote is already on its way. const casting = useRef(false); @@ -58,8 +69,18 @@ export function ElementalShowdown({ }); } + const openStats = useCallback(() => { + void navigate({ to: "/elemental-showdown/stats" }); + }, [navigate]); + // The reveal's own clock calls this, and so does a Voter who has read enough. const advance = useCallback(() => { + // The Stats this Vote earned take the screen rather than the next Matchup. + if (reveal?.unlockedElements) { + if (prefersReducedMotion()) openStats(); + else setUnlocking(reveal.unlockedElements); + return; + } const drawn = drawing.current; if (!drawn) return; drawing.current = null; @@ -73,7 +94,10 @@ export function ElementalShowdown({ // The reveal stays up rather than an empty screen taking its place. console.error(new Error("drawing the next Matchup failed", { cause })); }); - }, []); + }, [reveal, openStats]); + + if (unlocking) + return ; return (
+ (place % MOSAIC_COLUMNS) + Math.floor(place / MOSAIC_COLUMNS) * 2; + +// The Stats opening on the screen the Vote that opened them was cast on: the +// locked screen's blank mosaic, a tile per Active Element, flips to its own +// Element in a diagonal wave and the stamp lands on it. +export function UnlockMoment({ + elements, + onDone, +}: { + elements: StatsElement[]; + onDone: () => void; +}) { + const [hasFlipped, setFlipped] = useState(false); + + // A frame on the blank mosaic, so the wave is seen crossing it rather than + // having already crossed. + useEffect(() => { + const flipping = requestAnimationFrame(() => setFlipped(true)); + return () => cancelAnimationFrame(flipping); + }, []); + + useEffect(() => { + navigator.vibrate?.(UNLOCK_BUZZ); + const opening = setTimeout(onDone, UNLOCK_MS); + return () => clearTimeout(opening); + }, [onDone]); + + return ( +
+
+ {elements.map((element, place) => { + const flipDelay = `${waveStepsTo(place) * WAVE_STEP_MS}ms`; + return ( + + + {element.emoji} + + + ); + })} +
+

+ stats unlocked +

+
+ ); +} diff --git a/src/styles.css b/src/styles.css index d5e0556..979e2b2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -143,6 +143,15 @@ body { } } +/* Frames only: when the stamp lands and how long it takes come inline from + UnlockMoment.tsx. It is held out of sight until then by `both`. */ +@keyframes stats-unlocked-stamp { + from { + scale: 3; + opacity: 0; + } +} + /* Frames only: the blink rate and its step timing come inline from note-overlay.tsx, and motion-reduce:animate-none! leaves the caret steady. */ @keyframes desk-caret { From 2ff3c62fef80369884600b14d04edb29029a4e57 Mon Sep 17 00:00:00 2001 From: Jacob Cheatley Date: Tue, 22 Sep 2026 11:33:01 +1200 Subject: [PATCH 4/4] Elemental Showdown: fit the unlock's stamp to the phone it lands on At the 44px the prototype draws it the stamp is wider than a phone, and the prototype's locked screen keeps its mosaic near the top where a stamp across the middle misses it. On its own screen the mosaic is centred, so the stamp lands under it and takes its size from the screen up to that 44px. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AVEtgjCoEbaNHrYfzyCRNH --- .../elemental-showdown/UnlockMoment.tsx | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/projects/elemental-showdown/UnlockMoment.tsx b/src/projects/elemental-showdown/UnlockMoment.tsx index fa63146..b838a5d 100644 --- a/src/projects/elemental-showdown/UnlockMoment.tsx +++ b/src/projects/elemental-showdown/UnlockMoment.tsx @@ -2,11 +2,12 @@ import { useEffect, useState } from "react"; import { INK, NO_ELEMENT, PAPER } from "./element-colour"; import type { StatsElement } from "./showdown-stats"; -// The whole moment: the wave crossing the mosaic, the stamp landing on it, and -// a beat to read it before the Stats take the screen. +// The whole moment: the wave crossing the mosaic, the stamp landing under it, +// and a beat to read it before the Stats take the screen. export const UNLOCK_MS = 2600; -// One tile's flip, and how far behind the wave's last step it starts. +// How long one tile takes to flip, and how far behind the tile before it on +// the wave it starts. const TILE_FLIP_MS = 350; const WAVE_STEP_MS = 28; @@ -17,6 +18,12 @@ const STAMP_MS = 450; // Two short buzzes and a long one. const UNLOCK_BUZZ = [30, 60, 120]; +// The stamp stays one line whatever it lands on: Bowlby One sets these two +// words about nine times the font size wide, and the tilt and the outline add +// to that, so on a phone the size comes from the screen and 44px is the +// ceiling it is drawn at. +const STAMP_FONT_SIZE = "min(44px, 8vw)"; + // The mosaic is this many tiles wide, so a tile's place gives its row and its // column. The wave runs down the diagonal, a row counting for two columns so // that it leans rather than falling at 45°. @@ -26,7 +33,7 @@ const waveStepsTo = (place: number) => // The Stats opening on the screen the Vote that opened them was cast on: the // locked screen's blank mosaic, a tile per Active Element, flips to its own -// Element in a diagonal wave and the stamp lands on it. +// Element in a diagonal wave, and the stamp lands under it. export function UnlockMoment({ elements, onDone, @@ -51,7 +58,7 @@ export function UnlockMoment({ return (
@@ -83,9 +90,9 @@ export function UnlockMoment({ })}