Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions docs/features/printing-tags.md
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,98 @@ printings, artists, tags, and moderation from one screen.
mockup's own stage-switcher demo); artist/tag items' plain reference
`<img>` (no burst/reveal — "reposition, don't redesign") just moved
into the same shared `card` grid area instead of its own `Col`.
- **Fix round (owner live blocker, post-#310): word-stack sizing +
animation choreography sync**. Two independent owner-reported bugs
found on the live site after #310 landed:
- **Question box too small for its content**: the word stack
(`Word` in `WhatsThatWords.tsx`) rendered at a fixed `3.75rem`/
`4.5rem` per word (~220px total for all three, measured) — about
1.4x `wtc-mockup.html`'s own approved proportion (164px, measured
directly off that file with its demo-only scale transform
removed) — and since #310 bounded the whole hero to one viewport-
height row, every extra pixel the words claimed came straight out
of `HeroQuestionsArea`'s own budget (`HeroGrid`'s `auto` row sizes
to the words' content height, subtracting directly from the
`questions` row's `1fr` share). At 1400×900 this left even Level 1
(suggested-match card + all four answer controls, no candidate
grid to scroll) short by ~140px, forcing an internal scroll that
clipped the card mid-view. Fixed by shrinking `Word`'s height to a
`clamp()` of the viewport height (not a flat rem guess, so it
can't silently regress on a shorter viewport than was checked) —
deliberately smaller than even the mockup's own absolute number,
since the mockup was never height-constrained the way the pinned
hero now is. `HeroGrid`'s row-gap and `StarburstBackground`'s own
padding/margin (whatsthat.tsx) were also trimmed at `md`+ (never
approved content, pure chrome spacing that was also coming
straight out of the same budget), and the Level 1 reference
thumbnail's `maxWidth` was cut as a smaller, separately-named
lever once the above alone still left only a single-digit-px
margin. All of this was re-measured and re-tuned again on rebase
onto #313's three-tier `Footer` redesign, which is substantially
taller than the single-tier footer this fix's own first pass was
built against and ate further into the same budget — see
`WhatsThatWords.tsx`'s `Word` component and `QuestionFeed.tsx`'s
`HeroGrid`/reference-thumbnail comments for the exact before/after
numbers at each pass. New hard regression guard:
`QuestionFeedResponsive.spec.ts` asserts at 1400×900 that Level
1's `HeroQuestionsArea` never overflows (`scrollHeight <= clientHeight`) and all four answer controls are fully within the
viewport, not merely `toBeVisible()` (which only requires a
non-zero intersection, not full containment) — confirmed to fail
on the pre-fix code with the exact expected numbers before the fix
landed.
- **Pulse/pop desynced from a still-loading card**: the reveal
fade (`RevealOverlay`), the word-pop sequence
(`WhatsThatWords`), and the hero card pulse
(`CardPulseWrapper`) all used to start counting the moment their
own elements mounted, independent of whether the subject card's
`<img>` had actually finished loading — on a slow connection this
could reveal, pop, or pulse against a still-loading or half-
painted image. Owner's redesign of the choreography: the card
slot shows the blue cover state and holds it until the image's
`load` event fires, then the entire sequence (cover fade off +
word pops + card pulse, all still frame-for-frame in sync with
each other) runs as one queue anchored to that single moment.
Implemented via a shared `imageLoaded` boolean
(`QuestionFeed.tsx`) threaded into each of the three animated
components' own `$playing`/`playing` prop, which gates each
one's CSS `animation-play-state` (`paused` until told otherwise,
`running` once `imageLoaded` flips) — the timeline, delay
included, doesn't advance at all while paused, so flipping all
three at once genuinely starts them in lockstep rather than
merely un-pausing three independently-drifted clocks. A failed
load (`onError`, non-empty configured URL) keeps the cover up
permanently with no animation at all (no legitimate "reveal"
moment to sync to), while `revealed` still flips true so the rest
of the question UI isn't stranded behind it; reduced motion skips
the whole animated queue and jumps straight to `revealed` on
load, matching the owner's "swap to the image without pops,
immediately on load" instruction. A genuinely empty configured
URL (this test suite's own fixture convention — real cards always
carry a real CDN URL) is treated as trivially settled rather than
a failure, resolved synchronously in the fetch handler rather
than waiting on any browser event — an `<img src="">` resolves
its empty `src` against the _current page's own URL_ (confirmed
empirically, the URL spec's own "empty string" case) and
predictably fails to decode that as an image, so relying on the
resulting real `onError` event as the _only_ settle signal for
this specific case proved flaky under dev-server load.
**Real bug found and fixed during this pass, not just the
intended one**: the settle logic's own catch-up effect was
initially keyed on the card's identifier, which can legitimately
repeat between two consecutive feed items (documented already,
for different state, in the fetch handler's own comment) — a
repeat resolution resets `imageLoaded`/`revealed` unconditionally
but doesn't change the identifier, so an identifier-keyed effect
silently skips re-running and permanently strands the UI on
"Loading...". Fixed by keying that effect on a generation counter
bumped unconditionally in the same reset block instead — see
`docs/troubleshooting.md`'s dedicated entry for the full
symptom/cause/fix writeup. New regression coverage:
`WhatsThatWordsAnimation.spec.ts` gained a test that holds a real
(route-intercepted) image response and asserts every one of the
three animations is genuinely `animation-play-state: paused`
before the response resolves and `running` once released —
confirmed to fail on the pre-fix code.
- Regression coverage: `QuestionFeedResponsive.spec.ts`'s desktop-axis
test now asserts card-left-of-questions (was candidates-left-of-card
pre-#305); `WhatsThatWordsAnimation.spec.ts` (new) asserts computed
Expand Down
71 changes: 71 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,37 @@ server. If the isolated run passes, restart the main session's dev server
trusting further results from the stale one. Don't spend time root-causing
the exact HMR/cache mechanism unless it recurs after this fix.

**Addendum (2026-07-22, /whatsthat animation-sync fix)**: this exact
symptom — `revealed` (backing `question-feed`'s loading spinner) stuck
`false` forever, `<img src="">` never settling — recurred on a
genuinely fresh, isolated dev server (a brand-new worktree, freshly
killed and restarted, confirmed via `ps`/`readlink -f /proc/<pid>/cwd`
that no other session's server was reused), so it was **not** always the
stale-server artifact described above. The real cause that time: a
`useEffect` keyed on `item?.card.identifier` doing settle/gate logic
whose _reset_ lived unconditionally in the fetch handler
(`setRevealed(false)` etc. on every resolution, not just ones landing on
a genuinely new identifier). Two consecutive feed items can legitimately
share an
identifier (the existing fetch-handler comment already documents this
for `chipStates`), and dev-mode React Strict Mode's double effect
invocation makes a duplicate resolution routine even outside that case
— when it happens, the reset fires again but the identifier-keyed catch-
up effect has no dependency change to re-trigger it on, permanently
stranding the reset state. **Fix**: don't key a catch-up/settle effect on
a value that can legitimately repeat between consecutive items — key it
on a counter bumped unconditionally in the same reset block instead
(`imageGeneration` in `QuestionFeed.tsx`), so the effect re-runs every
time the reset does, with no dependency on whether the identifier text
itself changed. **Distinguishing the two causes**: the stale-server
version reproduces identically regardless of source code (swapping
branches on the _same_ long-running server fails the same way); this
version reproduces intermittently even on a fresh server and stops
reproducing (verified via a 15-iteration `--workers=1` loop with zero
source edits mid-run) once the generation-counter fix lands — a single
clean pass proves nothing for an intermittent race like this, only a
multi-iteration loop does.

## `npx prettier --write` reformats far more of a frontend file than you touched (trailing commas, wrapped ternaries appearing everywhere)

**Symptom**: running `npx prettier --write` on a file you made one small,
Expand Down Expand Up @@ -1131,6 +1162,46 @@ the flex-column wrapper (testing the single SVG alone) fixed it, which is
what pointed at the flex cross-axis stretch specifically rather than the
SVG/viewBox mechanics themselves.

## `/display`'s floating "n/M" sheet-position pill under-reports the last sheet (shows `2/3` instead of `3/3`) after scrolling all the way down

**Symptom**: `DisplayPage.spec.ts`'s D17 test ("the floating sheet-position
pill updates live while scrolling at phone width") fails with
`getByTestId('display-sheet-position-indicator')` stuck at `"2/3"` after
scrolling the last sheet into view via `scrollIntoView({ block: "center" })` at 390px wide — reproduces 100% of the time in isolation, both
locally and in CI (not the intermittent kind), even though the same test
was merely marked "flaky" (2 real failures then a lucky pass) in the PR
that introduced it (#313).

**Cause**: the indicator's `IntersectionObserver` (`DisplayPage.tsx`,
`visibleSheetIndex`) decides "which sheet is current" purely by a thin
vertical center band (`rootMargin: "-45% 0px -45% 0px"`) against the
page viewport. That heuristic structurally can't ever select the FIRST
or LAST sheet once the scrollable `content-container` (Layout.tsx) is
already at its true scroll extreme and a boundary sheet is short enough
that there's no room left below/above it to move its own center through
that band — confirmed by measuring the container directly:
`scrollHeight - clientHeight` (the real max scroll) was ~250px, but
centering the last (short) sheet would have needed roughly 500px+ of
scroll, a shortfall of 250-280px that no amount of further scrolling can
close, because the container is already at its true bottom. This isn't
specific to any one card count or viewport — it's inherent to a fixed
center-band test whenever a boundary sheet is short relative to the
viewport, and is unrelated to Footer/Navbar sizing (`/display` doesn't
even render a `Footer`).

**Fix**: inside the same `IntersectionObserver` callback, read the
scroll container's own `scrollTop`/`scrollHeight`/`clientHeight`
(via `entries[0].target.closest('[data-testid="content-container"]')`)
and check the true scroll extremes FIRST — at `scrollTop <= EPSILON`,
force index `0`; at `scrollTop + clientHeight >= scrollHeight - EPSILON`,
force the last index — falling through to the existing center-band
`Math.min(...intersectingIndices)` logic only when not at an edge. Kept
as a single writer to `visibleSheetIndex` (not a second `scroll`
listener racing the observer) since the observer already re-fires on the
same settling scroll event that changes intersection state. Verified
5/5 clean repeats of the D17 test plus the full 28-test `DisplayPage.spec.ts`
suite and CI's own shard-2/4 (76 tests) locally.

## `run_image_evidence_cohort` (Stage C) parent process's RSS climbs unboundedly and OOMs the whole box on a long run

**Symptom**: a long (tens-of-thousands-of-cards) `run_image_evidence_cohort`
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/features/display/DisplayPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1192,8 +1192,34 @@ export function DisplayPage() {
if (indexByElement.size === 0) {
return;
}
const lastIndex = sheets.length - 1;
// D17 follow-up (docs/troubleshooting.md "sheet-position pill under-reports the last
// sheet") - the centre-band check below can structurally never see the FIRST or LAST
// sheet as "current": once the container is scrolled to its true extreme, there's no
// room left to move a short boundary sheet any further through the centre band (measured
// directly - at phone width with a short trailing sheet, the container's own maxScroll
// can be hundreds of px short of what centring that sheet would require). This isn't tied
// to any one card count or viewport; it's inherent to a fixed centre-band test on a
// boundary item. So check the real scroll position FIRST, ahead of the centre-band
// result, on every firing of this same observer - at either true edge of the scrollable
// container, the boundary sheet IS the one on-screen, full stop.
const observer = new IntersectionObserver(
(entries) => {
const scrollContainer = entries[0]?.target.closest<HTMLElement>(
'[data-testid="content-container"]'
);
if (scrollContainer != null) {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const EPSILON = 2;
if (scrollTop <= EPSILON) {
setVisibleSheetIndex(0);
return;
}
if (scrollTop + clientHeight >= scrollHeight - EPSILON) {
setVisibleSheetIndex(lastIndex);
return;
}
}
const intersectingIndices = entries
.filter((entry) => entry.isIntersecting)
.map((entry) => indexByElement.get(entry.target))
Expand Down
27 changes: 25 additions & 2 deletions frontend/src/features/printingTags/cardPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,20 @@ export const RevealWrapper = styled.div`
// candidate grid's own "?" placeholders instead of a mismatched black flash. Black text
// (matching the page-wide font colour) checked against this blue: contrast ratio ~6.2:1,
// clearly better than the white it replaced (~3.4:1).
export const RevealOverlay = styled.div`
//
// Fix round (owner blocker, "the pulse doesn't sync with the pop") - this used to fade on a
// fixed 1.8s timer starting the moment it mounted, with no regard for whether the card image
// underneath had actually finished loading - a slow network could reveal a still-loading (or
// half-painted) image right as WhatsThatWords/CardPulseWrapper's own pops fired, breaking the
// "one queued moment" the owner asked for. `$playing` (paused by default, same
// `animation-play-state` mechanism as Word in WhatsThatWords.tsx) holds this at its own 0%
// frame - fully opaque, i.e. visually identical to today's pre-fade hold - until
// QuestionFeed.tsx confirms the image has settled, so the fade (and therefore the
// `onAnimationEnd`-driven `revealed` flip) can never start before the image is actually there
// to reveal. QuestionFeed.tsx never sets `$playing` at all for a failed load (see its own
// comment) - the cover simply stays at this 0% frame indefinitely instead of fading onto a
// broken image, which is the "failed treatment" the owner asked for.
export const RevealOverlay = styled.div<{ $playing: boolean }>`
position: absolute;
inset: 0;
background: ${STARBURST_OUTER_COLOR};
Expand All @@ -50,6 +63,7 @@ export const RevealOverlay = styled.div`
font-size: 4rem;
font-weight: bold;
animation: ${revealAnimation} 1.8s ease-in forwards;
animation-play-state: ${(props) => (props.$playing ? "running" : "paused")};
pointer-events: none;
`;

Expand Down Expand Up @@ -131,18 +145,27 @@ export const BurstSvg = styled.svg<{ $hero?: boolean }>`
// 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 RevealOverlay 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); }
`;

export const CardPulseWrapper = styled.div`
export const CardPulseWrapper = styled.div<{ $playing: boolean }>`
transform-origin: center;
width: 100%;
max-width: 320px;
animation: ${wtcCardPulse} 480ms cubic-bezier(0.34, 1.45, 0.64, 1) both;
animation-delay: 240ms;
animation-play-state: ${(props) => (props.$playing ? "running" : "paused")};

@media (prefers-reduced-motion: reduce) {
animation: none;
Expand Down
Loading
Loading