From a25db8fd7db2c9b195a10271083157086e5e3fec Mon Sep 17 00:00:00 2001 From: wilfordgrimley <2397930+WilfordGrimley@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:47:38 +0000 Subject: [PATCH 1/3] Rebuild /whatsthat onto the WTC spec: container-first hero, Tokyo-11 tokens Full visual/layout rewrite of the What's That Card? question feed per SPEC-wtc-rebuild.md: one @container-driven hero (no viewport breakpoints), the page-private --wtc-* identity tokens derived from the (in-flight) Tokyo-11 theme layer, and the three owner rulings (BurstSvg retired, session counter kept, WD3 phone compaction accepted). The Level 1/2/3 interaction contract, auto-tag-chip casting, no-re-presentation, and every data-testid the existing suites key off of are preserved verbatim. Co-Authored-By: Claude Fable 5 --- docs/features/printing-tags.md | 37 +- .../src/features/attributeVoting/ChipCard.tsx | 41 +- .../attributeVoting/NoMatchReasonStrip.tsx | 1 + .../src/features/printingTags/cardPanel.tsx | 511 ++-- .../questionFeed/QuestionFeed.test.tsx | 19 +- .../features/questionFeed/QuestionFeed.tsx | 2121 +++++++---------- .../features/questionFeed/WhatsThatWords.tsx | 343 +-- frontend/src/pages/whatsthat.tsx | 245 +- frontend/tests/QuestionFeedResponsive.spec.ts | 1004 +++----- frontend/tests/WhatsThatPWA.spec.ts | 8 +- .../tests/WhatsThatWordsAnimation.spec.ts | 413 +--- 11 files changed, 1623 insertions(+), 3120 deletions(-) diff --git a/docs/features/printing-tags.md b/docs/features/printing-tags.md index 13fd9aea5..b4b2a1b32 100644 --- a/docs/features/printing-tags.md +++ b/docs/features/printing-tags.md @@ -329,6 +329,33 @@ printings, artists, tags, and moderation from one screen. ## Frontend architecture +**SUPERSEDED (WTC rebuild, 2026-07-24, `SPEC-wtc-rebuild.md`, owner rulings on that spec's +three open questions)** — `/whatsthat`'s visual/layout tree was rebuilt from scratch onto the +Tokyo-11 theme's ruled token layer (page-private `--wtc-*` tokens derived from `--accent`/ +`--body`/`--conf`, defined in `whatsthat.tsx`'s `WtcTokenScope`), replacing the bespoke gold/ +navy/starburst-blue/deep-blue-field identity the many "quiz-reveal hero" (issue #305) bullets +below describe. The old dual-layout mechanism (`HeroGrid`'s 768px `grid-template-areas` swap, +`MobileButtonRow`/`MobileCandidateScroller`/`MobileChipRow`'s horizontal scrollers, +`Level2NarrowGrid`'s narrow-only 2x2 action grid, `WideWordmark`/`NarrowWordmark`'s CSS-display +fork) is retired in favour of ONE `@container`-driven hero (`WtcHero`/`Subject`/`QPanel` in +`QuestionFeed.tsx`) that folds continuously via flex-wrap + `clamp()` + `auto-fill`/`auto-fit` +grids — no viewport breakpoint drives sizing (container-first policy, WTC = first consumer). +Also retired: `BurstSvg`/`HoverBurst`/`useStarburstFrame` (the starburst animation, owner +ruling — the token-derived `--wtc-reveal-glow` field glow replaces it; reveal reads through the +mystery-card flip only), `CardPulseWrapper`/the sliced WHAT'S/THAT/CARD? pop sequence +(`WhatsThatWords.tsx` is now a plain, static, single-tree `

`), the `PageColumn` +`100dvh`-bounded hero + "portrait static top block" hack (the page is an ordinary scrolling +document now). Added: the quiet "N tagged this session" affordance (the only reward surface — +no streak/score/confetti) and the seven question-shapes-as-visually-distinct-modes framing +(confirm/shortlist/quick-negative/open-ended/artist/tag/follow-up) `SPEC-wtc-rebuild.md` +section 2 defines. The interaction contract (Level 1/2/3 flows, `getAutoTagChips`, no-re- +presentation, the singleton-NO terminal vote, per-item state reset, the rate-limit banner) is +unchanged — every bullet below describing THAT contract (not the retired visual mechanism) is +still accurate. The detailed "quiz-reveal hero"/starburst/gold-button narrative below is kept +for history (this doc's own established convention — see the `cardPanel.tsx` bullet's own prior +"SUPERSEDED" marker two bullets down) but no longer describes the current rendering; read it as +"how we got here," not "what's live." + - `frontend/src/pages/whatsthat.tsx` (renamed from `printingQueue.tsx`) + `QuestionFeed.tsx` render the single unified feed; the old standalone `PrintingTagQueue.tsx`/`GenericVoteQueue.tsx`/`ModerationQueue.tsx` tab @@ -1452,9 +1479,13 @@ printings, artists, tags, and moderation from one screen. `cardpicker/question_feed.py`, `cardpicker/attribute_tags.py` + `seed_attribute_tags` management command. - Frontend: `frontend/src/features/printingTags/` - (`PrintingTagPicker.tsx`, `starburstShape.ts`, `cardPanel.tsx`), - `frontend/src/features/questionFeed/WhatsThatWords.tsx` (quiz-reveal - hero's sliced-word teaser, issue #305), + (`PrintingTagPicker.tsx`, `starburstShape.ts` — kept only for its + `STARBURST_OUTER_COLOR` constant, `ChipCard.tsx`'s default/pre-rebuild + frame; `cardPanel.tsx` — WTC-rebuild-retinted, `BurstSvg`/`HoverBurst`/ + `useStarburstFrame`/`CardPulseWrapper` deleted), + `frontend/src/features/questionFeed/WhatsThatWords.tsx` (WTC rebuild: + now a plain, static, token-coloured `

` wordmark — no more sliced- + word pop sequence), `frontend/src/features/filters/ResolvedAttributeFilter.tsx`, `frontend/src/common/processing.ts::getPrintingMatchLabel`, `frontend/src/features/attributeVoting/` (`ChipCard.tsx`, diff --git a/frontend/src/features/attributeVoting/ChipCard.tsx b/frontend/src/features/attributeVoting/ChipCard.tsx index 5fee08924..b888e1926 100644 --- a/frontend/src/features/attributeVoting/ChipCard.tsx +++ b/frontend/src/features/attributeVoting/ChipCard.tsx @@ -6,6 +6,14 @@ * picker a user just interacted with, rather than introducing a second unrelated chip style. * Deliberately lighter than CandidateButton: no starburst/hover-zoom, since these chips are * small and numerous rather than one large focal candidate. + * + * WTC rebuild (2026-07-24, SPEC-wtc-rebuild.md section 4/1c "reason chip") - the ONE caller + * this component has today, NoMatchReasonStrip, sits inside the rebuilt /whatsthat's + * quick-negative (shape c) surface, which the spec's binding per-element table frames in + * `--danger` (a visibly different mode from a confirm/pick, per WD7) rather than this + * component's own default accent/blue frame. `variant` is additive and optional (default + * unchanged) specifically so this doesn't touch ReportCardPanel.tsx's own, unrelated use of + * the same component - only NoMatchReasonStrip opts into "danger". */ import styled from "@emotion/styled"; @@ -14,8 +22,12 @@ import Button from "react-bootstrap/Button"; import { STARBURST_OUTER_COLOR } from "@/features/printingTags/starburstShape"; -const StyledChipButton = styled(Button)` - border: 2px solid ${STARBURST_OUTER_COLOR}; +const StyledChipButton = styled(Button)<{ $variant: "accent" | "danger" }>` + border: 2px solid + ${(props) => + props.$variant === "danger" + ? "var(--bs-danger, #f7768e)" + : STARBURST_OUTER_COLOR}; border-radius: 0.5rem; background-color: transparent; color: inherit; @@ -25,19 +37,31 @@ const StyledChipButton = styled(Button)` &:hover, &:focus { - background-color: rgba(77, 141, 223, 0.15); - border-color: ${STARBURST_OUTER_COLOR}; + background-color: ${(props) => + props.$variant === "danger" + ? "color-mix(in srgb, var(--bs-danger, #f7768e) 15%, transparent)" + : "rgba(77, 141, 223, 0.15)"}; + border-color: ${(props) => + props.$variant === "danger" + ? "var(--bs-danger, #f7768e)" + : STARBURST_OUTER_COLOR}; color: inherit; } &.highlighted { - background-color: ${STARBURST_OUTER_COLOR}; + background-color: ${(props) => + props.$variant === "danger" + ? "var(--bs-danger, #f7768e)" + : STARBURST_OUTER_COLOR}; color: #000000; } &.highlighted:hover, &.highlighted:focus { - background-color: ${STARBURST_OUTER_COLOR}; + background-color: ${(props) => + props.$variant === "danger" + ? "var(--bs-danger, #f7768e)" + : STARBURST_OUTER_COLOR}; } &:disabled { @@ -52,6 +76,9 @@ interface ChipCardProps { disabled?: boolean; onClick: () => void; "data-testid"?: string; + /** Additive, optional (own comment above) - "danger" is the WTC quick-negative frame; + * omitted (every other caller) keeps this component's original accent/blue frame. */ + variant?: "accent" | "danger"; } export function ChipCard({ @@ -61,10 +88,12 @@ export function ChipCard({ disabled = false, onClick, "data-testid": dataTestId, + variant = "accent", }: ChipCardProps) { return ( choose(tagName)} data-testid={`no-match-reason-${tagName}`} + variant="danger" /> ))} diff --git a/frontend/src/features/printingTags/cardPanel.tsx b/frontend/src/features/printingTags/cardPanel.tsx index a5830ff09..92829924b 100644 --- a/frontend/src/features/printingTags/cardPanel.tsx +++ b/frontend/src/features/printingTags/cardPanel.tsx @@ -1,57 +1,40 @@ /** - * Shared visual mechanics behind the "What's That Card?" subject-card panel: the sticky - * starburst-backed card, the silhouette-reveal animation, the candidate-grid "mystery card" - * placeholder/hover-zoom/hover-burst, and the flavor-text pool. Originally lived inline in - * PrintingTagQueue.tsx (the single-card printing-tag queue); extracted verbatim so the + * Shared visual mechanics behind the "What's That Card?" subject-card panel: the mystery-card + * silhouette-reveal animation, the candidate-grid "mystery card" placeholder/hover-zoom, and + * the flavor-text pool. Originally lived inline in PrintingTagQueue.tsx; extracted so the * unified question feed (QuestionFeed.tsx) can reuse the exact same mechanics rather than - * re-implementing them - see docs/features/printing-tags.md's questionFeed section and - * journal/2026-07-14-queue-question-feed-design.md for why this is a re-composition, not a - * rewrite. Every comment below is unchanged from its original call site. + * re-implementing them - see docs/features/printing-tags.md's questionFeed section. + * + * WTC rebuild (2026-07-24, SPEC-wtc-rebuild.md, owner rulings) - this pass: + * - retints MysteryCardFace/its glyph onto the `--wtc-mystery-face`/`--wtc-mystery-glyph` + * tokens (WD1 - the old hardcoded starburst blue `#4d8ddf` mascot identity is killed); + * the glyph itself becomes plain, token-coloured text (no more `whatsthat-mark.svg` gold- + * gradient asset - that asset's fill is baked-in SVG, not retintable via CSS). + * - retires BurstSvg/HoverBurst/useStarburstFrame entirely (owner ruling 1: "BurstSvg + * starburst RETIRED - the token-derived field glow replaces it; reveal reads through the + * mystery-card flip only"). Nothing else in the app imports these (verified via a + * repo-wide grep before deleting), so this is a clean removal, not a stub. + * - retires `wtcCardPulse`/`CardPulseWrapper` (the card-pulse-in-sync-with-the-wordmark-pop + * effect) alongside the wordmark's own pop-in animation it was synced to (see + * WhatsThatWords.tsx's own rebuild note) - ANNEX C's animation inventory lists only the + * mystery reveal, the confirm-lands feedback, the static reveal glow, and the static + * solved affordance; the card pulse isn't one of them. + * - `revealAnimation` (the mystery-card fade itself) is UNCHANGED verbatim, including its + * existing reduced-motion gate (`$playing` never flips true under reduced motion - see + * QuestionFeed.tsx's `onCardImageSettled`) - preserved per the spec's file-level table. + * - `CardPanel`/`StaticCardPanel`'s old 767.98px max-width-derived-from-vh height cap (the + * "portrait static top block" hack) is retired (WD4 - the page is an ordinary scrolling + * document now); both collapse to a plain, un-media-queried `width: 100%` box. */ import { keyframes } from "@emotion/react"; import styled from "@emotion/styled"; -import { useEffect, useState } from "react"; -import Button from "react-bootstrap/Button"; -import { - STARBURST_OUTER_COLOR, - STARBURST_OUTER_FRAMES, -} from "@/features/printingTags/starburstShape"; - -// Silhouette-reveal: the card starts as a black silhouette with a "?" in -// the middle, then fades to reveal the real art. The Scryfall candidate -// list is deliberately not rendered until this finishes (see `revealed` state below) - the -// whole point is to test recognition before handing over the answer options. -// -// Fix round (owner live-review, "the blue should fade sooner") - this used to hold at full -// opacity for the first 55% of its 1.8s run (a leftover from BEFORE #317 gated `$playing` on -// the real image-load event below: back when this fired at mount time regardless of whether -// the image had actually arrived, that hold bought the network a moment to catch up). Now that -// `$playing` already stays paused at this 0% frame until QuestionFeed.tsx confirms the image -// has genuinely loaded (see MysteryCardFace's own `$playing` prop below and QuestionFeed.tsx's -// `onCardImageSettled`), the hold is pure redundant lag layered ON TOP of the real wait - by -// the time this is ever allowed to run, the image is already there, so holding at opacity 1 for -// another ~1s just reads as "nothing happened yet". Collapsed to a plain two-stop fade (no hold -// checkpoint at all) so the drop starts the instant playback resumes, and shortened 1.8s -> -// 0.8s + switched ease-in -> ease-out so the fade is visibly moving immediately rather than -// creeping through its own first chunk before accelerating (ease-in's shape does that same -// "slow start" thing a hold does, just continuously instead of as a flat plateau). Timings -// (see cardPanel.tsx/whatsthat PR body): WhatsThatWords' pop sequence (0/0.24s/0.48s delay, -// 0.48s duration each - ends at 0.48s/0.72s/0.96s) and CardPulseWrapper's pulse (0.24s delay, -// 0.48s duration - ends 0.72s) now land ACROSS this fade's own 0-0.8s run rather than only -// starting after it had long finished (old: hold 0-0.99s, fade 0.99s-1.8s), so the pops -// visibly overlap the fade's tail (its last ~40%, 0.48s-0.8s) as intended. -// -// Owner review round 3 ("slow all 3 stages down a bit") - WhatsThatWords.tsx's own pop -// duration/stagger and CardPulseWrapper's pulse both scaled by 4/3 since the paragraph above was -// written (0/0.24s/0.48s delay -> 0/0.32s/0.64s; 0.48s duration -> 0.64s each) - this fade itself -// is untouched (still 0.8s, unrelated to the "wordmark pop-in animation" the owner's round 3 ask -// was scoped to). THAT's own pop and the card's own pulse still start well inside this fade's -// 0-0.8s run (0.32s, comfortably before 0.8s) so the two are still visibly overlapping, just with -// CARD?'s own pop (0.64s delay, ending 1.28s) now finishing further past the fade's own end than -// before (was 0.96s, now 1.28s) - purely a side effect of the whole sequence running longer, not -// a deliberate re-tuning of this fade's own relationship to the words. +// Silhouette-reveal: the card starts as a solid "mystery" face with a "?" in the middle, then +// fades to reveal the real art. The candidate list is deliberately not rendered until this +// finishes (see `revealed` state in QuestionFeed.tsx) - the whole point is to test recognition +// before handing over the answer options. Unchanged from the pre-rebuild implementation - see +// this file's own header note. export const revealAnimation = keyframes` from { opacity: 1; } to { opacity: 0; } @@ -62,62 +45,52 @@ export const RevealWrapper = styled.div` overflow: hidden; `; -// Owner review round 3 ("one blue card, used everywhere") - `RevealOverlay` (the hero reveal -// cover) and `ArtPlaceholder`'s own CSS `::before` (below) used to be two independent -// implementations of the same idea (a blue "mystery card" backdrop with a "?" on it), sharing -// only their background colour constant. The owner read the large hero card and the small -// candidate cards as visibly different shades of blue - measured via computed -// `background-color` on both, they were already byte-identical (`rgb(77, 141, 223)`, -// `STARBURST_OUTER_COLOR`, both places) - the perceived difference was a context/contrast -// effect from the hero's own starburst bleed behind the large card, not a real colour -// mismatch (see this PR's own report). Consolidating both surfaces onto one shared component -// removes even the possibility of future drift between them, which is the actual ask ("so -// future changes are one place") independent of whether today's colours already matched. -// -// `MysteryCardFace` is that shared component: a `position: absolute; inset: 0` blue backdrop -// (fills whatever correctly-sized, `position: relative` box it's dropped into - `RevealWrapper` -// for the hero slot, `ArtPlaceholder` for the candidate-grid/no-match slots below) plus a -// centred "?" glyph. `$playing` is optional and defaults to the SAME "paused at the 0% frame" -// behaviour every other gated animation on this page uses (Word in WhatsThatWords.tsx, -// CardPulseWrapper below) - when a caller never passes it at all (every candidate-grid/no-match -// slot), `animation-play-state` falls back to `"paused"` forever, which is exactly the -// permanently-static backdrop `ArtPlaceholder`'s old `::before` was (just now a real element -// instead of generated content, so it can host the glyph `` below rather than a CSS -// `content` string). Only the hero reveal slot (QuestionFeed.tsx) ever passes `$playing`/ -// `onAnimationEnd`, fading away in lockstep with the blue exactly as `RevealOverlay` did. +// The one "mystery card" backdrop every blue/purple-tinted placeholder on the page renders - +// the large hero reveal slot (RevealWrapper) and every small candidate-grid/no-match slot +// (ArtPlaceholder) share this exact component, so there's only ever one place to retint. +// `$playing` is optional and defaults to the same "paused at the 0% frame" behaviour every +// other gated animation on this page uses - when a caller never passes it at all (every +// candidate-grid/no-match slot), `animation-play-state` falls back to `"paused"` forever, i.e. +// a permanently-static backdrop. Only the hero reveal slot (QuestionFeed.tsx) ever passes +// `$playing`/`onAnimationEnd`. export const MysteryCardFace = styled.div<{ $playing?: boolean }>` position: absolute; inset: 0; - background: ${STARBURST_OUTER_COLOR}; + background: var(--wtc-mystery-face); display: flex; align-items: center; justify-content: center; pointer-events: none; - /* 0.8s ease-out, no hold - see revealAnimation's own comment above for the full before/after - timing rationale (was 1.8s ease-in with a 55% hold, on the old RevealOverlay this replaces). */ + /* box-shadow, not filter/animation - a STATIC glow (ANNEX C: "no animation, so no + reduced-motion concern"), token-derived per the spec's --wtc-reveal-glow row. */ + box-shadow: 0 0 min(24px, 16cqi) var(--wtc-reveal-glow); animation: ${revealAnimation} 0.8s ease-out forwards; animation-play-state: ${(props) => (props.$playing ? "running" : "paused")}; `; -// The "?" glyph itself - `whatsthat-mark.svg`'s own gold-gradient mascot (the same asset the -// wordmark's own "?" and round 2's homepage panel already use), not a plain text character - -// matches the "yellow question mark" the owner asked for, and its own navy stroke/outline keeps -// it legible against the blue regardless of exact shade. Sized via `height: 66.6667%` (2/3 of -// `MysteryCardFace`'s own height, which - `position: absolute; inset: 0` on a definite-height -// containing block - IS a definite height itself, so a percentage-height child resolves -// normally) rather than a fixed rem/px value, so it scales correctly with every card size on -// the page (the large hero card and the small candidate tiles are very different pixel heights) -// without a separate size constant per surface. `width: auto` preserves the asset's own aspect -// ratio (`whatsthat-mark.svg`'s viewBox is already question-mark-shaped, not square) rather than -// stretching it to fill a square box. +// The "?" glyph itself - plain, token-coloured text (WD1 retires the old gold-gradient +// `whatsthat-mark.svg` mascot asset; its fill is baked into the SVG file itself and can't be +// retinted via CSS, so a real glyph asset can't carry the new `--wtc-mystery-glyph` token at +// all - a text character can). Sized via `font-size: 66.6667cqi` of MysteryCardFace's own +// width (a `container-type: inline-size` ancestor - the box this glyph sits inside is exactly +// as wide as it is tall on every mystery-card slot on the page, so `cqi` and "percent of the +// card's own height" resolve to the same visual proportion here) rather than a fixed rem/px +// value, so it scales correctly across every very-differently-sized card on the page (the +// large hero card and the small candidate tiles) without a separate size constant per surface. function MysteryCardQuestionMark() { return ( - + data-testid="mystery-card-glyph" + style={{ + fontSize: "66.6667cqi", + lineHeight: 1, + color: "var(--wtc-mystery-glyph)", + fontWeight: 900, + }} + > + ? + ); } @@ -129,8 +102,8 @@ interface MysteryCardProps { "data-testid"?: string; } -// The one composition every blue "mystery card" slot on the page renders - see MysteryCardFace's -// own comment for the full "one blue card, used everywhere" rationale. +// The one composition every "mystery card" slot on the page renders - see MysteryCardFace's +// own comment for the full "one shared card, used everywhere" rationale. export function MysteryCard({ playing, onAnimationEnd, @@ -141,207 +114,33 @@ export function MysteryCard({ $playing={playing} onAnimationEnd={onAnimationEnd} data-testid={dataTestId} + style={{ containerType: "inline-size" }} > ); } -// SUPERSEDED (quiz-reveal hero redesign, issue #305): this used to be `position: sticky` at -// >= md, tracking the outer page scroll via useStickyTop below so the card stayed in view -// beside a taller candidate column. The redesign's hero grid (see QuestionFeed.tsx's -// HeroGrid/HeroCardArea) bounds the whole hero to one viewport-height row at >= md and gives -// only the questions column its own internal scrollbar instead - the card's grid cell never -// scrolls in the first place, so there's nothing left for position: sticky to do. useStickyTop -// is removed alongside it (dead code with no other caller). Below `md`, HeroCardArea itself -// applies a compact `position: sticky` bar (see that component's own comment) - this styled -// component stays deliberately position-agnostic so that outer wrapper is the only thing -// controlling stickiness. -// -// `position: relative; z-index: 0;` together establish CardPanel's own local stacking context, -// containing BurstSvg's `z-index: -1` (see below) to just this panel - without a stacking -// context here, that negative z-index would search *up* the tree for the nearest positioned -// ancestor instead, risking the burst painting behind unrelated ancestor content rather than -// just behind this panel's own card art. `position: relative` alone (no z-index) does NOT -// establish a stacking context per the CSS spec, hence z-index: 0 (not left at auto) here. +// The reference card itself - normal document flow at every width now (WD4 retires the old +// 767.98px height-cap-via-max-width "portrait static top block" hack; the page is an ordinary +// scrolling document). `position: relative; z-index: 0` still establishes a local stacking +// context, harmless now that nothing negatively z-indexes into it, and kept so a future +// absolutely-positioned child (e.g. a badge overlay) has a sane containing block without a +// separate follow-up change. export const CardPanel = styled.div` position: relative; z-index: 0; width: 100%; - - // Fix round (owner live-review, "portrait static top block") - height-caps the reference - // card at narrow widths so the static top block (wordmark + card + name/badge/question text - // + static action row) plus the scrollable options row below it fits a typical phone - // viewport with no page scroll (QuestionFeedResponsive.spec.ts's Pixel-7 no-scroll assertion). - // Expressed as a max-WIDTH derived FROM the target height (width = height * 63/88, - // CARD_ASPECT_RATIO's own ratio), not a max-height layered on top of the existing width-driven - // box below - the img inside (RevealWrapper) still sizes itself via width: 100% plus - // aspect-ratio: 63/88, completely unchanged, so capping the WIDTH here to whatever value - // yields the target height achieves the same visual result without fighting that existing - // mechanism or breaking MysteryCardFace's inset: 0 tracking (which follows THIS box's width, - // not the image's intrinsic size). - // - // 32vh, not the first pass's 38vh - a real Playwright measurement (Pixel 7, this task's own - // report) with a genuinely-loaded (not empty-src) hero image found 38vh left the candidate - // options row only 69-105px of its own ~176px natural height even after also compacting the - // action-button grid (Level2NarrowGrid's own comment), forcing HeroQuestionsArea's - // overflow-y: auto defensive fallback to activate - not the zero-internal-scroll outcome the - // spec asks for. 32vh gives the text/action/options budget back the difference. - @media (max-width: 767.98px) { - max-width: min(100%, calc(32vh * 63 / 88)); - margin: 0 auto; - } `; -// Level 1's compact single-card confirmation screen (QuestionFeed.tsx) has no long scrollable -// candidate list to keep the card pinned against while scrolling past, at any viewport width - -// unlike Level 2's two-column layout. Same as CardPanel above now - `position: relative; -// z-index: 0;` gives BurstSvg/MysteryCard the positioned containing block they anchor -// themselves to, and its own local stacking context for BurstSvg's negative z-index, with -// nothing sticky or detached from normal document flow at any width. +// Level 1's compact single-card confirmation screen (QuestionFeed.tsx). Same box model as +// CardPanel above now that both have dropped their old mobile-only height cap (own comment). export const StaticCardPanel = styled.div` position: relative; z-index: 0; width: 100%; - - // Same height-cap-via-max-width as CardPanel above (see its own comment) - Level 1's - // compact single-card screen gets the same 32vh-capped card at narrow widths. - @media (max-width: 767.98px) { - max-width: min(100%, calc(32vh * 63 / 88)); - margin: 0 auto; - } -`; - -// Sized and centred purely with CSS (percentage width + aspect-ratio, both relative to -// CardPanel's own box) rather than a JS measurement - it scales naturally with the card's -// own responsive width at every breakpoint. -// -// `$hero` (additive, default off - wtc-redesign-spec.md §7) enlarges the burst for the quiz- -// reveal hero's left column, where it's meant to dominate the hero zone behind the card rather -// than stay tucked closely around it. Deliberately sized past the card's own box - the burst -// bleeding past CardPanel's edges (even into the hero's blue field or partway behind the -// words/questions columns) is on-aesthetic; CardPanel's own z-index: 0 stacking context (see -// above) keeps it from ever painting over the neighbouring columns' own text at >= md, since -// those render later in DOM order and are never negatively stacked themselves there. -// -// Below md, HeroCardArea (QuestionFeed.tsx) is a compact card column sitting beside a -// horizontally-scrollable answer column, not overlaid on top of it (see that component's own -// "mobile row" comment) - a burst bleeding past the card's own edges there lands in the row's -// own gap/blue field, the same "on-aesthetic bleed" the >= md case already accepts, not on top -// of scrolled-under text the way the card's old `position: sticky` bar (superseded) would have -// made a big bleed risky. -// -// Fix round (owner live-review, "no starburst visible on mobile") - the previous mobile value -// here, `90%`, was SMALLER than the card's own box (100% of the same CardPanel/StaticCardPanel -// this centers on), and `aspect-ratio: 1` makes it a perfect square besides - centered on a -// panel whose real box is a full-width, taller-than-wide portrait card, a burst narrower AND -// shallower than that box is entirely eclipsed by the (opaque) card art in every direction, by -// construction, regardless of the jagged/spiky silhouette's own shape (confirmed via a real -// Pixel 7 portrait screenshot + getBoundingClientRect() diff in this task's own report - the -// burst rendered at `opacity` 1/`width` ~108px sat fully inside the card image's own ~120px -// box). No value <= 100% can ever be visible here; `90%` reads as a scale-DOWN-from-desktop -// intent (the code comment historically called this "backing off to a modest size") rather -// than a literal CSS-width-of-container instruction - `200%` below is that same "back off from -// the >= md value" intent (230% * ~0.9, rounded), just actually large enough to bleed past the -// card's own edges the way every other breakpoint here already does. -export const BurstSvg = styled.svg<{ $hero?: boolean }>` - position: absolute; - top: 50%; - left: 50%; - width: ${(props) => (props.$hero ? "200%" : "55%")}; - aspect-ratio: 1; - transform: translate(-50%, -50%); - z-index: -1; - pointer-events: none; - - // Fix round (owner live-review, "portrait static top block") - the card name/badge/question - // text now sits directly under the card (QuestionFeed.tsx), exactly where this hero burst's - // enlarged 200% bleed radiates its lower spikes at narrow widths. Rather than shrinking the - // burst back down (the owner's own earlier ask keeps it visible at 200% bleed on mobile - see - // the fix round above), a bottom-fade mask keeps the top/sides fully opaque (nothing competes - // with those) and fades the lower ~35% toward transparent, so the spikes never fight the text - // below for contrast. $hero-only - the small per-candidate HoverBurst has no text sitting - // underneath it and is unaffected. - @media (max-width: 767.98px) { - ${(props) => - props.$hero && - ` - mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 88%); - -webkit-mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 88%); - `} - } - - @media (min-width: 768px) { - width: ${(props) => (props.$hero ? "230%" : "55%")}; - } -`; - -// The reference card itself pulses in lockstep with the "THAT" word (wtc-redesign-spec.md's -// owner addendum) - same easing/duration/delay as wtcWordPop in WhatsThatWords.tsx, but a much -// smaller amplitude (a full-size card visibly "breathing" at the word's 1.34x peak would read -// as violent, not playful) and disabled under reduced motion the same way. Deliberately a -// separate keyframe (not the words' shared one) since the two need different peak scales but -// must stay frame-for-frame in sync - keeping both timings as literal, matching values here and -// in WhatsThatWords.tsx is what actually keeps them in sync, not a shared constant (they're two -// independent CSS animations on unrelated elements with no runtime coupling). Re-armed the same -// way as the words - key this wrapper on the current item's card identifier so it remounts, -// and the animation restarts, on every new card. -// -// Fix round (owner blocker, "the pulse doesn't sync with the pop") - `$playing` (same -// paused-until-told-otherwise `animation-play-state` mechanism as Word in WhatsThatWords.tsx -// and MysteryCardFace above) holds this at its own 0% frame (scale(1) - visually identical to -// the un-pulsed rest state, so there's no flash) until QuestionFeed.tsx confirms the card -// image has actually loaded, so this can never fire early against a still-loading card - see -// QuestionFeed.tsx's own comment on the shared `imageLoaded` state that drives all three of -// these paused animations at once. -export const wtcCardPulse = keyframes` - 0% { transform: scale(1); } - 48% { transform: scale(1.1); } - 100% { transform: scale(1); } -`; - -// Round 3 (owner review, "slow all 3 stages down a bit") - 640ms/320ms delay, scaled by the -// same 4/3 factor as WhatsThatWords.tsx's own Word component (own comment there) - this MUST -// move in lockstep with THAT's own delay/duration (not an independent value that happens to -// match), per this component's own header comment above. -export const CardPulseWrapper = styled.div<{ $playing: boolean }>` - transform-origin: center; - width: 100%; - max-width: 320px; - animation: ${wtcCardPulse} 640ms cubic-bezier(0.34, 1.45, 0.64, 1) both; - animation-delay: 320ms; - animation-play-state: ${(props) => (props.$playing ? "running" : "paused")}; - - @media (prefers-reduced-motion: reduce) { - animation: none; - } `; -const STARBURST_FRAME_INTERVAL_MS = 150; - -// Cycles through the precomputed jagged frames (see starburstShape.ts) to reproduce the -// reference gif's flicker. Always starts at frame 0 and only starts advancing inside -// useEffect (client-only, post-mount), so server-rendered and first-client-render markup -// stay identical - no hydration mismatch. Skips animating entirely under -// prefers-reduced-motion. -export function useStarburstFrame( - frameCount: number = STARBURST_OUTER_FRAMES.length -): number { - const [frame, setFrame] = useState(0); - - useEffect(() => { - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { - return; - } - const id = setInterval(() => { - setFrame((previous) => (previous + 1) % frameCount); - }, STARBURST_FRAME_INTERVAL_MS); - return () => clearInterval(id); - }, [frameCount]); - - return frame; -} - // Zooms the thumbnail in on hover, rather than the whole button, so the border/label stay // put and only the artwork itself grows. Deliberately left uncropped (no overflow: hidden) // so the enlarged art is fully visible rather than cut off at the original box edge - @@ -362,6 +161,15 @@ export const ZoomableThumbnail = styled.div` &:hover img { transform: scale(1.6); } + + @media (prefers-reduced-motion: reduce) { + img { + transition: none; + } + &:hover img { + transform: none; + } + } `; const FLAVOR_TEXT = [ @@ -383,35 +191,23 @@ export function randomFlavorText(): string { // Real Magic card ratio (63mm x 88mm), matching the print-ready `.ratio-7x5` convention // already used elsewhere (custom.css) - reserves each thumbnail's box up front via CSS -// alone, so an image resolving its intrinsic size late over the network can't reflow the -// page (the starburst is centred on the card's own box - see CardPanel - so any unreserved -// reflow here would visibly resize the burst along with it). +// alone, so an image resolving its intrinsic size late over the network can't reflow the page. export const CARD_ASPECT_RATIO = "63 / 88"; -// Sized box for every Scryfall art box in the candidate grid - `` (own comment +// Sized box for every card-art slot in the candidate grid - `` (own comment // above) is always rendered as this box's first child at each call site (QuestionFeed.tsx), -// providing the blue "mystery card" backdrop + "?" glyph real artwork renders on top of (so a -// slow-loading image transitions from a blue "?" card into the real art instead of a blank +// providing the "mystery card" backdrop + "?" glyph real artwork renders on top of (so a +// slow-loading image transitions from the mystery face into the real art instead of a blank // flash) - and it's also the entire visual for the "No match" option, which has no real -// artwork to show at all - replacing the old black "Card Not Found :(" placeholder image. -// -// Round 3 (owner review, "one blue card, used everywhere") - the backdrop/glyph used to be this -// component's own `background` + a `::before { content: "?" }` pseudo-element, a second, -// independent implementation of the same idea `RevealOverlay` (cardPanel.tsx, now -// `MysteryCard`) already had for the hero card. Both are now `` - see that -// component's own comment for the full rationale. +// artwork to show at all. export const ArtPlaceholder = styled.div` position: relative; width: 100%; aspect-ratio: ${CARD_ASPECT_RATIO}; /* Deliberately no overflow: hidden here - object-fit: cover below already keeps the image - contained within this box on its own (it crops the underlying image content to fit, - it doesn't make the element itself overflow), and clipping at this level was - silently re-breaking ZoomableThumbnail's hover-zoom (added in a previous round - specifically *without* overflow: hidden, so the enlarged art could pop out uncropped) - - since ArtPlaceholder wraps ZoomableThumbnail, its own overflow: hidden clipped the zoom - right back down to this box's edge, reading as a hard rectangular cut through the - enlarged artwork. */ + contained within this box on its own, and clipping at this level would re-break + ZoomableThumbnail's hover-zoom (built specifically *without* overflow: hidden so the + enlarged art can pop out uncropped). */ img { position: relative; @@ -422,86 +218,75 @@ export const ArtPlaceholder = styled.div` } `; -// Bootstrap's `outline-secondary` border doesn't scale with the hover-zoomed thumbnail -// inside it (see ZoomableThumbnail) - it stays put as a stationary frame while the art -// visibly grows past it, breaking the effect - so it's dropped entirely (`border-0`, -// applied at each call site below) and this component only needs to own the "highlighted" -// look. Bootstrap's green `success` variant clashed with the page's blue "mystery" motif -// established elsewhere (ArtPlaceholder/MysteryCard, the starburst itself), so "this is -// the resolved consensus pick" is now a solid fill in that same blue instead - there's no -// built-in Bootstrap variant in this exact shade, hence the custom class rather than -// swapping to `variant="primary"`. Black text (matching the page-wide font colour) checked -// against it: ~6.2:1 contrast, clearly better than white's ~3.4:1; the artist line below it -// is Bootstrap's `.text-muted` grey, which nearly disappeared against this blue, so it's -// darkened to translucent black specifically inside `.highlighted` (needs `!important` - -// Bootstrap's own text-color utilities are declared `!important`, so nothing else can win -// against it). -// -// Bootstrap's own `.btn-outline-secondary:hover` background (a flat grey) was still -// showing through around the card on hover, which read as a mismatched grey frame against -// the page's blue theme. Per direct request, that hover highlight is now a scaled-down copy -// of the page's own starburst (HoverBurst below) instead of a flat colour - `position: -// relative` + `z-index: 0` here gives HoverBurst's `z-index: -1` a local stacking context -// to sit behind ArtPlaceholder/the text without leaking out to sit behind this button's -// *siblings* in the grid too (the same mechanism as CardPanel/BurstSvg on the page-level -// starburst - see the comment there for the underlying CSS stacking rule). -export const CandidateButton = styled(Button)` +// The spec's ".ctile" candidate tile (SPEC-wtc-rebuild.md section 1c "candidate tile" row) - +// `bg raised, border divider; .sel outline 2px --accent`. "highlighted" (this component's own +// className, kept unchanged at every call site) marks the suggested printing from a +// confirm_suggestion item that's been dropped to Level 2 via NOT SURE/NO - the ONLY documented +// candidate-tile highlight state in the binding token table is the outline-only ".sel" look +// (token-derived `--accent`, WD2: purple carries identity/selection), which replaces the +// pre-rebuild's solid accent-fill treatment. +export const CandidateButton = styled.button` position: relative; z-index: 0; - overflow: visible; + display: block; + width: 100%; + padding: 0; + background: var(--raised); + border: 1px solid var(--divider); + border-radius: var(--r-card, 8px); + overflow: hidden; + color: inherit; + text-align: left; + cursor: pointer; - &:hover, - &:focus { - background-color: transparent !important; + &:disabled { + opacity: 0.6; + cursor: default; } - &:hover .hover-burst { - opacity: 1; - transform: translate(-50%, -50%) scale(1); + &:focus-visible { + outline: 2px solid var(--accent, #bb9af7); } &.highlighted { - background-color: ${STARBURST_OUTER_COLOR}; - color: #000000; - } - - &.highlighted .text-muted { - color: rgba(0, 0, 0, 0.65) !important; + outline: 2px solid var(--accent, #bb9af7); + outline-offset: -1px; + border-color: var(--accent, #bb9af7); } `; -// A smaller copy of the same starburst geometry, driven by the same shared -// `starburstFrame` state as the page-level burst (see useStarburstFrame below) rather than -// a frame of its own, so a zoomed card's highlight visibly flickers/moves in lockstep with -// the big one on the left instead of holding still - every instance ticks over together -// regardless of which card is actually hovered, since only the hovered one is visible -// (opacity 0 otherwise) and re-rendering a handful of invisible polygons every frame is -// cheap. Centred on and scaled up from the button's own box, the same way the page-level -// burst is centred on the subject card. Faded/scaled in via CSS on CandidateButton's -// `:hover` above rather than JS state, so nothing needs to track which card is hovered. -// `$edge` (fix round, PR #305/#308) - the candidate grid's scroll box (HeroQuestionsArea, -// QuestionFeed.tsx) genuinely clips this burst's full 331.2% bloom for the leftmost/rightmost -// column in every row (confirmed via a real boundingBox()-vs-container overlap check, not just -// a visual read): even with that box's own added bleed room (2.5rem each side), a burst this -// oversized still overhangs past it for an edge column specifically (a middle column's bloom -// safely overlaps its neighbours instead, which is the existing, accepted "on-aesthetic bleed" -// look). Shrinking ONLY the edge columns' burst - not every candidate's - keeps the approved, -// full-size glow everywhere it geometrically fits, trading a uniformly smaller effect -// (which would look identical everywhere but weaker) for a fully unclipped one that's only -// slightly reduced right at the two edges where there's genuinely no more room to give it. -export const HoverBurst = styled.svg<{ $edge?: boolean }>` - position: absolute; - top: 50%; - left: 50%; - width: 331.2%; - aspect-ratio: 1; - transform: translate(-50%, -50%) scale(0.75); - opacity: 0; - transition: opacity 0.18s ease-out, transform 0.18s ease-out; - pointer-events: none; - z-index: -1; +// The spec's ".ccap" candidate caption (SPEC-wtc-rebuild.md section 1c "candidate caption" +// row) - name `--text` 700, set `--muted` monospace 10px; pad 5px 7px 6px; 11px. +export const CandidateCaption = styled.div` + padding: 5px 7px 6px; + font-size: 11px; + line-height: 1.25; + + .cn { + font-weight: 700; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } - @media (min-width: 768px) { - width: ${(props) => (props.$edge ? "150%" : "331.2%")}; + .cs { + color: var(--muted); + font-family: "Courier New", monospace; + font-size: 10px; } `; + +// The spec's ".candgrid" (SPEC-wtc-rebuild.md section 1c "candidate grid" row + section 3's +// "continuous fold points" table) - an intrinsic auto-fill grid in container units, replacing +// the old `MobileCandidateScroller` horizontal-scroll wrapper (retired, WD8) with a grid that +// folds continuously (6 -> 4 -> 3 -> 2 columns) as the hero container narrows, no breakpoint. +export const CandidateGrid = styled.div` + display: grid; + gap: clamp(7px, 1.6cqi, 11px); + margin-top: 4px; + grid-template-columns: repeat( + auto-fill, + minmax(clamp(78px, 15cqi, 116px), 1fr) + ); +`; diff --git a/frontend/src/features/questionFeed/QuestionFeed.test.tsx b/frontend/src/features/questionFeed/QuestionFeed.test.tsx index 383cb90c6..7376ea156 100644 --- a/frontend/src/features/questionFeed/QuestionFeed.test.tsx +++ b/frontend/src/features/questionFeed/QuestionFeed.test.tsx @@ -38,17 +38,16 @@ function renderFeed() { // every test below, same as a real animation completing. async function revealCard() { const overlay = await screen.findByTestId("question-feed-reveal-overlay"); - // Owner review round 3 ("one blue card, used everywhere") - a regression guard for the - // overlay's own "?" glyph (cardPanel.tsx's shared `MysteryCard`, rendered as an `` of - // `whatsthat-mark.svg` rather than plain text since round 3 - see that component's own - // comment), asserted here rather than only in Playwright since every caller of this helper - // already exercises the overlay's pre-reveal moment; this fires before the fade + // WTC rebuild (2026-07-24, SPEC-wtc-rebuild.md, owner ruling 1) - a regression guard for + // the overlay's own "?" glyph: cardPanel.tsx's shared `MysteryCard` now renders it as a + // plain, token-coloured `` rather than the old + // gold-gradient `whatsthat-mark.svg` `` (that asset's fill is baked-in SVG, not + // retintable onto the `--wtc-mystery-glyph` token - see that component's own comment). + // Asserted here rather than only in Playwright since every caller of this helper already + // exercises the overlay's pre-reveal moment; this fires before the fade // (fireEvent.animationEnd below) removes it from the DOM. - // `alt=""` (deliberately decorative - see MysteryCardQuestionMark's own comment) strips the - // "img" ARIA role, so a plain tag-selector query is more reliable here than getByRole. - expect(overlay.querySelector("img")).toHaveAttribute( - "src", - "/whatsthat-mark.svg" + expect(within(overlay).getByTestId("mystery-card-glyph")).toHaveTextContent( + "?" ); fireEvent.animationEnd(overlay); await waitFor(() => diff --git a/frontend/src/features/questionFeed/QuestionFeed.tsx b/frontend/src/features/questionFeed/QuestionFeed.tsx index 655aa7103..053377076 100644 --- a/frontend/src/features/questionFeed/QuestionFeed.tsx +++ b/frontend/src/features/questionFeed/QuestionFeed.tsx @@ -1,46 +1,44 @@ /** * The unified "What's That Card?" question feed - replaces the old printing/artist/tag tab - * switcher (PrintingTagQueue.tsx + GenericVoteQueue.tsx, both deleted alongside this file) - * with a single `GET 2/questionFeed/`-driven stream of one question at a time, typed per - * cardpicker.question_feed's three-tier ranked union. See docs/features/printing-tags.md's - * questionFeed section and journal/2026-07-14-queue-question-feed-design.md for the full - * design writeup (chip taxonomy grounding, layout rationale, starvation-risk tradeoff). + * switcher (PrintingTagQueue.tsx + GenericVoteQueue.tsx, both deleted alongside that earlier + * change) with a single `GET 2/questionFeed/`-driven stream of one question at a time, typed + * per cardpicker.question_feed's three-tier ranked union. See docs/features/printing-tags.md's + * questionFeed section for the full design writeup. * - * Candidate-type items (confirm_suggestion / identify_printing) now run through three stages - * instead of one grid screen - see the funnel proposal artifact (PR-E's HOLD) for the mocks - * and state diagram this implements: - * Level 1 - a single suggested printing, YES / NOT SURE / NO / SKIP, no grid. Only reached - * for confirm_suggestion items that actually carry a suggestedPrinting. - * Level 2 - the candidate grid (identify_printing lands here directly; confirm_suggestion - * lands here on NOT SURE/NO). The attribute-chip ring is now an opt-in, collapsed-by- - * default "Filter by attribute" disclosure rather than always-on chrome around the card - - * picking a candidate ignores filter state entirely (filters are navigation, never - * votes). Two classified exits sit below the grid: "None of these" (unchanged - still - * followed by the reason strip) and "Art matches, not an official printing" (a single - * pre-classified tap: isNoMatch printing vote + a positive custom-art tag vote, no reason - * strip since the tap already said why). - * Level 3 - conditional. Selecting a candidate auto-casts a positive tag vote for every - * attribute chip the candidate's own data derives (see attributeChips.ts's - * getAutoTagChips) - most of the time that's everything, and the feed advances straight - * to the next card. Level 3 only renders when a genuinely open question survives (an - * exclusion group whose candidate value doesn't match any of that group's chips - see - * getOpenExclusionGroups), presenting just those groups as a real single-select lock - * (picking one deselects its alternates), distinct from Level 2's filter panel, which - * keeps the funnel's usual independent tri-state cycling. - * - * Re-composition, not a rewrite: the sticky starburst card panel, reveal animation, and - * candidate-grid mechanics are the exact same code as the old PrintingTagQueue, now shared - * via cardPanel.tsx. ArtistVotePicker and QueueTagQuestion are reused directly for their - * question types, unforked. + * WTC REBUILD (2026-07-24, SPEC-wtc-rebuild.md, owner rulings on that spec's three open + * questions) - this is a full visual/layout rewrite of this file's RETURN TREE. The + * interaction contract (every function below `advance`/`selectCandidate`/`classifyAsCustomArt`/ + * `tapLevel3Chip`/`confirmLevel3`/`rejectSuggestion`/the fetch effect's per-item state reset) + * is preserved VERBATIM - only the JSX/styling changed. See the spec's section 2 + * ("question-shape inventory") for the shape -> contract mapping and section 5 for the + * file-level change rows this implements: + * - Deleted: the three styled gold/navy button overrides (ThumbButton/FilterToggleButton/ + * ThumbChip's `QUIZ_BUTTON_GOLD`/`_HOVER`/`_NAVY` treatment - WD1, bespoke identity killed), + * `HeroGrid`'s 768px `grid-template-areas` swap, `MobileButtonRow`/`MobileCandidateScroller`/ + * `MobileChipRow` + their shared `mobileScrollbarCSS`, `Level2NarrowGrid`'s narrow-only 2x2 + * action grid + its `Narrow*Area` wrappers, `WideWordmark`/`NarrowWordmark`'s CSS-display + * fork, `BurstSvg`/`HoverBurst`/`useStarburstFrame` (owner ruling 1), `CardPulseWrapper` + * (its own sync target, the wordmark pop, is retired alongside it - ANNEX C's animation + * inventory doesn't list it). + * - Replaced by: one `@container`-driven hero (`WtcHero`/`Subject`/`QPanel`, section 3) that + * folds continuously via flex-wrap + `clamp()` + `auto-fill`/`auto-fit` grids - no viewport + * breakpoint drives ANY sizing; the one permitted viewport media query + * (`.wtc-head { flex-direction: column }` below 520px) is a structural header reorder only. + * - Added: the quiet "N tagged this session" affordance (WD6, owner ruling 2 - kept, + * volume-rewarding/direction-neutral, the ONLY reward surface; no streak/score/confetti) + * and the quiet "confirm-lands" fade (ANNEX C) shown on a successful confirm/pick while the + * next item is in flight. + * - Preserved verbatim: Level 1/2/3 flows, `getAutoTagChips` auto-tagging on candidate pick, + * no-re-presentation (`rejectedCandidateIds`), the singleton-NO terminal vote, per-item + * state reset inside the fetch `.then()` (not a keyed `useEffect` - the stale-filter fix), + * the rate-limit banner, `data-card-*` attributes + the `mpc:card-selected` event (via + * `getPrintingCandidateDataAttributes`, unchanged), every `data-testid` this file's own + * Playwright/jest coverage keys off of. */ import styled from "@emotion/styled"; import React, { useEffect, useRef, useState } from "react"; import Alert from "react-bootstrap/Alert"; -import Badge from "react-bootstrap/Badge"; -import Button from "react-bootstrap/Button"; -import Col from "react-bootstrap/Col"; -import Row from "react-bootstrap/Row"; import { errorToNotification, isRateLimited } from "@/common/apiErrors"; import { getPrintingCandidateDataAttributes } from "@/common/cardDom"; @@ -72,26 +70,17 @@ import { NoMatchReasonStrip } from "@/features/attributeVoting/NoMatchReasonStri import { QueueTagQuestion } from "@/features/attributeVoting/QueueTagQuestion"; import { ArtPlaceholder, - BurstSvg, CandidateButton, + CandidateCaption, + CandidateGrid, CARD_ASPECT_RATIO, CardPanel, - CardPulseWrapper, - HoverBurst, MysteryCard, randomFlavorText, RevealWrapper, StaticCardPanel, - useStarburstFrame, ZoomableThumbnail, } from "@/features/printingTags/cardPanel"; -import { - STARBURST_INNER_COLOR, - STARBURST_INNER_FRAMES, - STARBURST_OUTER_COLOR, - STARBURST_OUTER_FRAMES, - STARBURST_VIEWBOX, -} from "@/features/printingTags/starburstShape"; import { WhatsThatWords } from "@/features/questionFeed/WhatsThatWords"; import { APIGetQuestionFeed, @@ -104,676 +93,490 @@ import { setNotification } from "@/store/slices/toastsSlice"; type FollowUp = "none" | "no-match-reason"; type CandidateStage = "level1" | "level2" | "level3"; -// Fix round (owner review round 2, "golden buttons") - every quiz action button on this page -// (Level 1's Yes/Not sure/No/Skip, Level 2's None of these/Art matches/Skip, Level 3's chip -// picker/Confirm/Skip, and the Filter-by-attribute toggle below) used its own Bootstrap variant -// (success/outline-secondary/outline-danger/link/primary) designed against the SITE'S neutral/ -// orange-tinted background, not this page's own deep-blue starburst field (whatsthat.tsx's -// StarburstBackground, #123a6b-#1d4d82). Measured contrast for the worst offender - the default -// grey `outline-secondary` text/border ("None of these"/"Skip"/"Not sure") against the field's -// deep-blue stop - was ~2.4:1, well under WCAG AA's 4.5:1 floor for text (and its 3:1 floor for -// UI-component borders): exactly the "hard to read" the owner reported live. Every one of these -// buttons now shares one gold treatment instead of its original semantic variant colour - gold -// outline/text at rest, filling solid gold with a dark-navy text swap on hover/focus/press - both -// using the wordmark's own two colours (`#F8D42B`, the composite/mark SVGs' own top gradient -// stop, and `#124063`, their own dark-navy stroke - see public/whatsthat-mark.svg) rather than -// inventing new ones. Measured contrast ratios (both computed the same way as the ~2.4:1 figure -// above): gold text on the field's deep-blue stop 7.84:1, gold text on the field's lighter -// highlight stop 5.93:1, dark-navy text on filled gold 7.45:1 - all comfortably past AA, most -// past AAA too (docs/features/printing-tags.md has the full table). This intentionally -// overrides EVERY variant used here (success/outline-danger/outline-secondary/link/primary) with -// the same gold language rather than keeping each one's own semantic colour - the owner's ask -// was for one consistent, readable treatment across the whole action row, not a per-variant -// patch. Page-scoped: only these three styled components (ThumbButton/FilterToggleButton/ -// ThumbChip, all local to this file) are touched - Bootstrap's own variant classes and the -// sitewide orange theme are untouched everywhere else in the app. NoMatchReasonStrip's own -// chips (ChipCard.tsx) are deliberately left alone - that component is shared with -// ReportCardPanel.tsx on a different page, so recolouring it here would leak past this page's -// scope; its existing blue-outline treatment already clears AA (docs/features/printing-tags.md). -const QUIZ_BUTTON_GOLD = "#f8d42b"; -const QUIZ_BUTTON_GOLD_HOVER = "#d6ab11"; -const QUIZ_BUTTON_NAVY = "#124063"; - -// Mobile funnel pass (thumb-native tap targets): Bootstrap's own default .btn height is -// ~38px (0.375rem vertical padding + 1.5 line-height + border) - short of the 44px minimum -// both Apple's HIG and WCAG 2.5.5 (Target Size, AA) call for. Every stacked full-width action -// button in the funnel (Level 1's YES/NOT SURE/NO, Level 2's None of these/Art matches/Skip, -// Level 3's Confirm & continue/Skip) goes through this wrapper instead of bare react-bootstrap -// Button - one place enforcing the floor rather than a min-height style prop repeated at every -// call site (and one place to revisit if the target size guidance ever changes). flex centering -// keeps short labels ("No", "Skip") vertically centered once the box is taller than its text, -// rather than leaving them pinned to the button's own top-padding baseline. -// -// Gold treatment (own comment above `QUIZ_BUTTON_GOLD`) splits on Bootstrap's own rendered -// `.btn-link` class (the Skip buttons' variant) vs every other variant used here - `.btn-link` -// never had a border or fill to begin with (plain underlined text), so it only gets a colour -// swap; every bordered/filled variant (success/outline-secondary/outline-danger/primary) gets -// the full outline-at-rest/filled-on-interaction treatment. `&&` doubles this selector's own -// specificity (Emotion's standard trick for this) so it reliably wins over Bootstrap's own -// single-class `.btn-success`/`.btn-outline-danger`/etc rules regardless of injection order. -const ThumbButton = styled(Button)` - min-height: 44px; - display: flex; - align-items: center; - justify-content: center; - - &&:not(.btn-link) { - color: ${QUIZ_BUTTON_GOLD}; - border-color: ${QUIZ_BUTTON_GOLD}; - background-color: transparent; - } +// --------------------------------------------------------------------------------------- +// Layout primitives (SPEC-wtc-rebuild.md section 1c's per-element binding table + section 3's +// container-first layout spec). Every size/spacing/colour value below is copied verbatim from +// that table - see wtc-mockup.html for the same values in their original mockup-authored form. +// --------------------------------------------------------------------------------------- - &&:not(.btn-link):hover, - &&:not(.btn-link):focus, - &&:not(.btn-link):active { - color: ${QUIZ_BUTTON_NAVY}; - background-color: ${QUIZ_BUTTON_GOLD}; - border-color: ${QUIZ_BUTTON_GOLD}; - } +const FeedRoot = styled.div``; - &&.btn-link { - color: ${QUIZ_BUTTON_GOLD}; - } +// wtc-head: wordmark + the quiet session-count affordance. The ONE permitted viewport +// breakpoint (section 3) - a structural reorder (wordmark above the pill on a narrow +// VIEWPORT), never a size change. +const WtcHead = styled.div` + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 14px; + flex-wrap: wrap; + max-width: 1180px; + margin: 0 auto 12px; - &&.btn-link:hover, - &&.btn-link:focus, - &&.btn-link:active { - color: ${QUIZ_BUTTON_GOLD_HOVER}; + @media (max-width: 520px) { + flex-direction: column; + align-items: flex-start; + gap: 8px; } +`; - &&:disabled { - opacity: 0.6; +// The quiet, non-gamified reward affordance (WD6, owner ruling 2) - a muted resolved-count, +// deliberately NOT a score/streak (no confetti, no sound, direction-neutral - see ANNEX A). +const SolvedPill = styled.span` + display: inline-flex; + align-items: center; + gap: 8px; + background: rgba(0, 0, 0, 0.28); + border: 1px solid var(--divider); + border-radius: var(--r-pill); + padding: 5px 12px; + font-size: 12px; + color: var(--muted); + + b { + color: var(--success); + font-variant-numeric: tabular-nums; } `; -// "Filter by attribute" / "Hide filters" - a variant="link" toggle, not one of the stacked -// action buttons above (ThumbButton doesn't apply here; it isn't full-width or button-styled). -// The plain-link version used p-0 (zero padding), collapsing its tap target to just the text's -// own line-height (~24px, measured) - well under the 44px floor despite being the ONLY way to -// reach the attribute-chip filter on Level 2. Padding restores a real hit area without -// resembling a filled button (still variant="link" - text + underline, no background/border). -// -// Gold treatment - same colour pair and rationale as ThumbButton's own `.btn-link` case above -// (this is always variant="link" itself, so no bordered/filled case to split on here). -const FilterToggleButton = styled(Button)` - min-height: 44px; +const SolvedDots = styled.span` display: inline-flex; - align-items: center; - padding: 0.5rem 0; + gap: 3px; - && { - color: ${QUIZ_BUTTON_GOLD}; - } + i { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--divider); + display: inline-block; - &&:hover, - &&:focus, - &&:active { - color: ${QUIZ_BUTTON_GOLD_HOVER}; + &.f { + background: var(--success); + } } `; -// Level 3's per-attribute chip picker (a wrapped row of inline pills, not stacked full-width - -// ThumbButton's flex-stack styling doesn't fit here). Was Button size="sm", the smallest -// Bootstrap variant - shorter still than the already-under-target default. min-height alone -// (not ThumbButton's display/alignment) since these need to stay inline-sized to their own -// label, wrapping naturally in the row. -// -// Gold treatment - these toggle between two variants to show selection state (`outline- -// secondary` untouched, `primary` once tapped - see the `state === "positive" ? "primary" : -// "outline-secondary"` call site below), so the gold swap reuses that same split rather than a -// hover-only one: `.btn-primary` (selected) is PERSISTENTLY filled gold/navy-text - the same -// pairing ThumbButton only shows transiently on hover - so a selected chip stays visibly -// "pressed" rather than reverting to plain outline the instant the pointer leaves it. -const ThumbChip = styled(Button)` - min-height: 44px; +// The one hero container (section 3) - `container-type: inline-size` so every descendant +// below folds against ITS OWN rendered width, not the viewport. `Subject`/`QPanel` wrap +// intrinsically via flex-basis; no media query drives the subject<->question column split. +const WtcHero = styled.div` + container-type: inline-size; + container-name: hero; + max-width: 1180px; + margin: 0 auto; + display: flex; + flex-wrap: wrap; + gap: clamp(12px, 2.2cqi, 22px); + align-items: start; +`; - &&.btn-outline-secondary { - color: ${QUIZ_BUTTON_GOLD}; - border-color: ${QUIZ_BUTTON_GOLD}; - background-color: transparent; +const Subject = styled.div` + flex: 1 1 300px; + min-width: 0; + max-width: clamp(240px, 30cqi, 340px); + + /* Continuous fold point (section 3's table): the subject compacts to horizontal (WD3) on a + narrow CONTAINER, not a narrow viewport - keeps the confirm hero reachable near the top + on a phone with no bounded-height hack (WD4). */ + @container hero (max-width: 560px) { + flex: 1 1 100%; + max-width: none; } +`; - &&.btn-outline-secondary:hover, - &&.btn-outline-secondary:focus { - color: ${QUIZ_BUTTON_NAVY}; - background-color: ${QUIZ_BUTTON_GOLD}; - border-color: ${QUIZ_BUTTON_GOLD}; - } +const QPanel = styled.div` + flex: 2.2 1 440px; + min-width: 0; +`; - &&.btn-primary { - color: ${QUIZ_BUTTON_NAVY}; - background-color: ${QUIZ_BUTTON_GOLD}; - border-color: ${QUIZ_BUTTON_GOLD}; +const SubjectCardBox = styled.div` + background: var(--raised); + border: 1px solid var(--divider); + border-radius: var(--r-card); + overflow: hidden; + + @container hero (max-width: 560px) { + display: flex; + align-items: stretch; } `; -// --------------------------------------------------------------------------------------- -// Quiz-reveal hero (issue #305, wtc-redesign-spec.md) - one grid-area map per breakpoint: -// "card words" / "card questions" at >= md (the card spans both rows on the left; words sit -// over the questions on the right); "words words" / "card questions" below md (words stays -// full-width up top, unchanged from before this fix round, but the card now sits in its own -// compact LEFT column beside the questions instead of stacking above/below them - see the -// mobile-row fix round below for why). -// -// Owner addendum to the approved design: the reference card must stay fully visible while -// the user works through the questions, not scroll away with them. At >= md the whole hero -// is bounded to one viewport-height row (leaving the page's own outer scroll - see -// ContentContainer in Layout.tsx - with nothing to do while answering) and only -// HeroQuestionsArea below scrolls internally; the card's own grid cell never scrolls, so -// there's no sticky/negative-z-index mechanism left to run (see CardPanel's own comment in -// cardPanel.tsx for what this replaces). -// -// Fix round (PR #305/#308 owner review): this used to bound itself via its own -// `max-height: calc(100dvh - NavbarHeight - 2rem)` - wrong on two independent counts. (1) the -// static NavbarHeight constant regularly undercounts the navbar's real rendered height (issue -// #250), and (2) even with an accurate navbar height, the flat "2rem" guess ignored -// StarburstBackground's own real padding/margin (4.5rem, not 2rem) AND Footer's entire height -// below it - so the true total page content routinely exceeded the space actually available, -// forcing Layout.tsx's ContentContainer to scroll as a whole and breaking the "hero stays -// pinned" invariant live despite passing CI (a scrollTop-only assertion on the inner questions -// box never exercised that outer container). Replaced with `flex: 1; min-height: 0` below - -// FeedRoot/StarburstContent (whatsthat.tsx) now do this arithmetic structurally instead of via -// a hand-maintained calc, so this can't drift out of sync with either figure again. -// -// Fix round (owner live-review, "the card covers the questions on scroll") - below md, this -// used to collapse to a single column ("words" "card" "questions" stacked top-to-bottom) with -// HeroCardArea's own `position: sticky` bar riding ON TOP of HeroQuestionsArea (z-index: 5) as -// the page scrolled - the two areas shared the same horizontal space by design, so the sticky -// card was ALWAYS going to paint over whatever text had scrolled up underneath it (confirmed -// live: a real wheel-scroll + getBoundingClientRect() diff in this task's own report showed -// the card's box fully nested inside the questions box's own bounds post-scroll - not a fluke, -// the geometry guaranteed it). Below md now mirrors >= md's own "card beside, not above/below, -// the questions" shape instead - a real, disjoint grid COLUMN for the card (see -// grid-template-columns below), so the two areas structurally cannot overlap regardless of -// scroll position or either one's own height, the same invariant >= md already had. See -// HeroCardArea's own comment for the compact column width and MobileButtonRow/MobileChipRow/ -// MobileCandidateScroller (below) for how the answer options fill the narrower remaining -// width without wrapping into an unreadable number of rows. -// --------------------------------------------------------------------------------------- +const SubjectArt = styled.div` + aspect-ratio: ${CARD_ASPECT_RATIO}; + position: relative; -// Fix round (owner live-review, "portrait static top block") - below md, this used to keep the -// card in its own narrow LEFT column beside the questions ("words words" / "card questions", -// minmax(0, 7.5rem) minmax(0, 1fr)) - the mobile-row fix round's answer to the earlier sticky- -// bar overlap bug. The owner's follow-up review found that arrangement wastes space and clips -// question text behind the card (nothing wrong with the card/questions non-overlap invariant it -// gave, just the wrong axis for a narrow screen). Below md now stacks vertically instead -// ("words" / "card" / "questions", single column) - card above questions, full width, the same -// shape >= md's own "words" row/"card questions" 2-row layout uses, just collapsed to one -// column. `flex: 1; min-height: 0` now applies at EVERY width (previously >= md only) - the -// static top block (wordmark, height-capped card, its name/badge/question text, the static -// "Filter by attribute"/"None of these" action row) plus the scrollable candidate row below it -// need the same bounded-viewport-height treatment >= md already had, so -// QuestionFeedResponsive.spec.ts's Pixel-7 "no page scroll needed" assertion has a real, -// resolvable height to size against (see PageColumn/StarburstBackground in whatsthat.tsx, now -// also bounded at every width). -const HeroGrid = styled.div` - display: grid; - gap: 0.75rem 0.5rem; - grid-template-columns: 1fr; - grid-template-rows: auto auto minmax(0, 1fr); - grid-template-areas: "words" "card" "questions"; - flex: 1; - min-height: 0; - - @media (min-width: 768px) { - /* Row-gap trimmed from 1.5rem to 1rem, then to 0.5rem on rebase onto #313's taller - three-tier Footer (fix round, owner blocker) - purely spacing between the words/ - questions rows, not approved content, and every pixel of it also comes straight out of - HeroQuestionsArea's own budget the same way the words row's own height does (see the - Word component's own comment in WhatsThatWords.tsx for the full arithmetic). Column gap - (2.5rem) is untouched - that's the card/questions horizontal gutter HeroQuestionsArea's - own bleed math already accounts for. */ - gap: 0.5rem 2.5rem; - grid-template-columns: minmax(0, 42%) minmax(0, 1fr); - grid-template-rows: auto minmax(0, 1fr); - grid-template-areas: "card words" "card questions"; + @container hero (max-width: 560px) { + flex: 0 0 132px; + width: 132px; + aspect-ratio: auto; } `; -// QuestionFeed's own root - `flex: 1; min-height: 0` opts into StarburstContent's -// `display: flex; flex-direction: column; height: 100%` (whatsthat.tsx) so HeroGrid's own -// `flex: 1` above has a real, resolvable height to consume. Only meaningful when this is a -// direct flex child of a flex parent with a definite height - true for the common, -// non-moderator render path (StarburstContent renders this directly), but NOT for the -// moderator Tab.Container/Tab.Content/Tab.Pane switcher (whatsthat.tsx), which isn't part of -// that flex chain - this deliberately falls back to auto/natural height there instead -// (unchanged from before this fix round), rather than extending the flex chain through three -// more react-bootstrap wrapper components for a small, privileged audience. -// -// Fix round (owner live-review, "portrait static top block") - previously gated to >= md only -// (below md, the whole page scrolled normally, so nothing needed this flex chain). Now applies -// at every width - PageColumn/StarburstBackground (whatsthat.tsx) bound height below md too, so -// this flex chain needs to run unconditionally for the static-top-block/scrollable-candidate- -// row split to have a real height budget to divide up. -const FeedRoot = styled.div` +const SubjectArtTitle = styled.div` + position: absolute; + left: 0; + right: 0; + bottom: 0; + z-index: 2; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.72)); + color: #fff; + font-weight: 700; + font-size: clamp(13px, 3.4cqi, 17px); + padding: 22px 10px 8px; + text-shadow: 0 1px 2px #000; +`; + +const SubjectCap = styled.div` display: flex; - flex-direction: column; - flex: 1; - min-height: 0; + align-items: center; + gap: 8px; + padding: 8px 11px; + font-size: 12px; + color: var(--muted); + border-top: 1px solid var(--divider); + background: var(--conf); + + .glyph { + width: 18px; + height: 18px; + flex: 0 0 18px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--accent); + font-weight: 900; + } + + @container hero (max-width: 560px) { + flex: 1; + border-top: none; + border-left: 1px solid var(--divider); + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 4px; + } `; -// Fix round (owner live-review, "portrait static top block") - SUPERSEDES two prior mobile -// mechanisms in turn: the original stack-plus-`position: sticky` bar (overlapped scrolled -// questions, the original owner-reported bug), then the disjoint-grid-COLUMN fix round's own -// `position: sticky; align-self: start` (safe from overlap, but put the card beside the -// questions instead of above them, wasting horizontal space and letting question text clip -// behind the card - the NEXT owner-reported bug). No `position: sticky` anywhere now (this -// codebase's own sticky/overflow lesson - docs/lessons.md - plus the task's own explicit ask): -// the card sits in normal flow, above the questions, at every width. Nothing left to keep it -// "pinned" against - PageColumn/StarburstBackground (whatsthat.tsx) now bound the WHOLE hero -// to the viewport at every width (not just >= md), so the card's own grid cell never scrolls -// in the first place, the same invariant >= md has always had. -const HeroCardArea = styled.div` - grid-area: card; +const QHead = styled.div` display: flex; align-items: center; - justify-content: center; - min-width: 0; - height: 100%; - min-height: 0; + gap: 10px; + flex-wrap: wrap; + margin: 2px 0 12px; +`; + +const Prompt = styled.p` + font-size: clamp(17px, 3.4cqi, 22px); + font-weight: 800; + color: var(--text); + margin: 0; `; -// Below md, the "answer options" for whichever stage is active (Level 1's four stacked -// buttons, Level 2's candidate grid, Level 3's exclusion-group chips) render in a single -// horizontally-scrollable row instead of stacking/wrapping to fit the narrow width. Originally -// (mobile-row fix round) this row sat BESIDE a compact card column - the "portrait static top -// block" fix round moved the card back above the questions (HeroGrid's own comment), but kept -// this same horizontal-scroll-row mechanism for whichever options a stage actually has: each -// control keeps a comfortable minimum width and the row scrolls sideways for overflow, rather -// than wrapping into many rows or shrinking controls below a usable size. Level 2 specifically -// is now the ONLY genuinely scrollable region in the whole hero at narrow widths (see -// Level2NarrowGrid below) - everything above it (wordmark, card, name/badge/question text, the -// static action row) is fixed, non-scrolling chrome. `-webkit-overflow-scrolling: touch` + a -// visible (not hidden - see HeroQuestionsArea's own comment on why a scrollable region should -// still look scrollable) thin themed scrollbar match the desktop candidate column's existing -// scrollbar treatment for visual consistency. Reverts to normal (no horizontal scroll, no -// forced row) at >= md - untouched, each call site's own pre-existing desktop layout -// (flex-column button stack, Bootstrap Row/Col grid, wrapped chip row) is unaffected there. -// -// Deliberately NOT applied to the artist/tag question types (ArtistVotePicker/ -// QueueTagQuestion) - those are search/autocomplete-shaped UI, not a set of discrete "pick -// one" options, and forcing them into a horizontal filmstrip would make them harder to use, -// not easier; they keep their existing normal vertical stacking at every width. -// Shared scrollbar treatment (visible, not hidden - see HeroQuestionsArea's own comment on why -// a scrollable region should still look scrollable) for all three mobile-row variants below - -// factored out as a plain string rather than a fourth wrapper component, since each variant's -// own base (column-stack vs. already-a-row vs. Bootstrap grid) differs too much to share a -// single styled-component base beyond this. -const mobileScrollbarCSS = ` - scrollbar-width: thin; - scrollbar-color: rgba(255, 255, 255, 0.25) transparent; - - &::-webkit-scrollbar { - height: 8px; +const ShapePill = styled.span` + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 3px 10px; + border-radius: var(--r-pill); + + &.easy { + color: var(--btn-ink); + background: var(--success); } - &::-webkit-scrollbar-track { - background: transparent; + + &.pick { + color: var(--btn-ink); + background: var(--accent); + } + + &.neg { + color: var(--btn-ink); + background: var(--danger); } - &::-webkit-scrollbar-thumb { - background-color: rgba(255, 255, 255, 0.25); - border-radius: 4px; + + &.hard { + color: var(--accent); + background: transparent; + border: 1px dashed var(--accent); } `; -// Level 1's YES/NOT SURE/NO/SKIP - a plain vertical button stack at every width today (matches -// Bootstrap's own `d-flex flex-column gap-2` exactly at >= md, so desktop is byte-for-byte -// unaffected), switching to a horizontal nowrap row below md instead of stacking full-width -// beside the now-narrow card column. -const MobileButtonRow = styled.div` - display: flex; - flex-direction: column; - gap: 0.5rem; +const QHint = styled.p` + font-size: 13px; + color: var(--muted); + margin: -6px 0 12px; +`; - @media (max-width: 767.98px) { - flex-direction: row; - flex-wrap: nowrap; - align-items: stretch; - overflow-x: auto; - padding-bottom: 0.5rem; - ${mobileScrollbarCSS} +// The spec's `.btn` base + variants (section 1c) - min 44px thumb targets (mobile funnel +// pass, WCAG 2.5.5/Apple HIG), replacing the old `ThumbButton`/`FilterToggleButton` gold +// overrides with plain token-derived variants. A native