Skip to content
Merged
2 changes: 2 additions & 0 deletions .github/coverage-acks.txt
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,5 @@ coverage-ack: frontend/tests/QuestionFeed.spec.ts::question feed - confirm_sugge
coverage-ack: frontend/tests/QuestionFeed.spec.ts::question feed - confirm_suggestion question type > YES confirms the suggested printing directly, without visiting the grid — the fixed Level 1/2/3 ladder was removed by the #728 de-hardcoding; the grid now coexists with the suggestion on the same page
coverage-ack: frontend/tests/QuestionFeed.spec.ts::question feed - confirm_suggestion question type > NOT SURE drops to Level 2's candidate grid without casting a printing vote, but does POST an abstention — the fixed Level 1/2/3 ladder was removed by the #728 de-hardcoding; the grid now coexists with the suggestion on the same page
coverage-ack: frontend/tests/QuestionFeed.spec.ts::question feed - confirm_suggestion question type > NO drops to Level 2's candidate grid, excluding the rejected suggestion, without casting a vote — the fixed Level 1/2/3 ladder was removed by the #728 de-hardcoding; the grid now coexists with the suggestion on the same page
coverage-ack: frontend/tests/QuestionFeed.spec.ts::question feed - confirm_suggestion question type > NO on the suggestion collapses its slot (never a selectable tile again) and keeps the remaining candidates selectable on the same page, without casting a vote — restructured in WTC design intent repass (#704); slot collapse behavior updated
coverage-ack: frontend/tests/QuestionFeed.spec.ts::question feed - confirm_suggestion question type > NO on a singleton suggestion (no other candidates) skips the grid entirely and immediately casts the terminal no-match vote — restructured in WTC design intent repass (#704); slot collapse behavior updated
52 changes: 33 additions & 19 deletions docs/features/printing-tags.md
Original file line number Diff line number Diff line change
Expand Up @@ -908,8 +908,9 @@ for history (this doc's own established convention — see the `cardPanel.tsx` b
intentionally identical transitions — "an honest skip beats a coerced
guess"), but NO additionally records the rejected candidate's
identifier client-side (`rejectedCandidateIds` — never NOT SURE, which
is genuine uncertainty, not a rejection) so Level 2 excludes it — see
the no-re-presentation rule below. `identify_printing` items (and
is genuine uncertainty, not a rejection) so Level 2 retains it as a
de-emphasised, re-selectable tile — see the no-re-presentation rule
below. `identify_printing` items (and
`confirm_suggestion` items without a `suggestedPrinting`) skip Level 1
entirely.
- **Level 2** — the candidate grid. The attribute-chip ring is now an
Expand Down Expand Up @@ -989,23 +990,36 @@ for history (this doc's own established convention — see the `cardPanel.tsx` b
`RetractImplicitVoteRequest` types).
- **No-re-presentation rule** (owner-directed fix, was a real live bug:
Level 1 "Is it M21 203?" → NO → Level 2 grid containing only M21 203
again): within a single question item's flow, a candidate the user
has just rejected is never re-presented as a selectable answer at a
later level — each level's display set is candidates minus
already-rejected-this-item. Level 2's grid is computed from
`nonRejectedCandidates` (all candidates minus `rejectedCandidateIds`,
filtered _before_ the attribute-chip filter, so "N hidden by your
tags" doesn't conflate a rejection with a filter), and the singleton
case — rejecting the one and only candidate, or a rejection that
happens to empty the remaining set — skips the grid entirely: the
prompt swaps to a contextual "Got it — not that one. Is it any
official printing at all?" with the rejected candidate shown only as
grayed, non-interactive context (never a button), falling straight
through to the same classified-exit choice (None of these / custom
art / skip) that always rendered below the grid. `rejectedCandidateIds`
is per-item state, reset alongside every other per-question field in
the same fetch effect (see the module's own comment on why that reset
can't be a separate dependency-keyed effect).
again): within a single question item's flow, the suggested candidate
is asked about exactly once, in its own slot, and is never
re-presented as a grid tile while that slot is still asking —
`gridCandidates` keeps it out (`candidate.identifier !== suggestedCandidateId`), so the old asked-twice shape cannot recur.
A candidate the user has explicitly REJECTED at the suggestion slot
is the deliberate exception (issue #748): the slot collapses to a
contextual "Got it — not that one. Is it any official printing at
all?" plus a "You said: not M21 203" context line, and the rejected
candidate STAYS in the grid as a de-emphasised, fully re-selectable
tile — `data-rejected="true"` with a "you said no · tap to
reconsider" note — the recover path for a mis-tap, where tapping the
tile casts it as a real pick. `gridCandidates` is therefore every
candidate with `rejectedCandidateIds.has(id) || id !== suggestedCandidateId`: the rejected set is INCLUDED, not subtracted,
and the grid still runs through the attribute-chip filter separately
(a rejection is a `gridCandidates` decision, chip hiding a
`visibleCandidates` one), so "N hidden by your tags" never conflates
the two. A rejected candidate never joins an illustration cluster —
a cluster renders only one representative tile, which would silently
bury the reconsider path — so it always renders standalone as a
de-emphasised, ungrouped tile. The "none left" state
(`suggestionRejectedWithNoneLeft`) is decided by candidate count, not
grid count — no candidate OTHER than the rejected suggestion, since
the rejected one is now a grid member: in that state the grid is just
the single de-emphasised tile, the question was already resolved by
the terminal vote (next paragraph), and the filter panel and bottom
action row stay hidden while the reason strip carries the flow.
`rejectedCandidateIds` is per-item state, reset alongside every other
per-question field in the same fetch effect (see the module's own
comment on why that reset can't be a separate dependency-keyed
effect).

**Singleton "No" now casts the terminal vote immediately** (owner-
reported "dedup doesn't work" bug, fixed after this bullet originally
Expand Down
71 changes: 71 additions & 0 deletions frontend/src/features/attributeVoting/ActionButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* DESIGN-REPASS Rule 1 (issue #711) - the ONE action-button primitive shared by every answer
* row on the What's That Card surface. All primary decision and action buttons (Yes, No, Not
* Sure, Skip, and the embedded custom action triggers - the tag question's Apply/Not
* applicable, the no-match reason strip's Skip, the artist picker's candidate buttons) must
* share identical sizing, padding, corner radii, and typography metrics, so no answer row
* reads at a different scale than its siblings.
*
* This is the same geometry as QuestionFeed.tsx's own `Btn` (SPEC-wtc-rebuild.md section 1c's
* `.btn` row: min-height 44px, font 15px/600, pad 6px 16px, `--r-btn` radius, 1px border) plus
* Rule 2's `touch-action: manipulation` (kills the mobile double-tap-zoom gesture that
* otherwise swallows a fast single tap). It exists as a separate primitive rather than an
* import of `Btn` so the attribute-voting funnel components that render inside the question
* feed (and in the card-detail modal, their other caller) share one uniform geometry without
* the questionFeed -> attributeVoting import direction flipping.
*
* Variants mirror the surface's token palette; `w-100` stays available as a plain Bootstrap
* utility class when a caller needs a full-cell button (the artist picker's grid cells).
*/

import styled from "@emotion/styled";

export const ActionButton = styled.button`
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font: inherit;
font-size: 15px;
font-weight: 600;
padding: 6px 16px;
border-radius: var(--r-btn);
border: 1px solid transparent;
cursor: pointer;
line-height: 1.2;
text-align: center;
/* DESIGN-REPASS Rule 2 (#715) - opt out of the mobile double-tap-zoom gesture. */
touch-action: manipulation;

&:disabled {
opacity: 0.6;
cursor: default;
}

&.primary {
background: var(--primary);
color: var(--btn-ink);
border-color: var(--primary);
}

&.secondary {
background: var(--raised);
color: var(--text);
border-color: var(--divider);
}

&.ghost {
background: transparent;
color: var(--muted);
border-color: transparent;
}

/* The artist picker's consensus highlight (kept as its own variant so the "this is the
current consensus" signal survives the geometry unification) - success-green fill. */
&.success {
background: var(--success);
color: var(--btn-ink);
border-color: var(--success);
}
`;
37 changes: 24 additions & 13 deletions frontend/src/features/attributeVoting/ArtistVotePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
* thumbnail image) rather than thumbnail buttons.
*/

import React, { useEffect, useState } from "react";
import Button from "react-bootstrap/Button";
import React, { useEffect, useRef, useState } from "react";
import Col from "react-bootstrap/Col";
import Form from "react-bootstrap/Form";
import Row from "react-bootstrap/Row";
Expand All @@ -19,6 +18,7 @@ import {
CanonicalArtist,
} from "@/common/schema_types";
import { useAppDispatch } from "@/common/types";
import { ActionButton } from "@/features/attributeVoting/ActionButton";
import {
APIGetArtistCandidates,
APIGetArtistConsensus,
Expand Down Expand Up @@ -77,6 +77,10 @@ export function ArtistVotePicker({
const [loading, setLoading] = useState<boolean>(false);
const [submitting, setSubmitting] = useState<boolean>(false);
const [revealPickerAnyway, setRevealPickerAnyway] = useState<boolean>(false);
// Issue #715 - same synchronous in-flight guard as QueueTagQuestion: `disabled={submitting}`
// lags a fast double-tap by a render, so the ref drops the second entry before it can cast
// the artist vote twice.
const inFlightRef = useRef<boolean>(false);

useEffect(() => {
APIGetArtistConsensus(backendURL, cardIdentifier)
Expand All @@ -99,6 +103,10 @@ export function ArtistVotePicker({
}, [backendURL, cardIdentifier, query]);

const submit = (artistName: string | undefined, isUnknown: boolean) => {
if (inFlightRef.current) {
return;
}
inFlightRef.current = true;
setSubmitting(true);
APISubmitArtistVote(
backendURL,
Expand Down Expand Up @@ -140,7 +148,10 @@ export function ArtistVotePicker({
])
);
})
.finally(() => setSubmitting(false));
.finally(() => {
inFlightRef.current = false;
setSubmitting(false);
});
};

if (confidentlyKnownArtistName != null && !revealPickerAnyway) {
Expand Down Expand Up @@ -191,29 +202,29 @@ export function ArtistVotePicker({
) : (
<Row className="g-2 mt-1" xs={2} md={3}>
<Col>
<Button
variant={consensus?.isUnknown ? "success" : "outline-secondary"}
className="w-100"
<ActionButton
className={
consensus?.isUnknown ? "success w-100" : "secondary w-100"
}
disabled={submitting}
onClick={() => submit(undefined, true)}
>
Unknown artist
</Button>
</ActionButton>
</Col>
{candidates.map((candidate) => (
<Col key={candidate.name}>
<Button
variant={
<ActionButton
className={
consensus?.resolvedArtist?.name === candidate.name
? "success"
: "outline-secondary"
? "success w-100"
: "secondary w-100"
}
className="w-100"
disabled={submitting}
onClick={() => submit(candidate.name, false)}
>
{candidate.name}
</Button>
</ActionButton>
</Col>
))}
</Row>
Expand Down
31 changes: 24 additions & 7 deletions frontend/src/features/attributeVoting/NoMatchReasonStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,15 @@
* not an empty header.
*/

import React, { useState } from "react";
import Button from "react-bootstrap/Button";
import React, { useRef, useState } from "react";
import Col from "react-bootstrap/Col";
import Row from "react-bootstrap/Row";

import { errorToNotification, isRateLimited } from "@/common/apiErrors";
import { getOrCreateAnonymousId } from "@/common/cookies";
import { useTagDisplayName } from "@/common/tagDisplayNames";
import { useAppDispatch } from "@/common/types";
import { ActionButton } from "@/features/attributeVoting/ActionButton";
import { ChipCard } from "@/features/attributeVoting/ChipCard";
import { APISubmitTagVote, useGetTagsQuery } from "@/store/api";
import { setNotification } from "@/store/slices/toastsSlice";
Expand Down Expand Up @@ -119,13 +119,21 @@ export function NoMatchReasonStrip({
const [submittingTagName, setSubmittingTagName] = useState<string | null>(
null
);
// Issue #715 - same synchronous in-flight guard as the other funnel components: the visual
// `disabled` lags a fast double-tap by a render, so the ref drops the second chip tap (and
// the second Skip) before a vote can be cast twice.
const inFlightRef = useRef<boolean>(false);
const { data: existingTags } = useGetTagsQuery();
const existingTagNames =
existingTags != null ? new Set(existingTags.map((tag) => tag.name)) : null;
const isVisible = (tagName: string) =>
existingTagNames == null || existingTagNames.has(tagName);

const choose = (tagName: string) => {
if (inFlightRef.current) {
return;
}
inFlightRef.current = true;
setSubmittingTagName(tagName);
APISubmitTagVote(
backendURL,
Expand Down Expand Up @@ -153,7 +161,10 @@ export function NoMatchReasonStrip({
])
);
})
.finally(() => setSubmittingTagName(null));
.finally(() => {
inFlightRef.current = false;
setSubmittingTagName(null);
});
};

return (
Expand Down Expand Up @@ -196,14 +207,20 @@ export function NoMatchReasonStrip({
);
})}
<div className="mt-2">
<Button
variant="outline-secondary"
<ActionButton
className="ghost"
disabled={submittingTagName != null}
onClick={() => onDone()}
onClick={() => {
if (inFlightRef.current) {
return;
}
inFlightRef.current = true;
onDone();
}}
data-testid="no-match-reason-skip"
>
Skip
</Button>
</ActionButton>
</div>
</div>
);
Expand Down
Loading
Loading