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
124 changes: 96 additions & 28 deletions docs/features/printing-tags.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,13 @@ printings, artists, tags, and moderation from one screen.
defaults to `UNRESOLVED` on both `printing_tag_status` and
`artist_vote_status` simultaneously, which is why a flat sum of the
three category counts (the pre-fix implementation) over-counted every
such card 2-3x. `QuestionFeed.tsx`'s headline leads with `confirmable`
("N quick confirmations ready") when non-zero, falling back to `total`
once nothing quick remains.
such card 2-3x. `QuestionFeed.tsx` surfaces all three via a single small,
muted stats line ("N ready · N in catalog · N contested",
`question-feed-stats`) tucked at the bottom of the question column - a
fix round on the quiz-reveal hero (PR #305/#308's owner review) retired
the old standalone headline/subcounts text that used to sit above the
question and eat into the vertical space needed to keep the answer
buttons above the fold.

## Frontend architecture

Expand Down Expand Up @@ -578,17 +582,22 @@ printings, artists, tags, and moderation from one screen.
- **Palette** (W6/W7): the page's old `#ff4719` orange full-bleed field
— kept as "the page's deliberate identity" by a 2026-07-18 decision
— is **superseded**: `StarburstBackground` (`whatsthat.tsx`) is now a
deep-blue radial vignette (`#1a4f8a` → `#123a6b` → body `#0f2537`),
reconciling with issue #302's sitewide orange retheme instead of
clashing with it (two similar oranges were _less_ distinguishable
than the old blue-on-orange pairing this page shipped with
originally). The page-scoped `ACCENT_NAVY` override (buttons/links/
pills recolored so they'd clear AA against the orange field) is
deep-blue field, reconciling with issue #302's sitewide orange retheme
instead of clashing with it (two similar oranges were _less_
distinguishable than the old blue-on-orange pairing this page shipped
with originally). The page-scoped `ACCENT_NAVY` override (buttons/
links/pills recolored so they'd clear AA against the orange field) is
removed entirely — off that field and onto the standard dark body,
the sitewide accent `#df6919` already clears AA (4.61:1) with no
override needed. The starburst's own two identity colors (outer blue
`#4d8ddf`, inner white `#ffffff`, `starburstShape.ts`) are unchanged —
only the field _behind_ it moved.
only the field _behind_ it moved. A fix round on the owner's live
review (PR #305/#308) flattened the original three-stop radial
(`#1a4f8a` → `#123a6b` → body `#0f2537`, a pronounced vignette that
"felt unnatural") to a two-stop `#1d4d82` → `#123a6b` gradient with no
third, darker stop at all — a small highlight around the starburst
that settles into a flat deep blue well before the edges, instead of
fading further toward near-black.
- **Enlarged hero starburst**: `BurstSvg` (`cardPanel.tsx`) gained an
additive, default-off `$hero` prop (`width: 230%` of the card box vs.
the existing `55%`, at `md`+) so it dominates the hero's left column
Expand All @@ -603,24 +612,83 @@ printings, artists, tags, and moderation from one screen.
field, a real legibility problem the desktop case doesn't have.
- **Reference-card pinning** (owner addendum): the card must stay fully
visible while the user works through the questions. At `md`+, the
whole `HeroGrid` is bounded to one viewport-height row
(`calc(100dvh - NavbarHeight - 2rem)`) and only `HeroQuestionsArea`
scrolls internally (`overflow-y: auto`, a subtle themed scrollbar via
`scrollbar-color`/`::-webkit-scrollbar-thumb`, not default browser
chrome) — the card's own grid cell never scrolls, so the old sticky-
plus-negative-z-index mechanism (see the superseded `cardPanel.tsx`
bullet above) has nothing left to do and was removed. Below `md`
(where a bounded-height scroll box would read as a cramped cage on a
small screen), `HeroCardArea` instead becomes a `position: sticky`
compact bar (shrunk via `max-width`, same `cardNode` markup, not a
separate rendering) that pins to the viewport top while the questions
scroll beneath it — the phone-shaped interpretation of the same
"keep the reference comparable" intent, not a literal port of the
desktop mechanism. Verified via `QuestionFeedResponsive.spec.ts`'s
scroll-then-reread-`boundingBox()` assertion (full equality, not just
visibility) on desktop; the phone interpretation was visually
verified via real Playwright screenshots (scrolled state), not
covered by its own dedicated assertion.
whole `HeroGrid` is bounded to one viewport-height row and only
`HeroQuestionsArea` scrolls internally (`overflow-y: auto`, a subtle
themed scrollbar via `scrollbar-color`/`::-webkit-scrollbar-thumb`,
not default browser chrome) — the card's own grid cell never scrolls,
so the old sticky-plus-negative-z-index mechanism (see the superseded
`cardPanel.tsx` bullet above) has nothing left to do and was removed.
Below `md` (where a bounded-height scroll box would read as a cramped
cage on a small screen), `HeroCardArea` instead becomes a
`position: sticky` compact bar (shrunk via `max-width`, same
`cardNode` markup, not a separate rendering) that pins to the
viewport top while the questions scroll beneath it — the phone-shaped
interpretation of the same "keep the reference comparable" intent,
not a literal port of the desktop mechanism. Verified via
`QuestionFeedResponsive.spec.ts`'s scroll-then-reread-`boundingBox()`
assertion (full equality, not just visibility) on desktop; the phone
interpretation was visually verified via real Playwright screenshots
(scrolled state), not covered by its own dedicated assertion.
- **Fix round (PR #305/#308 owner review)**: the original
`HeroGrid { max-height: calc(100dvh - NavbarHeight - 2rem) }`
passed CI but let the whole page scroll live — the flat `2rem`
guess never accounted for `StarburstBackground`'s real
padding/margin (4.5rem, not 2rem) or `Footer`'s entire height below
it, so total page content routinely exceeded
`Layout.tsx`'s `ContentContainer` and forced its own outer
scrollbar to activate, moving the "pinned" card along with
everything else. Replaced with pure flex sizing instead of a
hand-maintained calc: `whatsthat.tsx`'s `PageColumn` (flex column,
height locked to `calc(100dvh - navbarHeight)` at `md`+ via the new
`useNavbarHeight()` hook — see below) wraps `StarburstBackground` +
`Footer`, `StarburstBackground` takes `flex: 1 1 auto; min-height: 0` (whatever's left after `Footer`'s natural size), and
that flex chain propagates down through `StarburstContent` →
`QuestionFeed.tsx`'s own `FeedRoot` → `HeroGrid` (now
`flex: 1; min-height: 0` instead of its old `max-height` calc) — it
structurally can't drift out of sync with `Footer`'s real height
again. Deliberately NOT extended through the moderator
`Tab.Container`/`Tab.Content`/`Tab.Pane` switcher (a small,
privileged audience) — that branch keeps its previous natural/auto
height, unchanged, rather than wiring three more react-bootstrap
wrappers into the flex chain. `useNavbarHeight()`
(`frontend/src/common/useNavbarHeight.ts`) replaces the hardcoded
`NavbarHeight` constant (issue #250 — confirmed 64-88px real vs the
constant's 50px in some auth/nav-link states, see
`docs/troubleshooting.md`) with a `ResizeObserver`-measured value,
for `Layout.tsx`'s `ContentContainer` (sitewide — this is what was
hiding the first several px of top-of-page content, including this
page's hero title and `/display`'s own toolbar, behind the real
navbar in a taller-navbar state) and this page's `PageColumn`.
Scoped, not a blanket swap — every other `NavbarHeight` consumer
(`Explore.tsx`, `ProjectEditor.tsx`, `FinishedMyProject.tsx`) is
unchanged, and #250 stays open for that broader decision (the hook
also doesn't yet handle the navbar's own crowded-state wrapping to
a second, taller row — only the single-row height mismatch).
Strengthened `QuestionFeedResponsive.spec.ts`'s own pinning
assertion with a real `page.mouse.wheel()` scroll (not just
`el.scrollTop` on the inner questions box) plus a
`content-container` testid check — that gap (never exercising the
outer container) is exactly what let the original bug pass CI.
- **Hover-zoom/hover-burst edge clipping** (fix round, PR #305/#308
owner review): `ZoomableThumbnail`'s hover-zoom and `HoverBurst`'s
glow (`cardPanel.tsx`) were both deliberately built with no
`overflow: hidden` of their own so the enlarged art/glow could pop out
uncropped — the pinning fix above's `overflow-y: auto` on
`HeroQuestionsArea` forces `overflow-x: auto` too (CSS's own "visible
computes to auto once the other axis isn't visible" rule), silently
re-clipping both right at that box's left/right edges, worst on the
left where the first column in every row sits flush against it with
no buffer. Two-part fix: `HeroQuestionsArea` itself gets `margin: 0 -2.5rem` + matching `padding` (bleeds its own clip boundary 2.5rem
into the real empty space already there — the grid's own column gap
on the left, the page's outer margin on the right — with zero visible
resting-layout shift, verified via `boundingBox()` diff); `HoverBurst`
gained an additive `$edge` prop that shrinks its 331.2% bloom to 150%
for the first/last column specifically (`index % 4 === 0 || index % 4 === 3`, the two columns still short on room even with the added
bleed) — interior columns keep the full-size, unmodified glow.
Regression coverage: `QuestionFeedResponsive.spec.ts`'s new
horizontal-only containment check (vertical clipping at the top/
bottom of this box is its intended scroll behaviour; only left/right
clipping is the bug).
- **One hero card slot per item type, not per stage**: Level 3 dropped
its old inline 48px thumbnail+name row (the shared hero card already
shows the same art, one persistent slot across every stage per the
Expand Down
22 changes: 17 additions & 5 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -882,11 +882,23 @@ new always-visible link is needed — put it in the right-hand
icon button + the Sources button, with real spare width). This keeps the
navbar's real rendered height identical to `origin/master`'s (confirmed
via the same `boundingBox()` check) rather than papering over the
symptom with a longer Playwright timeout or a spec edit. The deeper fix
— replacing the hardcoded `NavbarHeight` constant with a real
`ResizeObserver`-driven measurement — is a legitimate follow-up (this
whole class of bug recurs the next time a link is added to that row) but
is out of scope for a single-link addition.
symptom with a longer Playwright timeout or a spec edit.

**Partial deeper fix landed** (fix round on PR #305/#308's `/whatsthat`
quiz-reveal hero, owner review) —
`frontend/src/common/useNavbarHeight.ts` replaces the constant with a
real `ResizeObserver`-measured value, but only for the two consumers a
live report actually confirmed broken: `Layout.tsx`'s `ContentContainer`
(sitewide — every page's own top-of-content offset) and
`/whatsthat`'s own `PageColumn` height calc. Every other consumer
(`Explore.tsx`, `ProjectEditor.tsx`, `FinishedMyProject.tsx`) still uses
the static constant directly — this issue stays open for that broader
swap. The hook also only measures the navbar's _current_ height; it
doesn't change anything about the underlying wrapping behaviour above
(a crowded, every-link-visible state can still jump from 64px to 88px
when one more link tips it to a second line) — the hook picks that jump
up correctly via its own `ResizeObserver`, but the wrapping itself is
still exactly the layout fragility this entry describes.

## A `reparse_collector_evidence`/Stage D retraction pass silently never routes its own newly-touched cards to slow-path review

Expand Down
72 changes: 72 additions & 0 deletions frontend/src/common/useNavbarHeight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { useEffect, useState } from "react";

import { NavbarHeight } from "@/common/constants";

/**
* Real, measured height of the fixed navbar (`nav.navbar`, rendered by Navbar.tsx), replacing
* the hardcoded `NavbarHeight` constant for consumers whose own positioning math breaks when the
* real navbar is taller than that guess (issue #250 - confirmed via
* `docs/troubleshooting.md`'s own `boundingBox()` measurement: 64px real vs the constant's 50px
* in the fully-authenticated, every-backend-feature-enabled state, and up to 88px once the
* crowded left-hand `Nav` wraps to a second line). `NavbarHeight` itself is left untouched here
* (still the SSR/pre-mount fallback below, and still what every OTHER heightDelta consumer -
* Explore.tsx, ProjectEditor.tsx, FinishedMyProject.tsx - uses directly) - this hook is an
* additive, opt-in replacement for the specific call sites confirmed broken by a real navbar/
* content collision (Layout.tsx's `ContentContainer`, QuestionFeed.tsx's hero grid), not a
* blanket swap-out of the constant everywhere; #250 stays open for that broader decision.
*
* A plain `ResizeObserver` on the actual DOM node (found by the same `nav.navbar` selector
* `docs/troubleshooting.md` already uses to confirm this bug) rather than global state - the
* navbar is a single, always-mounted element and every consumer just needs its current height,
* so there's no real state to coordinate between components. `Navbar.tsx` itself renders inside
* `DisableSSR` (client-only), so this hook has nothing to observe until after that first client
* paint - a `MutationObserver` on `document.body` catches the node appearing, then hands off to
* `ResizeObserver` for every height change after that (nav wrapping to a second line on
* window resize, or a conditionally-rendered link appearing once an async `whoami`/backend-
* config query resolves).
*/
export function useNavbarHeight(): number {
const [height, setHeight] = useState<number>(NavbarHeight);

useEffect(() => {
let resizeObserver: ResizeObserver | null = null;

const observeNavbar = (nav: Element) => {
resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry != null) {
setHeight(entry.contentRect.height);
}
});
resizeObserver.observe(nav);
// Capture the current height immediately too - ResizeObserver's callback fires async on
// the next frame, and we'd rather start with a real measurement than the fallback for
// however long that takes.
setHeight(nav.getBoundingClientRect().height);
};

const existingNavbar = document.querySelector("nav.navbar");
if (existingNavbar != null) {
observeNavbar(existingNavbar);
return () => resizeObserver?.disconnect();
}

// DisableSSR mounts the navbar a tick after this component's own first client render -
// watch for it to appear, then switch to ResizeObserver as above.
const mutationObserver = new MutationObserver(() => {
const nav = document.querySelector("nav.navbar");
if (nav != null) {
mutationObserver.disconnect();
observeNavbar(nav);
}
});
mutationObserver.observe(document.body, { childList: true, subtree: true });

return () => {
mutationObserver.disconnect();
resizeObserver?.disconnect();
};
}, []);

return height;
}
16 changes: 15 additions & 1 deletion frontend/src/features/printingTags/cardPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,17 @@ export const CandidateButton = styled(Button)`
// 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.
export const HoverBurst = styled.svg`
// `$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%;
Expand All @@ -330,4 +340,8 @@ export const HoverBurst = styled.svg`
transition: opacity 0.18s ease-out, transform 0.18s ease-out;
pointer-events: none;
z-index: -1;

@media (min-width: 768px) {
width: ${(props) => (props.$edge ? "150%" : "331.2%")};
}
`;
6 changes: 3 additions & 3 deletions frontend/src/features/questionFeed/QuestionFeed.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -473,9 +473,9 @@ describe("QuestionFeed", () => {
renderFeed();
await revealCard();

const headline = await screen.findByTestId("question-feed-headline");
expect(headline.textContent).toBe("Still need help with: 3 cards");
expect(headline.textContent).not.toMatch(/undefined/);
const stats = await screen.findByTestId("question-feed-stats");
expect(stats.textContent).toBe("0 ready · 3 in catalog · 0 contested");
expect(stats.textContent).not.toMatch(/undefined/);
});

it("shows the rate-limit banner (not a toast) when a printing vote is rejected with 429", async () => {
Expand Down
Loading
Loading