+
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 && (
- {
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