diff --git a/src/projects/elemental-showdown/ElementalShowdown.test.tsx b/src/projects/elemental-showdown/ElementalShowdown.test.tsx index d26a331..4dbf4cc 100644 --- a/src/projects/elemental-showdown/ElementalShowdown.test.tsx +++ b/src/projects/elemental-showdown/ElementalShowdown.test.tsx @@ -1,8 +1,16 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ElementalShowdown } from "./ElementalShowdown"; +import type { VoteReveal } from "./matchup-score"; import type { NextMatchup } from "./matchup-selection"; +import { REVEAL_LINGER_MS } from "./Reveal"; import type { ShowdownElement } from "./schema"; import type { VoteCast } from "./showdown-schema"; @@ -33,17 +41,27 @@ const matchup = ( bottom: ShowdownElement, ): NextMatchup => ({ state: "matchup", top, bottom }); +// Ten Votes with the crowd behind fire: the Voter crushed it too, as half of +// them did. +const reveal = (crowd: Partial = {}): VoteReveal => ({ + state: "reveal", + counts: { "-2": 1, "-1": 0, "0": 1, "1": 3, "2": 5 }, + vote: 2, + sameShare: 0.5, + headline: "with", + crowdMean: 1.4, + ...crowd, +}); + +type CastVote = (options: { data: VoteCast }) => Promise; + function showdown({ shown = matchup(fire, water), - castVote = vi.fn<(options: { data: VoteCast }) => Promise>( - async () => {}, - ), + castVote = vi.fn(async () => reveal()), nextMatchup = async () => matchup(plant, fire), }: { shown?: NextMatchup; - castVote?: ReturnType< - typeof vi.fn<(options: { data: VoteCast }) => Promise> - >; + castVote?: ReturnType>; nextMatchup?: () => Promise; } = {}) { render( @@ -65,6 +83,7 @@ const cast = (value: number) => ({ afterEach(() => { vi.restoreAllMocks(); + vi.useRealTimers(); }); describe("the Tug as a slider", () => { @@ -137,23 +156,80 @@ describe("the Tug as a slider", () => { }); }); -describe("after a Vote", () => { - it("brings on the next Matchup", async () => { +describe("the reveal", () => { + it("keeps the crowd out of sight until the Vote is cast", () => { + showdown(); + + expect(screen.queryByText("▲ your vote")).toBeNull(); + expect(screen.queryByText("the crowd")).toBeNull(); + expect(screen.queryByText(/voted the same/)).toBeNull(); + }); + + it("marks the Voter's own bar and says where the crowd stands", async () => { const user = userEvent.setup(); showdown(); await user.click(pill()); - await screen.findByText("plant"); + const ownBar = await screen.findByText("▲ your vote"); + expect(ownBar.parentElement).toHaveTextContent("🔥 crushes"); + expect(screen.getByText("with the crowd")).toBeInTheDocument(); + expect(screen.getByText("50% voted the same, of 10")).toBeInTheDocument(); + expect(screen.getByText("the crowd")).toBeInTheDocument(); + }); + + it("dresses a Matchup nobody has settled up as no crowd at all", async () => { + const user = userEvent.setup(); + showdown({ + castVote: vi.fn(async () => + reveal({ + counts: { "-2": 0, "-1": 1, "0": 0, "1": 1, "2": 1 }, + vote: 2, + sameShare: 1 / 3, + headline: "early", + crowdMean: null, + }), + ), + }); + + await user.click(pill()); + + await screen.findByText("early days, only 3 votes"); + expect(screen.queryByText("the crowd")).toBeNull(); + expect(screen.queryByText(/voted the same/)).toBeNull(); + }); +}); + +describe("after a Vote", () => { + it("brings on the next Matchup when the draining bar runs out", async () => { + // the clock, not userEvent: the wait the draining bar shows is a timer + vi.useFakeTimers(); + showdown(); + fireEvent.click(pill()); + await act(async () => {}); + expect(screen.getByText("with the crowd")).toBeInTheDocument(); + + await act(async () => void vi.advanceTimersByTime(REVEAL_LINGER_MS)); + + expect(screen.getByText("plant")).toBeInTheDocument(); expect(screen.queryByText("water")).toBeNull(); }); + it("brings on the next Matchup at once when the Voter taps", async () => { + const user = userEvent.setup(); + showdown(); + await user.click(pill()); + await screen.findByText("with the crowd"); + + fireEvent.pointerDown(tug()); + + await screen.findByText("plant"); + }); + it("casts once, however often the Tug is let go while the Vote is on its way", async () => { const user = userEvent.setup(); const castVote = showdown({ - castVote: vi.fn<(options: { data: VoteCast }) => Promise>( - () => new Promise(() => {}), - ), + castVote: vi.fn(() => new Promise(() => {})), }); await user.click(pill()); @@ -166,9 +242,7 @@ describe("after a Vote", () => { const failed = vi.spyOn(console, "error").mockImplementation(() => {}); const user = userEvent.setup(); showdown({ - castVote: vi.fn<(options: { data: VoteCast }) => Promise>(() => - Promise.reject(new Error("offline")), - ), + castVote: vi.fn(() => Promise.reject(new Error("offline"))), }); await user.click(pill()); diff --git a/src/projects/elemental-showdown/ElementalShowdown.tsx b/src/projects/elemental-showdown/ElementalShowdown.tsx index 24a7f40..76c66ca 100644 --- a/src/projects/elemental-showdown/ElementalShowdown.tsx +++ b/src/projects/elemental-showdown/ElementalShowdown.tsx @@ -1,6 +1,7 @@ import { Link } from "@tanstack/react-router"; -import { useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { INK, PAPER, textOn } from "./element-colour"; +import type { VoteReveal } from "./matchup-score"; import type { NextMatchup } from "./matchup-selection"; import type { VoteCast, VoteValue } from "./showdown-schema"; import { Tug } from "./Tug"; @@ -13,14 +14,18 @@ export function ElementalShowdown({ nextMatchup, }: { shown: NextMatchup; - castVote: (options: { data: VoteCast }) => Promise; + castVote: (options: { data: VoteCast }) => Promise; nextMatchup: () => Promise; }) { const [matchup, setMatchup] = useState(shown); + const [reveal, setReveal] = useState(null); const [votesCast, setVotesCast] = useState(0); // 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); + // Drawn while the Voter reads the reveal, so the next Matchup is there the + // moment they are done with this one. + const drawing = useRef | null>(null); function cast(value: VoteValue) { if (matchup.state !== "matchup" || casting.current) return; @@ -29,10 +34,9 @@ export function ElementalShowdown({ castVote({ data: { topElementId: top.id, bottomElementId: bottom.id, value }, }) - .then(nextMatchup) - .then((next) => { - setMatchup(next); - setVotesCast((count) => count + 1); + .then((crowd) => { + setReveal(crowd); + drawing.current = nextMatchup(); }) .catch((cause: unknown) => { // Nothing was stored, so the Matchup stays up and the next drag is @@ -44,6 +48,23 @@ export function ElementalShowdown({ }); } + // The reveal's own clock calls this, and so does a Voter who has read enough. + const advance = useCallback(() => { + const drawn = drawing.current; + if (!drawn) return; + drawing.current = null; + drawn + .then((next) => { + setMatchup(next); + setReveal(null); + setVotesCast((count) => count + 1); + }) + .catch((cause: unknown) => { + // The reveal stays up rather than an empty screen taking its place. + console.error(new Error("drawing the next Matchup failed", { cause })); + }); + }, []); + return (
) : ( diff --git a/src/projects/elemental-showdown/Reveal.tsx b/src/projects/elemental-showdown/Reveal.tsx new file mode 100644 index 0000000..bba5c76 --- /dev/null +++ b/src/projects/elemental-showdown/Reveal.tsx @@ -0,0 +1,186 @@ +import { useEffect, useState } from "react"; +import { INK, PAPER } from "./element-colour"; +import type { HeadlineKind, VoteReveal } from "./matchup-score"; +import type { ShowdownElement } from "./schema"; +import { SEAM_LANDINGS, seamAt, tabLabel } from "./tug"; + +// How long the reveal holds the screen on its own, which is what the draining +// bar drains over. +export const REVEAL_LINGER_MS = 3500; + +// The bars growing out of their labels and the crowd line travelling to the +// mean, both in the Tug's own spring. +const SETTLE_MS = 700; + +// Every label is one width, so every count bar starts at the same divider and +// its length is that value's share of the Votes and nothing else. The gap is +// the run a bar at 100% leaves at the far edge. +const LABEL_WIDTH_PX = 100; +const DIVIDER_WIDTH_PX = 1.5; +const EDGE_GAP_PX = 12; + +const barWidth = (share: number) => + `calc(${LABEL_WIDTH_PX + DIVIDER_WIDTH_PX}px + ${share} * (100% - ${LABEL_WIDTH_PX + EDGE_GAP_PX}px))`; + +// A percentage sits over the Element's colour when its bar is too short to +// hold it, so it is drawn in white cut out of ink. +const PERCENTAGE_OUTLINE = [ + "1.5px 0", + "-1.5px 0", + "0 1.5px", + "0 -1.5px", + "1px 1px", + "-1px -1px", + "1px -1px", + "-1px 1px", +] + .map((offset) => `${offset} ${INK}`) + .join(", "); + +const percent = (share: number) => `${Math.round(share * 100)}%`; + +// Placeholder copy, as the prototype wrote it: the owner writes the real words. +function headlineCopy(kind: HeadlineKind, voteCount: number) { + switch (kind) { + case "first": + return "first to call it"; + case "early": + return `early days, only ${voteCount} votes`; + case "split": + return "the crowd is split"; + case "with": + return "with the crowd"; + case "close": + return "close to the crowd"; + case "against": + return "against the crowd"; + } +} + +// The crowd's split on the Matchup just voted on, in the Tug's own place: the +// five tabs have become five count bars and the seam has not moved. +export function Reveal({ + reveal: { counts, vote, sameShare, headline, crowdMean }, + top, + bottom, + onAdvance, +}: { + reveal: VoteReveal; + top: ShowdownElement; + bottom: ShowdownElement; + onAdvance: () => void; +}) { + const [hasSettled, setSettled] = useState(false); + const voteCount = Object.values(counts).reduce( + (total, forValue) => total + forValue, + 0, + ); + + // A frame at the tabs' own length, so the bars are seen growing out of them. + useEffect(() => { + const settling = requestAnimationFrame(() => setSettled(true)); + return () => cancelAnimationFrame(settling); + }, []); + + useEffect(() => { + const advancing = setTimeout(onAdvance, REVEAL_LINGER_MS); + return () => clearTimeout(advancing); + }, [onAdvance]); + + return ( + <> + {SEAM_LANDINGS.map((landing) => { + const isOwn = landing === vote; + const share = counts[landing] / voteCount; + return ( + + + + + {percent(share)} + + + + + {tabLabel(landing, top, bottom)} + + {isOwn && ( + + ▲ your vote + + )} + + + ); + })} + + {crowdMean !== null && ( +
+ + the crowd + +
+ )} + + {/* The card takes the winner's side, below the link back to the + Portfolio when that is the top one. */} +
= 0 + ? "top-[calc(max(0.75rem,env(safe-area-inset-top))+1.75rem)]" + : "bottom-[max(0.875rem,env(safe-area-inset-bottom))]" + }`} + style={{ background: INK, color: PAPER }} + > +

+ {headlineCopy(headline, voteCount)} +

+ {crowdMean !== null && ( +

+ {percent(sameShare)} voted the same, of {voteCount} +

+ )} +
+
+ + ); +} diff --git a/src/projects/elemental-showdown/Tug.tsx b/src/projects/elemental-showdown/Tug.tsx index 3699234..199b7ad 100644 --- a/src/projects/elemental-showdown/Tug.tsx +++ b/src/projects/elemental-showdown/Tug.tsx @@ -1,5 +1,7 @@ import { useRef, useState } from "react"; import { INK, PAPER, textOn } from "./element-colour"; +import type { VoteReveal } from "./matchup-score"; +import { Reveal } from "./Reveal"; import type { ShowdownElement } from "./schema"; import type { VoteValue } from "./showdown-schema"; import { @@ -31,6 +33,8 @@ export function Tug({ bottom, isFirstMatchup, onCast, + reveal, + onAdvance, }: { top: ShowdownElement; bottom: ShowdownElement; @@ -38,6 +42,10 @@ export function Tug({ // in, and it is the one that says how this works. isFirstMatchup: boolean; onCast: (value: VoteValue) => void; + // The crowd, once this Voter's own Vote is in it. Until then the Tug is the + // whole screen and the crowd cannot sway them. + reveal: VoteReveal | null; + onAdvance: () => void; }) { const [value, setValue] = useState(0); const [isDragging, setDragging] = useState(false); @@ -45,7 +53,13 @@ export function Tug({ const seam = seamAt(value); const sentence = voteSentence(value, top, bottom); + // Revealed, the Tug has nothing left to drag and a touch anywhere is the + // Voter asking for the next Matchup rather than waiting out the bar. function startDrag(event: React.PointerEvent) { + if (reveal) { + onAdvance(); + return; + } dragFrom.current = event.clientY; setDragging(true); event.currentTarget.setPointerCapture(event.pointerId); @@ -68,6 +82,12 @@ export function Tug({ } function onKeyDown(event: React.KeyboardEvent) { + if (reveal) { + if (event.key !== "Enter") return; + event.preventDefault(); + onAdvance(); + return; + } const step = ARROW_STEPS[event.key]; if (step !== undefined) { event.preventDefault(); @@ -100,39 +120,54 @@ export function Tug({ - {SEAM_LANDINGS.map((landing) => ( - - {tabLabel(landing, top, bottom)} - - ))} + {reveal ? ( + + ) : ( + <> + {SEAM_LANDINGS.map((landing) => ( + + {tabLabel(landing, top, bottom)} + + ))} - + + + )} - {isFirstMatchup && ( + {isFirstMatchup && !reveal && (

{ const voter = voterOfRequest() ?? crypto.randomUUID(); setCookie(VOTER_COOKIE, voter, VOTER_COOKIE_OPTIONS); - await castVote(voter, data); + return castVote(voter, data); }); diff --git a/src/projects/elemental-showdown/elemental-showdown.server.test.ts b/src/projects/elemental-showdown/elemental-showdown.server.test.ts index 4ee17e3..ee2aee0 100644 --- a/src/projects/elemental-showdown/elemental-showdown.server.test.ts +++ b/src/projects/elemental-showdown/elemental-showdown.server.test.ts @@ -87,6 +87,63 @@ describe("castVote", () => { expect(await storedVotes()).toMatchObject([{ value: 2 }]); }); + it("counts the Vote just cast in the reveal it hands back", async () => { + const fire = await insertElement("fire"); + const water = await insertElement("water"); + + const reveal = await castVote(VOTER, { + topElementId: fire, + bottomElementId: water, + value: 2, + }); + + expect(reveal).toMatchObject({ + state: "reveal", + counts: { "2": 1 }, + vote: 2, + sameShare: 1, + headline: "first", + }); + }); + + it("reveals the Matchup from the side it was shown, whichever Element is on top", async () => { + const fire = await insertElement("fire"); + const water = await insertElement("water"); + // fire crushes water, from fire's side: the Matchup's own way round + await castVote(OTHER_VOTER, { + topElementId: fire, + bottomElementId: water, + value: 2, + }); + + // this Voter is shown water on top, and gives fire a weak win from there + const reveal = await castVote(VOTER, { + topElementId: water, + bottomElementId: fire, + value: -1, + }); + + expect(reveal).toMatchObject({ + counts: { "-2": 1, "-1": 1, "0": 0, "1": 0, "2": 0 }, + vote: -1, + }); + }); + + it("reveals the Matchup again on a repeat Vote, showing the Vote that stands", async () => { + const fire = await insertElement("fire"); + const water = await insertElement("water"); + const matchup = { topElementId: fire, bottomElementId: water }; + await castVote(VOTER, { ...matchup, value: 2 }); + + const reveal = await castVote(VOTER, { ...matchup, value: 1 }); + + expect(reveal).toMatchObject({ + state: "reveal", + counts: { "1": 0, "2": 1 }, + vote: 2, + }); + }); + it("keeps one Vote per Voter, so another Voter's verdict on the same Matchup is its own", async () => { const fire = await insertElement("fire"); const water = await insertElement("water"); diff --git a/src/projects/elemental-showdown/elemental-showdown.server.ts b/src/projects/elemental-showdown/elemental-showdown.server.ts index 865460b..3aed08a 100644 --- a/src/projects/elemental-showdown/elemental-showdown.server.ts +++ b/src/projects/elemental-showdown/elemental-showdown.server.ts @@ -1,8 +1,13 @@ -import { eq } from "drizzle-orm"; +import { and, count, eq } from "drizzle-orm"; import { db } from "@/db/index.server"; +import { noVotes, revealFor, type VoteReveal } from "./matchup-score"; import { drawMatchup, type NextMatchup } from "./matchup-selection"; import { elements, votes } from "./schema"; -import { type VoteCast, voteValueSchema } from "./showdown-schema"; +import { + type VoteCast, + type VoteValue, + voteValueSchema, +} from "./showdown-schema"; // A visitor with no cookie has voted on nothing, so they are offered the whole // roster's Matchups. @@ -25,24 +30,61 @@ export async function nextMatchup( return drawMatchup({ elements: roster, votes: cast, random }); } +// A Matchup is stored from the lower-id Element's side and shown from the top +// Element's, so when the higher-id Element is on top every Vote of it reads +// with its sign flipped, going either way. +const fromTheOtherSide = (value: VoteValue) => voteValueSchema.parse(-value); + // The Tug reads a Vote from the top Element's side; a Matchup is stored one way -// round only, so a Vote cast with the higher-id Element on top comes in with -// its sign flipped. A Voter who votes on the same Matchup twice (two tabs, a -// double submit) is not an error: their first Vote stands. +// round only. A Voter who votes on the same Matchup twice (two tabs, a double +// submit) is not an error: their first Vote stands, and the reveal is the +// crowd as it is now, read back from the Matchup's own side. export async function castVote( voter: string, { topElementId, bottomElementId, value }: VoteCast, -) { +): Promise { const topIsLow = topElementId < bottomElementId; + const elementLow = topIsLow ? topElementId : bottomElementId; + const elementHigh = topIsLow ? bottomElementId : topElementId; + const asShown = (stored: VoteValue) => + topIsLow ? stored : fromTheOtherSide(stored); + const thisMatchup = and( + eq(votes.elementLow, elementLow), + eq(votes.elementHigh, elementHigh), + ); + await db .insert(votes) .values({ voter, - elementLow: topIsLow ? topElementId : bottomElementId, - elementHigh: topIsLow ? bottomElementId : topElementId, - value: topIsLow ? value : voteValueSchema.parse(-value), + elementLow, + elementHigh, + value: topIsLow ? value : fromTheOtherSide(value), }) .onConflictDoNothing({ target: [votes.voter, votes.elementLow, votes.elementHigh], }); + + // 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([ + db + .select({ value: votes.value, voteCount: count() }) + .from(votes) + .where(thisMatchup) + .groupBy(votes.value), + db + .select({ value: votes.value }) + .from(votes) + .where(and(thisMatchup, eq(votes.voter, voter))), + ]); + if (!stood) + throw new Error( + `the Vote on Matchup ${elementLow}-${elementHigh} was cast but is not there to read back`, + ); + + const counts = noVotes(); + for (const { value: stored, voteCount } of tally) + counts[asShown(stored)] += voteCount; + return revealFor(counts, asShown(stood.value)); } diff --git a/src/projects/elemental-showdown/matchup-score.test.ts b/src/projects/elemental-showdown/matchup-score.test.ts index c011134..75bb563 100644 --- a/src/projects/elemental-showdown/matchup-score.test.ts +++ b/src/projects/elemental-showdown/matchup-score.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest"; import { headlineFor, mirrorEffectiveness, + noVotes, + revealFor, scoreMatchup, + type VoteCounts, } from "./matchup-score"; import type { VoteValue } from "./showdown-schema"; @@ -143,3 +146,25 @@ describe("headlineFor", () => { expect(headlineFor(split, -2)).toBe("split"); }); }); + +describe("revealFor", () => { + // The Votes on one Matchup as counts, the Voter's own among them. + const counted = (...cast: VoteValue[]): VoteCounts => { + const counts = noVotes(); + for (const value of cast) counts[value] += 1; + return counts; + }; + + it("gives the share of the crowd that voted as the Voter did", () => { + expect(revealFor(counted(2, 2, 1, 0, -2), 2).sameShare).toBe(0.4); + }); + + it("scores the crowd from the counts, the Voter's own Vote among them", () => { + expect(revealFor(counted(...votes(5, 1)), 1).headline).toBe("with"); + }); + + it("keeps the crowd's mean back until the Matchup has a verdict", () => { + expect(revealFor(counted(...votes(4, 1)), 1).crowdMean).toBeNull(); + expect(revealFor(counted(...votes(5, 1)), 1).crowdMean).toBeCloseTo(0.71); + }); +}); diff --git a/src/projects/elemental-showdown/matchup-score.ts b/src/projects/elemental-showdown/matchup-score.ts index 9651295..4526953 100644 --- a/src/projects/elemental-showdown/matchup-score.ts +++ b/src/projects/elemental-showdown/matchup-score.ts @@ -1,4 +1,4 @@ -import type { VoteValue } from "./showdown-schema"; +import { VOTE_VALUES, type VoteValue } from "./showdown-schema"; // 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 @@ -35,6 +35,34 @@ export type MatchupSums = { squareSum: number; }; +// How many Votes each of the five values has, read from one Element's side. +export type VoteCounts = Record; + +// A Matchup nobody has voted on: what counting starts from, since a tally only +// names the values somebody chose. +export const noVotes = (): VoteCounts => ({ + "-2": 0, + "-1": 0, + "0": 0, + "1": 0, + "2": 0, +}); + +// What a Voter is shown the moment their Vote lands, read from the side the +// Matchup was shown to them. +export type VoteReveal = { + state: "reveal"; + // The Voter's own Vote is one of these. + counts: VoteCounts; + // The Vote that stands: a Voter who votes twice keeps their first. + vote: VoteValue; + sameShare: number; + headline: HeadlineKind; + // 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; +}; + export type MatchupScore = { voteCount: number; // Read from the lower-id Element's side, the orientation a Vote is stored in. @@ -159,3 +187,30 @@ export function headlineFor( if (stepsApart === 0) return "with"; return stepsApart === 1 ? "close" : "against"; } + +const sumsOf = (counts: VoteCounts): MatchupSums => + VOTE_VALUES.reduce( + (sums, value) => ({ + voteCount: sums.voteCount + counts[value], + valueSum: sums.valueSum + value * counts[value], + squareSum: sums.squareSum + value * value * counts[value], + }), + { voteCount: 0, valueSum: 0, squareSum: 0 }, + ); + +// The reveal of one Matchup to the Voter who has just cast `vote` on it, from +// the counts their own Vote is already in. +export function revealFor(counts: VoteCounts, vote: VoteValue): VoteReveal { + const sums = sumsOf(counts); + const score = scoreMatchup(sums); + const headline = headlineFor(score, vote); + const hasVerdict = headline !== "first" && headline !== "early"; + return { + state: "reveal", + counts, + vote, + sameShare: counts[vote] / sums.voteCount, + headline, + crowdMean: hasVerdict ? score.meanVote : null, + }; +} diff --git a/src/projects/elemental-showdown/showdown-schema.ts b/src/projects/elemental-showdown/showdown-schema.ts index a658564..36a8a8c 100644 --- a/src/projects/elemental-showdown/showdown-schema.ts +++ b/src/projects/elemental-showdown/showdown-schema.ts @@ -11,7 +11,9 @@ export type ElementKind = z.infer; // One Vote read from the first Element's side: two steps is a strong win, one a // weak win, zero too close to call. -export const voteValueSchema = z.literal([-2, -1, 0, 1, 2]); +export const VOTE_VALUES = [-2, -1, 0, 1, 2] as const; + +export const voteValueSchema = z.literal(VOTE_VALUES); export type VoteValue = z.infer; diff --git a/src/projects/elemental-showdown/tug.ts b/src/projects/elemental-showdown/tug.ts index d9f6e87..69ec0d4 100644 --- a/src/projects/elemental-showdown/tug.ts +++ b/src/projects/elemental-showdown/tug.ts @@ -15,9 +15,10 @@ const SEAM_STEP_PERCENT = 17; // The five landings, top to bottom: one edge tab marks each. export const SEAM_LANDINGS = [2, 1, 0, -1, -2] as const satisfies VoteValue[]; -// Where the seam rests, as a percentage down the screen. The winner takes -// ground, so the seam moves away from the Element that is winning. -export const seamAt = (value: VoteValue) => 50 + SEAM_STEP_PERCENT * value; +// Where the seam rests, as a percentage down the screen, for any point on the +// Vote scale: the five landings and the crowd's mean between them. The winner +// takes ground, so the seam moves away from the Element that is winning. +export const seamAt = (value: number) => 50 + SEAM_STEP_PERCENT * value; // A drag down is the top Element taking ground, a drag up the bottom one. export const draggedVote = (dragPx: number): VoteValue => { diff --git a/src/styles.css b/src/styles.css index 291aca3..d5e0556 100644 --- a/src/styles.css +++ b/src/styles.css @@ -135,6 +135,14 @@ body { } } +/* Frames only: how long the reveal lingers comes inline from Reveal.tsx, which + moves on by the same clock. */ +@keyframes reveal-drain { + to { + transform: scaleX(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 {