diff --git a/docs/features/grid-selector.md b/docs/features/grid-selector.md index f2e765eac..a9e501416 100644 --- a/docs/features/grid-selector.md +++ b/docs/features/grid-selector.md @@ -71,9 +71,12 @@ more isolated follow-up per the owner's Phase-2 direction. ## Select Version section (issue #167, Proposal H §4.4′) -The unified display page's (`/display`) rail "Choose Image" accordion -body no longer renders the flat `GridSelectorResults`/`CardResultSet` -grid — it mounts `SelectVersionResults.tsx`, which groups the same +The unified display page's (`/display`) rail's Select Version surface +(promoted + always open since the editor-completion package's left-panel +fidelity rebuild - see that section's own note below; was the "Choose +Image" accordion before that round) no longer renders the flat +`GridSelectorResults`/`CardResultSet` grid — it mounts +`SelectVersionResults.tsx`, which groups the same candidate identifier list into the three ordered groups `docs/proposals/proposal-h-unified-display-page.md`'s §4.4′ specifies, and weaves in its three verification moments. **Scope: this replaces the @@ -149,11 +152,30 @@ still back that modal. `altered-frame > custom-art > ai-art` (`SELECT_VERSION_REASON_TAG_PRIORITY`), an arbitrary but documented choice, since the spec doesn't pin `custom-art` vs. `ai-art`'s relative - order. (2) The "Choose Image" accordion title/testids were kept - as-is (not renamed to "Select Version") to avoid churning every - existing passing test that references that exact heading text — a - pure-copy rename is a small, separate follow-up if the owner wants the - label itself to match the spec's own name for this section. + order. +- **Resolved by the editor-completion package's left-panel fidelity + rebuild (E2/E3/L4)**: the rail heading is now genuinely "Select + Version" (the once-deferred pure-copy rename above), promoted to an + always-visible, always-open surface with no `AutofillCollapse` wrapper + at all - it's no longer one accordion among several (D3: art selection + is the primary surface, not something a user has to expand). The same + round also fixed the rail-specific fidelity breakages this section used + to carry: `initialSettingsVisible={false}` on `useGridSelectorSearch` + (Filters starts collapsed in the rail regardless of viewport width - + previously auto-opened cramped on any desktop-width rail) and + `layout="stacked"` on `SelectVersionResults` (the Filters disclosure + renders full-width, stacked, in the rail's own scroll container instead + of the modal's `Col lg={3}` sidebar split - "Jump to Version" no longer + wraps vertically, bottom controls no longer clip at the rail edge). The + always-on `FilterChipBar` wall moved INTO the Filters disclosure for + the stacked/rail caller (still reachable, no longer a permanent + multi-row height sink atop every result); `GridSelectorFilters` gained + an additive `hiddenSections` prop the rail uses to drop "View" + (Group-by/Compressed - both redundant in the rail: it groups results + itself, and the 380px rail already forces compact tiles). Every one of + these is additive/optional and defaults to today's modal behavior - + `GridSelectorModal.tsx`'s own caller passes none of them and is + unaffected. ## Key files @@ -184,11 +206,11 @@ still back that modal. a 150-synthetic-card grid in a mocked sandbox; it doesn't reproduce real backend/ES latency or real image-CDN load timing at that scale. - Issue #167's Select Version section: see its own "Open items" bullet - above for the two owner-decidable gaps (group 2's ai-art/custom-art - order, and the un-renamed "Choose Image" label). The Filters column - (`GridSelectorFilters`) reused inside the rail still uses the same - viewport-keyed Bootstrap column breakpoints as the classic modal's - `FiltersColumn` (pre-existing, not introduced by this build) — cramped - at the rail's ~380px width, a known layout gap `CardRow`'s own - "embedded" variant comment already flags for the results grid and - wasn't in this issue's scope to fix for the filters column too. + above for the one still-owner-decidable gap (group 2's ai-art/custom-art + order). The rail's own Filters-column cramping (the previous bullet + here) was fixed by the editor-completion package's left-panel fidelity + rebuild - see this section's own "Resolved by..." paragraph above + (`layout="stacked"` drops the `Col lg={3}` sidebar split for the rail + caller specifically; `GridSelectorModal.tsx`'s own sidebar layout is + unchanged, so this is a rail-only fix, not a change to the shared + column-breakpoint default itself). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a952137ce..ab8a1e4ca 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1131,6 +1131,37 @@ 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. +## `DisplayPage.spec.ts`'s "floating sheet-position pill updates live while scrolling at phone width (D17)" fails intermittently with "2/3" instead of "3/3" + +**Symptom**: `tests/DisplayPage.spec.ts`'s D17 sheet-position-pill test +(phone viewport, 18 cards / 3 sheets) intermittently reports the +`display-sheet-position-indicator` still reading `2/3` after +`scrollIntoView({block:"center"})` on the last sheet, where `3/3` is +expected - reproduces even running that ONE test alone, +`--repeat-each=5`+, on both a freshly-modified branch and on plain +`origin/master` with zero changes. + +**Cause**: a genuine, pre-existing flake in this test's own +IntersectionObserver-based timing (its `rootMargin: "-45% 0px -45% 0px"` +thin center-band check races the browser's `scrollIntoView` completing), +not a regression from any particular change - confirmed by reproducing +the same ~1-in-6 failure rate on unmodified `origin/master` in this same +sandboxed VM. A change that happens to add or remove incidental +rendering cost nearby (e.g. widening a `useMemo`'s dependency array on +the sheet-content builder) can shift the failure rate up or down without +being the actual cause - don't chase a "regression" here without first +checking the identical test against a clean `origin/master` checkout in +the same environment, `--repeat-each=5` or more (a single passing/failing +run either way is not enough evidence). + +**Fix/mitigation**: none applied - this is a test-timing flake to +tolerate (retry) rather than a product bug to fix; if it starts failing +CI at a rate that matters, the real fix is loosening the test's own +`scrollIntoView`-then-assert race (e.g. an explicit +`waitForFunction`/poll on the indicator text before asserting, instead of +relying on `expect(...).toContainText`'s own retry window alone), not +touching the sheet-content pipeline it happens to render. + ## `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` diff --git a/frontend/src/features/cardDetailedView/CardDetailedViewBody.tsx b/frontend/src/features/cardDetailedView/CardDetailedViewBody.tsx new file mode 100644 index 000000000..c48f23b9e --- /dev/null +++ b/frontend/src/features/cardDetailedView/CardDetailedViewBody.tsx @@ -0,0 +1,288 @@ +/** + * The editor-completion package's §7.5/R3 extraction (docs/proposals - the "editor completion" + * left-panel-fidelity round, item E6/X5). Pulls CardDetailedViewModal.tsx's body content out into + * region-level, independently-mountable sub-blocks - CardMetaTable, CardIdentifierCopy, + * CardDownloadFavorite, PrintingTagsBlock, ReportBlock - plus CardDetailedViewBody, which + * recomposes them in the modal's own exact order. This is deliberately NOT one blob: the /display + * left rail's new demoted "Card Details"/"Printing Tags"/"Report" sections (DisplayPage.tsx) mount + * CardMetaTable+CardDownloadFavorite, PrintingTagsBlock, and ReportBlock individually, each as its + * own collapsed AutofillCollapse per the D3 hierarchy - never the whole modal body, since the rail + * already has its own art-selection surface (Select Version) and doesn't want a second full-size + * card image render. + * + * Acceptance test for this extraction (per the task's own directive) is + * tests/visual/CardDetailedViewModal.visual.spec.ts staying green UNMODIFIED - its aria snapshot + * asserts the modal's exact DOM shape, so CardDetailedViewModal.tsx's own render below must stay + * byte-for-byte equivalent to what CardDetailedViewModal.tsx used to render inline. Every + * sub-block is fully self-contained (fetches its own hook data - useGetLanguagesQuery, + * useDoImageDownload, useTagDisplayName, selectRemoteBackendURL - rather than threading it down + * from a parent), so the rail can mount any one of them standalone with no shared parent state. + * + * `showAddToProjectForm` (default true, matching every existing behavior) is the one prop this + * extraction adds: AddCardToProjectForm is dropped from the rail's own Card Details mount (the + * slot is already in the project there - see DisplayPage.tsx's Rail composition) but stays in the + * modal, unconditionally, exactly as before. + */ +import React, { useState } from "react"; +import Badge from "react-bootstrap/Badge"; +import Button from "react-bootstrap/Button"; + +import { PrintingConsensusResponse } from "@/common/schema_types"; +import { useTagDisplayName } from "@/common/tagDisplayNames"; +import { CardDocument, useAppDispatch, useAppSelector } from "@/common/types"; +import { imageSizeToMBString, toTitleCase } from "@/common/utils"; +import { ArtistSupportLink } from "@/components/ArtistSupportLink"; +import { AutofillTable } from "@/components/AutofillTable"; +import { ClickToCopy } from "@/components/ClickToCopy"; +import { RightPaddedIcon } from "@/components/icon"; +import { SetIcon } from "@/components/SetIcon"; +import { AttributeVotingPanel } from "@/features/attributeVoting/AttributeVotingPanel"; +import { AddCardToFavorites } from "@/features/card/AddCardToFavorites"; +import { AddCardToProjectForm } from "@/features/card/AddCardToProjectForm"; +import { useDoImageDownload } from "@/features/download/downloadImages"; +import { PrintingTagPicker } from "@/features/printingTags/PrintingTagPicker"; +import { ReportCardPanel } from "@/features/reporting/ReportCardPanel"; +import { useGetLanguagesQuery } from "@/store/api"; +import { selectRemoteBackendURL } from "@/store/slices/backendSlice"; +import { setNotification } from "@/store/slices/toastsSlice"; + +//# region CardIdentifierCopy + +interface CardIdentifierCopyProps { + identifier: string; +} + +/** Thin, named wrapper over ClickToCopy for the metadata table's "Identifier" row - exported per + * the extraction's own sub-block list (E6) so a future standalone "copy identifier" affordance + * doesn't need to re-derive this from CardMetaTable. */ +export function CardIdentifierCopy({ identifier }: CardIdentifierCopyProps) { + return ; +} + +//# endregion + +//# region CardMetaTable + +interface CardMetaTableProps { + cardDocument: CardDocument; +} + +export function CardMetaTable({ cardDocument }: CardMetaTableProps) { + const getLanguagesQuery = useGetLanguagesQuery(); + const getTagDisplayName = useTagDisplayName(); + const languageNameByCode = Object.fromEntries( + (getLanguagesQuery.data ?? []).map((row) => [row.code, row.name]) + ); + + return ( + 0 ? ( + + {cardDocument.sourceVerbose} + + ) : ( + cardDocument.sourceVerbose + ), + ], + ["Source Type", cardDocument.sourceType], + ["Class", toTitleCase(cardDocument.cardType)], + [ + "Identifier", + , + ], + ["Language", languageNameByCode[cardDocument.language]], + [ + "Tags", + cardDocument.tags.length > 0 ? ( + <> + {cardDocument.tags.map((tag) => ( + + {getTagDisplayName(tag)} + + ))} + + ) : ( + "Untagged" + ), + ], + ["Resolution", `${cardDocument.dpi} DPI`], + ["Date Created", cardDocument.dateCreated], + ["Date Modified", cardDocument.dateModified], + ["File Size", imageSizeToMBString(cardDocument.size, 2)], + [ + "Canonical Card", + cardDocument.canonicalCard ? ( + <> + {" "} + {cardDocument.canonicalCard.expansionCode.toUpperCase()}{" "} + {cardDocument.canonicalCard.collectorNumber} + + ) : ( + "Unknown" + ), + ], + [ + "Canonical Aritst", + cardDocument.canonicalArtist != null ? ( + + {cardDocument.canonicalArtist.name} + + ) : ( + "Unknown" + ), + ], + ]} + hover={true} + alignment={"left"} + uniformWidth={false} + columnLabels={true} + /> + ); +} + +//# endregion + +//# region CardDownloadFavorite + +interface CardDownloadFavoriteProps { + cardDocument: CardDocument; +} + +export function CardDownloadFavorite({ + cardDocument, +}: CardDownloadFavoriteProps) { + const dispatch = useAppDispatch(); + const queueImageDownload = useDoImageDownload(); + + return ( + <> + {cardDocument.sourceType === "Google Drive" && ( +
+ +
+ )} + + + ); +} + +//# endregion + +//# region PrintingTagsBlock + +interface PrintingTagsBlockProps { + cardDocument: CardDocument; +} + +export function PrintingTagsBlock({ cardDocument }: PrintingTagsBlockProps) { + const backendURL = useAppSelector(selectRemoteBackendURL); + const [printingConsensus, setPrintingConsensus] = + useState(null); + + return ( + <> +
+
What's That Card?
+

+ Help us figure out which real-world printing this card is! +

+ + {printingConsensus != null && + printingConsensus.resolvedPrinting == null && + backendURL != null && ( + <> +
+ + + )} + + ); +} + +//# endregion + +//# region ReportBlock + +interface ReportBlockProps { + cardDocument: CardDocument; +} + +export function ReportBlock({ cardDocument }: ReportBlockProps) { + return ; +} + +//# endregion + +//# region CardDetailedViewBody + +interface CardDetailedViewBodyProps { + cardDocument: CardDocument; + /** Modal-only (default true, matching every existing behavior): the rail's own "Card Details" + * mount passes false, since the slot is already in the project there - see this file's own + * module comment. */ + showAddToProjectForm?: boolean; +} + +/** The modal's own right-column content (heading + metadata + download/favorite + add-to-project + * + report + printing tags), recomposed from the sub-blocks above in their original order - this + * is what makes CardDetailedViewModal.tsx's own render byte-for-byte unchanged. */ +export function CardDetailedViewBody({ + cardDocument, + showAddToProjectForm = true, +}: CardDetailedViewBodyProps) { + return ( + <> +

{cardDocument.name}

+ + + {showAddToProjectForm && ( + + )} + + + + ); +} + +//# endregion diff --git a/frontend/src/features/cardDetailedView/CardDetailedViewModal.tsx b/frontend/src/features/cardDetailedView/CardDetailedViewModal.tsx index 132afa02c..4e392c2af 100644 --- a/frontend/src/features/cardDetailedView/CardDetailedViewModal.tsx +++ b/frontend/src/features/cardDetailedView/CardDetailedViewModal.tsx @@ -2,38 +2,30 @@ * If the user clicks a card in the project editor, this component will be displayed, * which is a modal that shows a higher-resolution version of the card, * some more information (e.g. size, dote uploaded, etc.), and a button to download the full res image. + * + * The editor-completion package's §7.5/R3 extraction (E6/X5) moved this modal's own right-column + * body content out into CardDetailedViewBody.tsx's region-level sub-blocks, so the /display left + * rail's demoted Card Details/Printing Tags/Report sections can mount the same content + * individually (see that file's own module comment). This component's render below is + * deliberately unchanged in shape - same Modal chrome, same left-column image, same right-column + * content, now sourced from CardDetailedViewBody rather than inlined - so + * tests/visual/CardDetailedViewModal.visual.spec.ts's aria snapshot keeps passing unmodified, + * which is this extraction's own acceptance test. */ -import React, { memo, useState } from "react"; -import Badge from "react-bootstrap/Badge"; +import React, { memo } from "react"; import Button from "react-bootstrap/Button"; import Modal from "react-bootstrap/Modal"; import Row from "react-bootstrap/Row"; import { getCardDataAttributes } from "@/common/cardDom"; -import { PrintingConsensusResponse } from "@/common/schema_types"; -import { useTagDisplayName } from "@/common/tagDisplayNames"; -import { CardDocument, useAppDispatch, useAppSelector } from "@/common/types"; -import { imageSizeToMBString, toTitleCase } from "@/common/utils"; -import { ArtistSupportLink } from "@/components/ArtistSupportLink"; -import { AutofillTable } from "@/components/AutofillTable"; -import { ClickToCopy } from "@/components/ClickToCopy"; +import { CardDocument } from "@/common/types"; import DisableSSR from "@/components/DisableSSR"; -import { RightPaddedIcon } from "@/components/icon"; -import { SetIcon } from "@/components/SetIcon"; -import { AttributeVotingPanel } from "@/features/attributeVoting/AttributeVotingPanel"; -import { AddCardToFavorites } from "@/features/card/AddCardToFavorites"; -import { AddCardToProjectForm } from "@/features/card/AddCardToProjectForm"; import { MemoizedCardImage, MemoizedCardProportionWrapper, } from "@/features/card/Card"; -import { useDoImageDownload } from "@/features/download/downloadImages"; -import { PrintingTagPicker } from "@/features/printingTags/PrintingTagPicker"; -import { ReportCardPanel } from "@/features/reporting/ReportCardPanel"; -import { useGetLanguagesQuery } from "@/store/api"; -import { selectRemoteBackendURL } from "@/store/slices/backendSlice"; -import { setNotification } from "@/store/slices/toastsSlice"; +import { CardDetailedViewBody } from "@/features/cardDetailedView/CardDetailedViewBody"; interface CardDetailedViewProps { cardDocument: CardDocument; @@ -49,27 +41,6 @@ export function CardDetailedViewModal({ show, handleClose, }: CardDetailedViewProps) { - //# region queries and hooks - - const dispatch = useAppDispatch(); - const queueImageDownload = useDoImageDownload(); - const getLanguagesQuery = useGetLanguagesQuery(); - const backendURL = useAppSelector(selectRemoteBackendURL); - const getTagDisplayName = useTagDisplayName(); - - const [printingConsensus, setPrintingConsensus] = - useState(null); - - //# endregion - - //# region computed constants - - const languageNameByCode = Object.fromEntries( - (getLanguagesQuery.data ?? []).map((row) => [row.code, row.name]) - ); - - //# endregion - return (
-

{cardDocument.name}

- 0 ? ( - - {cardDocument.sourceVerbose} - - ) : ( - cardDocument.sourceVerbose - ), - ], - ["Source Type", cardDocument.sourceType], - ["Class", toTitleCase(cardDocument.cardType)], - [ - "Identifier", - , - ], - ["Language", languageNameByCode[cardDocument.language]], - [ - "Tags", - cardDocument.tags.length > 0 ? ( - <> - {cardDocument.tags.map((tag) => ( - - {getTagDisplayName(tag)} - - ))} - - ) : ( - "Untagged" - ), - ], - ["Resolution", `${cardDocument.dpi} DPI`], - ["Date Created", cardDocument.dateCreated], - ["Date Modified", cardDocument.dateModified], - ["File Size", imageSizeToMBString(cardDocument.size, 2)], - [ - "Canonical Card", - cardDocument.canonicalCard ? ( - <> - {" "} - {cardDocument.canonicalCard.expansionCode.toUpperCase()}{" "} - {cardDocument.canonicalCard.collectorNumber} - - ) : ( - "Unknown" - ), - ], - [ - "Canonical Aritst", - cardDocument.canonicalArtist != null ? ( - - {cardDocument.canonicalArtist.name} - - ) : ( - "Unknown" - ), - ], - ]} - hover={true} - alignment={"left"} - uniformWidth={false} - columnLabels={true} - /> - {cardDocument.sourceType === "Google Drive" && ( -
- -
- )} - - - -
-
What's That Card?
-

- Help us figure out which real-world printing this card is! -

- - {printingConsensus != null && - printingConsensus.resolvedPrinting == null && - backendURL != null && ( - <> -
- - - )} +
diff --git a/frontend/src/features/display/ConfidenceElement.tsx b/frontend/src/features/display/ConfidenceElement.tsx new file mode 100644 index 000000000..b4fa3eb6c --- /dev/null +++ b/frontend/src/features/display/ConfidenceElement.tsx @@ -0,0 +1,63 @@ +/** + * The /display left rail's promoted confidence element (editor-completion package, D14/E2#3, + * L3). Per the task's own scope for this round, this is the PLACEHOLDER cut of D14 - the visual + * slot (SetIcon + resolved/suggested read + a "not this printing" affordance) rather than the + * full interactive version the design spec's own E2#3 describes (a live Scryfall-hover popover + + * a real `useTagVoting` vote dispatch on "not this printing"). Deliberately narrower, not + * silently different: the "not this printing" button renders disabled with an explanatory title, + * and there's no image-hover popover - wiring either up to the real vote/Scryfall-image path is + * left to a follow-up round, flagged in this task's own report. + * + * Renders nothing when there's no printing-identity signal to show at all (no cardDocument, or + * neither a resolved canonicalCard nor a suggestedCanonicalCard) - same "never guess" precedent + * ArtistSection.tsx and PrintOptionsSection.tsx already follow for a card that isn't resolved yet. + */ +import React from "react"; + +import { CardDocument } from "@/common/types"; +import { SetIcon } from "@/components/SetIcon"; + +interface ConfidenceElementProps { + cardDocument: CardDocument | undefined; +} + +export function ConfidenceElement({ cardDocument }: ConfidenceElementProps) { + if (cardDocument == null) { + return null; + } + + const resolvedPrinting = cardDocument.canonicalCard; + const suggestedPrinting = cardDocument.suggestedCanonicalCard; + const printing = resolvedPrinting ?? suggestedPrinting; + if (printing == null) { + return null; + } + + const status: "confirmed" | "suggested" = + resolvedPrinting != null ? "confirmed" : "suggested"; + + return ( +
+ + + + + {printing.expansionCode.toUpperCase()} · {printing.collectorNumber} + + + {status === "confirmed" ? "Confirmed" : "Suggested"} + + {status === "suggested" && ( + + )} +
+ ); +} diff --git a/frontend/src/features/display/DisplayPage.tsx b/frontend/src/features/display/DisplayPage.tsx index 4b31bc50d..37406f143 100644 --- a/frontend/src/features/display/DisplayPage.tsx +++ b/frontend/src/features/display/DisplayPage.tsx @@ -5,7 +5,7 @@ * + accordion (AutofillCollapse, per the owner's accordion amendment). Choose Image is wired to * the real candidate/version picker (originally Step 2 PR 2a's flat GridSelectorResults grid, * replaced by the unified Select Version section - issue #167, SelectVersionResults.tsx - see - * ChooseImageSection below; still shares useGridSelectorSearch.ts's search/filter state machine + * SelectVersionSection below; still shares useGridSelectorSearch.ts's search/filter state machine * with GridSelectorModal.tsx's own unchanged modal variant). The always-visible header now * carries the real requested-printing badge (Step 2 PR 2b - degraded-style variant keyed off * EditorSearchResponse.degradedQueries, wired end to end through searchResultsSlice's @@ -191,6 +191,16 @@ * PDFGenerator with its own unrelated defaults - a genuine settings-parity gap, out of scope for * this issue (D9/D10 resolve the SAVE-vs-PRINT ordering and the route linkage, not settings * portability), left for a future issue. + * + * Editor-completion package, E19/X19 (lime rounded corner-only cut guides) inherits this exact + * same gap: PagePreview.tsx's screenPresentation variant now renders the reference's lime corner + * guides on THIS page's own live sheet (screen-only, gated on screenPresentation - PDFGenerator's + * own fast preview is unaffected), but the REAL exported PDF's guide style is drawn by + * PDFGenerator.tsx/PDF.tsx's own independent cutLineColor/cutLineShape settings on the Print page - + * upstream already carries the corner-only geometry this needs (`CutLineCorner`, `cutLineShape: + * "InsideOnly"` - confirmed by reading `upstream/master`'s `PDF.tsx` directly, not assumed), so no + * new PDF engine work is required, only wiring a lime preset through - genuine screen/print parity + * for the guide COLOR is blocked on the same settings-portability gap above, not attempted here. */ import styled from "@emotion/styled"; import React, { @@ -224,10 +234,17 @@ import { RenderIfVisible } from "@/components/RenderIfVisible"; import { CardbackToolbarButton } from "@/features/card/CommonCardback"; import { DeckbuilderConfirmAffordance } from "@/features/card/DeckbuilderConfirmAffordance"; import { RequestedPrintingBadge } from "@/features/card/RequestedPrintingBadge"; +import { + CardDownloadFavorite, + CardMetaTable, + PrintingTagsBlock, + ReportBlock, +} from "@/features/cardDetailedView/CardDetailedViewBody"; import { ArtistSection } from "@/features/display/ArtistSection"; import { AttributesSection } from "@/features/display/AttributesSection"; import { CardSpacingControl } from "@/features/display/CardSpacingControl"; import { CatalogBrowseResults } from "@/features/display/CatalogBrowseResults"; +import { ConfidenceElement } from "@/features/display/ConfidenceElement"; import { paginateSlotsForDisplay } from "@/features/display/displayPagination"; import { FinishFooter } from "@/features/display/FinishFooter"; import { MarginProfileControl } from "@/features/display/MarginProfileControl"; @@ -238,6 +255,7 @@ import { SavedDecksLandingPanel, useHasSavedDecksForLanding, } from "@/features/display/SavedDecksLandingPanel"; +import { buildScryfallReferenceUrl } from "@/features/display/scryfallReference"; import { SlotActionsSection } from "@/features/display/SlotActionsSection"; import { ProjectDraftSummary, @@ -323,20 +341,32 @@ const SHEET_MAX_WIDTH_PX = 960; //# endregion //# region accordion sections +// +// Editor-completion package, left-panel fidelity rebuild (E1-E6, X1) - the redline's "demoted +// zone" (§1's D3 hierarchy): every section here is a collapsed AutofillCollapse near the bottom +// of the rail, in the E5 order Card Details -> Attributes -> Printing Tags -> Print Options -> +// Slot Actions -> Report. "Choose Image" and "Artist" are gone from this type entirely - they're +// now part of the always-visible PROMOTED zone (RailHeader + ArtistSection + ConfidenceElement + +// the always-open Select Version surface), not collapsible sections at all. Card Details/ +// Printing Tags/Report are net-new (L13-L15, §7.5 - the CardDetailedViewBody extraction, E6/X5) - +// the metadata/report/tagging surfaces the modal has always had, never mounted in the rail +// before this round. type AccordionSectionKey = - | "chooseImage" + | "cardDetails" | "attributes" + | "printingTags" | "printOptions" - | "artist" - | "slotActions"; + | "slotActions" + | "report"; const DEFAULT_EXPANDED: Record = { - chooseImage: true, + cardDetails: false, attributes: false, + printingTags: false, printOptions: false, - artist: false, slotActions: false, + report: false, }; interface RailSectionProps { @@ -374,7 +404,6 @@ interface RailHeaderProps { cardName: string | undefined; cardIdentifier: string | undefined; searchQuery: SearchQuery | undefined; - onOpenChooseImage: () => void; } const RailHeader = ({ @@ -383,9 +412,11 @@ const RailHeader = ({ cardName, cardIdentifier, searchQuery, - onOpenChooseImage, }: RailHeaderProps) => ( -
+
Slot {slot + 1}{" "} {face} @@ -402,15 +433,15 @@ const RailHeader = ({
{/* Adapts CardSlot.tsx's own mount of this component (same props, same gating logic inside - DeckbuilderConfirmAffordance itself - not forked) for the rail's status header: N's - "open the grid selector" becomes "expand (or keep expanded, if already open) the Choose - Image accordion section" here instead of opening GridSelectorModal, since the rail has - no modal to open - see the design doc's §4.3/§4.4. */} + DeckbuilderConfirmAffordance itself - not forked) for the rail's status header. The + editor-completion package's E2/L4 promotes Select Version to an always-open surface (no + collapse chrome), so N no longer needs to "expand" anything - onOpenGridSelector is a + no-op here now; the surface is already visible below. */} {cardIdentifier != null && ( undefined} /> )}
@@ -418,9 +449,44 @@ const RailHeader = ({ //# endregion -//# region Choose Image section (issue #167 - the unified Select Version section, spec §4.4′) +//# region promoted zone - Artist line + Confidence element (E2, L2/L3) +// +// Editor-completion package, E2 (#2/#3) - the always-visible zone between the status header and +// the (also always-open) Select Version surface below. ArtistSection.tsx is reused verbatim, not +// forked (D3: artist support is top-priority, always visible - a collapsed accordion hid the one +// thing D3 says must be promoted); only its mount location and outer chrome (the `.artist-line` +// lifted-CSS class, E1/§5) changed - the "Artist" AutofillCollapse wrapper this used to sit inside +// is simply gone. ConfidenceElement.tsx is net-new (D14/L3 - see that file's own module comment +// for why this round ships the narrower placeholder cut, not the full interactive version). + +interface PromotedZoneProps { + cardDocument: CardDocument | undefined; +} + +const PromotedZone = ({ cardDocument }: PromotedZoneProps) => ( + <> +
+ +
+
+ +
+ +); + +//# endregion -interface ChooseImageSectionProps { +//# region Select Version section (issue #167 - the unified Select Version section, spec §4.4′) +// +// Editor-completion package, E2/E3/L4 (Bkg 1/2/4/5) - promoted to the always-visible, always-open +// art surface (renamed "Select Version", no AutofillCollapse wrapper at all - it's no longer a +// collapsible section, "Choose Image" as an accordion key is gone from AccordionSectionKey +// entirely). `initialSettingsVisible={false}` on useGridSelectorSearch and `layout="stacked"` on +// SelectVersionResults fix the redline's Bkg 2/4/5 breakages (filters auto-opening cramped inside +// the 380px rail, "Jump to Version" wrapping vertically, bottom controls clipping at the rail +// edge) - see those two files' own prop comments. + +interface SelectVersionSectionProps { face: Faces; slot: number; query: SearchQuery | undefined; @@ -437,13 +503,13 @@ interface ChooseImageSectionProps { // SelectVersionResults (issue #167) - grouped by printing/reason-tag/unknown per the spec's §4.4′ // rather than GridSelectorResults' flat CardResultSet grid; GridSelectorModal.tsx's own modal // variant (CardSlot.tsx's editor grid) is untouched, out of this issue's scope. -const ChooseImageSection = ({ +const SelectVersionSection = ({ face, slot, query, selectedImage, backendURL, -}: ChooseImageSectionProps) => { +}: SelectVersionSectionProps) => { const dispatch = useAppDispatch(); const searchResultsForQuery = useAppSelector((state) => @@ -457,9 +523,13 @@ const ChooseImageSection = ({ ) ) ?? []; const focusRef = useRef(null); + // E3/X2 (Bkg 5) - the rail always starts with Filters collapsed, regardless of viewport width + // (the modal's own GridSelectorModal caller doesn't pass this, so its width-based default is + // unchanged). const search = useGridSelectorSearch({ imageIdentifiers: searchResultsForQuery, active: true, + initialSettingsVisible: false, }); const onSelectImage = (identifier: string) => { @@ -473,10 +543,28 @@ const ChooseImageSection = ({ }; if (searchResultsForQuery.length === 0) { + // E17 - the Select-Version empty state is one of the two directed-help homes the spec names + // (the other is the on-card sheet slot, see PagePreview's own loadState="failed" rendering); + // a deterministic Scryfall reference link costs zero backend work (buildScryfallReferenceUrl, + // scryfallReference.ts). + const findCardUrl = buildScryfallReferenceUrl(query); return ( -

- No candidate images found for this slot's query. -

+
+

+ No candidate images found for this slot's query. +

+ {findCardUrl != null && ( + + Find this card ↗ + + )} +
); } @@ -515,6 +603,7 @@ const ChooseImageSection = ({ : undefined } backendURL={backendURL} + layout="stacked" /> ); @@ -794,6 +883,72 @@ const ActionBarSearchGroup = styled.div` } `; +// Editor-completion package, E1/X6 - the mockup's rail stylesheet, lifted verbatim per the +// owner's grant (spec §5: "the left rail MAY lift the mockup's CSS verbatim rather than +// re-approximate through react-bootstrap idioms"). Scoped to this one wrapper (not global) so it +// only ever touches the /display rail's own promoted-zone markup (PromotedZone/ConfidenceElement +// above) - nothing here overrides AutofillCollapse's own chrome for the demoted accordion +// sections below (the owner's grant covers dropping that look "where fidelity requires"; the +// promoted zone is where it's required, the demoted accordions keep today's proven pattern). +const RailRoot = styled.div` + .rail-head { + background: #22303f; + } + .artist-line { + background: #22303f; + } + .select-version-heading { + margin: 0; + padding: 8px 0 4px; + font-weight: 600; + } + .confidence { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin: 6px 0; + padding: 6px 8px; + background: #2b3e50; + border: 1px solid #16202b; + border-radius: 6px; + font-size: 12px; + } + .set-symbol { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + background: #4e5d6c; + border: 1px solid #7f8fa0; + font-weight: 700; + font-size: 11px; + } + .conf-badge { + padding: 1px 6px; + border: 1px solid #4e5d6c; + border-radius: 10px; + } + .conf-badge.confirmed { + border-color: #3f7a2f; + color: #a7e08a; + } + .conf-badge.suggested { + border-color: #df6919; + color: #ffb27d; + } + .conf-x { + margin-left: auto; + padding: 2px 8px; + font-size: 11px; + background: transparent; + border: 1px solid #7f8fa0; + color: #ffb27d; + } +`; + interface RailProps { selectedSlotRef: SelectedSlotRef | null; // CardDocument | undefined, not just CardDocument: useCardDocumentsByIdentifier's own return @@ -846,35 +1001,68 @@ const Rail = ({ ...previous, [key]: !previous[key], })); - // "focus, if already open" (design doc §4.3.4) - always force-open, never toggle-closed, so - // the Confirm affordance's N path can't accidentally collapse a section the user already had - // open. - const onOpenChooseImage = () => - setExpandedSections((previous) => ({ ...previous, chooseImage: true })); + + const selectedCardDocument = + selectedImage != null + ? cardDocumentsByIdentifier[selectedImage] + : undefined; return ( -
+ - - + {/* E2/E3/L4 - Select Version, promoted + always open (renamed from "Choose Image", no + collapse chrome at all - the primary art surface, not one accordion among several). */} +
+
Select Version
+ +
+ {/* E5 - the demoted zone, collapsed AutofillCollapse sections in the D3 order: Card + Details -> Attributes -> Printing Tags -> Print Options -> Slot Actions -> Report. Card + Details/Printing Tags/Report are net-new here (L13-L15, §7.5's CardDetailedViewBody + extraction, E6/X5) - AddCardToProjectForm is deliberately not mounted (the slot is + already in the project). */} + + {/* Lazy-mount, unlike the other demoted sections below: AutofillCollapse keeps every + section's children in the DOM regardless of collapse state (Attributes/Print + Options/Slot Actions already rely on that - see this file's own test-fixture comment + on Attributes' unconditional tag-consensus fetch), but this section's own content + (CardMetaTable's language lookup, PrintingTagsBlock's printing-candidates/consensus + fetch below) is real new backend traffic /display never issued before this round - + mounting it on every slot selection, whether or not the user ever opens it, would be + a silent new per-click cost. Gating on expandedSections keeps it opt-in, same as the + user actually clicking to open the section. */} + {!expandedSections.cardDetails ? ( + <> + ) : selectedCardDocument != null ? ( + <> + + + + ) : ( +

+ Select an image for this slot first. +

+ )}
- + {/* Lazy-mount - see the Card Details section's own comment above; PrintingTagsBlock's + PrintingTagPicker is the specific real backend call this guards. */} + {!expandedSections.printingTags ? ( + <> + ) : selectedCardDocument != null ? ( + + ) : ( +

+ Select an image for this slot first. +

+ )}
- + -
+ + {!expandedSections.report ? ( + <> + ) : selectedCardDocument != null ? ( + + ) : ( +

+ Select an image for this slot first. +

+ )} +
+ ); }; @@ -938,6 +1140,14 @@ export function DisplayPage() { const projectCardback = useAppSelector(selectProjectCardback); const isProjectEmpty = useAppSelector(selectIsProjectEmpty); const frontsVisible = useAppSelector(selectFrontsVisible); + // E17/E18 (X18) - the sheet's own dark loading/failed slot states use the SAME coarse, + // whole-app fetch-status flag CardGrid.tsx already reads for its own loading affordance, + // rather than a per-query resolved-candidates check (which would need per-slot store access + // from inside a plain .map() callback, outside a selector context) - an approximation, not + // E17's literal per-query condition, documented as a deviation in this task's own report. + const searchResultsLoading = useAppSelector( + (state) => state.searchResults.status === "loading" + ); const cardDocumentsByIdentifier = useCardDocumentsByIdentifier(); // Issue #275 (design doc ADDENDUM D9) - the silent local draft auto-backup (F1) and the @@ -1077,7 +1287,7 @@ export function DisplayPage() { // the on-screen sheet and the exported PDF in lockstep with no extra plumbing. const spacing = useAppSelector(selectCardSpacing); - // Still needed below (ChooseImageSection's own backendURL prop) - issue #275 removed every + // Still needed below (SelectVersionSection's own backendURL prop) - issue #275 removed every // OTHER consumer this used to have (the inline export pipeline, see this file's own module // comment for where that pipeline moved). const backendURL = useAppSelector(selectRemoteBackendURL); @@ -1140,15 +1350,33 @@ export function DisplayPage() { : "" }` : undefined; + // E17/E18 (X18) - a slot with an active query but no resolved image is either still + // being fetched (dark loading sweep) or definitively empty (dark "no art" mark + the + // deterministic Scryfall reference link, E17 v1); a genuinely query-less slot (e.g. a + // shared-cardback back face) gets neither, same as before this round. + const hasQuery = + (query?.query != null && query.query.length > 0) || + query?.expansionCode != null; + const loadState: "loading" | "failed" | undefined = + identifier == null && hasQuery + ? searchResultsLoading + ? "loading" + : "failed" + : undefined; const content: PagePreviewSlotContent = { imageUrl: cardDocument?.mediumThumbnailUrl, name: cardDocument?.name ?? `Slot ${entry.slot + 1}`, queryText, + loadState, + findCardUrl: + loadState === "failed" + ? buildScryfallReferenceUrl(query) + : undefined, }; return content; }), })), - [pages, activeFace, cardDocumentsByIdentifier] + [pages, activeFace, cardDocumentsByIdentifier, searchResultsLoading] ); const handleSlotClick = (pageIndex: number, indexOnPage: number) => { diff --git a/frontend/src/features/display/scryfallReference.ts b/frontend/src/features/display/scryfallReference.ts new file mode 100644 index 000000000..e01e8d823 --- /dev/null +++ b/frontend/src/features/display/scryfallReference.ts @@ -0,0 +1,28 @@ +/** + * Editor-completion package, E17 - the directed-help affordance's v1 cut (owner: "help users find + * an instance where their art is available ... direct them"). Builds a deterministic, zero-crawl + * outbound reference link from what the catalog already knows about an unresolved query - a + * Scryfall printing page when the query names a printing (expansionCode+collectorNumber), else a + * Scryfall name search - never a crawl, never anything stored (same governing-premise posture as + * ArtistSupportLink.tsx). The ranked "known-available" resolver the spec's own Open Q3 floats is a + * real backend dependency, deliberately NOT built here - this is the zero-backend-work v1 only. + */ +import { SearchQuery } from "@/common/types"; + +export function buildScryfallReferenceUrl( + query: SearchQuery | undefined +): string | undefined { + if (query == null) { + return undefined; + } + if (query.expansionCode != null && query.expansionCode.length > 0) { + const set = query.expansionCode.toLowerCase(); + return query.collectorNumber != null && query.collectorNumber.length > 0 + ? `https://scryfall.com/card/${set}/${query.collectorNumber}` + : `https://scryfall.com/search?q=set%3A${encodeURIComponent(set)}`; + } + if (query.query != null && query.query.length > 0) { + return `https://scryfall.com/search?q=${encodeURIComponent(query.query)}`; + } + return undefined; +} diff --git a/frontend/src/features/gridSelector/GridSelectorFilters.tsx b/frontend/src/features/gridSelector/GridSelectorFilters.tsx index 338619255..db266e560 100644 --- a/frontend/src/features/gridSelector/GridSelectorFilters.tsx +++ b/frontend/src/features/gridSelector/GridSelectorFilters.tsx @@ -42,6 +42,14 @@ interface GridSelectorFiltersProps { sourceSettings: SourceSettings; setSourceSettings: (value: SourceSettings) => void; projectFilter: FilterSettings | undefined; // TODO: terrible name for this. + /** Editor-completion package, E4/X4 (Bkg 3/4) - additive, optional section exclusion list. + * `undefined` (every existing caller - GridSelectorModal, CatalogBrowseResults) renders every + * section, unchanged. The /display rail's SelectVersionResults caller passes `["view"]`: the + * rail groups results itself (Bkg 3 - "Group by"/FacetByFilter duplicates that), and the 380px + * rail forces compressed tiles anyway (Bkg 4 - the "Card display style"/Compressed toggle is + * near-inert there and clips at the rail edge) - both controls live inside the one "View" + * accordion (ViewSettings.tsx), so hiding that section kills both in one exclusion. */ + hiddenSections?: Array<"jump" | "view" | "sort" | "filter">; } export const GridSelectorFilters = ({ @@ -59,6 +67,7 @@ export const GridSelectorFilters = ({ sourceSettings, setSourceSettings, projectFilter, + hiddenSections, }: GridSelectorFiltersProps) => { // TODO: // constrain languages according to gloabl search settings @@ -68,76 +77,85 @@ export const GridSelectorFilters = ({ const viewVisible = useAppSelector(selectViewVisible); const sortVisible = useAppSelector(selectSortVisible); const filterVisible = useAppSelector(selectFilterVisible); + const hidden = new Set(hiddenSections ?? []); return ( - dispatch(toggleJumpToVersionVisible())} - zIndex={4} - title={
Jump to Version
} - sticky={false} - pad={2} - > - -
- dispatch(toggleViewVisible())} - zIndex={3} - title={
View
} - sticky={false} - pad={2} - > - -
- dispatch(toggleSortVisible())} - zIndex={2} - title={
Sort
} - sticky={false} - pad={2} - > - -
- dispatch(toggleFilterVisible())} - zIndex={1} - title={
Filter
} - sticky={false} - pad={2} - > - <> - dispatch(toggleJumpToVersionVisible())} + zIndex={4} + title={
Jump to Version
} + sticky={false} + pad={2} + > + - - - -
+ + )} + {!hidden.has("view") && ( + dispatch(toggleViewVisible())} + zIndex={3} + title={
View
} + sticky={false} + pad={2} + > + +
+ )} + {!hidden.has("sort") && ( + dispatch(toggleSortVisible())} + zIndex={2} + title={
Sort
} + sticky={false} + pad={2} + > + +
+ )} + {!hidden.has("filter") && ( + dispatch(toggleFilterVisible())} + zIndex={1} + title={
Filter
} + sticky={false} + pad={2} + > + <> + + + + +
+ )}
); }; diff --git a/frontend/src/features/gridSelector/SelectVersionResults.tsx b/frontend/src/features/gridSelector/SelectVersionResults.tsx index 385be32e8..2eb25bdfa 100644 --- a/frontend/src/features/gridSelector/SelectVersionResults.tsx +++ b/frontend/src/features/gridSelector/SelectVersionResults.tsx @@ -60,7 +60,6 @@ import { GenericErrorPage } from "@/features/ui/GenericErrorPage"; import { APISubmitTagVote } from "@/store/api"; import { selectCardDocumentsByIdentifiers } from "@/store/slices/cardDocumentsSlice"; import { setNotification } from "@/store/slices/toastsSlice"; -import { selectCompressed } from "@/store/slices/viewSettingsSlice"; const ATTRIBUTE_TAG_NAMES = new Set( ALL_ATTRIBUTE_CHIPS.map((chip) => chip.tagName) @@ -359,6 +358,19 @@ interface SelectVersionResultsProps { search: GridSelectorSearch; requestedPrinting: RequestedPrinting | undefined; backendURL: string; + /** Editor-completion package, E3/X3 (Bkg 2/4/5) - additive, optional layout switch. Default + * `"sidebar"` is today's unchanged `Col lg={3}` filters-beside-results split (this component's + * one existing caller, DisplayPage.tsx's ChooseImageSection, is about to become the rail's + * "stacked" caller below - there is no other caller today, but the prop stays optional/ + * additive per the standing "shared components gain only additive props" discipline). + * `"stacked"` drops the Row/Col split entirely: the disclosed Filters render full-width, top- + * to-bottom in the rail's own scroll container (fixes "Jump to Version" wrapping vertically + * and the bottom controls clipping at the rail edge), and the always-on FilterChipBar - a + * permanent multi-row height sink even when unused - is removed from this surface (folded into + * the Filters disclosure isn't needed since the chip bar's own filtering is orthogonal to the + * Filter/Sort/Jump controls; it simply doesn't render in the rail at all, per the redline's + * kill list). */ + layout?: "sidebar" | "stacked"; } export function SelectVersionResults({ @@ -369,9 +381,17 @@ export function SelectVersionResults({ search, requestedPrinting, backendURL, + layout = "sidebar", }: SelectVersionResultsProps) { const getTagDisplayName = useTagDisplayName(); - const compressed = useAppSelector(selectCompressed); + // Editor-completion package, E4/L9 (Bkg 4) - this component's one caller is the /display rail + // (see this file's own module comment), which the redline pins to always-compressed tiles: the + // 380px rail forces compact tiles regardless, and the "Card display style" toggle that would + // otherwise flip this is killed from the rail's Filters disclosure (GridSelectorFilters' + // hiddenSections=["view"] below) - so this no longer reads the shared, editor-wide + // viewSettingsSlice.compressed flag at all, to guarantee the rail can never render uncompressed + // tiles even if that shared flag happens to be false from some other surface. + const compressed = true; const cardDocumentsByIdentifier = useAppSelector((state) => selectCardDocumentsByIdentifiers(state, search.sortedFilteredIdentifiers) ); @@ -549,26 +569,107 @@ export function SelectVersionResults({ groups.nonCanonical.length === 0 && groups.unknown.length === 0; + const filtersElement = ( + + ); + + // E4/Bkg 1 - the always-on FilterChipBar wall is a permanent multi-row height sink; removed + // from the stacked (rail) surface entirely, kept for the sidebar layout (no other caller today, + // but this stays additive per the standing discipline - see this component's own prop comment). + const resultsElement = ( + <> + {layout !== "stacked" && ( + + )} + {groups.canonical.length > 0 && ( +
+ {groups.canonical.map(renderPrintingGroup)} +
+ )} + {groups.nonCanonical.length > 0 && ( +
+ {groups.nonCanonical.map(renderReasonTagGroup)} +
+ )} + {groups.unknown.length > 0 && ( +
+ {groups.unknown.map((identifier) => { + // No printing/reason-tag identity to label this tile with (the "honest residue" - + // see selectVersionGrouping.ts) - falls back to the same "Option N" numbering the + // flat grid this section replaces always used (search.originalIndexMap, the same + // map GridSelectorResults/CardResultSet already thread through for consistent + // numbering), rather than inventing a new, less informative label. + const originalIndex = search.originalIndexMap.get(identifier); + const label = + originalIndex != null ? `Option ${originalIndex + 1}` : "Unknown"; + return ( + + ); + })} +
+ )} + {noResults && ( + + )} + + ); + + if (layout === "stacked") { + // E3/X3 (Bkg 2/4/5) - no Row/Col split at all: the disclosed Filters (when settingsVisible) + // render full-width, top-to-bottom, in the rail's own overflow-y:auto scroll container - the + // `.rail .sv-filters` lifted-CSS hook (E1/§5) targets this wrapper. E4/Bkg 1 - the + // FilterChipBar moves IN here too (moment (b)'s art-as-filter mechanic is real, shipped + // behavior - the redline's L5 fix folds the wall into the Filters disclosure rather than + // deleting it outright, so it's still reachable, just no longer a permanent height sink atop + // every result). + return ( +
+ {search.settingsVisible && ( +
+ + {filtersElement} +
+ )} + {resultsElement} +
+ ); + } + return ( {search.settingsVisible && ( - + {filtersElement} )} - - {groups.canonical.length > 0 && ( -
- {groups.canonical.map(renderPrintingGroup)} -
- )} - {groups.nonCanonical.length > 0 && ( -
- {groups.nonCanonical.map(renderReasonTagGroup)} -
- )} - {groups.unknown.length > 0 && ( -
- {groups.unknown.map((identifier) => { - // No printing/reason-tag identity to label this tile with (the "honest residue" - - // see selectVersionGrouping.ts) - falls back to the same "Option N" numbering the - // flat grid this section replaces always used (search.originalIndexMap, the same - // map GridSelectorResults/CardResultSet already thread through for consistent - // numbering), rather than inventing a new, less informative label. - const originalIndex = search.originalIndexMap.get(identifier); - const label = - originalIndex != null - ? `Option ${originalIndex + 1}` - : "Unknown"; - return ( - - ); - })} -
- )} - {noResults && ( - - )} + {resultsElement}
); diff --git a/frontend/src/features/gridSelector/useGridSelectorSearch.ts b/frontend/src/features/gridSelector/useGridSelectorSearch.ts index cdca03fd8..f4ef89482 100644 --- a/frontend/src/features/gridSelector/useGridSelectorSearch.ts +++ b/frontend/src/features/gridSelector/useGridSelectorSearch.ts @@ -42,12 +42,19 @@ export interface UseGridSelectorSearchArgs { active: boolean; /** When false, ignore project-level search settings and use unconstrained defaults instead. */ applySearchSettings?: boolean; + /** Editor-completion package, E3/X2 (Bkg 5) - additive, optional override for the initial + * `settingsVisible` value. `undefined` (every existing caller - GridSelectorModal) preserves + * today's width-based default below; the /display rail passes `false` so the Filters + * disclosure starts collapsed there regardless of viewport width, instead of auto-opening + * cramped inside the 380px rail. */ + initialSettingsVisible?: boolean; } export function useGridSelectorSearch({ imageIdentifiers, active, applySearchSettings = true, + initialSettingsVisible, }: UseGridSelectorSearchArgs) { //# region queries and hooks @@ -66,8 +73,9 @@ export function useGridSelectorSearch({ const [settingsVisible, setSettingsVisible] = useState( () => - typeof window === "undefined" || - window.innerWidth >= SmallViewportFiltersBreakpointPx + initialSettingsVisible ?? + (typeof window === "undefined" || + window.innerWidth >= SmallViewportFiltersBreakpointPx) ); const [filterSettings, setFilterSettings] = useState( diff --git a/frontend/src/features/pdf/PagePreview.tsx b/frontend/src/features/pdf/PagePreview.tsx index 2c26d2197..5d0fbcb7c 100644 --- a/frontend/src/features/pdf/PagePreview.tsx +++ b/frontend/src/features/pdf/PagePreview.tsx @@ -13,8 +13,28 @@ * `transform: scale()` to fit the available preview width - so every measurement inside stays * in mm, matching computeLayout()'s own units, and only the single outer transform changes * when the preview panel resizes. + * + * Editor-completion package (E18/E19/E20/X18/X19) - the screenPresentation variant (R7/D17) + * gained three more screen-only treatments, all gated on that same prop so PDFGenerator's own + * fast preview (screenPresentation's default, false) is completely unaffected: + * - E20 (the no-white invariant): the slot fill switches from the light `#d9d9d9` placeholder + * to the theme's dark field color, always - this is what kills the ``'s own pre-paint + * white flash too (the background sits behind the img the whole time it's decoding). + * - E18 (dark empty/loading/failed states): a slot with no resolved image renders a distinct- + * grey pinline (never the same line as the page's own pinline) plus either an indeterminate + * orange loading sweep or a muted "no art" mark + directed-help link, additively alongside + * the existing name/query-text label (item 1, owner's hands-on review) - never replacing it, + * so a slot's own accessible name/query text stays exactly as findable as before. + * - E19 (lime rounded corner-only cut guides): the screen-side guide render swaps the full + * dashed-rectangle trim line for four small corner L-brackets at true scale, matching the + * mockup's redline. PDFGenerator's own fast preview keeps today's full-rectangle + * approximation - this is a screen-only restyle of the /display sheet, not a new shared + * default. See PagePreview's own PDF-parity note further down for why the ACTUAL exported + * PDF's guide style is out of this task's scope, not silently left inconsistent. */ +import { keyframes } from "@emotion/react"; +import styled from "@emotion/styled"; import React, { useMemo } from "react"; import { CardHeightMM, CardWidthMM } from "@/common/constants"; @@ -24,6 +44,84 @@ import { LayoutSpacing, } from "@/features/pdf/layout"; +// E20 - the anti-white-flash fill: sits behind both the slot itself and every it renders, +// so there's never a frame where an empty/loading slot or a still-decoding image shows white. +const SCREEN_SLOT_BG = "#2B3E50"; +// E18 - deliberately distinct from the page's own pinline (rgba(235,235,235,.18), see this +// component's screenPresentation page-border rule above) so the two never read as the same line. +const SCREEN_SLOT_PINLINE = "rgba(143, 160, 176, 0.4)"; +const SCREEN_MUTED_TEXT = "#8fa0b0"; +const LIME_GUIDE_COLOR = "#8ae234"; + +// E18 - the indeterminate loading sweep. `prefers-reduced-motion` gets a static bar at a fixed +// position instead of an animated one, matching E11's own reduced-motion rule elsewhere in this +// package (no transform/opacity animation under that preference). +const loadingSweep = keyframes` + 0% { transform: translateX(-20%); } + 100% { transform: translateX(240%); } +`; + +const LoadingTrack = styled.div` + position: absolute; + left: 12%; + right: 12%; + top: calc(50% - 2px); + height: 4px; + border-radius: 2px; + background: #22303f; + overflow: hidden; +`; + +const LoadingSweep = styled.div` + position: absolute; + left: 12%; + top: calc(50% - 2px); + height: 4px; + width: 35%; + border-radius: 2px; + background: #df6919; + animation: ${loadingSweep} 1.1s ease-in-out infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none; + width: 60%; + } +`; + +// E19 - the lime, rounded, corner-only cut guide: a small L-bracket (two legs) at each of a +// card's four trim corners, replacing the full dashed-rectangle trim line the screenPresentation +// sheet used to draw. Dimensions match the mockup's own rendered interpretation ("0.6mm stroke + +// ~3mm legs" - see the design spec's own flagged-for-visual-approval note): real mm units, so the +// legs/stroke scale at true sheet scale automatically via this component's one outer +// `transform: scale()`, no cqw container-query trick needed (unlike the static mockup, this is a +// live React tree already inside that transform). +const CUT_GUIDE_LEG_MM = 3; +const CUT_GUIDE_STROKE_MM = 0.6; + +const CutCornerLeg = styled.div<{ + axis: "horizontal" | "vertical"; + corner: "tl" | "tr" | "bl" | "br"; +}>` + position: absolute; + background: ${LIME_GUIDE_COLOR}; + border-radius: ${CUT_GUIDE_STROKE_MM / 2}mm; + width: ${(props) => + props.axis === "horizontal" ? CUT_GUIDE_LEG_MM : CUT_GUIDE_STROKE_MM}mm; + height: ${(props) => + props.axis === "horizontal" ? CUT_GUIDE_STROKE_MM : CUT_GUIDE_LEG_MM}mm; + ${(props) => + props.corner === "tl" || props.corner === "bl" ? "left: 0;" : "right: 0;"} + ${(props) => + props.corner === "tl" || props.corner === "tr" ? "top: 0;" : "bottom: 0;"} +`; + +const CutCornerMark = ({ corner }: { corner: "tl" | "tr" | "bl" | "br" }) => ( + <> + + + +); + // Matches the CSS spec's own absolute definition of "mm" (96px per inch, 25.4mm per inch) - // used only to size the outer non-scaled wrapper so it doesn't reserve extra blank space // around the scaled-down page; every measurement on the page itself stays in real "mm" units. @@ -50,6 +148,17 @@ export interface PagePreviewSlotContent { * "still waiting on art". Typically the slot's own search query text; `undefined` when there's * genuinely no query to show (e.g. a shared-cardback slot). Ignored when imageUrl is set. */ queryText?: string; + /** Editor-completion package, E17/E18 (X18) - only meaningful on the screenPresentation + * variant, and only for a slot with no resolved imageUrl. `"loading"` renders the dark + * indeterminate sweep (candidates/image still being fetched); `"failed"` renders the muted "no + * art" mark + (when findCardUrl is set) the directed-help link. `undefined` (every non-/display + * caller, and any /display slot with no active query at all - e.g. a shared-cardback back + * face) renders neither, same as before this round. */ + loadState?: "loading" | "failed"; + /** Editor-completion package, E17 - a deterministic Scryfall reference link + * (scryfallReference.ts), shown only alongside `loadState === "failed"`. `undefined` renders no + * link (there was nothing in the query to build one from). */ + findCardUrl?: string; } export interface PagePreviewProps { @@ -183,7 +292,14 @@ export function PagePreview({ width: slotWidthMM + "mm", height: slotHeightMM + "mm", overflow: "hidden", - background: "#d9d9d9", + // E20 - always the dark field color on the screenPresentation variant (never just + // for empty/loading/failed slots): this is what's behind the below too, so + // there's no white pre-paint flash while a filled slot's own image decodes. + background: screenPresentation ? SCREEN_SLOT_BG : "#d9d9d9", + border: + screenPresentation && content?.loadState != null + ? `1px solid ${SCREEN_SLOT_PINLINE}` + : undefined, cursor: onSlotClick != null ? "pointer" : undefined, outline: isSelected ? "3px solid #df691a" : undefined, outlineOffset: isSelected ? "-3px" : undefined, @@ -201,6 +317,12 @@ export function PagePreview({ objectFit: "cover", display: "block", pointerEvents: "none", + // E20 - the img's own box, pre-paint (impl note from the spec: set it on the + // slot container AND the img itself - the flash is the img's own box, not just + // its parent's). + backgroundColor: screenPresentation + ? SCREEN_SLOT_BG + : undefined, }} /> )} @@ -217,7 +339,7 @@ export function PagePreview({ gap: "1mm", padding: "2mm", textAlign: "center", - color: "#4a4a4a", + color: screenPresentation ? SCREEN_MUTED_TEXT : "#4a4a4a", pointerEvents: "none", }} > @@ -243,22 +365,76 @@ export function PagePreview({ {content.queryText} )} + {/* E17/E18 - additive to the name/query-text label above (never replacing it): + a slim indeterminate loading sweep while candidates/the image are still + being fetched, or a muted "no art" mark + directed-help link once that's + settled with nothing found. Screen-only, gated on loadState (undefined for + every non-/display caller and any /display slot with no active query at + all). */} + {screenPresentation && content.loadState === "loading" && ( + + + + )} + {screenPresentation && content.loadState === "failed" && ( +
+ ✗ no art + {content.findCardUrl != null && ( + + )} +
+ )}
)} - {showCutLines && ( -
- )} + {showCutLines && + (screenPresentation ? ( +
+ + + + +
+ ) : ( +
+ ))} {content?.willGenerateBleed === true && (
{ await expect(page.getByTestId("display-rail-idle")).toBeVisible(); }); - test("selecting a slot swaps the rail from idle to that slot's header + accordion, defaulting to Choose Image open", async ({ + test("selecting a slot swaps the rail from idle to that slot's header + promoted zone, with Select Version always open", async ({ page, network, }) => { @@ -186,19 +186,21 @@ test.describe("DisplayPage (Proposal H, Step 1)", () => { await expect(railHeader).toContainText("Slot 1"); await expect(railHeader).toContainText("front"); - // Compressed view (viewSettingsSlice's real, hardcoded default) renders only the bare card - // image - no per-card "Option N" text - so toggle it off first, same precedent as - // CardSlot.spec.ts's own version-picker test. - await page.getByText("Compressed").click(); - - // Choose Image is open by default (real candidate grid, wired in PR 2a); the other four - // sections start collapsed - per the owner's accordion amendment (design doc §2). - await expect(page.getByText("Option 1")).toBeVisible(); + // Editor-completion package (E2/E3/L4) - Select Version is promoted + always open (no + // collapse chrome at all, renamed from "Choose Image"); the rail hard-pins compressed=true + // (E4/L9), so the candidate tiles never show "Option N" text - the tile's own is + // the reliable selector instead (see the next test). + await expect( + page.getByRole("heading", { name: "Select Version" }) + ).toBeVisible(); + await expect( + page.getByTestId("display-rail-content").getByAltText(cardDocument1.name) + ).toBeVisible(); await expect(page.getByRole("button", { name: /Filters/ })).toBeVisible(); await expect(page.getByTestId("attribute-chip-Full Art")).not.toBeVisible(); }); - test("selecting a candidate image in Choose Image updates the sheet's slot immediately", async ({ + test("selecting a candidate image in Select Version updates the sheet's slot immediately", async ({ page, network, }) => { @@ -209,11 +211,11 @@ test.describe("DisplayPage (Proposal H, Step 1)", () => { const sheetSlot = page.getByTestId("page-preview-slot").first(); await sheetSlot.click(); - // Compressed view (the default) hides "Option N" text entirely - see the previous test. - await page.getByText("Compressed").click(); // searchResultsThreeResults (mocks/handlers.ts) resolves "my search query" to - // [cardDocument1, cardDocument2, cardDocument3] in that order - Option 1 is cardDocument1. - await page.getByText("Option 2").click(); + // [cardDocument1, cardDocument2, cardDocument3] in that order. The rail always renders + // compressed tiles (E4/L9), so there's no "Option N" text to click - the tile's own + // (present in both compressed and uncompressed rendering, Card.tsx) is what's clickable. + await page.getByAltText(cardDocument2.name).click(); await expect(sheetSlot.locator("img")).toHaveAttribute("alt", "Card 2"); // The rail's own header (identity text) reflects the same real-time selection - same Redux @@ -223,7 +225,7 @@ test.describe("DisplayPage (Proposal H, Step 1)", () => { ); }); - test("the embedded Choose Image section has no OverflowCol-style forced scroll region (would double-scroll inside the rail)", async ({ + test("the embedded Select Version section has no OverflowCol-style forced scroll region (would double-scroll inside the rail)", async ({ page, network, }) => { @@ -232,10 +234,10 @@ test.describe("DisplayPage (Proposal H, Step 1)", () => { await importText(page, "my search query"); await page.getByRole("link", { name: "Editor" }).click(); await page.getByTestId("page-preview-slot").first().click(); - // Compressed view (the default) hides "Option N" text entirely - see the earlier tests. - await page.getByText("Compressed").click(); - const candidateCard = page.getByText("Option 1"); + const candidateCard = page + .getByTestId("display-rail-content") + .getByAltText(cardDocument1.name); await expect(candidateCard).toBeVisible(); // GridSelectorResults' "modal" variant wraps this in an OverflowCol, which sets // overflow-y: scroll unconditionally - a second, competing scroll region nested inside the @@ -288,20 +290,19 @@ test.describe("DisplayPage (Proposal H, Step 1)", () => { const slots = page.getByTestId("page-preview-slot"); await slots.first().click(); - // Compressed view (the default) hides "Option N" text entirely - see the earlier tests. - // This is a global view setting, not slot-specific state, so toggling it once here holds - // for the rest of the test (including after selecting the second slot below). - await page.getByText("Compressed").click(); await page .getByRole("heading", { name: "Attributes", exact: true }) .click(); await expect(page.getByTestId("attribute-chip-Full Art")).toBeVisible(); // Selecting the other real slot swaps the rail's whole subtree - Attributes should be back - // to collapsed, not still expanded from the last slot. + // to collapsed, not still expanded from the last slot. Select Version (always open, E2/E3) + // still shows its candidate tile regardless. await slots.nth(1).click(); await expect(page.getByTestId("attribute-chip-Full Art")).not.toBeVisible(); - await expect(page.getByText("Option 1")).toBeVisible(); + await expect( + page.getByTestId("display-rail-content").getByAltText(cardDocument1.name) + ).toBeVisible(); }); test("the Fronts/Backs toggle button reflects the shared frontsVisible view setting", async ({ @@ -593,7 +594,7 @@ test.describe("DisplayPage (Proposal H, Step 1)", () => { ).toHaveCount(0); }); - test("the Confirm? affordance's NO expands the Choose Image accordion section instead of opening a modal (the rail has no modal to open)", async ({ + test("the Confirm? affordance's NO is a no-op on Select Version's always-open state (E2/E3 promoted the section, so there's nothing left to expand)", async ({ page, network, }) => { @@ -612,14 +613,10 @@ test.describe("DisplayPage (Proposal H, Step 1)", () => { await page.getByRole("link", { name: "Editor" }).click(); await page.getByTestId("page-preview-slot").first().click(); - // Choose Image is open by default - collapse it first so NO's "expand it" effect is - // observable, rather than trivially already true. - await page - .getByRole("heading", { name: "Choose Image", exact: true }) - .click(); - await expect( - page.getByRole("button", { name: /Filters/ }) - ).not.toBeVisible(); + // Select Version is promoted + always open now (E2/E3/L4) - no accordion left for NO to + // "expand"; onOpenGridSelector is a documented no-op (see RailHeader's own comment). The + // surface is visible both before and after clicking NO. + await expect(page.getByRole("button", { name: /Filters/ })).toBeVisible(); const header = page.getByTestId("display-rail-header"); await header.getByTestId("deckbuilder-confirm-badge").hover(); @@ -771,7 +768,7 @@ test.describe("DisplayPage (Proposal H, Step 1)", () => { await expect(select).toHaveValue("force-bleed"); }); - test("the Artist section shows a support link for a card with a known canonical artist", async ({ + test("the promoted artist line shows a support link for a card with a known canonical artist (E2 - promoted, no longer a collapsed accordion)", async ({ page, network, }) => { @@ -786,7 +783,6 @@ test.describe("DisplayPage (Proposal H, Step 1)", () => { await importText(page, "1 card 8 (xyz) 001"); await page.getByRole("link", { name: "Editor" }).click(); await page.getByTestId("page-preview-slot").first().click(); - await page.getByRole("heading", { name: "Artist", exact: true }).click(); const link = page.getByTestId("artist-support-link"); await expect(link).toBeVisible(); diff --git a/frontend/tests/DisplaySlotStates.spec.ts b/frontend/tests/DisplaySlotStates.spec.ts new file mode 100644 index 000000000..f50680e30 --- /dev/null +++ b/frontend/tests/DisplaySlotStates.spec.ts @@ -0,0 +1,173 @@ +import { expect } from "@playwright/test"; +import { http, HttpResponse } from "msw"; + +import { cardDocument1 } from "@/common/test-constants"; +import { + cardDocumentsNoResults, + cardDocumentsOneResult, + defaultHandlers, + searchResultsNoResults, + searchResultsOneResult, + sourceDocumentsOneResult, +} from "@/mocks/handlers"; + +import { test } from "../playwright.setup"; +import { loadPageWithDefaultBackend } from "./test-utils"; + +/** + * Editor-completion package (E18/E20, X18) - the /display sheet's dark empty/loading/failed + * states, and the no-white invariant they all share. Every assertion here reads real computed + * styles (`getComputedStyle`), not class names or inline-style source text - the same discipline + * DisplayPage.spec.ts's own requested-printing-badge color test already follows, since a stray + * cascade rule could otherwise silently paint white even where the source sets a dark value. + */ + +function parseRGB(color: string): [number, number, number] { + const parts = color.match(/\d+(\.\d+)?/g); + if (parts == null || parts.length < 3) { + throw new Error(`unparsable color: ${color}`); + } + return [Number(parts[0]), Number(parts[1]), Number(parts[2])]; +} + +function expectNotWhite(rgb: [number, number, number]) { + const [r, g, b] = rgb; + expect(r === 255 && g === 255 && b === 255).toBe(false); +} + +test.describe("DisplayPage sheet slot states (editor-completion, E18/E20)", () => { + test("a filled slot's own dark anti-flash background is never white, on both the slot and its ", async ({ + page, + network, + }) => { + network.use( + cardDocumentsOneResult, + sourceDocumentsOneResult, + searchResultsOneResult, + ...defaultHandlers + ); + await loadPageWithDefaultBackend(page, "display"); + await page + .getByRole("textbox", { name: "import-text" }) + .fill("my search query"); + await page.getByRole("button", { name: "import-text-submit" }).click(); + await expect(page.getByTestId("display-page")).toBeVisible(); + + const slot = page.getByTestId("page-preview-slot").first(); + const img = slot.locator("img"); + await expect(img).toHaveAttribute("alt", cardDocument1.name); + + const slotBg = await slot.evaluate( + (el) => getComputedStyle(el).backgroundColor + ); + const imgBg = await img.evaluate( + (el) => getComputedStyle(el).backgroundColor + ); + expectNotWhite(parseRGB(slotBg)); + expectNotWhite(parseRGB(imgBg)); + }); + + test("a slot still awaiting search results renders the dark loading state, never white", async ({ + page, + network, + }) => { + // A genuinely never-resolving request (same precedent as CardImageStates.spec.ts's own + // "never resolves" test) keeps searchResultsSlice's status at "loading" indefinitely, so the + // slot's own loadState="loading" branch is deterministically observable rather than racing a + // real fetch that might settle before the assertion runs. + network.use( + http.post( + /\/3\/editorSearch\/$/, + () => new Promise(() => undefined) + ), + cardDocumentsOneResult, + sourceDocumentsOneResult, + ...defaultHandlers + ); + await loadPageWithDefaultBackend(page, "display"); + await page + .getByRole("textbox", { name: "import-text" }) + .fill("my search query"); + await page.getByRole("button", { name: "import-text-submit" }).click(); + await expect(page.getByTestId("display-page")).toBeVisible(); + + const slot = page.getByTestId("page-preview-slot").first(); + await expect(slot.locator("img")).toHaveCount(0); + const loadingBar = slot.getByTestId("page-preview-slot-loading"); + await expect(loadingBar).toBeVisible(); + + const slotBg = await slot.evaluate( + (el) => getComputedStyle(el).backgroundColor + ); + expectNotWhite(parseRGB(slotBg)); + const trackBg = await loadingBar.evaluate( + (el) => getComputedStyle(el).backgroundColor + ); + expectNotWhite(parseRGB(trackBg)); + }); + + test("a slot with genuinely no candidate images renders the dark failed state (muted ✗ + a deterministic 'Find this card' link), never white", async ({ + page, + network, + }) => { + network.use( + cardDocumentsNoResults, + sourceDocumentsOneResult, + searchResultsNoResults, + ...defaultHandlers + ); + await loadPageWithDefaultBackend(page, "display"); + await page + .getByRole("textbox", { name: "import-text" }) + .fill("an unfindable card"); + await page.getByRole("button", { name: "import-text-submit" }).click(); + await expect(page.getByTestId("display-page")).toBeVisible(); + + const slot = page.getByTestId("page-preview-slot").first(); + await expect(slot.locator("img")).toHaveCount(0); + await expect(slot.getByTestId("page-preview-slot-failed")).toBeVisible(); + await expect(slot.getByTestId("page-preview-slot-failed")).toContainText( + "no art" + ); + + // E17 v1 - the deterministic, zero-backend Scryfall reference link (scryfallReference.ts): + // a name-only query (no expansionCode/collectorNumber) falls back to a plain name search. + const findLink = slot.getByTestId("page-preview-find-card-link"); + await expect(findLink).toBeVisible(); + await expect(findLink).toHaveAttribute( + "href", + "https://scryfall.com/search?q=an%20unfindable%20card" + ); + + const slotBg = await slot.evaluate( + (el) => getComputedStyle(el).backgroundColor + ); + expectNotWhite(parseRGB(slotBg)); + }); + + test("the always-visible Select Version empty state also offers the directed-help Scryfall link (E17's other surface)", async ({ + page, + network, + }) => { + network.use( + cardDocumentsNoResults, + sourceDocumentsOneResult, + searchResultsNoResults, + ...defaultHandlers + ); + await loadPageWithDefaultBackend(page, "display"); + await page + .getByRole("textbox", { name: "import-text" }) + .fill("an unfindable card"); + await page.getByRole("button", { name: "import-text-submit" }).click(); + await expect(page.getByTestId("display-page")).toBeVisible(); + await page.getByTestId("page-preview-slot").first().click(); + + const link = page.getByTestId("display-select-version-find-card-link"); + await expect(link).toBeVisible(); + await expect(link).toHaveAttribute( + "href", + "https://scryfall.com/search?q=an%20unfindable%20card" + ); + }); +}); diff --git a/frontend/tests/SelectVersionSection.spec.ts b/frontend/tests/SelectVersionSection.spec.ts index 213329e82..3ec73002a 100644 --- a/frontend/tests/SelectVersionSection.spec.ts +++ b/frontend/tests/SelectVersionSection.spec.ts @@ -26,7 +26,8 @@ const selectVersionHandlers = [ // Issue #167 - the unified Select Version section // (docs/proposals/proposal-h-unified-display-page.md §4.4′), mounted as the display page rail's -// "Choose Image" accordion body. cardDocumentsSelectVersionMixedResults/ +// always-open "Select Version" surface (editor-completion package, E2/E3/L4 - promoted, renamed +// from "Choose Image", no longer a collapsible accordion). cardDocumentsSelectVersionMixedResults/ // searchResultsSelectVersionMixedResults (mocks/handlers.ts) cover all three of the spec's // groups in one result set - see those fixtures' own comments for the exact shape. test.describe("SelectVersionSection (issue #167)", () => { @@ -39,9 +40,15 @@ test.describe("SelectVersionSection (issue #167)", () => { await importText(page, "my search query"); await page.getByRole("link", { name: "Editor" }).click(); await page.getByTestId("page-preview-slot").first().click(); - // Compressed view (the real, hardcoded default) hides per-card header text - same precedent - // as DisplayPage.spec.ts's own tests. - await page.getByText("Compressed").click(); + // The rail always renders compressed tiles now (editor-completion package, E4/L9 - the + // toggle is gone entirely, hard-pinned true) - no "Compressed" click needed any more. + }; + + // The filter-chip bar moved inside the Filters disclosure (editor-completion package, E4/Bkg 1 + // - see SelectVersionResults.tsx's own comment); the rail also starts with Filters collapsed + // by default (E3/Bkg 5), so the two filter-chip-bar tests below need it opened first. + const openFilters = async (page: import("@playwright/test").Page) => { + await page.getByRole("button", { name: /Filters/ }).click(); }; test("groups candidates into canonical (by printing), non-canonical (by reason tag), and unknown sections", async ({ @@ -136,6 +143,7 @@ test.describe("SelectVersionSection (issue #167)", () => { }) => { network.use(...selectVersionHandlers); await openSelectVersionSection(page); + await openFilters(page); // cardDocument18 (in the unknown group) has a resolved "Full Art" tag - its "More like this" // button should activate the Full Art filter chip. @@ -161,6 +169,7 @@ test.describe("SelectVersionSection (issue #167)", () => { }) => { network.use(...selectVersionHandlers); await openSelectVersionSection(page); + await openFilters(page); // Manually activate the "Old Border" filter chip - cardDocument18 carries a *suggested* (not // resolved) Old Border vote, so it should still be filtered in (per this task's documented