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. */} -
- +const RailHeader = ({ + face, + slot, + cardName, + searchQuery, + cardDocument, + detailsOpen, + onToggleDetails, +}: RailHeaderProps) => { + const resolvedPrinting = + cardDocument?.canonicalCard ?? cardDocument?.suggestedCanonicalCard ?? null; + return ( +
+
+ {/* 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) => ( <> +
.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; + } + + /* control stack (item 7) - Print Options + Slot Actions + Report */ + .cstack { + padding: 8px 10px; + } + .cs-group { + margin-bottom: 10px; + } + .cs-legend { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #8fa0b0; + margin-bottom: 5px; + } + /* Component-scoped override of PrintOptionsSection's own Form.Select (same non-fork discipline + as .rail-source-toggle above - reused component, rail-scoped CSS only). */ + .cstack .form-select { + background: #22303f; + color: #ebebeb; + border: 1px solid #4e5d6c; + font-size: 13px; + padding: 4px 8px; + width: 100%; + border-radius: 0; + } + .cstack p.text-muted { + font-size: 10px; + color: #8fa0b0; + margin-top: 4px; + } + .cs-foot { + border-top: 1px solid #16202b; + padding-top: 8px; + } `; +//# region bottom control stack (item 7, SPEC-rail-delegacy.md §B/§F/RD5) +// +// Print Options + Slot Actions + Report - the last three of the nine removed grey AutofillCollapse +// sections - collapse into ONE designed `.cstack` at the rail bottom (RD5): a per-group `.cs-legend` +// label replaces each section's own accordion header, and Report is a single `btn-outline-danger` +// that expands to `ReportCardPanel`'s reason chips in place (already that component's own stock +// behavior - `ReportBlock` needs no changes at all). + +interface ControlStackProps { + face: Faces; + slot: number; + query: SearchQuery | undefined; + selectedCardDocument: CardDocument | undefined; + onSlotDeleted: () => void; +} + +const ControlStack = ({ + face, + slot, + query, + selectedCardDocument, + onSlotDeleted, +}: ControlStackProps) => ( +
+
+
Print options
+ +
+
+
Slot actions
+ +
+
+ {selectedCardDocument != null ? ( + + ) : ( +

+ Select an image for this slot first. +

+ )} +
+
+); + +//# 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 ? ( - - ) : ( -

- Select an image for this slot first. -

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

- Select an image for this slot first. -

- )} -
+ {/* 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 = ( + <> +
+ Filter versions + + Border, frame, and treatment filters + + +
+ +
+ {/* O1/RD1 - the implicit-vote awareness line, kept gated on >=1 active chip (unlike the + mockup's decorative always-on demo copy): `voteLayer.awarenessCopy` names the tags + actually at stake, which has nothing to describe with zero active chips. */} + {votesOn && voteLayer != null && activeAttributeTags.size > 0 && ( +
+ + + {voteLayer.awarenessCopy(Array.from(activeAttributeTags))} + +
+ )} +
+
+ + + ); + + 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. */} +
+ + + {filteredIdentifiers.length.toLocaleString()} + {" "} + version {filteredIdentifiers.length !== 1 ? "s" : ""} - {activeAttributeTags.size > 0 && ( -
- {Array.from(activeAttributeTags).map((tagName) => ( - toggleAttributeTag(tagName)} - data-testid={`funnel-active-pill-${tagName}`} - > - {getTagDisplayName(tagName)} × - - ))} -
- )} + + {/* 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 && ( -
- - Frame and treatment filters - - -
- -
- )} - - {/* B'. advanced filters (E4, unchanged) - full-width, stacked, in the rail's own scroll - container. */} - {search.settingsVisible && ( -
- {filtersElement} -
- )} - - {/* C. implicit-vote awareness line (F4a) - votes-on + >=1 active chip only. */} - {votesOn && voteLayer != null && activeAttributeTags.size > 0 && ( - - {" "} - {voteLayer.awarenessCopy(Array.from(activeAttributeTags))} - + + ) : ( + search.settingsVisible && + typeof document !== "undefined" && + createPortal( + +
+
+
+ Filters — refine versions + +
+
{filterFieldsetsBody}
+
+ , + document.body + ) )} {/* post-pick ack (F4c). */} {votesOn && justSupportedTags != null && ( @@ -1867,7 +2020,7 @@ export function SelectVersionResults({ )} - {/* D. survivors grid, count-proportional (F1/D21). */} + {/* survivors grid, count-proportional (F1/D21). */} {tier === "none" ? (
{ +test.describe("Display left rail CSS fidelity guard (SPEC-rail-delegacy.md)", () => { test.describe.configure({ timeout: 60_000 }); const railFidelityHandlers = [ cardDocumentsSelectVersionMixedResults, sourceDocumentsOneResult, searchResultsSelectVersionMixedResults, - // The Attributes rail section fetches tag consensus the moment a slot is selected regardless - // of whether it's ever opened - see DisplayPage.spec.ts's own identical comment. tagConsensusTwoUnresolvedTags, submitTagVoteResolvesToApply, castImplicitVoteSuccess, @@ -75,7 +50,7 @@ test.describe("Display left rail CSS fidelity guard (SPEC-display-left-rail.md)" ...defaultHandlers, ]; - test("promoted zone, Select Version, and the unified filter/grid resolve the spec's literal §0/§2 values, not Bootstrap defaults", async ({ + test("rail-head (rev #1/#2/#3), D14, Select Version header, and the desktop/tablet float Filters panel resolve the spec's literal §D values, not Bootstrap defaults", async ({ page, network, }) => { @@ -83,10 +58,7 @@ test.describe("Display left rail CSS fidelity guard (SPEC-display-left-rail.md)" await openSelectVersionSection(page); await expect(page.getByTestId("display-rail-content")).toBeVisible(); - // RailHeader `.rail-head` (§2: "p-2 (8px)" -> "padding:8px 10px, no bottom margin"). O1 fix - // round (corrected SPEC-display-left-rail.md §D.1, 2026-07-23, owner-approved): the divider - // used to be the unthemed Bootstrap `.border-bottom` utility - normalized to the explicit - // `#16202b` = rgb(22, 32, 43) hairline every rail block boundary now shares. + // `.rail-head` (§D.1, inherited verbatim) - padding:8px 10px, #16202b hairline. await expect(page.getByTestId("display-rail-header")).toHaveCSS( "padding", "8px 10px" @@ -96,93 +68,112 @@ test.describe("Display left rail CSS fidelity guard (SPEC-display-left-rail.md)" "1px solid rgb(22, 32, 43)" ); - // Machine-diff fix round (§D.1: ".rail-head .slot" 14px/700, ".rail-head .name" 15px + - // margin-top:1px) - neither had its own font-size at all, so both fell through to the - // Bootstrap body default (16px). Fixed as component-scoped inline styles on these exact two - // nodes (not a new `.rail-head .slot`/`.rail-head .name` RailRoot selector) - `.slot`/`.name` - // are bare classnames that could in principle appear elsewhere, per the #400 rule. - const railHeaderSlot = page - .getByTestId("display-rail-header") - .locator("> div") - .nth(0); - await expect(railHeaderSlot).toHaveCSS("font-size", "14px"); - const railHeaderName = page - .getByTestId("display-rail-header") - .locator("> div") - .nth(1); - await expect(railHeaderName).toHaveCSS("font-size", "15px"); - await expect(railHeaderName).toHaveCSS("margin-top", "1px"); - - // `.artist-line` (§2: "px-2 py-1 (8/4)" -> "padding:8px 10px"; §0 promoted zone surface is - // $dark/$input-bg, #22303f = rgb(34, 48, 63)). O1 (as above) - normalized border-bottom. - // Machine-diff fix round (§D.1: ".artist-line" 13px) - the Bootstrap `small` utility this - // wrapper used to carry (0.875em -> 14px off a 16px parent) was close but not the spec's own - // exact literal value; replaced with an explicit `font-size:13px` inline style. - const artistLine = page.getByTestId("display-artist-section").locator(".."); - await expect(artistLine).toHaveCSS("padding", "8px 10px"); - await expect(artistLine).toHaveCSS("background-color", "rgb(34, 48, 63)"); - await expect(artistLine).toHaveCSS("font-size", "13px"); - await expect(artistLine).toHaveCSS( - "border-bottom", + // Rev #3 (RD8) - the `66px` subject-card preview, aspect 63/88, `1px rgba(235,235,235,.15)` + // border. This fixture's slot has a real selected image, so the ART variant renders (not the + // dashed empty state). + const subject = page.getByTestId("display-rail-subject"); + await expect(subject).toBeVisible(); + await expect(subject).toHaveCSS("width", "66px"); + await expect(subject).toHaveCSS( + "border", + "1px solid rgba(235, 235, 235, 0.15)" + ); + + // `.idcol .slot`/`.name` (§D.1, inherited) - 14px/700 + face 11px uppercase; name 15px. + const slotLine = page.getByTestId("display-rail-header").locator(".slot"); + await expect(slotLine).toHaveCSS("font-size", "14px"); + await expect(slotLine).toHaveCSS("font-weight", "700"); + const nameLine = page.getByTestId("display-rail-header").locator(".name"); + await expect(nameLine).toHaveCSS("font-size", "15px"); + await expect(nameLine).toHaveCSS("margin-top", "1px"); + + // Rev #1/RD6 - "More details" toggle (§D.2 `.detmore`, 11px, #8fa0b0) starts closed; its body + // (the whole Card-Details metadata block) is hidden until toggled. + const moreDetailsToggle = page.getByTestId( + "display-rail-more-details-toggle" + ); + await expect(moreDetailsToggle).toHaveCSS("font-size", "11px"); + await expect(moreDetailsToggle).toHaveAttribute("aria-expanded", "false"); + await expect( + page.getByTestId("display-rail-more-details-body") + ).toBeHidden(); + await moreDetailsToggle.click(); + const detailsBody = page.getByTestId("display-rail-more-details-body"); + await expect(detailsBody).toBeVisible(); + await expect(detailsBody).toHaveCSS( + "border-top", "1px solid rgb(22, 32, 43)" ); + // RD7 - the canonical printing id is NOT repeated in "More details" (it lives once in D14) - + // the metadata table still carries the OTHER Card Details rows (e.g. a Language row). + await expect(detailsBody).toContainText("Language"); - // D14 confidence band `.d14` (§2: "margin:6px 0;padding:6px 8px;border-radius:6px chip" -> - // "margin:0;padding:8px 10px, full-width band, border-bottom" - kills the floating-chip - // inset margin; §3: confidence-chip surface #2b3e50 = rgb(43, 62, 80)). + // D14 confidence band `.d14` (§D.1, inherited, LOCKED) - unchanged by this round; the + // canonical printing id ("2X2 · 117"-shaped `.idtext`) lives here, exactly once in the rail. const d14 = page.getByTestId("display-confidence-element"); await expect(d14).toBeVisible(); - await expect(d14).toHaveCSS("margin", "0px"); await expect(d14).toHaveCSS("padding", "8px 10px"); await expect(d14).toHaveCSS("background-color", "rgb(43, 62, 80)"); - await expect(d14).toHaveCSS("border-bottom", "1px solid rgb(22, 32, 43)"); - - // Select Version wrapper (§2: "px-2 pt-2 (8/8-top)" -> "padding:8px 10px"). O1 fix round - // (corrected SPEC-display-left-rail.md §D.1, 2026-07-23) - this wrapper gained a - // block-boundary hairline it never had before (mockup: `.sv{border-bottom:1px solid - // var(--divider)}`), normalized straight to `#16202b`. - const selectVersionWrapper = page - .locator(".select-version-heading") - .locator(".."); - await expect(selectVersionWrapper).toHaveCSS("padding", "8px 10px"); - await expect(selectVersionWrapper).toHaveCSS( - "border-bottom", - "1px solid rgb(22, 32, 43)" - ); - // Machine-diff fix round (§D.1: ".select-version-heading" 14px/600) - had margin/padding/ - // font-weight already but no font-size rule, so it fell through to the Bootstrap body - // default (16px). - await expect(page.locator(".select-version-heading")).toHaveCSS( - "font-size", - "14px" + + // `.artist-line` (§D.1, inherited) - unchanged. + const artistLine = page.getByTestId("display-artist-section").locator(".."); + await expect(artistLine).toHaveCSS("padding", "8px 10px"); + await expect(artistLine).toHaveCSS("font-size", "13px"); + + // Item 2 (RD2) - the Select Version header row `.svhead`: count, Sort `Form.Select`, Filters + // toggle - replacing the old always-visible funnel-head count+pills bar. + const svhead = page.getByTestId("svhead"); + await expect(svhead).toBeVisible(); + await expect(svhead).toHaveCSS("font-size", "12px"); + await expect(svhead).toHaveCSS("margin-bottom", "6px"); + const sortSelect = page.getByTestId("funnel-sort-select"); + await expect(sortSelect).toBeVisible(); + await expect(sortSelect).toHaveCSS("font-size", "12px"); + await expect(sortSelect).toHaveCSS("max-width", "150px"); + expect(await sortSelect.evaluate((el) => el.tagName)).toBe("SELECT"); + + const filtersToggle = page.getByTestId("funnel-filters-toggle"); + await expect(filtersToggle).toHaveCSS("font-size", "14px"); + await expect(filtersToggle).toHaveCSS("padding", "4px 8px"); + expect(await filtersToggle.evaluate((el) => el.tagName)).toBe("BUTTON"); + await expect(filtersToggle).toHaveAttribute("aria-expanded", "false"); + + // Item 2/3/5 (RD4/O3) - at the default (desktop) viewport, opening Filters renders the FLOAT + // panel (fixed-positioned toward the viewport centre, with a backdrop) - not the phone-only + // in-rail Collapse. + await expect(page.getByTestId("filters-panel-inline")).toHaveCount(0); + await filtersToggle.click(); + await expect(filtersToggle).toHaveAttribute("aria-expanded", "true"); + const floatPanel = page.getByTestId("filters-panel-float"); + await expect(floatPanel).toBeVisible(); + await expect(floatPanel).toHaveCSS("position", "fixed"); + await expect(floatPanel).toHaveCSS("width", "440px"); + await expect(floatPanel).toHaveCSS( + "border", + "1px solid rgb(127, 143, 160)" ); + await expect(page.getByTestId("filters-panel-scrim")).toBeVisible(); - // Unified Frame+Treatment filter fieldset (§6/§2: "padding:6px 8px; margin-bottom:6px"; §0 - // raised surface #22303f = rgb(34, 48, 63)). O1 fix round (corrected SPEC-display-left-rail.md - // §D.1, 2026-07-23, owner-approved): border normalized from the unthemed `rgba(0,0,0,.22)` to - // the `#16202b` rail-boundary hairline every other block boundary now shares. - const fieldset = page.getByTestId("funnel-unified-filter"); + // O1/RD1 - ONE chip surface inside the panel: the "Filter versions" fieldset (`.fset`, 10px + // uppercase legend `#8fa0b0`) carries the funnel's own Border/Frame/Treatment chips - no + // separate `.achip` attribute-vote fieldset exists any more. + const fieldset = floatPanel.getByTestId("funnel-unified-filter"); await expect(fieldset).toBeVisible(); - await expect(fieldset).toHaveCSS("padding", "6px 8px"); - await expect(fieldset).toHaveCSS("margin-bottom", "6px"); - await expect(fieldset).toHaveCSS("background-color", "rgb(34, 48, 63)"); - await expect(fieldset).toHaveCSS("border", "1px solid rgb(22, 32, 43)"); - - // The fieldset's own last `.ufilter .row` (Frame + Treatment sharing one row) and the - // continuous `.vgrid` result grid (§7/§2) both use the mockup's literal "gap:6px" - no exact - // Bootstrap spacing-scale match (`gap-1`=4px, `gap-2`=8px). - await expect(page.getByTestId("funnel-frame-treatment-row")).toHaveCSS( - "gap", - "6px" - ); - await expect(page.getByTestId("select-version-continuous-grid")).toHaveCSS( - "gap", - "6px" - ); + await expect(fieldset.locator(".lg")).toHaveCSS("font-size", "10px"); + await expect(fieldset.locator(".lg")).toHaveText("Filter versions"); + await expect( + floatPanel.getByTestId("funnel-frame-treatment-row") + ).toHaveCSS("gap", "6px"); - // Machine-diff fix round (§D.1: "Tile ✓ canonical tag"/"Tile Alt tag" 7px/800, - // rgba(...,.92)) - was 8px / alpha .9. cardDocument15 (resolved/canonical) and cardDocument16 - // (custom-art/non-canonical) are both present in this fixture (cardDocumentsSelectVersionMixedResults). + // The float panel closes via the backdrop click (O3's own "escapes... no stacking hazard" + // affordance) - clicked at a corner offset since the scrim's own default centre point falls + // inside the (also roughly-centred) panel itself at this viewport. + await page + .getByTestId("filters-panel-scrim") + .click({ position: { x: 5, y: 5 } }); + await expect(page.getByTestId("filters-panel-float")).toHaveCount(0); + + // Machine-diff-precedent tile styling (§D.1, inherited) - unchanged by this round. const canonCornerTag = page.getByTestId( `select-version-tile-corner-${cardDocument15.identifier}` ); @@ -195,35 +186,9 @@ test.describe("Display left rail CSS fidelity guard (SPEC-display-left-rail.md)" `select-version-tile-corner-${cardDocument16.identifier}` ); await expect(altCornerTag).toHaveCSS("font-size", "7px"); - await expect(altCornerTag).toHaveCSS( - "background-color", - "rgba(91, 192, 222, 0.92)" - ); - - // Ghost "+N" expand tile (§D.1: "Ghost \"+N\" tile" ... dashed outline) - a real `