diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts
index 5fa6b72d6..2f6af8f53 100644
--- a/frontend/jest.setup.ts
+++ b/frontend/jest.setup.ts
@@ -29,6 +29,16 @@ configureReact({ asyncUtilTimeout: 10_000 });
configureDom({ asyncUtilTimeout: 10_000 });
// retrieved from https://stackoverflow.com/a/68539103/13021511
+// Rail-delegacy round (SPEC-rail-delegacy.md) - `addEventListener`/`removeEventListener` added
+// alongside the deprecated `addListener`/`removeListener` pair this polyfill already carried:
+// `useViewportTier.ts` (display/useViewportTier.ts) calls the modern
+// `MediaQueryList.addEventListener("change", ...)` form, which real browsers and jsdom's own
+// native `matchMedia` both support - this polyfill hadn't caught up, so any component mounting
+// that hook under Jest (SelectVersionResults.tsx, this round) threw
+// "mql.addEventListener is not a function" the moment its effect ran. `matches: false` still
+// resolves every tier query to false, which `useViewportTier.ts`'s own fallback chain reads as
+// "desktop" - unchanged behavior for every existing caller, just no longer a hard crash for a new
+// one.
global.matchMedia =
global.matchMedia ||
function () {
@@ -36,6 +46,8 @@ global.matchMedia =
matches: false,
addListener: function () {},
removeListener: function () {},
+ addEventListener: function () {},
+ removeEventListener: function () {},
};
};
diff --git a/frontend/src/features/card/RequestedPrintingBadge.tsx b/frontend/src/features/card/RequestedPrintingBadge.tsx
index c6a7b7dbc..69a4f3928 100644
--- a/frontend/src/features/card/RequestedPrintingBadge.tsx
+++ b/frontend/src/features/card/RequestedPrintingBadge.tsx
@@ -17,9 +17,21 @@ import { selectIsSearchQueryDegraded } from "@/store/slices/searchResultsSlice";
interface RequestedPrintingBadgeProps {
query: SearchQuery | undefined;
+ /** Rail-delegacy round (SPEC-rail-delegacy.md §C/RD7) - additive, optional pair. When
+ * `showOnlyOnMismatch` is true, the badge renders ONLY when `resolvedPrinting` (the card's
+ * `canonicalCard` ?? `suggestedCanonicalCard`) is missing or names a different printing than
+ * `query` requested - a genuine mismatch worth flagging, never a static second copy of an
+ * identity the D14 confidence band already shows once. `undefined`/`false` (every existing
+ * caller - CardSlot.tsx) preserves today's always-show-when-requested behavior untouched. */
+ showOnlyOnMismatch?: boolean;
+ resolvedPrinting?: { expansionCode: string; collectorNumber: string } | null;
}
-export function RequestedPrintingBadge({ query }: RequestedPrintingBadgeProps) {
+export function RequestedPrintingBadge({
+ query,
+ showOnlyOnMismatch = false,
+ resolvedPrinting = null,
+}: RequestedPrintingBadgeProps) {
// Called unconditionally on every render of this component regardless of whether the badge
// ends up rendering anything - satisfies the rules-of-hooks the same way DisplayPage.tsx's own
// Rail component previously had to (see its own comment on why this selector runs ahead of any
@@ -39,10 +51,47 @@ export function RequestedPrintingBadge({ query }: RequestedPrintingBadgeProps) {
return null;
}
+ const isMismatch =
+ resolvedPrinting == null ||
+ resolvedPrinting.expansionCode.toLowerCase() !==
+ query.expansionCode.toLowerCase() ||
+ (query.collectorNumber != null &&
+ resolvedPrinting.collectorNumber !== query.collectorNumber);
+
+ if (showOnlyOnMismatch && !isMismatch) {
+ return null;
+ }
+
const printingBadge = `${query.expansionCode.toUpperCase()}${
query.collectorNumber ? " " + query.collectorNumber : ""
}`;
+ // The rail-delegacy round's `.mismatch` flag (SPEC-rail-delegacy.md §D.2) is a single warning-
+ // coloured style, not the two-state plain/degraded badge look every other caller keeps - see
+ // that table's `.rhead .mismatch` row (`10px` mono, `#ffc107`/`#111`, `padding:1px 7px`).
+ if (showOnlyOnMismatch) {
+ return (
+
+ requested ≠ shown: {printingBadge}
+
+ );
+ }
+
return (
- {" "}
- {cardDocument.canonicalCard.expansionCode.toUpperCase()}{" "}
- {cardDocument.canonicalCard.collectorNumber}
- >
- ) : (
- "Unknown"
- ),
- ],
+ ...(showCanonicalCard
+ ? [
+ [
+ "Canonical Card",
+ cardDocument.canonicalCard ? (
+ <>
+ {" "}
+ {cardDocument.canonicalCard.expansionCode.toUpperCase()}{" "}
+ {cardDocument.canonicalCard.collectorNumber}
+ >
+ ) : (
+ "Unknown"
+ ),
+ ],
+ ]
+ : []),
[
"Canonical Aritst",
cardDocument.canonicalArtist != null ? (
diff --git a/frontend/src/features/display/DisplayPage.tsx b/frontend/src/features/display/DisplayPage.tsx
index 1db98d24c..65f8a609d 100644
--- a/frontend/src/features/display/DisplayPage.tsx
+++ b/frontend/src/features/display/DisplayPage.tsx
@@ -213,6 +213,7 @@ import React, {
import { Accordion } from "react-bootstrap";
import Button from "react-bootstrap/Button";
import Col from "react-bootstrap/Col";
+import Collapse from "react-bootstrap/Collapse";
import Form from "react-bootstrap/Form";
import Offcanvas, { OffcanvasPlacement } from "react-bootstrap/Offcanvas";
import Row from "react-bootstrap/Row";
@@ -233,7 +234,6 @@ import {
useAppSelector,
} from "@/common/types";
import { useLongPress } from "@/common/useLongPress";
-import { AutofillCollapse } from "@/components/AutofillCollapse";
import { RightPaddedIcon } from "@/components/icon";
import { RenderIfVisible } from "@/components/RenderIfVisible";
import { CardSlotContextMenu } from "@/features/card/CardSlotContextMenu";
@@ -247,7 +247,6 @@ import {
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";
@@ -355,124 +354,141 @@ const SHEET_MAX_WIDTH_PX = 960;
//# endregion
-//# region accordion sections
+//# region rail-delegacy round (SPEC-rail-delegacy.md) - the nine grey AutofillCollapse 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 =
- | "cardDetails"
- | "attributes"
- | "printingTags"
- | "printOptions"
- | "slotActions"
- | "report";
-
-const DEFAULT_EXPANDED: Record = {
- cardDetails: false,
- attributes: false,
- printingTags: false,
- printOptions: false,
- slotActions: false,
- report: false,
-};
-
-interface RailSectionProps {
- sectionKey: AccordionSectionKey;
- title: string;
- expandedSections: Record;
- onToggle: (key: AccordionSectionKey) => void;
- children: React.ReactElement;
-}
-
-const RailSection = ({
- sectionKey,
- title,
- expandedSections,
- onToggle,
- children,
-}: RailSectionProps) => (
- {title}}
- expanded={expandedSections[sectionKey]}
- onClick={() => onToggle(sectionKey)}
- pad={2}
- // CSS-fidelity source-map pass (SPEC-display-left-rail.md §2) - "AutofillCollapse header in
- // rail: Superhero's stock .card-header 0.5rem 1rem (8/16) -> rail-scoped padding:7px 10px",
- // now travelling with THIS call site (component-scoped) instead of RailRoot's own
- // now-removed `.card-header` descendant-selector override - see AutofillCollapse.tsx's own
- // `headerPadding` prop comment for the full rationale.
- headerPadding="7px 10px"
- >
- {children}
-
-);
+// The editor-completion package's "demoted zone" (RailSection/AutofillCollapse, Card Details ->
+// Attributes -> Printing Tags -> Print Options -> Slot Actions -> Report) is REMOVED - every one
+// of those grey drop-downs is gone from the rail per the owner-approved rail-delegacy round
+// (2026-07-24). Their contents fold into designed elements instead (§B/§F of the spec):
+// - Card Details' metadata + Download/Favourite -> the rail-head "More details" disclosure
+// (RailHeader below); the printing identifier itself moves to the D14 band (ONE occurrence).
+// - Attributes (the separate `.achip` explicit-vote fieldset, AttributesSection.tsx) is
+// SCRAPPED outright (RD1/O1) - the funnel's own Border/Frame/Treatment chips (already the
+// implicit-vote surface, SelectVersionResults.tsx) are the ONE chip surface now; explicit
+// attribute voting stays only in the D14 identify follow-up (AttributeVotingPanel, inside
+// PrintingTagsBlock below).
+// - Printing Tags (PrintingTagsBlock - PrintingTagPicker + AttributeVotingPanel follow-up) ->
+// the IdentifyPanel band hanging directly off D14 (item 6).
+// - Print Options + Slot Actions + Report -> the one bottom ControlStack (item 7).
+// Jump to Version (GridSelectorFilters' own AutofillCollapse) is separately scrapped inside
+// SelectVersionResults.tsx/GridSelectorFilters.tsx's own hiddenSections wiring - not this file's
+// concern.
//# endregion
-//# region always-visible rail header
+//# region always-visible rail header (rail-delegacy round, rev #1/#2/#3 - SPEC-rail-delegacy.md §B/§C)
+//
+// Rewritten for the rail-delegacy round (2026-07-24, owner-approved): the rail-head stays LEAN
+// (RD6) - a `66px` subject-card preview of the slot's own selected art (RD8, `.subject`, a dashed
+// "No art selected" empty state otherwise) beside the identity column (slot/face + name), a
+// conditional requested≠resolved MISMATCH flag only (RD7 - `RequestedPrintingBadge`'s new
+// `showOnlyOnMismatch` prop; the canonical printing id itself lives ONCE, in the D14 band below,
+// never repeated here), and a "More details" disclosure (RD6/RD1's item-4 disposition) whose body
+// is the WHOLE Card-Details metadata block (`CardMetaTable` + `CardDownloadFavorite`) - previously
+// one of the nine grey `AutofillCollapse` sections, now folded in place.
interface RailHeaderProps {
face: Faces;
slot: number;
cardName: string | undefined;
searchQuery: SearchQuery | undefined;
+ cardDocument: CardDocument | undefined;
+ detailsOpen: boolean;
+ onToggleDetails: () => void;
}
-// D14 fix round (SPEC-display-left-rail.md §3, owner-approved 2026-07-23): the
-// `DeckbuilderConfirmAffordance` mount that used to co-render here (badge + hover ComparePin +
-// Y/N buttons) is REMOVED - `ConfidenceElement.tsx` (mounted in `PromotedZone` just below,
-// directly under this header) supersedes it entirely with a fuller, always-informative form
-// (set-icon confidence anchor, Scryfall reference popover, a real "✗ not this printing" vote).
-// `DeckbuilderConfirmAffordance` itself is untouched - still mounted in CardSlot.tsx's editor
-// grid and inside `SelectVersionResults.tsx`'s suggested-printing confirm ribbon, both out of
-// this round's scope. Density (§2): `p-2` (8px all sides) -> explicit `8px 10px` (horizontal
-// tightened, matching the mockup's live value), no separate `mt-1` gap before the badge (the
-// badge's own margin is enough).
-const RailHeader = ({ face, slot, cardName, searchQuery }: RailHeaderProps) => (
-
- {/* Machine-diff fix round (SPEC-display-left-rail.md §D.1, corrected 2026-07-23) - `.rail-head`
- itself sets no font-size, so `.slot`/`.name` used to fall through to the Bootstrap body
- default (16px) instead of the spec's own `14px/700` and `15px` values. Component-scoped
- inline styles on these two specific nodes (not a new `.rail-head .slot`/`.rail-head .name`
- RailRoot descendant selector) per the #400 rule - `.slot`/`.name` are bare, reusable
- classnames that could in principle appear elsewhere, so the fix travels with the exact DOM
- node instead of a broader selector. */}
-
- Slot {slot + 1}{" "}
- {face}
-
-
- {cardName ?? (
- No art selected yet
- )}
-
- {/* Item (c) of the frontend-polish package extracted this into its own shared component
- (RequestedPrintingBadge.tsx) so CardSlot.tsx's editor slots could mount the identical
- badge - one place the degraded-style logic lives, so the two surfaces can't drift. */}
-
+ {/* RD8 (rev #3) - a PREVIEW of the same thumbnail URL the selected `.vtile`/`CardImage`
+ already renders (not a second full render; Select Version stays the art surface). */}
+ {cardDocument != null ? (
+
+
+
+ ) : (
+
+ No art
+
+ selected
+
+ )}
+
+
+ Slot {slot + 1} {face}
+
+
+ {cardName ?? "No art selected yet"}
+
+ {/* RD7 - the canonical printing id lives ONCE, in D14; this is a conditional MISMATCH
+ flag only (requested printing differs from the resolved/suggested one), never a
+ static second copy. */}
+
+
+
+
+
+
+
+
+ {/* RD6 (O2 answered) - the WHOLE Card-Details metadata block (Resolution/DPI, File
+ size, Source, Source type, Class, Identifier, Language, Tags, dates) plus Download +
+ Favourite lives ONLY here now - one of the nine removed grey AutofillCollapse
+ sections, folded in place. */}
+
+ {cardDocument != null ? (
+ <>
+ {/* RD7 - the printing id lives ONCE, in D14; drop CardMetaTable's own
+ "Canonical Card" row here so it's never a static second copy. */}
+
+
+ >
+ ) : (
+
+ Select an image for this slot first.
+
+ )}
+
+
+
-
-);
+ );
+};
//# endregion
@@ -489,18 +505,76 @@ const RailHeader = ({ face, slot, cardName, searchQuery }: RailHeaderProps) => (
interface PromotedZoneProps {
cardDocument: CardDocument | undefined;
backendURL: string;
+ identifyOpen: boolean;
+ onToggleIdentify: () => void;
}
+// Rail-delegacy round (item 6, SPEC-rail-delegacy.md §B/§F) - the "Printing Tags" grey accordion
+// (PrintingTagPicker consensus/search/candidate-grid + the AttributeVotingPanel follow-up) is
+// REMOVED as a standalone section and rehung directly off the D14 band it's ABOUT ("what printing
+// is this"), opened on demand - never a grey accordion. `PrintingTagsBlock` is reused verbatim
+// (CardDetailedViewBody.tsx) - it already owns the exact PrintingTagPicker + conditional
+// AttributeVotingPanel-when-unresolved composition item 6/RD1 call for; the ONE explicit
+// attribute-vote surface stays here (RD1/O1) - the funnel's own chips (SelectVersionResults.tsx)
+// are implicit-only.
+interface IdentifyPanelProps {
+ cardDocument: CardDocument | undefined;
+ open: boolean;
+ onToggle: () => void;
+}
+
+const IdentifyPanel = ({
+ cardDocument,
+ open,
+ onToggle,
+}: IdentifyPanelProps) => {
+ if (cardDocument == null) {
+ return null;
+ }
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
// Fix round (SPEC-display-left-rail.md §3): ConfidenceElement now renders FIRST - it's identity
// (directly under the header's name/RequestedPrintingBadge), not demoted metadata, per the
// spec's explicit placement call. ArtistSection follows, still promoted/always-visible (D3),
// just no longer ahead of D14. ConfidenceElement owns its own full-width band styling
// (`.d14` - background/border-bottom/padding all live in its own markup now, RailRoot's CSS
// below), so it no longer needs an outer padded wrapper here; ArtistSection still does
-// (`.artist-line`) - density (§2): `px-2 py-1` (8/4) -> explicit `8px 10px`.
-const PromotedZone = ({ cardDocument, backendURL }: PromotedZoneProps) => (
+// (`.artist-line`) - density (§2): `px-2 py-1` (8/4) -> explicit `8px 10px`. Rail-delegacy round
+// adds the IdentifyPanel directly below ConfidenceElement (item 6 - "hangs off D14", same subject).
+const PromotedZone = ({
+ cardDocument,
+ backendURL,
+ identifyOpen,
+ onToggleIdentify,
+}: PromotedZoneProps) => (
<>
+
+);
+
+//# endregion
+
interface RailProps {
selectedSlotRef: SelectedSlotRef | null;
// CardDocument | undefined, not just CardDocument: useCardDocumentsByIdentifier's own return
@@ -1181,8 +1540,13 @@ const Rail = ({
onSlotDeleted,
onImplicitSupport,
}: RailProps) => {
- const [expandedSections, setExpandedSections] =
- useState>(DEFAULT_EXPANDED);
+ // Rail-delegacy round - the old six-key `expandedSections` accordion state is gone with the
+ // grey sections it drove; the two remaining disclosures ("More details", the D14 identify
+ // panel) each get their own plain boolean, defaulting closed per slot (this component fully
+ // remounts on slot change via its caller's own `key`, so these reset for free - see
+ // LeftRailOffcanvas's own comment on that `key`).
+ const [detailsOpen, setDetailsOpen] = useState(false);
+ const [identifyOpen, setIdentifyOpen] = useState(false);
const projectMember = useAppSelector((state) =>
selectedSlotRef != null
@@ -1208,12 +1572,6 @@ const Rail = ({
? cardDocumentsByIdentifier[selectedImage]?.name
: undefined;
- const onToggle = (key: AccordionSectionKey) =>
- setExpandedSections((previous) => ({
- ...previous,
- [key]: !previous[key],
- }));
-
const selectedCardDocument =
selectedImage != null
? cardDocumentsByIdentifier[selectedImage]
@@ -1226,21 +1584,27 @@ const Rail = ({
slot={selectedSlotRef.slot}
cardName={cardName}
searchQuery={query}
+ cardDocument={selectedCardDocument}
+ detailsOpen={detailsOpen}
+ onToggleDetails={() => setDetailsOpen((previous) => !previous)}
/>
- {/* E2 (#2/#3) - the promoted, always-visible zone: D14 confidence element + artist support
- line, both retired as/never-were collapsible accordion sections (D3). Fix round
- (SPEC-display-left-rail.md §3): ConfidenceElement now renders BEFORE ArtistSection - it
- is identity (directly under name + RequestedPrintingBadge), not demoted metadata; see
- PromotedZone's own comment for the full ordering rationale. */}
+ {/* E2 (#2/#3) - the promoted, always-visible zone: D14 confidence element + the identify
+ panel that hangs off it (item 6) + artist support line, none of which are collapsible
+ accordion sections (D3). Fix round (SPEC-display-left-rail.md §3): ConfidenceElement
+ renders BEFORE ArtistSection - it is identity, not demoted metadata; see PromotedZone's
+ own comment for the full ordering rationale. */}
setIdentifyOpen((previous) => !previous)}
/>
{/* Fix round (SPEC-display-left-rail.md §4): the Sources accordion - sources gate art
availability, so the owner brief puts it in the LEFT rail (a deviation from
proposal-h-display-layout-spec.md §4.2's right-rail placement - see
SourcesAccordion.tsx's own module comment for the full note). Sits between the promoted
- identity zone and Select Version, matching the mockup's own left-rail order. */}
+ identity zone and Select Version, matching the mockup's own left-rail order. NOT one of
+ the nine removed grey sections (SPEC-rail-delegacy.md §B/RD - owner answer #3).*/}
{/* 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).
@@ -1248,7 +1612,10 @@ const Rail = ({
(SPEC-display-left-rail.md §D.1, corrected 2026-07-23) - this wrapper gains a
`select-version-wrapper` class carrying the normalized `#16202b` bottom hairline (see
RailRoot's own rule below) - it had no block-boundary divider of its own before. */}
-
+
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 ? (
-
- ) : (
-
- )}
-
+ {/* Rail-delegacy round (item 7, RD5) - Print Options + Slot Actions + Report collapse into
+ ONE designed control stack, the last of the nine removed grey AutofillCollapse
+ sections. AddCardToProjectForm is deliberately not mounted (the slot is already in the
+ project). */}
+
);
};
diff --git a/frontend/src/features/gridSelector/SelectVersionResults.test.tsx b/frontend/src/features/gridSelector/SelectVersionResults.test.tsx
index 40a9623d5..e748d64fb 100644
--- a/frontend/src/features/gridSelector/SelectVersionResults.test.tsx
+++ b/frontend/src/features/gridSelector/SelectVersionResults.test.tsx
@@ -91,7 +91,12 @@ function buildVoteLayer(
function makeSearch(identifiers: string[]): GridSelectorSearch {
const defaults = getDefaultSearchSettings({});
return {
- settingsVisible: false,
+ // Rail-delegacy round (RD4/O1, SPEC-rail-delegacy.md) - the funnel's own Border/Frame/
+ // Treatment chips now live INSIDE the one Filters panel (`search.settingsVisible`), not
+ // always rendered above the grid - every test in this file exercises those chips directly,
+ // so the fixture starts the panel open (in the real app this is one `funnel-filters-toggle`
+ // click away, exercised end-to-end in tests/SelectVersionSection.spec.ts instead).
+ settingsVisible: true,
setSettingsVisible: jest.fn(),
filterSettings: defaults.filterSettings,
setFilterSettings: jest.fn(),
@@ -304,14 +309,15 @@ describe("SelectVersionResults funnel (funnel-spec.md F1-F7)", () => {
const chip = screen.getByTestId("funnel-treatment-chip-Full Art");
expect(chip).toHaveAttribute("data-chip-membership", "settled");
await user.click(chip);
- // Filtering down to Full Art narrows the survivor count to 1 (only fa-1 carries it), which
- // collapses the axis rows per D21's hero tier - the ORIGINAL `chip` element is detached once
- // that happens, so the active state is asserted via the head's persistent active-pill instead
- // (F1's summary line, which survives the collapse) rather than re-querying the gone chip.
+ // Filtering down to Full Art narrows the survivor count to 1 (only fa-1 carries it). Rail-
+ // delegacy round (RD1/RD4) - the fieldset no longer collapses at the "hero" tier (D21's own
+ // pill-summary it used to collapse TO is retired along with the always-visible arrangement),
+ // so the chip stays in the DOM and its own `data-state` is still the right place to assert
+ // "still active."
await waitFor(() =>
expect(screen.getByTestId("funnel-count")).toHaveTextContent("1 version")
);
- expect(screen.getByTestId("funnel-active-pill-Full Art")).toBeVisible();
+ expect(chip).toHaveAttribute("data-state", "positive");
const tile = screen.getByTestId("select-version-tile-fa-1");
const card = tile.querySelector(".mpccard") as HTMLElement;
diff --git a/frontend/src/features/gridSelector/SelectVersionResults.tsx b/frontend/src/features/gridSelector/SelectVersionResults.tsx
index e836f8dc1..dadc0dd74 100644
--- a/frontend/src/features/gridSelector/SelectVersionResults.tsx
+++ b/frontend/src/features/gridSelector/SelectVersionResults.tsx
@@ -52,14 +52,23 @@ import styled from "@emotion/styled";
import React, { Ref, useEffect, useMemo, useRef, useState } from "react";
import Button from "react-bootstrap/Button";
import Col from "react-bootstrap/Col";
+import Collapse from "react-bootstrap/Collapse";
+import Form from "react-bootstrap/Form";
import Row from "react-bootstrap/Row";
import ToggleButton from "react-bootstrap/ToggleButton";
import ToggleButtonGroup from "react-bootstrap/ToggleButtonGroup";
+import { createPortal } from "react-dom";
import { errorToNotification, isRateLimited } from "@/common/apiErrors";
+import { SortByOptions } from "@/common/constants";
import { getOrCreateAnonymousId } from "@/common/cookies";
import { useTagDisplayName } from "@/common/tagDisplayNames";
-import { CardDocument, useAppDispatch, useAppSelector } from "@/common/types";
+import {
+ CardDocument,
+ SortBy,
+ useAppDispatch,
+ useAppSelector,
+} from "@/common/types";
import {
ALL_ATTRIBUTE_CHIPS,
AttributeChipDef,
@@ -73,6 +82,7 @@ import {
} from "@/features/attributeChips/attributeChips";
import { MemoizedEditorCard } from "@/features/card/Card";
import { DeckbuilderConfirmAffordance } from "@/features/card/DeckbuilderConfirmAffordance";
+import { useViewportTier } from "@/features/display/useViewportTier";
import { GridSelectorFilters } from "@/features/gridSelector/GridSelectorFilters";
import {
groupSelectVersionCandidates,
@@ -81,6 +91,7 @@ import {
SelectVersionReasonTagGroup,
} from "@/features/gridSelector/selectVersionGrouping";
import { GridSelectorSearch } from "@/features/gridSelector/useGridSelectorSearch";
+import { FilterSettings as FilterSettingsElement } from "@/features/searchSettings/FilterSettings";
import { GenericErrorPage } from "@/features/ui/GenericErrorPage";
import { APISubmitTagVote } from "@/store/api";
import { selectCardDocumentsByIdentifiers } from "@/store/slices/cardDocumentsSlice";
@@ -505,6 +516,104 @@ const UnifiedFilterDivider = styled.span`
margin: 0 2px;
`;
+/**
+ * Rail-delegacy round (RD4/O3, SPEC-rail-delegacy.md) - the desktop/tablet Filters float panel is
+ * rendered via `ReactDOM.createPortal(..., document.body)`, not a plain in-tree `position:fixed`
+ * node: `LeftRailOffcanvas` (DisplayPage.tsx) is `position:sticky` at the inline `lg`+ breakpoint,
+ * which unconditionally establishes its own stacking context (CSS spec - sticky positioning
+ * always does, regardless of its own `z-index`) - any `position:fixed` descendant's `z-index`
+ * would only be compared against ITS siblings inside that local context, not the page-level sheet
+ * region, so a plain fixed node stayed BEHIND the sheet's own card tiles (caught live: Playwright
+ * couldn't click through to the backdrop, the tile intercepted the click). A real portal escapes
+ * every ancestor stacking context entirely, matching the spec's own "frame-level Overlay, escaping
+ * the 380px rail column and the tablet drawer's own clipping" requirement literally, not just in
+ * effect. Every class name below duplicates the same tokens `RailRoot`'s own `.fpanel.inline`
+ * (phone, still in-tree) rule carries in DisplayPage.tsx - see `SPEC-rail-delegacy.md` §D.2, kept
+ * in lockstep the same way any other two-container "shared body" pairing in this codebase is.
+ */
+const FloatFiltersPortalRoot = styled.div`
+ .fscrim {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.45);
+ z-index: 1050;
+ }
+ .fpanel.float {
+ position: fixed;
+ left: 50%;
+ top: 64px;
+ transform: translateX(-50%);
+ width: 440px;
+ max-width: calc(100% - 32px);
+ max-height: calc(100% - 96px);
+ overflow-y: auto;
+ z-index: 1051;
+ background: #22303f;
+ border: 1px solid #7f8fa0;
+ box-shadow: 0 12px 34px rgba(0, 0, 0, 0.6);
+ padding: 0;
+ }
+ .fpanel.float .fpwrap {
+ padding: 12px;
+ }
+ .fptitle {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: #4e5d6b;
+ font-size: 12px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ position: sticky;
+ top: 0;
+ }
+ .fptitle button {
+ background: transparent;
+ border: 1px solid rgba(235, 235, 235, 0.2);
+ color: #ebebeb;
+ padding: 2px 8px;
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 12px;
+ }
+ .fset {
+ border: none;
+ margin: 0 0 9px;
+ padding: 0;
+ }
+ .fset:last-child {
+ margin-bottom: 0;
+ }
+ .fset > .lg {
+ display: block;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: #8fa0b0;
+ margin-bottom: 4px;
+ }
+ .fsep {
+ height: 1px;
+ background: #16202b;
+ margin: 9px -8px;
+ }
+ .implicit-note {
+ font-size: 10px;
+ color: #8fa0b0;
+ margin-top: 7px;
+ display: flex;
+ gap: 5px;
+ align-items: flex-start;
+ line-height: 1.4;
+ }
+ .implicit-note .ic {
+ color: #5bc0de;
+ flex: 0 0 auto;
+ }
+`;
+
//# endregion
//# region continuous grid (addendum item 2) - tile-corner annotations
@@ -1048,6 +1157,11 @@ export function SelectVersionResults({
voteLayer,
}: SelectVersionResultsProps) {
const getTagDisplayName = useTagDisplayName();
+ // Rail-delegacy round (RD4/O3, SPEC-rail-delegacy.md) - tier-conditional Filters panel
+ // placement: phone = in-rail Collapse; desktop/tablet = a fixed-positioned panel toward the
+ // viewport centre (stacked/rail layout only - the sidebar/modal layout's own GridSelectorFilters
+ // AutofillCollapse is untouched).
+ const viewportTier = useViewportTier();
// 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 at
// the dense/medium disclosure tiers; F1/D21 relaxes this to expanded (compressed=false) tiles
@@ -1211,15 +1325,12 @@ export function SelectVersionResults({
filteredIdentifiers.length
);
- // D21 - "many (>8)" auto-expands the advanced Filters disclosure once, the first time the
- // tier becomes dense; a user who manually re-collapses it afterwards is respected (this effect
- // only depends on `tier`, so it won't re-fire while `tier` stays "dense").
- useEffect(() => {
- if (layout === "stacked" && tier === "dense") {
- search.setSettingsVisible(true);
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [layout, tier]);
+ // Rail-delegacy round (RD4/O1, SPEC-rail-delegacy.md) - the D21 "auto-expand once dense" effect
+ // is RETIRED: the funnel's axis chips now live INSIDE the same one Filters panel as the
+ // advanced fieldsets (item 2/3/5 unified), which the mockup keeps closed by default at every
+ // survivor count - auto-forcing it open on a busy result set would fight that calm, fully
+ // user-toggled design. `search.settingsVisible` is still the one shared open/closed flag
+ // (renamed "Filters" in the UI, RD2/item 2), just never force-set here any more.
// F3 - per-axis chip membership, computed over survivors filtered by every OTHER axis's active
// chips (never the axis's own selection - otherwise picking Black would make White/Silver
@@ -1710,53 +1821,126 @@ export function SelectVersionResults({
);
if (layout === "stacked") {
- // F1 - only axes with >=1 visible chip actually render (FunnelAxisRow itself returns null
- // otherwise); D21 - axes stay visible while narrowing is still useful (dense/medium tiers),
- // collapsing to the head's active-pill summary at hero/none (nothing left worth partitioning
- // at that point).
- const showAxes = tier === "dense" || tier === "medium";
+ // Rail-delegacy round (item 2/3/5, RD1/RD2/RD4, SPEC-rail-delegacy.md) - the funnel's own
+ // Border/Frame/Treatment chips are no longer a separate always-visible block above the grid;
+ // they're now ONE fieldset inside the SAME Filters panel as the advanced fieldsets (DPI/
+ // size/languages/tags/NSFW), all gated behind the ONE `svhead` "Filters" toggle
+ // (`search.settingsVisible`). `FunnelAxisRow` still self-hides an axis with no surviving
+ // chips (F3, unchanged).
+ const filterFieldsetsBody = (
+ <>
+
+
+
+ >
+ );
+
+ const closeFilters = () => search.setSettingsVisible(false);
+ const isPhoneTier = viewportTier === "phone";
+
return (
- {/* A. funnel head - count, active-tag pills (always shown, any tier, so the user can
- still see/clear a filter even once the axis rows themselves collapse), the Filters
- disclosure toggle. */}
-
-
- {filteredIdentifiers.length.toLocaleString()} version
+ {/* item 2 (RD2) - the SV header row: [N versions] [Sort ▾] [Filters ▾], replacing the
+ old always-visible count+pills bar. */}
+
- )}
+
+ {/* RD2 - a compact Form.Select of the 6 SortByOptions replaces the old
+ NullableSortByFilter tree-select (O5 accepted). */}
+
+ search.setSortBy(
+ event.target.value === ""
+ ? undefined
+ : (event.target.value as SortBy)
+ )
+ }
+ data-testid="funnel-sort-select"
+ >
+
+ {Object.entries(SortByOptions).map(([value, label]) => (
+
+ ))}
+
{/* Owner fix round (2026-07-23, SPEC-display-left-rail.md §8 "buttons-look-like-
- buttons" audit) - the RULE WINS over the reference mockup's own underlined-text
- treatment: this performs an action (expands/collapses the filter disclosure), so it
- reads as a real button (`outline-light` - `outline-secondary`'s near-invisible on
- this dark surface), not a link. This also AGREES with upstream, which already
- renders GridSelectorFilters' own settings toggle as a Button - see the spec's own
- upstream-divergence note for the "does NOT diverge" record. */}
+ buttons" audit) - a real button, not underlined text (this performs an action). */}
search.setSettingsVisible((v) => !v)}
data-testid="funnel-filters-toggle"
>
@@ -1769,96 +1953,65 @@ export function SelectVersionResults({
- {/* B. Border (own exclusive row) + unified Frame+Treatment block (§6, addendum item 1 -
- "i want the filters list unified, treatment and frame type can sit in one spot to
- save space"). Border keeps its own row (owner named only Frame+Treatment to merge);
- Frame (still an exclusive `FunnelAxisRow`) and Treatment (new tri-state chips) share
- ONE bordered fieldset. Only axes with >=1 surviving candidate render at all (F3,
- unchanged); D21 - axes stay visible at dense/medium tiers, collapse to the head's
- active-pill summary at hero/none. */}
- {showAxes && (
-