diff --git a/docs/features/foreign-order-resilience.md b/docs/features/foreign-order-resilience.md index aea013e45..206cad8e7 100644 --- a/docs/features/foreign-order-resilience.md +++ b/docs/features/foreign-order-resilience.md @@ -255,12 +255,33 @@ already offered exactly as it always was for any slot. - **Per-surface consent ruling** (owner's second security-review round): self-import/own saved decks allowed by default; shared decks viewed by others deny-by-default behind an explicit per-deck recipient opt-in with - a reversible "Hide" control. **Only the editor/self-import surface is - wired up in this pass** — `SharedDeckViewer.tsx` and any other read-only - viewer were not touched, so they simply don't synthesize orphan - CardDocuments at all yet (safe-by-omission: deny-by-default is the - correct posture there, just not yet built as an explicit opt-in flow). - Building that opt-in UI is future work, not a regression. + a reversible "Hide" control. **SHIPPED (editor-polish round, item 11, + 2026-07-24)** — `SharedDeckViewer.tsx` now synthesizes orphan awareness + for a shared-deck recipient (still NOT a full `synthesizeOrphanCardDocument` + merge into Redux — this component stays local-state-only, per its own + module comment — just enough to detect a face whose `selectedImage` + passes `isLikelyDriveFileId` but wasn't resolved by `APIGetCards`, the + same "unindexed by this catalog" orphan definition Phase 1 already + uses): a `useConsentToast` prompt keyed `shared-deck-orphans:${shareId}` + (per-DECK, not per-identifier or global — a second shared deck asks + independently even in the same session), deny-by-default (decline or + dismiss both leave every orphan face behind a `🔒 External image hidden` placeholder, and NOTHING is fetched — not even the direct-Google + URL is built — until the recipient opts in), and a persistent "N + external images hidden — Review"/"Hide" banner for the reversibility + the base `useConsentToast` `Promise` contract doesn't natively + offer (the banner's own local `imagesRevealed` boolean is independent + of the toast's one-shot stored decision — flipping it back and forth + never re-prompts or touches `sessionStorage`). The revealed image uses + `getOrphanSmallImageURL` directly (orphanCard.ts) — still never routed + through the image-CDN Worker/R2 bucket, same posture as the editor's own + orphan rendering. Test coverage: `SharedDeckViewer.test.tsx` (jest/RTL, + not Playwright — this is a plain local-state component with no + `PagePreview` sheet/rail chrome to drive through a page-load E2E flow). + **Still not built**: any OTHER read-only viewer this catalog might grow + later inherits nothing automatically — this is `SharedDeckViewer.tsx` + specifically, not a shared hook/component other future recipient + surfaces can mount directly (a real gap if a second such surface is + ever added, flagged here rather than silently assumed-covered). - **Bleed normalization for orphans**: `PDF.tsx`'s `isBleedNormalizationEligible` still gates on `sourceType === GoogleDrive || sourceType === LocalFile`, which an orphan (no `sourceType`) never matches — an orphan's PDF embed uses the plain @@ -310,6 +331,14 @@ already offered exactly as it always was for any slot. `ParsedXmlImport`'s new `cardback` field and `parseXMLFile`'s gated `setSelectedCardback` dispatch, fixing the Common Cardback panel bug (see "Rendering surfaces & acceptance" above). +- `frontend/src/features/savedDecks/SharedDeckViewer.tsx` — (editor-polish + round, item 11, 2026-07-24) the shared-deck recipient's own orphan + detection + consent gate (`isOrphanFace`, the `useConsentToast` mount, + the `HiddenOrphanBadge`/`ExtBanner` presentation) — see "Per-surface + consent ruling" above for the full behaviour. +- `frontend/src/features/savedDecks/SharedDeckPage.tsx` — threads the + route's own `shareId` query param into `SharedDeckViewer`'s `shareId` + prop (the per-deck consent-key scope). - Tests: `orphanCard.test.ts`, `processing.test.ts` (bracket-token cases), `listenerMiddleware.test.ts` (both the per-slot AND, as of 2026-07-23, the project-cardback listener), `ImportXML.test.ts` (front + the b:null @@ -321,4 +350,7 @@ already offered exactly as it always was for any slot. (both reported symptoms, end to end, on the unified `/display` page as of the 2026-07-23 acceptance-surface correction, with screenshots, plus the sheet's own `orphan-badge` assertions and a dedicated narrow-viewport case - added in the same follow-up). + added in the same follow-up), and (editor-polish round, item 11) + `SharedDeckViewer.test.tsx` (jest/RTL — consent prompt, decline-hides, + accept-reveals, the reversible banner toggle, and the per-deck-id + independence case). diff --git a/docs/features/grid-selector.md b/docs/features/grid-selector.md index 56a633b12..cdc26fcb3 100644 --- a/docs/features/grid-selector.md +++ b/docs/features/grid-selector.md @@ -409,6 +409,58 @@ described above. `onSlotContextMenu` (every other `PagePreview` caller, e.g. `PDFGenerator`'s fast preview), renders with zero behavior change — no cue, no long-press handlers, the browser's native menu untouched. + **Editor-polish round (EPcue, SPEC-editor-polish.md §D.8, 2026-07-24)**: + the cue grows `20×20` → `26×26` (glyph `13px` → `17px`), higher-contrast + (`rgba(11,21,32,.92)` bg, `1.5px #abb6c2` border, `#fff` glyph, + drop-shadow) so it reads over card art, and its render gate tightens + from "a context menu is wired" alone to "the slot holds a card **and** + a context menu is wired" — an empty slot now shows no cue at all. The + same round also ships the `⟲` flip button this bullet's own "future + selection-checkbox/flip button" note anticipated: top-right corner, + same `26×26` sizing/reveal behaviour as the cue, an additive + `onSlotFlip?(index)` prop plus a SEPARATE `content.flippable` flag + (deliberately independent of `content.imageUrl` — gating the flip + button on the CURRENTLY-effective face's own image, the same way the + cue is gated, would strand a user the moment they flip to a face with + no art of its own, since the very button that let them flip would + vanish along with the image). `DisplayPage.tsx` tracks a per-slot + `flippedPreviewSlots` set (sheet-local, preview-only — never touches + `activeFace`/selection state) so flipping one slot never affects any + other slot or the project's own Fronts/Backs view setting. +- **Ghost tile gains a thumbnail + `+N` + (EP1, SPEC-editor-polish.md §D.4, 2026-07-24)**: the "+N more + copies"/"Show fewer" ghost tile (the "already-link-styled" control the + bullet above this one references) used to be a plain dashed empty box + with text. It now renders the first hidden copy's own + `smallThumbnailUrl`, dimmed (`rgba(11,21,32,.62)` overlay), with a + centred `+N` and a "more copies" caption — a real preview of what's + being compressed, not just a bare count. Only the EXPAND ("+N") ghost + gets this treatment (`GhostThumb`/`GhostDim`/`GhostPlus`/`GhostCap`, + `SelectVersionResults.tsx`); the COLLAPSE ("−") ghost stays plain text + (nothing to preview there). Border REV: `1px rgba(235,235,235,.15)` + (was `1px dashed #abb6c2`). +- **Data-driven Sort (EP7, SPEC-editor-polish.md + §D.4, REVISES RD2, 2026-07-24)**: the `.sortsel` `Form.Select` on the + `layout="stacked"` (funnel/rail) surface stops being the backend-driven + 6-option `SortByOptions` list (`search.sortBy`/`dateCreatedDescending` + etc. — that select is untouched on the OTHER, `layout="sidebar"`/modal + path, which never had a funnel to begin with) and becomes a + client-side comparator over fields the response already carries: ** + Confirmation status** (`canonicalCard` → `suggestedCanonicalCard` → + neither), **Resolution (DPI) high→low**, **File size low→high**, + **Pinned sources first** (reads the SAME `getLocalStoragePinnedSourcePks` + helper `SourcesAccordion.tsx` writes, re-read fresh on every Sort + change — not reactively synced mid-render if a pin is toggled + elsewhere in the rail without reselecting the ordering), and **Name + (A→Z)**. Only reorders the TOP-LEVEL canonical/non-canonical/unknown + groups — `selectVersionGrouping.ts`'s own section ordering and each + group's internal representative/rest ordering are untouched. "Community + vote weight" (the dispatch's original seventh ordering) needs a + per-card numeric weight the response doesn't carry yet + (`suggestedCanonicalCardConfidence` is a currently-always-`undefined` + seam) — owner-ruled (amendment 2, the same round): ship the five now, + render NOTHING for vote-weight until that seam lands (no disabled + placeholder). - **Open items, not resolved here (owner call needed)**: (1) group 2's sub-order beyond "frame type first" — this build picked `altered-frame > custom-art > ai-art` diff --git a/frontend/src/components/AutofillCollapse.tsx b/frontend/src/components/AutofillCollapse.tsx index 9dadc1676..1213abe31 100644 --- a/frontend/src/components/AutofillCollapse.tsx +++ b/frontend/src/components/AutofillCollapse.tsx @@ -31,6 +31,17 @@ interface AutofillCollapseProps { * JumpToVersion/CardResultSet/GridSelectorFilters) renders with Bootstrap's own stock padding, * byte-for-byte unchanged. */ headerPadding?: string; + /** Additive, optional (SPEC-editor-polish.md §D.3, EP3 - "the grey #4E5D6B header/pins/body + * band is killed -> dark #22303f throughout"). Overrides the header's own hardcoded `#4E5D6B` + * ONLY for the caller that supplies this - see the header's own comment for why that value is + * otherwise deliberately locked (owner ruling, 2026-07-23) and must not be edited in place. + * `undefined` (every caller except `SourcesAccordion.tsx`, EP3's own revision target) keeps + * the shared `#4E5D6B` default, byte-for-byte unchanged. */ + headerBackground?: string; + /** Additive, optional (SPEC-editor-polish.md §D.3, same EP3 de-grey) - the body's own + * background; `undefined` (every non-Sources caller) keeps Bootstrap Card's own stock + * default, unchanged. */ + bodyBackground?: string; } /** @@ -55,6 +66,8 @@ export function AutofillCollapse({ pad = 0, id, headerPadding, + headerBackground, + bodyBackground, }: AutofillCollapseProps) { return ( <> @@ -79,7 +92,7 @@ export function AutofillCollapse({ // the two tokens are one hex digit apart by design, not by accident. Do not "fix" // this back to `#4e5d6c` again; see SPEC-display-left-rail.md §D.0 for the explicit // note distinguishing them. - backgroundColor: "#4E5D6B", + backgroundColor: headerBackground ?? "#4E5D6B", zIndex: zIndex + 1, cursor: "pointer", ...(headerPadding != null ? { padding: headerPadding } : {}), @@ -100,7 +113,14 @@ export function AutofillCollapse({ - + {/* https://react-bootstrap.netlify.app/docs/utilities/transitions/#collapse */}
diff --git a/frontend/src/features/display/ConfidenceElement.tsx b/frontend/src/features/display/ConfidenceElement.tsx index 3548e2731..a618e22cb 100644 --- a/frontend/src/features/display/ConfidenceElement.tsx +++ b/frontend/src/features/display/ConfidenceElement.tsx @@ -15,10 +15,18 @@ * backend has actually supplied one (see the D14 numeric-score honesty note below), else the * qualitative "Suggested" pill the shipped placeholder already used. * - * Hovering/focusing the set icon opens a `Popover` showing the printing's Scryfall reference - * image, straight from Scryfall's own CDN (`buildScryfallReferenceImageUrl` - - * `scryfallReference.ts`) - display-only, nothing fetched/stored by this catalog (governing - * premise + #271). + * Editor-polish round (EP9, SPEC-editor-polish.md §D.1 `.compare`/§D.2 `.statepill.cmp`) - the + * compare-reference TRIGGER moves off the set icon and onto the Confirmed/`% confident` pill + * itself (owner ask); the pill LOOK is untouched (LOCKED, D14/#271/owner answer #2), only its + * behaviour gains a hover/focus/tap toggle. The reveal itself no longer renders as a `Popover` + * anchored to this component's own DOM position - it's lifted to the rail HEAD, beside the + * 116px subject image (`RailHeader` in DisplayPage.tsx), since that's a different component in + * the tree. `compareOpen`/`onCompareToggle`/`onCompareHover` are the lifted-state seam: omitted + * (every caller before this round), this component behaves exactly as before - the pill is + * plain, non-interactive text, zero behaviour change (shared-component "additive only" rule). + * `buildScryfallReferenceImageUrl` itself is unchanged, reused verbatim - still zero backend + * seam, still nothing fetched/stored beyond the one `` pointed at Scryfall's own CDN + * (governing premise + #271). * * "✗ not this printing" casts a REAL vote (`APISubmitPrintingTag` with `isNoMatch: true`) - the * same printing-tag vote schema `DeckbuilderConfirmAffordance`'s own YES/NO already uses, just @@ -28,7 +36,11 @@ * about the card's OWN currently-attached printing, which `isNoMatch` models correctly). Owner * answer #2 (2026-07-23): stays visible - de-emphasised via CSS opacity, not hidden - on an * already-`confirmed` printing too, consistent with D1's "explicit human dissent opens a - * human-vs-human contest" semantics; casts the exact same vote call in both states. + * human-vs-human contest" semantics; casts the exact same vote call in both states. Editor-polish + * round (EP8, §D.2 `.notthis`) restyles this from a flat `btn-outline-danger` bar to the + * pre-#413 `DeckbuilderConfirmAffordance` pill idiom (tinted, rounded `10px`) - see this file's + * own `NotThisPrintingButton` styled component for the exact token source (git history: commit + * before #413's rail-delegacy round removed the grey accordion look). * * D14 numeric-score honesty note (owner answer #1, 2026-07-23): the backend does not expose a * calibrated confidence score today - `suggestedCanonicalCard` is a machine-cast VOTE, not a %. @@ -39,30 +51,100 @@ * * diverges from upstream: fork-only confidence element (#271); no upstream counterpart. */ -import React, { useState } from "react"; +import styled from "@emotion/styled"; +import React, { useEffect, useState } from "react"; import Button from "react-bootstrap/Button"; -import OverlayTrigger from "react-bootstrap/OverlayTrigger"; -import Popover from "react-bootstrap/Popover"; import { errorToNotification, isRateLimited } from "@/common/apiErrors"; import { getOrCreateAnonymousId } from "@/common/cookies"; import { CardDocument, useAppDispatch } from "@/common/types"; import { SetIcon } from "@/components/SetIcon"; -import { buildScryfallReferenceImageUrl } from "@/features/display/scryfallReference"; import { APISubmitPrintingTag } from "@/store/api"; import { setNotification } from "@/store/slices/toastsSlice"; +// EP8 (SPEC-editor-polish.md §D.2 `.notthis`, REV of the post-#413 look) - restores the +// pre-#413 `DeckbuilderConfirmAffordance` pill idiom: a tinted, rounded (radius 10px) danger +// pill, instead of the bland full-width `btn-outline-danger` bar the rail-delegacy round left +// behind. Owner answer #2's opacity-.6-on-confirmed de-emphasis is preserved via the +// `data-confirmed` attribute selector, same mechanism as before this round. +const NotThisPrintingButton = styled(Button)` + margin-left: auto; + padding: 2px 10px; + font-size: 11px; + font-weight: 600; + line-height: 1.4; + border-radius: 10px; + background: rgba(217, 83, 79, 0.12); + color: #f0b3b1; + border: 1px solid rgba(217, 83, 79, 0.55); + + &:hover:not(:disabled), + &:focus:not(:disabled) { + background: #d9534f; + color: #fff; + border-color: #d9534f; + } + + &[data-confirmed="true"] { + opacity: 0.6; + } +`; + +// EP9 - the pill's compare-trigger affordance: `role=button tabIndex=0` only when a toggle +// handler is actually supplied (additive, behaviour-preserving otherwise). Pill LOOK (colour/ +// border/radius) is untouched - LOCKED - this only adds cursor/interaction, never overrides the +// `.statepill.confirmed`/`.statepill.suggested` colour rules already on the plain `` below. +const ComparePill = styled.span` + cursor: zoom-in; +`; + +// EP9 (§G a11y note - "the D14 pill compare-trigger is... a `tap-toggle` under (pointer:coarse) +// (no hover)") - a coarse pointer (touch-primary) has no meaningful hover state at all, so its +// tap must ITSELF toggle the reveal; a fine pointer (mouse) already gets show/hide for free from +// hover/focus, so wiring a click-toggle there TOO would immediately re-hide the reveal the +// instant a real mouse click follows the hover that already opened it (mouse interaction always +// hovers before it clicks) - this hook is what keeps the two mechanisms from fighting each +// other, matching the spec's own explicit "(no hover)" qualifier for the coarse-pointer path. +function useCoarsePointer(): boolean { + const [coarse, setCoarse] = useState(false); + useEffect(() => { + if (typeof window === "undefined" || window.matchMedia == null) { + return; + } + const mediaQuery = window.matchMedia("(pointer: coarse)"); + setCoarse(mediaQuery.matches); + const handleChange = (event: MediaQueryListEvent) => + setCoarse(event.matches); + mediaQuery.addEventListener?.("change", handleChange); + return () => mediaQuery.removeEventListener?.("change", handleChange); + }, []); + return coarse; +} + interface ConfidenceElementProps { cardDocument: CardDocument | undefined; backendURL: string; + /** EP9 - lifted compare-reveal state (owned by `Rail` in DisplayPage.tsx, since the reveal + * itself renders in a sibling component, beside the subject image). All three omitted + * (default) keeps the pill a plain, non-interactive `` - zero behaviour change for any + * caller that doesn't opt in. */ + compareOpen?: boolean; + onCompareToggle?: () => void; + onCompareShow?: () => void; + onCompareHide?: () => void; } export function ConfidenceElement({ cardDocument, backendURL, + compareOpen, + onCompareToggle, + onCompareShow, + onCompareHide, }: ConfidenceElementProps) { const dispatch = useAppDispatch(); const [submitting, setSubmitting] = useState(false); + const coarsePointer = useCoarsePointer(); if (cardDocument == null) { return null; @@ -86,10 +168,6 @@ export function ConfidenceElement({ const idLabel = `${printing.expansionCode.toUpperCase()} ${ printing.collectorNumber }`; - const imageUrl = buildScryfallReferenceImageUrl( - printing.expansionCode, - printing.collectorNumber - ); const castNotThisPrinting = () => { setSubmitting(true); @@ -119,82 +197,82 @@ export function ConfidenceElement({ .finally(() => setSubmitting(false)); }; - const popover = ( - - - {imageUrl != null ? ( - {`Scryfall - ) : ( - - No reference image available. - - )} -
- Scryfall CDN · display-only, nothing stored -
-
-
- ); + // EP9 - the pill is only interactive (compare-trigger) when the caller opted in; every prop + // omitted keeps this a plain ``, exactly as before this round. + const compareEnabled = onCompareToggle != null; + const compareProps = compareEnabled + ? { + role: "button" as const, + tabIndex: 0, + "aria-pressed": compareOpen ?? false, + "aria-label": `Show Scryfall reference image for ${idLabel}`, + // Coarse pointer (touch): tap IS the toggle, no hover wired at all (see + // `useCoarsePointer`'s own comment). Fine pointer (mouse): hover/focus show it, + // mouseleave/blur hide it - no click handler, so a click following the hover that + // already opened it can never immediately re-close it. + onClick: coarsePointer ? onCompareToggle : undefined, + onMouseEnter: coarsePointer ? undefined : onCompareShow, + onMouseLeave: coarsePointer ? undefined : onCompareHide, + onFocus: coarsePointer ? undefined : onCompareShow, + onBlur: coarsePointer ? undefined : onCompareHide, + onKeyDown: (event: React.KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onCompareToggle?.(); + } + }, + "data-testid": "display-confidence-compare-trigger", + } + : {}; return (
- + ) + )} + {printing.expansionCode.toUpperCase()} · {printing.collectorNumber} {status === "confirmed" ? ( - + Confirmed - + ) : ( - {confidenceScore != null ? `${confidenceScore}% confident` : "Suggested"} - + )} - +
); } diff --git a/frontend/src/features/display/DisplayPage.tsx b/frontend/src/features/display/DisplayPage.tsx index 65f8a609d..d7622a2bb 100644 --- a/frontend/src/features/display/DisplayPage.tsx +++ b/frontend/src/features/display/DisplayPage.tsx @@ -260,7 +260,10 @@ import { SavedDecksLandingPanel, useHasSavedDecksForLanding, } from "@/features/display/SavedDecksLandingPanel"; -import { buildScryfallReferenceUrl } from "@/features/display/scryfallReference"; +import { + buildScryfallReferenceImageUrl, + buildScryfallReferenceUrl, +} from "@/features/display/scryfallReference"; import { SlotActionsSection } from "@/features/display/SlotActionsSection"; import { SourcesAccordion } from "@/features/display/SourcesAccordion"; import { @@ -376,16 +379,40 @@ const SHEET_MAX_WIDTH_PX = 960; //# endregion -//# region always-visible rail header (rail-delegacy round, rev #1/#2/#3 - SPEC-rail-delegacy.md §B/§C) +//# region always-visible rail header (rail-delegacy round, rev #1/#2/#3 - SPEC-rail-delegacy.md §B/§C; +// editor-polish round items 4/5/6/9 - SPEC-editor-polish.md §D.1, amendment 1) // // 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 +// (RD6) - a 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. +// never repeated here). +// +// Editor-polish round (SPEC-editor-polish.md §D.1/§C, owner amendment 1): +// - EP5 (REV RD8) - the subject grows 66px -> 116px. +// - EP6 (N) - a per-slot Front/Back toggle (`.fbtoggle`) beside the identity text; flipping it +// swaps ONLY the subject preview's own image/label to the OTHER face's own resolved art (a +// real, distinct ProjectMember - Front/Back are separate slots in this app's own data model, +// not one card's two sides) - never the D14/identify/More-details data below, which all stay +// pinned to the slot's actual editing face throughout (EP6's own wording scopes this to "the +// subject preview" specifically). No real back-face resolution (e.g. a plain shared cardback, +// or nothing selected at all) renders the `.backart` placeholder stripe instead of a second +// empty state. +// - EP4 (REV RD5) - Slot Actions (Change/Duplicate/Delete) relocate here as a compact icon row +// (`SlotActionsSection`'s new `compact` prop), beside the subject image; REMOVED from the +// bottom `ControlStack` (see that component's own comment). +// - EP9 (N) - the Scryfall compare reveal (trigger now lives on the D14 pill, +// `ConfidenceElement.tsx`) renders HERE, `position:absolute; left:126px` (116px subject + +// 10px `.rhead-row` gap), since it must anchor beside the subject image, a different +// component than the D14 band that triggers it - see `Rail`'s own comment for the lifted +// `compareOpen` state this seam depends on. +// - Amendment 1 (owner, 2026-07-24 post-review, BINDING) - "More details" RELOCATES out of the +// rail head entirely, to directly under the D14 band (`MoreDetailsSection` below, mounted +// from `PromotedZone`) - ruled without a design re-pass, so this component drops both the +// `.detmore` toggle AND the `.detbody` Collapse it used to own; `MoreDetailsSection` inherits +// that exact JSX unchanged (same `CardMetaTable`/`CardDownloadFavorite`, same testids), just +// moved. interface RailHeaderProps { face: Faces; @@ -393,8 +420,18 @@ interface RailHeaderProps { cardName: string | undefined; searchQuery: SearchQuery | undefined; cardDocument: CardDocument | undefined; - detailsOpen: boolean; - onToggleDetails: () => void; + /** EP6 - which face's art the `.subject` box currently shows; defaults to `face` when the + * toggle has never been touched (see `Rail`'s own `faceOverride` state). */ + previewFace: Faces; + previewCardDocument: CardDocument | undefined; + onToggleFace: (face: Faces) => void; + /** EP4 - threaded straight to the relocated `SlotActionsSection` (same props that component + * always took; only its render SITE and `compact` layout changed). */ + onSlotDeleted: () => void; + /** EP9 - lifted from `Rail` (see that component's own comment); `undefined` printing (nothing + * resolved yet) never renders the reveal regardless of `compareOpen`. */ + compareOpen: boolean; + comparePrinting: { expansionCode: string; collectorNumber: string } | null; } const RailHeader = ({ @@ -403,24 +440,52 @@ const RailHeader = ({ cardName, searchQuery, cardDocument, - detailsOpen, - onToggleDetails, + previewFace, + previewCardDocument, + onToggleFace, + onSlotDeleted, + compareOpen, + comparePrinting, }: RailHeaderProps) => { const resolvedPrinting = cardDocument?.canonicalCard ?? cardDocument?.suggestedCanonicalCard ?? null; + const compareImageUrl = + comparePrinting != null + ? buildScryfallReferenceImageUrl( + comparePrinting.expansionCode, + comparePrinting.collectorNumber + ) + : undefined; + const showBackArtPlaceholder = + previewFace !== face && previewCardDocument == 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 ? ( -
+ {/* RD8 (rev #3)/EP5 - a PREVIEW of the same thumbnail URL the selected `.vtile`/ + `CardImage` already renders (not a second full render; Select Version stays the art + surface). EP6 - previews `previewFace`, not always the slot's own editing `face`. */} + {previewCardDocument != null ? ( +
+ ) : showBackArtPlaceholder ? ( +
+ Back face +
+ not set +
) : (
- Slot {slot + 1} {face} + Slot {slot + 1} {previewFace}
{cardName ?? "No art selected yet"} @@ -447,45 +512,66 @@ const RailHeader = ({ showOnlyOnMismatch resolvedPrinting={resolvedPrinting} /> -
- -
+ + Front + + + Back + + + )} + {/* EP4 (REV RD5) - the compact icon row, relocated from the bottom control stack. */} +
-
- -
- {/* 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. -

- )} + {/* EP9 - anchored beside the subject image (`left:126px` = 116px subject + 10px + `.rhead-row` gap); only mounted while open, so its own hover/tap logic + (`ConfidenceElement.tsx`) never has to coordinate unmount timing with this component. */} + {compareOpen && compareImageUrl != null && ( +
+ { +
+ Scryfall CDN · display-only, nothing stored +
-
- + )} +
); }; @@ -507,8 +593,69 @@ interface PromotedZoneProps { backendURL: string; identifyOpen: boolean; onToggleIdentify: () => void; + detailsOpen: boolean; + onToggleDetails: () => void; + compareOpen: boolean; + onCompareToggle: () => void; + onCompareShow: () => void; + onCompareHide: () => void; +} + +// Editor-polish round, owner amendment 1 (2026-07-24, BINDING) - "More details" RELOCATES from +// the rail head to directly under the D14 band; this is the exact JSX `RailHeader` used to own +// (same `CardMetaTable`/`CardDownloadFavorite`, same testids - `display-rail-more-details-*` - +// so every existing behavior assertion querying those testids keeps working unchanged), just +// moved into `PromotedZone`, between `ConfidenceElement` and `IdentifyPanel` per the amendment's +// own "renders directly under the D14 confidence band" instruction. +interface MoreDetailsSectionProps { + cardDocument: CardDocument | undefined; + open: boolean; + onToggle: () => void; } +const MoreDetailsSection = ({ + cardDocument, + open, + onToggle, +}: MoreDetailsSectionProps) => ( +
+ + +
+ {/* 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. +

+ )} +
+
+
+
+); + // 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 @@ -567,9 +714,28 @@ const PromotedZone = ({ backendURL, identifyOpen, onToggleIdentify, + detailsOpen, + onToggleDetails, + compareOpen, + onCompareToggle, + onCompareShow, + onCompareHide, }: PromotedZoneProps) => ( <> - + + {/* Amendment 1 - directly under the D14 band, ahead of the identify panel. */} + 116px, EP9 + needs '.rhead-row' positioned so the '.compare' reveal (further down) can anchor to it. */ .rhead-row { + position: relative; display: flex; gap: 10px; align-items: flex-start; } .subject { - flex: 0 0 66px; - width: 66px; + flex: 0 0 116px; + width: 116px; aspect-ratio: 63 / 88; position: relative; overflow: hidden; @@ -1262,6 +1452,20 @@ const RailRoot = styled.div` padding: 4px; line-height: 1.3; } + /* EP6 (N) - the back-face-not-set placeholder stripe: shown only while previewing the back + face AND nothing real resolved for it (never a second dashed empty box - visually distinct + from '.subject.empty' per §D.1's own literal token). */ + .subject.backart { + background: linear-gradient(135deg, #2a2320, #1f1a17); + display: flex; + align-items: center; + justify-content: center; + text-align: center; + color: #a99; + font-size: 9px; + padding: 4px; + line-height: 1.3; + } .idcol { flex: 1; min-width: 0; @@ -1285,8 +1489,50 @@ const RailRoot = styled.div` color: #8fa0b0; font-style: italic; } + /* EP6 (N) - the per-slot Front/Back segmented toggle, 'ToggleButtonGroup' restyled to the + spec's own literal tokens (react-bootstrap's own outline-info variant is fully overridden + here, component-scoped to '.fbtoggle' only - no other 'ToggleButtonGroup' mount sitewide + carries this class). */ + .fbtoggle { + margin-top: 7px; + border: 1px solid #6b7d8e; + } + .fbtoggle .btn { + font-size: 11px; + font-weight: 700; + padding: 2px 12px; + background: #22303f; + color: #8fa0b0; + border-color: #6b7d8e; + border-radius: 0; + } + .fbtoggle .btn.active, + .fbtoggle .btn:focus, + .fbtoggle .btn:hover { + background: #5bc0de; + color: #062430; + border-color: #5bc0de; + box-shadow: none; + } + /* EP4 (REV RD5, §D.1 '.slotacts-top .iact') - the compact icon row's OWN sizing lives in + 'SlotActionsSection.tsx''s 'IconAction' styled-component (component-scoped there, same + discipline as '.rail-source-toggle'/'.cstack .form-select' elsewhere in this file); this + selector only carries the row's own gap/margin, which is genuinely this call site's concern + (the compact row's OTHER caller, if one is ever added, may want different spacing). */ + .slotacts-top { + gap: 6px; + margin-top: 8px; + } + /* Amendment 1 (owner, 2026-07-24, BINDING) - "More details" moved out of the rail head to + directly under the D14 band; same padded/divider rhythm as its new neighbours ('.d14'/ + '.idhang', both '#2b3e50') rather than the rail-head's own '#22303f', since it's still + "about the currently-identified printing," the same subject D14 covers. */ + .detmore-wrap { + background: #2b3e50; + border-bottom: 1px solid #16202b; + padding: 8px 10px; + } .detmore { - margin-top: 6px; background: transparent; border: none; color: #8fa0b0; @@ -1307,6 +1553,44 @@ const RailRoot = styled.div` border-top: 1px solid #16202b; font-size: 11px; } + /* EP9 (N, §D.1 '.compare') - the Scryfall reference reveal, anchored beside the 116px subject + image (116 + the '.rhead-row' 10px gap = 126). 'pointer-events: none' is load-bearing, not + decorative: at z-index 40 this panel paints ABOVE later DOM siblings (the D14 band sits + right after the rail head, and the panel's own aspect-ratio height easily reaches down far + enough to visually cover the very pill that triggered it) - without this, the panel would + intercept the pointer the instant it appears, firing a real mouseleave on the now-covered + pill, hiding the panel, un-covering the pill, re-firing mouseenter, and re-showing it - an + infinite open/close oscillation confirmed live via Playwright (repeated onMouseEnter firing + with the state never settling to true) before this fix. Purely visual, never itself a + click/hover target - ConfidenceElement.tsx's own pill stays the sole interactive surface, + exactly as the mockup's own hover-reveal (not a second interactive layer) intends. */ + .compare { + position: absolute; + left: 126px; + top: 0; + z-index: 40; + width: 150px; + background: #0b1520; + border: 1px solid #5bc0de; + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.6); + padding: 5px; + pointer-events: none; + } + .compare img { + display: block; + width: 100%; + aspect-ratio: 63 / 88; + object-fit: cover; + } + .compare .cap { + font-size: 9px; + color: #8fa0b0; + margin-top: 4px; + } + .compare .cap b { + color: #5bc0de; + font-weight: 700; + } /* identify panel band (item 6) - hangs off D14, same surface (§2/#2b3e50) */ .idhang { @@ -1351,6 +1635,7 @@ const RailRoot = styled.div` color: #ebebeb; font-weight: 700; } + /* EP7 (SPEC-editor-polish.md §D.4 '.sortsel', REV RD2) - 'max-width' 150px -> 172px. */ .sortsel { background: #22303f; color: #ebebeb; @@ -1358,7 +1643,24 @@ const RailRoot = styled.div` font-size: 12px; padding: 3px 6px; border-radius: 0; - max-width: 150px; + max-width: 172px; + } + /* EP10 (N, §D.4 '.vloading') - replaces '.vgrid' while 'search.displaySpinner' is true. */ + .vloading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 140px; + color: #8fa0b0; + font-size: 12px; + } + /* EP10 - tints the site's canonical spinner '--primary' inside the rail only (component- + scoped via this ancestor selector, same discipline as '.rail-source-toggle' above - it + cannot reach a 'Spinner' mounted anywhere outside this styled-component's own DOM scope). */ + .vloading .spinner-border { + color: #df6919; } .filtersbtn { background: transparent; @@ -1462,43 +1764,27 @@ const RailRoot = styled.div` } `; -//# region bottom control stack (item 7, SPEC-rail-delegacy.md §B/§F/RD5) +//# region bottom control stack (item 7, SPEC-rail-delegacy.md §B/§F/RD5; +// editor-polish item 4, SPEC-editor-polish.md §D.7 - REVISES 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). +// Print Options + Report - EP4 (REV RD5, §D.7) moves Slot Actions OUT of this stack entirely, up +// into the rail head's compact icon row (`RailHeader`'s own `SlotActionsSection compact` mount) - +// "no full-width slot-action buttons anywhere" per EP4's own wording. What's left collapses 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) => ( +const ControlStack = ({ selectedCardDocument }: ControlStackProps) => (
Print options
-
-
Slot actions
- -
{selectedCardDocument != null ? ( @@ -1547,6 +1833,16 @@ const Rail = ({ // LeftRailOffcanvas's own comment on that `key`). const [detailsOpen, setDetailsOpen] = useState(false); const [identifyOpen, setIdentifyOpen] = useState(false); + // EP6 - which face's art the rail-head subject preview currently shows; `null` means "the + // slot's own editing face" (the default, reset on every slot change for the same reason as + // `detailsOpen`/`identifyOpen` above). + const [faceOverride, setFaceOverride] = useState(null); + // EP9 - the D14 pill's compare-reveal, lifted here since the trigger (ConfidenceElement, + // inside PromotedZone) and the reveal itself (anchored beside the subject image, inside + // RailHeader) are sibling components. `compareOpen` covers BOTH the click-toggle (persists + // until toggled again or a hover leaves) and the hover/focus show/hide pair - see + // `ConfidenceElement`'s own `compareProps` for how the two compose without fighting each other. + const [compareOpen, setCompareOpen] = useState(false); const projectMember = useAppSelector((state) => selectedSlotRef != null @@ -1555,6 +1851,22 @@ const Rail = ({ ); const query = projectMember?.query; + // EP6 - the OTHER face's own ProjectMember (Front/Back are separate slots in this app's data + // model, not two sides of one card - see RailHeader's own module comment). Always computed + // (never conditionally-called) so this hook call is unconditional regardless of + // `selectedSlotRef`, matching `projectMember`'s own pattern just above. + const otherFace: Faces | null = + selectedSlotRef != null + ? selectedSlotRef.face === Front + ? Back + : Front + : null; + const otherProjectMember = useAppSelector((state) => + selectedSlotRef != null && otherFace != null + ? selectProjectMember(state, otherFace, selectedSlotRef.slot) + : undefined + ); + if (selectedSlotRef == null) { return (
setDetailsOpen((previous) => !previous)} + previewFace={previewFace} + previewCardDocument={previewCardDocument} + onToggleFace={setFaceOverride} + onSlotDeleted={onSlotDeleted} + compareOpen={compareOpen} + comparePrinting={comparePrinting} /> - {/* 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. */} + {/* E2 (#2/#3) - the promoted, always-visible zone: D14 confidence element + "More details" + (amendment 1) + 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)} + detailsOpen={detailsOpen} + onToggleDetails={() => setDetailsOpen((previous) => !previous)} + compareOpen={compareOpen} + onCompareToggle={() => setCompareOpen((previous) => !previous)} + onCompareShow={() => setCompareOpen(true)} + onCompareHide={() => setCompareOpen(false)} /> {/* 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 @@ -1626,17 +1972,11 @@ const Rail = ({ onImplicitSupport={onImplicitSupport} />
- {/* 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). */} - + {/* Rail-delegacy round (item 7, RD5)/editor-polish item 4 (REV RD5) - Print Options + + Report collapse into ONE designed control stack (Slot Actions moved up to the rail + head - see ControlStack's own comment). AddCardToProjectForm is deliberately not + mounted (the slot is already in the project). */} + ); }; @@ -1695,6 +2035,16 @@ export function DisplayPage() { ); const [selectedSlotRef, setSelectedSlotRef] = useState(null); + // EP6 (item 6/E24, SPEC-editor-polish.md §D.8 `.slot-flip`) - which slots (by their own + // `entry.slot` number, not page-relative index - the same slot can appear on different pages + // across re-layouts) are currently previewing their OTHER face on the SHEET itself, via the + // reserved corner `⟲` button. Distinct from `activeFace` (the project-wide Fronts/Backs view + // setting) - this is a per-slot, sheet-local override on top of it, the same "preview only, + // doesn't touch selection state" posture `Rail`'s own `faceOverride` takes for the rail-head + // subject box. + const [flippedPreviewSlots, setFlippedPreviewSlots] = useState>( + new Set() + ); // Issue #267 (design doc ADDENDUM D12/F9) - the populated-state search bar's dual Add/Browse // mode. One boolean drives BOTH the search bar's own mode toggle and the center region's @@ -1912,7 +2262,16 @@ export function DisplayPage() { pageIndex, entries, slots: entries.map((entry) => { - const projectMember = entry.member[activeFace]; + // EP6 - a slot in `flippedPreviewSlots` renders its OTHER face's own resolved image on + // the sheet itself (preview only - `activeFace`/selection state are untouched); only + // actually flips when that other face has a real member to show. + const otherFace = activeFace === Front ? Back : Front; + const effectiveFace = + flippedPreviewSlots.has(entry.slot) && + entry.member[otherFace] != null + ? otherFace + : activeFace; + const projectMember = entry.member[effectiveFace]; const identifier = projectMember?.selectedImage; const cardDocument = identifier != null @@ -1961,13 +2320,42 @@ export function DisplayPage() { orphanLabel: cardDocument?.isOrphan ? cardDocument.sourceName : undefined, + // EP6/item 6/E24 - a card on EITHER face (not just the currently-effective one) - + // see PagePreviewSlotContent's own `flippable` comment for why this must be + // independent of `imageUrl`. + flippable: + entry.member.front?.selectedImage != null || + entry.member.back?.selectedImage != null, }; return content; }), })), - [pages, activeFace, cardDocumentsByIdentifier, searchResultsLoading] + [ + pages, + activeFace, + cardDocumentsByIdentifier, + searchResultsLoading, + flippedPreviewSlots, + ] ); + // EP6 - toggles a slot's sheet-local face preview (see `flippedPreviewSlots`' own comment). + const handleSlotFlip = (pageIndex: number, indexOnPage: number) => { + const entry = sheets[pageIndex]?.entries[indexOnPage]; + if (entry == null) { + return; + } + setFlippedPreviewSlots((previous) => { + const next = new Set(previous); + if (next.has(entry.slot)) { + next.delete(entry.slot); + } else { + next.add(entry.slot); + } + return next; + }); + }; + const handleSlotClick = (pageIndex: number, indexOnPage: number) => { const entry = sheets[pageIndex]?.entries[indexOnPage]; if (entry == null) { @@ -2103,7 +2491,7 @@ export function DisplayPage() { mode, a plain controlled Form.Control in Browse mode - see ActionBarSearchGroup's own comment for why ImportText itself stays unaware of Browse mode), and the existing Import.tsx dropdown (D15 - Text/XML/CSV/URL, verbatim, unforked). */} -
@@ -2200,7 +2588,7 @@ export function DisplayPage() { Print & Settings -
+ {/* Issue #266 (design doc §4.1) - ONE node, all widths: inline sticky 380px column at @@ -2391,6 +2779,9 @@ export function DisplayPage() { y ) } + onSlotFlip={(indexOnPage) => + handleSlotFlip(sheet.pageIndex, indexOnPage) + } />
diff --git a/frontend/src/features/display/SlotActionsSection.tsx b/frontend/src/features/display/SlotActionsSection.tsx index 649a0453b..170e55870 100644 --- a/frontend/src/features/display/SlotActionsSection.tsx +++ b/frontend/src/features/display/SlotActionsSection.tsx @@ -1,15 +1,25 @@ /** - * The display page rail's Slot Actions accordion section (Proposal H pane migration, left-panel + * The display page rail's Slot Actions section (Proposal H pane migration, left-panel * unification - docs/proposals/proposal-h-unified-display-page.md §5). Same action list * (getCardSlotMenuActions), same handlers, as CardSlot.tsx's own 3-dot dropdown/context menu - * "rendered as a plain action list inside the section body instead of a dropdown/context-menu * overlay," per the design doc's own component-mapping row for this section. * + * Editor-polish round (EP4, SPEC-editor-polish.md §D.1 `.slotacts-top .iact`, REV RD5) - gains + * an additive, optional `compact` prop: `32×30` icon-only buttons in a horizontal, wrapping row + * (rail-head placement, beside the subject image), instead of the full-width `outline-*` button + * column below (still the bottom `ControlStack`'s own look before this round - now retired + * entirely, since EP4 moves every caller of this component to `compact`). Same `menuActions`, + * same handlers, same per-action `data-testid`s either way - only the layout/size differs, so + * every existing "click `display-slot-action-delete`" test keeps working regardless of which + * variant is mounted. + * * diverges from upstream: upstream renders the CardSlotMenuActions list only as Dropdown.Items / - * a context menu (CardSlotContextMenu.tsx); this rail renders the SAME action list as a stacked - * outline-light/outline-danger button column instead. Behavior/actions are identical; only the - * presentation diverges (SPEC-display-left-rail.md §8's buttons-look-like-buttons audit). + * a context menu (CardSlotContextMenu.tsx); this rail renders the SAME action list as a button + * row/column instead. Behavior/actions are identical; only the presentation diverges + * (SPEC-display-left-rail.md §8's buttons-look-like-buttons audit; SPEC-editor-polish.md §D.1). */ +import styled from "@emotion/styled"; import React from "react"; import Button from "react-bootstrap/Button"; @@ -24,6 +34,46 @@ import { duplicateSlot, } from "@/store/slices/projectSlice"; +// EP4 - the compact rail-head icon button: 32×30, 14px glyph, transparent/`#abb6c2`/1px +// `#abb6c2`, danger variant `#f0a6a3`/1px `#d9534f`, hover fills solid. A plain `.danger` +// CLASS, not a transient (`$`-prefixed) styled-component prop: emotion only auto-filters +// `$`-prefixed props from reaching the DOM when `styled()` wraps a plain intrinsic tag +// (`styled.button`) - wrapping another REACT COMPONENT (`styled(Button)`, as here) can't do +// that filtering, since `Button` itself has no idea a `$`-prefixed prop needs stripping before +// its own `...rest` spread onto the native `