Skip to content

Commit 3fb4570

Browse files
Rework layout per review: starburst anchored right, card centered in chip ring
Card panel + candidate grid columns swapped back (candidates left, starburst anchored right) and intro text right-aligned to match. The starburst is now contained to the card's own box (moved inside AttributeChipPanel's card slot, sized off CardArea instead of the whole ring+chips panel) rather than bleeding across the full page and covering the heading/candidate grid. AttributeChipPanel restructured to a CSS grid ring (standalone chips top, the two exclusion groups left/right, card dead center) instead of stacking chips above the card image. Also: tier 4 now prioritizes a card with one AI vote + one agreeing human vote (one vote from crossing PRINTING_TAG_MIN_VOTES=2) over a totally fresh zero-vote card - a small, concrete step toward "closest to resolving first" without building a full scoring system. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016i9S7LQsCL3FGaih3ZTRBJ
1 parent b04cc47 commit 3fb4570

7 files changed

Lines changed: 168 additions & 52 deletions

File tree

MPCAutofill/cardpicker/question_feed.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
from typing import Optional
1616

17+
from django.db.models import Count
18+
1719
from cardpicker.artist_consensus import get_contested_artist_card_ids
1820
from cardpicker.attribute_tags import ATTRIBUTE_CHIP_TAG_NAMES
1921
from cardpicker.models import (
@@ -168,11 +170,21 @@ def _tier_3_moderation(user: object) -> Optional[QuestionFeedItem]:
168170

169171

170172
def _tier_4_fresh(anonymous_id: str) -> Optional[QuestionFeedItem]:
173+
# A card with one AI-sourced vote plus one *agreeing* human vote (weight 1.5 at default
174+
# settings - still short of PRINTING_TAG_MIN_VOTES=2) is exactly as close to resolving as
175+
# a card can get without being resolved outright, yet it's excluded from tier 1 (any human
176+
# vote moves a card out of tier 1's "AI-only" pool) and isn't contested (agreeing votes,
177+
# not conflicting, so tier 2's contested check doesn't catch it either) - it lands here,
178+
# in tier 4, with zero votes and 28,112 genuinely-untouched cards. `-vote_count` surfaces
179+
# these "one vote from resolving" cards first within this tier, a small, concrete answer
180+
# to "prioritize whichever question is closest to actually resolving" without building a
181+
# full scoring system (out of scope - see this module's docstring).
171182
printing_card = (
172183
Card.objects.filter(printing_tag_status=PrintingTagStatus.UNRESOLVED)
173184
.exclude(pk__in=get_contested_card_ids())
174185
.exclude(printing_tags__anonymous_id=anonymous_id)
175-
.order_by("-date_created")
186+
.annotate(vote_count=Count("printing_tags", distinct=True))
187+
.order_by("-vote_count", "-date_created")
176188
.first()
177189
)
178190
if printing_card is not None:

MPCAutofill/cardpicker/tests/test_question_feed.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,23 @@ def test_tier_4_fresh_unresolved_printing_when_nothing_higher_priority_exists(se
125125
assert item.type.value == "identify_printing"
126126
assert item.card.identifier == card.identifier
127127

128+
def test_tier_4_prioritizes_a_card_one_vote_from_resolving_over_a_totally_fresh_one(self, db):
129+
# zero votes at all - the common case, 28k+ of these exist at once
130+
CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED)
131+
# one AI vote + one *agreeing* human vote (weight 1.5 < PRINTING_TAG_MIN_VOTES=2, so
132+
# not yet resolved) - excluded from tier 1 (has a human vote) and not contested
133+
# (agreeing, not conflicting), so it falls through to tier 4 same as a fresh card,
134+
# but is one vote closer to actually resolving than one with zero votes.
135+
almost_resolved = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED)
136+
printing = CanonicalCardFactory()
137+
CardPrintingTagFactory(card=almost_resolved, printing=printing, source=VoteSource.AI)
138+
CardPrintingTagFactory(card=almost_resolved, printing=printing, source=VoteSource.USER)
139+
140+
item = get_next_question_feed_item("anon-1", AnonymousUser())
141+
142+
assert item is not None
143+
assert item.card.identifier == almost_resolved.identifier
144+
128145
def test_tier_4_artist_when_no_printing_candidates_remain(self, db):
129146
card = CardFactory(
130147
printing_tag_status=PrintingTagStatus.RESOLVED, artist_vote_status=ArtistVoteStatus.UNRESOLVED

frontend/src/features/attributeChips/AttributeChipPanel.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ function Wrapper({ onSubmitted }: { onSubmitted?: (tagName: string) => void }) {
2525
tagConfidence={{}}
2626
chipStates={states}
2727
onChipStatesChange={setStates}
28+
cardSlot={<div data-testid="card-slot-stub">card</div>}
2829
/>
2930
</Provider>
3031
);

frontend/src/features/attributeChips/AttributeChipPanel.tsx

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,52 @@ const ChipRow = styled.div`
6262
justify-content: center;
6363
`;
6464

65+
const ChipColumn = styled.div`
66+
display: flex;
67+
flex-direction: column;
68+
gap: 0.4rem;
69+
align-items: stretch;
70+
`;
71+
72+
// A 3x3 grid with the card slot dead center and chips forming a ring around it - "top" holds
73+
// the standalone toggles, "left"/"right" hold the two exclusion groups (arbitrarily assigned;
74+
// nothing about a group is inherently left- or right-handed). Empty grid-template-columns
75+
// cells (corners, bottom) collapse via `auto` sizing rather than reserving dead space.
76+
const ChipRing = styled.div`
77+
display: grid;
78+
grid-template-areas:
79+
". top ."
80+
"left card right"
81+
". . .";
82+
grid-template-columns: auto minmax(0, 1fr) auto;
83+
grid-template-rows: auto auto auto;
84+
gap: 0.6rem;
85+
align-items: center;
86+
justify-items: center;
87+
`;
88+
89+
const TopArea = styled(ChipRow)`
90+
grid-area: top;
91+
`;
92+
93+
const LeftArea = styled(ChipColumn)`
94+
grid-area: left;
95+
`;
96+
97+
const RightArea = styled(ChipColumn)`
98+
grid-area: right;
99+
`;
100+
101+
// position: relative so an absolutely-positioned burst rendered as part of `cardSlot` (see
102+
// QuestionFeed.tsx) sizes and centers itself against the card's own box specifically, not
103+
// this whole ring (which includes the flanking chip columns and would make the burst far
104+
// larger, and off-center, than intended - see docs/features/printing-tags.md's Stage 7).
105+
const CardArea = styled.div`
106+
grid-area: card;
107+
width: 100%;
108+
position: relative;
109+
`;
110+
65111
interface AttributeChipPanelProps {
66112
backendURL: string;
67113
cardIdentifier: string;
@@ -71,6 +117,10 @@ interface AttributeChipPanelProps {
71117
* filtering (QuestionFeed.tsx) needs to read the same state. */
72118
chipStates: Record<string, ChipVoteState>;
73119
onChipStatesChange: (next: Record<string, ChipVoteState>) => void;
120+
/** The card image/reveal-overlay/caption, rendered dead center with chips forming a ring
121+
* around it - passed in rather than owned here so QuestionFeed.tsx keeps sole ownership of
122+
* the reveal-animation state machine (revealed/onAnimationEnd) that slot's contents depend on. */
123+
cardSlot: React.ReactNode;
74124
}
75125

76126
export function AttributeChipPanel({
@@ -79,6 +129,7 @@ export function AttributeChipPanel({
79129
tagConfidence,
80130
chipStates,
81131
onChipStatesChange,
132+
cardSlot,
82133
}: AttributeChipPanelProps) {
83134
const dispatch = useAppDispatch();
84135
const getTagDisplayName = useTagDisplayName();
@@ -176,17 +227,27 @@ export function AttributeChipPanel({
176227
);
177228
};
178229

230+
// EXCLUSION_GROUPS[0] (Border Color) renders left, [1] (Frame Style) renders right - an
231+
// arbitrary but fixed assignment, not a semantic left/right meaning for either group.
232+
const [leftGroup, rightGroup] = EXCLUSION_GROUPS;
233+
179234
return (
180-
<div data-testid="attribute-chip-panel">
181-
<ChipRow className="mb-2">
235+
<ChipRing data-testid="attribute-chip-panel">
236+
<TopArea>
182237
{STANDALONE_CHIPS.map((chip) => renderChip(chip.tagName, chip.label))}
183-
</ChipRow>
184-
{EXCLUSION_GROUPS.map((group) => (
185-
<ChipRow key={group.id} className="mb-2">
186-
{group.chips.map((chip) => renderChip(chip.tagName, chip.label))}
187-
</ChipRow>
188-
))}
189-
</div>
238+
</TopArea>
239+
{leftGroup != null && (
240+
<LeftArea>
241+
{leftGroup.chips.map((chip) => renderChip(chip.tagName, chip.label))}
242+
</LeftArea>
243+
)}
244+
<CardArea>{cardSlot}</CardArea>
245+
{rightGroup != null && (
246+
<RightArea>
247+
{rightGroup.chips.map((chip) => renderChip(chip.tagName, chip.label))}
248+
</RightArea>
249+
)}
250+
</ChipRing>
190251
);
191252
}
192253

frontend/src/features/printingTags/cardPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export const BurstSvg = styled.svg`
129129
position: absolute;
130130
top: 50%;
131131
left: 50%;
132-
width: 340%;
132+
width: 140%;
133133
aspect-ratio: 1;
134134
transform: translate(-50%, -50%);
135135
z-index: -1;

frontend/src/features/questionFeed/QuestionFeed.tsx

Lines changed: 50 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -227,11 +227,14 @@ export function QuestionFeed() {
227227
const hiddenCount = allCandidates.length - visibleCandidates.length;
228228
const noMatchDisabled = !hasAnyExplicitChip(chipStates);
229229

230-
const cardPanel = (
231-
<CardPanel
232-
ref={cardPanelRef}
233-
style={stickyTop != null ? { top: stickyTop } : undefined}
234-
>
230+
// BurstSvg renders alongside (not inside) RevealWrapper deliberately - RevealWrapper has
231+
// overflow: hidden (it clips the silhouette-reveal animation to the card's own box), which
232+
// would also clip the burst's intentional bleed if it were a descendant instead of a
233+
// sibling. Both size themselves against whichever positioned ancestor contains them -
234+
// AttributeChipPanel's CardArea now, so the burst centers on and scales with the card's own
235+
// rendered width specifically, not the wider ring (card + flanking chip columns) around it.
236+
const cardImage = (
237+
<>
235238
<BurstSvg viewBox={STARBURST_VIEWBOX}>
236239
<polygon
237240
points={STARBURST_OUTER_FRAMES[starburstFrame]}
@@ -242,17 +245,6 @@ export function QuestionFeed() {
242245
fill={STARBURST_INNER_COLOR}
243246
/>
244247
</BurstSvg>
245-
{isCandidateType && (
246-
<div className="mb-2">
247-
<AttributeChipPanel
248-
backendURL={backendURL}
249-
cardIdentifier={item.card.identifier}
250-
tagConfidence={item.tagConfidence ?? {}}
251-
chipStates={chipStates}
252-
onChipStatesChange={setChipStates}
253-
/>
254-
</div>
255-
)}
256248
<RevealWrapper>
257249
<img
258250
src={item.card.mediumThumbnailUrl}
@@ -269,6 +261,30 @@ export function QuestionFeed() {
269261
)}
270262
</RevealWrapper>
271263
<div className="text-center mt-1">{item.card.name}</div>
264+
</>
265+
);
266+
267+
// The card renders dead center with chips forming a ring around it (AttributeChipPanel's
268+
// ChipRing grid) rather than stacked above it - the starburst behind the whole assembly is
269+
// purely decorative (pointer-events: none throughout), so it never competes with any of
270+
// this for clicks regardless of how it visually bleeds.
271+
const cardPanel = (
272+
<CardPanel
273+
ref={cardPanelRef}
274+
style={stickyTop != null ? { top: stickyTop } : undefined}
275+
>
276+
{/* cardPanel is only ever rendered from the isCandidateType branch below - the
277+
artist/tag/moderation branch renders its own plain image directly, uninvolved with
278+
chips or the starburst. BurstSvg now lives inside `cardImage` itself (see above),
279+
not here, so it sizes against the card's own box rather than this whole ring. */}
280+
<AttributeChipPanel
281+
backendURL={backendURL}
282+
cardIdentifier={item.card.identifier}
283+
tagConfidence={item.tagConfidence ?? {}}
284+
chipStates={chipStates}
285+
onChipStatesChange={setChipStates}
286+
cardSlot={cardImage}
287+
/>
272288
</CardPanel>
273289
);
274290

@@ -287,16 +303,7 @@ export function QuestionFeed() {
287303
<Row className="g-4">
288304
{isCandidateType ? (
289305
<>
290-
{/* position + a non-auto z-index together give this column its own local
291-
stacking context, containing CardPanel's z-index: -1 (see cardPanel.tsx) so
292-
it can't escape and render the whole panel - chips included - unclickable
293-
behind this sibling column at the hit-testing layer. position: relative
294-
alone does NOT establish a stacking context - see
295-
docs/features/printing-tags.md's Stage 7 section for the full story. */}
296-
<Col xs={12} md={5} style={{ position: "relative", zIndex: 0 }}>
297-
{cardPanel}
298-
</Col>
299-
<Col xs={12} md={7}>
306+
<Col xs={12} md={5}>
300307
{!revealed ? (
301308
<div className="text-center py-4">
302309
<Spinner size={2} />
@@ -438,17 +445,18 @@ export function QuestionFeed() {
438445
</>
439446
)}
440447
</Col>
448+
{/* position + a non-auto z-index together give this column its own local
449+
stacking context, containing CardPanel's z-index: -1 (see cardPanel.tsx) so
450+
it can't escape and render the whole panel - chips included - unclickable
451+
behind this sibling column at the hit-testing layer. position: relative
452+
alone does NOT establish a stacking context - see
453+
docs/features/printing-tags.md's Stage 7 section for the full story. */}
454+
<Col xs={12} md={7} style={{ position: "relative", zIndex: 0 }}>
455+
{cardPanel}
456+
</Col>
441457
</>
442458
) : (
443459
<>
444-
<Col xs={12} md={5}>
445-
<img
446-
src={item.card.mediumThumbnailUrl}
447-
alt={item.card.name}
448-
style={{ width: "100%" }}
449-
/>
450-
<div className="text-center mt-1">{item.card.name}</div>
451-
</Col>
452460
<Col xs={12} md={7}>
453461
{item.type === "artist" && (
454462
<>
@@ -510,6 +518,14 @@ export function QuestionFeed() {
510518
</>
511519
)}
512520
</Col>
521+
<Col xs={12} md={5}>
522+
<img
523+
src={item.card.mediumThumbnailUrl}
524+
alt={item.card.name}
525+
style={{ width: "100%" }}
526+
/>
527+
<div className="text-center mt-1">{item.card.name}</div>
528+
</Col>
513529
</>
514530
)}
515531
</Row>

frontend/src/pages/whatsthat.tsx

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,20 +62,29 @@ const StarburstContent = styled.div`
6262
padding: 0 1.5rem;
6363
`;
6464

65+
// The starburst+card assembly anchors to the right of the page (see QuestionFeed.tsx's
66+
// column order), so the intro copy above it reads right-to-left too, keeping the whole
67+
// header visually aligned with what sits below it rather than starting from the opposite edge.
68+
const IntroText = styled.div`
69+
text-align: right;
70+
`;
71+
6572
function PrintingQueueOrDefault() {
6673
const remoteBackendConfigured = useRemoteBackendConfigured();
6774

6875
return remoteBackendConfigured ? (
6976
<>
7077
<StarburstBackground>
7178
<StarburstContent>
72-
<h1>What&apos;s That Card?</h1>
73-
<p>
74-
Test your Magic: the Gathering knowledge! One card at a time, help
75-
identify which real-world printing, artist, or descriptor tag each
76-
card image depicts - contested and AI-suggested cards come first,
77-
since they need your eyes the most.
78-
</p>
79+
<IntroText>
80+
<h1>What&apos;s That Card?</h1>
81+
<p>
82+
Test your Magic: the Gathering knowledge! One card at a time, help
83+
identify which real-world printing, artist, or descriptor tag each
84+
card image depicts - contested and AI-suggested cards come first,
85+
since they need your eyes the most.
86+
</p>
87+
</IntroText>
7988
<AuthWidget />
8089
<QuestionFeed />
8190
</StarburstContent>

0 commit comments

Comments
 (0)