From ae395fdfa9bbb8b58a272c73d3297e896d8337a3 Mon Sep 17 00:00:00 2001 From: wilfordgrimley <2397930+WilfordGrimley@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:19:29 +0000 Subject: [PATCH 1/2] Add cardback reminder gate, apply-all/set-default prompt, and PDF-wait experience (progress bar + What's That Card? embed) Implements SPEC-cardback-pdfwait.md PKG1 (no-cardback reminder gate, toolbar/rail apply-all + set-default prompts with affected-slot thumbnails, custom-cardback flip-icon indicator) and PKG2 (determinate/ indeterminate PDF generation progress bar, embedded What's That Card? game during PDF wait with teardown-to-outro). Co-Authored-By: Claude Fable 5 --- .github/coverage-acks.txt | 8 + docs/features/pdf-generator.md | 47 ++- docs/user-guide.md | 36 +- frontend/src/common/types.ts | 11 + .../card/CardbackApplyPrompt.test.tsx | 155 +++++++++ .../src/features/card/CardbackApplyPrompt.tsx | 285 ++++++++++++++++ frontend/src/features/card/CommonCardback.tsx | 84 ++++- .../src/features/card/cardbackApply.test.ts | 104 ++++++ frontend/src/features/card/cardbackApply.ts | 65 ++++ .../card/cardbackDefaultPreference.ts | 35 ++ frontend/src/features/display/DisplayPage.tsx | 38 +++ .../src/features/display/PrePrintSaveGate.tsx | 29 +- .../features/display/SlotCardbackControl.tsx | 196 +++++++++++ .../cardbackReminderSuppression.test.ts | 48 +++ .../display/cardbackReminderSuppression.ts | 47 +++ .../display/useCardbackReminderGate.test.tsx | 170 ++++++++++ .../display/useCardbackReminderGate.tsx | 226 +++++++++++++ .../gridSelector/GridSelectorModal.tsx | 17 +- frontend/src/features/import/ImportXML.tsx | 7 +- frontend/src/features/pdf/PDFGenerator.tsx | 234 ++++++++----- frontend/src/features/pdf/PDFWaitPanel.tsx | 316 ++++++++++++++++++ .../src/features/pdf/PagePreview.test.tsx | 90 +++++ frontend/src/features/pdf/PagePreview.tsx | 34 +- .../src/store/slices/projectSlice.test.ts | 104 ++++++ frontend/src/store/slices/projectSlice.ts | 61 +++- frontend/tests/CardbackFlow.spec.ts | 193 +++++++++++ .../tests/CardbackPdfWaitFidelity.spec.ts | 220 ++++++++++++ frontend/tests/DisplayFinishFooter.spec.ts | 20 ++ frontend/tests/DisplayPage.spec.ts | 23 +- frontend/tests/GridSelectorModal.spec.ts | 19 +- .../tests/GridSelectorModalVariants.spec.ts | 9 +- frontend/tests/PDFWaitExperience.spec.ts | 205 ++++++++++++ frontend/tests/UnsavedWorkGuard.spec.ts | 7 + frontend/tests/test-utils.ts | 17 + 34 files changed, 3044 insertions(+), 116 deletions(-) create mode 100644 frontend/src/features/card/CardbackApplyPrompt.test.tsx create mode 100644 frontend/src/features/card/CardbackApplyPrompt.tsx create mode 100644 frontend/src/features/card/cardbackApply.test.ts create mode 100644 frontend/src/features/card/cardbackApply.ts create mode 100644 frontend/src/features/card/cardbackDefaultPreference.ts create mode 100644 frontend/src/features/display/SlotCardbackControl.tsx create mode 100644 frontend/src/features/display/cardbackReminderSuppression.test.ts create mode 100644 frontend/src/features/display/cardbackReminderSuppression.ts create mode 100644 frontend/src/features/display/useCardbackReminderGate.test.tsx create mode 100644 frontend/src/features/display/useCardbackReminderGate.tsx create mode 100644 frontend/src/features/pdf/PDFWaitPanel.tsx create mode 100644 frontend/tests/CardbackFlow.spec.ts create mode 100644 frontend/tests/CardbackPdfWaitFidelity.spec.ts create mode 100644 frontend/tests/PDFWaitExperience.spec.ts diff --git a/.github/coverage-acks.txt b/.github/coverage-acks.txt index 0a483ea23..b9320c70d 100644 --- a/.github/coverage-acks.txt +++ b/.github/coverage-acks.txt @@ -52,3 +52,11 @@ coverage-ack: frontend/tests/ProjectEditorMobileScroll.spec.ts::* — owner-rule # PR #337 established for this file's title staleness pattern. The new title carries the # unchanged behavior forward (un-skipped, not removed) at a new, non-stale title. coverage-ack: frontend/tests/PostExportContributionPrompt.spec.ts::Post-export contribution prompt (issue #166) - classic Print! tab > also appears after a successful export from PDFGenerator.tsx's own classic tab — renamed + un-skipped in the same change, see the file's own new title + +# Cardback flow round (SPEC-cardback-pdfwait.md §C.2) - the toolbar cardback picker no longer +# auto-closes on select (the apply-all/set-default prompt renders inline instead), so these 3 +# titles changed to describe the new stays-open behaviour; the underlying selection coverage is +# preserved, not dropped - see this PR's own body for the full before/after. +coverage-ack: frontend/tests/GridSelectorModal.spec.ts::GridSelectorModal – JumpToVersion > submitting a valid option number selects that card and closes the modal — cardback flow round (SPEC-cardback-pdfwait.md §C.2): the toolbar cardback picker no longer auto-closes on select (the apply-all/set-default prompt renders inline instead), so the title changed to describe the new behaviour; the underlying selection coverage is preserved, not dropped. +coverage-ack: frontend/tests/GridSelectorModal.spec.ts::GridSelectorModal – JumpToVersion > submitting a valid identifier selects that card and closes the modal — same rename as the sibling "option number" test above, same reason. +coverage-ack: frontend/tests/GridSelectorModalVariants.spec.ts::GridSelectorModal - keyboard navigation > Tab reaches a result card and Enter selects it, same as a click would — cardback flow round (SPEC-cardback-pdfwait.md §C.2): same auto-close-removed rename as GridSelectorModal.spec.ts's JumpToVersion tests. diff --git a/docs/features/pdf-generator.md b/docs/features/pdf-generator.md index 26732a18e..0525830c6 100644 --- a/docs/features/pdf-generator.md +++ b/docs/features/pdf-generator.md @@ -105,10 +105,12 @@ not just Proposal B's bleed-normalized cards): - **Live progress**: a large export paced to 3 req/s can now take several minutes (honestly reported, not hidden) - `PDFProps.reportImageProgress` (mirroring the existing `reportImageFailure` pattern, threaded through `pdf.worker.ts` → comlink's `onImageProgress` → - `pdfRenderService` → `PDFGenerator.tsx`) drives a "Fetching images: N/M" indicator so the wait - reads as working, not hung. `total` is an approximation (unique card count, not slot count - a - duplicate card in the deck fetches once per slot, so `completed` can end up slightly ahead of - it), intentionally not presented as an exact fraction for that reason. + `pdfRenderService` → `PDFGenerator.tsx`) drives progress feedback so the wait reads as working, + not hung. `total` is an approximation (unique card count, not slot count - a duplicate card in + the deck fetches once per slot, so `completed` can end up slightly ahead of it), intentionally + not presented as an exact fraction for that reason. See "PDF-generation wait experience" below + for the current UI this drives (a real `ProgressBar`, not the bare text line this originally + shipped as). - **In-app confirm modal, not `window.confirm()`**: the incident's own screenshot showed Firefox's "allow notifications?" anti-spam chrome sitting next to the native confirm dialog - a browser can silently start auto-suppressing FUTURE `window.confirm()` calls on an origin once @@ -140,6 +142,43 @@ Full spec + approval record: `docs/proposals/proposal-b-bleed-normalization.md`. **A real crash caught only by running `tests/PDFGenerator.spec.ts`, not by `tsc`/`jest`**: the first version skipped the old proportional rescale by setting `transform: "none"` when normalized. `@react-pdf/renderer`'s own stylesheet parser (`@react-pdf/stylesheet`) has a bug where any single-token transform value throws deep inside its internals (see `docs/lessons.md`'s entry for the exact mechanism) - and their custom reconciler doesn't propagate that as a rejection anywhere, so `pdf(...).toBlob()` just hangs forever with zero console/page error. All 3 download-path Playwright tests hung at their timeout; a stashed pre-Proposal-B baseline confirmed they pass cleanly with no other changes. Fixed by using `transform: undefined` (omitting the key) instead of `"none"` - all 4 tests pass afterward, matching baseline timing. +## PDF-generation wait experience (SPEC-cardback-pdfwait.md §D, PKG2) + +`PDFGenerator.tsx` derives a `waitPhase` (`"idle" | "fetching" | "assembling" | "done"`) from the +existing `isDownloading`/`isSavingToDrive` + `imageFetchProgress` state, rather than tracking it as +independent state - fewer places that can drift out of sync. `imageFetchProgress == null` reads as +`"fetching"` (nothing reported yet, not `"assembling"` - a real bug caught by this round's own +Playwright coverage: treating the brief pre-first-callback window as null-means-assembling flashed +"Assembling PDF…" before any fetch had actually started). + +- **2a - progress bar** (`PDFWaitPanel.tsx`'s `PDFProgressBox`, replaces the old bare + `pdf-image-fetch-progress` text line): a real Bootstrap `ProgressBar`, determinate + (`now={min(completed/total,1)*100}`, capped at 99% - never a false 100% before the phase + genuinely ends) while fetching, honest indeterminate (`animated striped`, `aria-busy`) while + assembling (no progress callback exists for `@react-pdf/renderer`'s own layout/encode phase - + Annex A-3/`PB1`: swap to determinate if a future render seam exposes one), and a green `done` + bar once the export genuinely succeeds. +- **2b - embedded "What's That Card?" game** (`PDFWaitPanel.tsx`'s `PDFWaitGameEmbed`): the right + column (normally the live PDF preview) becomes a chrome frame around `` rendered + **verbatim** (`next/dynamic({ssr:false})`, imported only once `isDownloading`/`isSavingToDrive` + flips true - never eagerly bundled while a user is still configuring the PDF, mirroring bug 1's + own lazy-WASM posture above) plus a persistent build-status ribbon. No forked component, no new + voting mechanic - the exact `/whatsthat` funnel (`docs/features/printing-tags.md`). Torn down + (unmounted) the instant generation finishes, replaced by the existing + `PostExportContributionPrompt` as the embed's own outro (context-aware "one nudge, not two" - + the standalone bottom-of-settings mount, described below, is suppressed whenever this outro is + already showing the same prompt in the right column). + +## Cardback reminder gate on the classic direct "Generate PDF"/"Save PDF to Google Drive" buttons + +A user can reach `/print` directly (bookmark, refresh, any entry that skips the editor's Finish +footer/`usePrePrintSaveGate` entirely) - so `PDFGenerator.tsx`'s own Generate/Save-to-Drive click +handlers wrap themselves in `useCardbackReminderGate` (`frontend/src/features/display/ useCardbackReminderGate.tsx`) independently of `PrePrintSaveGate.tsx`'s own composition of the +same hook. Both call sites read the same per-project `sessionStorage` suppression key +(`cardbackReminderSuppression.ts`), so passing through the reminder once (from either entry) is +enough for the rest of that session - see `docs/features/printing-tags.md`'s neighbour, +`SPEC-cardback-pdfwait.md` §C.1, for the gate's own full design. + ## Post-export contribution prompt (issue #166) `useDownloadPDF`/`useSaveToDrivePDF` (this file) are the shared success diff --git a/docs/user-guide.md b/docs/user-guide.md index 1b5133349..c57c4c3dc 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -136,10 +136,38 @@ cloud-download counter beside it (nav+footer redesign, 2026-07-22 - this used to sit in the top navbar; it now lives next to the exports it counts, here and again on the Print page). Your project is also quietly backed up to this browser as you work — a small "Draft -backed up locally" note under the buttons confirms it — and pressing -**Print / Export →** while signed in with unsaved changes offers to save -your deck first, since the print/PDF step can use a lot of your -browser's memory and you don't want to lose your work if it struggles. +backed up locally" note under the buttons confirms it. + +Pressing **Print / Export →** first checks whether your deck is still +riding the site's default cardback and, if so, asks once: **Choose a +cardback** (opens the same picker described below) or **Use current & +continue** — dismissing the prompt any other way (✕, Esc, clicking +outside it) is treated the same as **Use current & continue**, not as a +cancel, so it never blocks you from printing. It only asks once per +print session either way. Only after that does it (while signed in with +unsaved changes) offer to save your deck first, since the print/PDF step +can use a lot of your browser's memory and you don't want to lose your +work if it struggles. + +**Choosing a cardback**: the toolbar's **Cardback** button picks one for +the whole project — the picker also offers to **Apply to all card +backs** (this also overwrites any individual slot you'd already given a +different, custom back — the count and thumbnails make that explicit +before you tap it) and to **Set as my default cardback** for future +projects, both optional and never pre-selected for you. Picking a back +for a single card instead (the slot's own "Choose a different back…" +control) never touches any other slot unless you separately opt in to +its own "Apply to all" — and a slot with a custom back gets a small dot +on its own flip (⟲) icon on the print sheet, so you can spot which cards +diverge from the deck default at a glance. + +**While your PDF builds**, the Print page shows a real progress bar +(fetching your images, then laying out/encoding the file) and, beside +it, an embedded round of **What's That Card?** so waiting on a big +export isn't dead time — answering (or skipping) a card there is +optional and never blocks the export. Once the PDF is ready, the game +is replaced by a one-time nudge pointing you at the full **What's That +Card?** page if you'd like to keep going. ## Saving and re-using a project diff --git a/frontend/src/common/types.ts b/frontend/src/common/types.ts index 4a8c5be5c..166cb678b 100644 --- a/frontend/src/common/types.ts +++ b/frontend/src/common/types.ts @@ -163,6 +163,17 @@ export type Project = { * exactly; an entry is only ever present for a non-"auto" choice - see projectSlice's * `setManualOverride`. */ manualOverrides: { [identifier: string]: ManualOverride }; + /** Cardback flow round (SPEC-cardback-pdfwait.md §C.1, Annex A-1) - whether the user has ever + * explicitly chosen a project cardback this project (toolbar/rail "Apply to all"/the reminder + * gate's own "Choose a cardback" all count - anything that dispatches `setSelectedCardback` + * with a real image), as distinct from `cardback` simply being non-null. Null-ness alone can't + * carry this distinction once a real site-default seed exists (Annex A-1's own note: "a + * default always resolves a non-null projectCardback"); today, with no seed wired up yet, this + * collapses to "has `cardback` ever been set", but the flag is tracked as its own field so the + * `usePrePrintSaveGate`/reminder-gate fire condition (`ridingUntouchedDefault`) doesn't need to + * change again once Annex A-1's seed lands. Optional (not every existing `Project`-shaped + * fixture/test object sets it) - `undefined` reads as `false` everywhere this is consulted. */ + cardbackExplicitlySet?: boolean; }; export interface DFCPairs { diff --git a/frontend/src/features/card/CardbackApplyPrompt.test.tsx b/frontend/src/features/card/CardbackApplyPrompt.test.tsx new file mode 100644 index 000000000..7760433c2 --- /dev/null +++ b/frontend/src/features/card/CardbackApplyPrompt.test.tsx @@ -0,0 +1,155 @@ +/** + * Cardback flow round (SPEC-cardback-pdfwait.md §C.2, OWNER AMENDMENT 2/OQ-B) - component-level + * coverage for the shared apply-all/set-default prompt: the two entries' distinct copy/chrome + * (toolbar's "Not now" skip link vs. rail's never-pre-checked trap-guard line), the done-state + * flip on each button, and the OWNER AMENDMENT 2 thumbnail grid. + */ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; + +import { CardbackApplyPrompt } from "@/features/card/CardbackApplyPrompt"; + +describe("CardbackApplyPrompt", () => { + test("toolbar entry: project-wide copy, a 'Not now' skip link, no trap-guard note", () => { + const onDismiss = jest.fn(); + render( + + ); + + expect( + screen.getByText(/Set as this project’s cardback/) + ).toBeInTheDocument(); + expect(screen.getByTestId("cardback-apply-all-button")).toHaveTextContent( + "Apply to all (3)" + ); + expect(screen.queryByTestId("cardback-apply-prompt-trapnote")).toBeNull(); + expect( + screen.getByTestId("cardback-apply-prompt-not-now") + ).toBeInTheDocument(); + }); + + test("rail entry: per-slot copy, the never-pre-checked trap-guard note, no skip link", () => { + render( + + ); + + expect( + screen.getByText(/Applied to this slot’s back only/) + ).toBeInTheDocument(); + expect( + screen.getByTestId("cardback-apply-prompt-trapnote") + ).toHaveTextContent(/never pre-checked/); + expect(screen.queryByTestId("cardback-apply-prompt-not-now")).toBeNull(); + }); + + test("apply-all button is never pre-checked/done, and flips to a done state only after a real click", async () => { + const user = userEvent.setup(); + const onApplyAll = jest.fn(); + render( + + ); + + const applyButton = screen.getByTestId("cardback-apply-all-button"); + expect(applyButton).toHaveTextContent("Apply to all (6)"); + expect(onApplyAll).not.toHaveBeenCalled(); + + await user.click(applyButton); + + expect(onApplyAll).toHaveBeenCalledTimes(1); + expect(applyButton).toHaveTextContent("Applied to all ✓"); + }); + + test("set-default button flips to a done state independently of apply-all (two distinct, individually-skippable actions)", async () => { + const user = userEvent.setup(); + const onSetDefault = jest.fn(); + render( + + ); + + const defaultButton = screen.getByTestId("cardback-set-default-button"); + await user.click(defaultButton); + + expect(onSetDefault).toHaveBeenCalledTimes(1); + expect(defaultButton).toHaveTextContent("Default set ✓"); + // The apply-all button is untouched - the two choices are independent. + expect(screen.getByTestId("cardback-apply-all-button")).toHaveTextContent( + "Apply to all (1)" + ); + }); + + test("OWNER AMENDMENT 2 - renders a thumbnail (front + current custom back) for every affected slot, above the count line", () => { + render( + + ); + + const thumbnails = screen.getByTestId("cardback-apply-prompt-thumbnails"); + expect(thumbnails).toHaveTextContent("Slot 2"); + expect(thumbnails).toHaveTextContent("Slot 5"); + // also overrides copy names the count. + expect(screen.getByText(/also overrides 2 cards/)).toBeInTheDocument(); + }); + + test("renders no thumbnail grid when nothing is currently custom", () => { + render( + + ); + + expect(screen.queryByTestId("cardback-apply-prompt-thumbnails")).toBeNull(); + }); +}); diff --git a/frontend/src/features/card/CardbackApplyPrompt.tsx b/frontend/src/features/card/CardbackApplyPrompt.tsx new file mode 100644 index 000000000..fbb4dc053 --- /dev/null +++ b/frontend/src/features/card/CardbackApplyPrompt.tsx @@ -0,0 +1,285 @@ +/** + * Cardback flow round (SPEC-cardback-pdfwait.md §C.2/§E.2, OWNER AMENDMENT 2/OQ-B) - the shared + * inline "Apply to all card backs" + "Set as my default cardback" prompt, rendered from BOTH + * cardback-pick entries (the toolbar's project-wide `GridSelectorModal` footer, and the rail's + * per-slot picker) - one component, so the two surfaces can't drift (§C.2's own binding + * requirement). Never a second stacked modal - always rendered inline by its caller. + * + * Token table E.2 (BINDING, #302 palette): `.cbprompt` panel `#22303f` / `1px #16202b` border + + * left `3px #df6919`; `.applybtn` primary-tinted (`#df6919` border / `#ffb27d` text, hover fills + * `#df6919`/`#fff`, done state `#5cb85c`/`#8fe08f`); `.defbtn` info-tinted (`#5bc0de` border / + * `#8fd7ea` text, hover fills `#5bc0de`/`#062430`, same done state); `.trapnote` `#ffd76a` + * (rail only); `.skip` `#8fa0b0` underline link (toolbar only, since the rail per-slot picker + * already IS the "no modal, ever" surface - leaving the section collapsed is itself "not now"). + */ +import styled from "@emotion/styled"; +import React, { useState } from "react"; + +import { CustomBackSlotThumbnail } from "@/features/card/cardbackApply"; + +const Panel = styled.div` + background: #22303f; + border: 1px solid #16202b; + border-left: 3px solid #df6919; + padding: 10px 12px; + margin-top: 12px; +`; + +const Title = styled.div` + font-size: 13px; + font-weight: 700; + color: #ebebeb; + margin-bottom: 2px; + display: flex; + align-items: center; + gap: 6px; +`; + +const Intro = styled.div` + font-size: 12px; + color: #8fa0b0; + margin-bottom: 10px; +`; + +const Choice = styled.div` + display: flex; + align-items: center; + gap: 10px; + padding: 8px 0; + border-top: 1px solid #16202b; + + &:first-of-type { + border-top: none; + } +`; + +const ChoiceLabel = styled.div` + flex: 1; + min-width: 0; + + .h { + font-size: 13px; + color: #ebebeb; + } + + .s { + font-size: 11px; + color: #8fa0b0; + } +`; + +const ApplyButton = styled.button<{ $done: boolean }>` + background: transparent; + border: 1px solid ${(props) => (props.$done ? "#5cb85c" : "#df6919")}; + color: ${(props) => (props.$done ? "#8fe08f" : "#ffb27d")}; + font-family: inherit; + font-size: 13px; + padding: 4px 10px; + cursor: ${(props) => (props.$done ? "default" : "pointer")}; + border-radius: 0; + white-space: nowrap; + pointer-events: ${(props) => (props.$done ? "none" : "auto")}; + + &:hover { + background: ${(props) => (props.$done ? "transparent" : "#df6919")}; + color: ${(props) => (props.$done ? "#8fe08f" : "#fff")}; + } +`; + +const DefaultButton = styled.button<{ $done: boolean }>` + background: transparent; + border: 1px solid ${(props) => (props.$done ? "#5cb85c" : "#5bc0de")}; + color: ${(props) => (props.$done ? "#8fe08f" : "#8fd7ea")}; + font-family: inherit; + font-size: 13px; + padding: 4px 10px; + cursor: ${(props) => (props.$done ? "default" : "pointer")}; + border-radius: 0; + white-space: nowrap; + pointer-events: ${(props) => (props.$done ? "none" : "auto")}; + + &:hover { + background: ${(props) => (props.$done ? "transparent" : "#5bc0de")}; + color: ${(props) => (props.$done ? "#8fe08f" : "#062430")}; + } +`; + +const SkipRow = styled.div` + margin-top: 8px; + text-align: right; + + a { + color: #8fa0b0; + font-size: 12px; + text-decoration: underline; + cursor: pointer; + } +`; + +const TrapNote = styled.div` + font-size: 11px; + color: #ffd76a; + margin-top: 8px; + display: flex; + gap: 5px; + align-items: flex-start; +`; + +const ThumbGrid = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 10px; +`; + +const ThumbPair = styled.div` + display: flex; + align-items: center; + gap: 3px; + background: #16202b; + border: 1px solid rgba(235, 235, 235, 0.15); + padding: 3px; +`; + +const ThumbImg = styled.div<{ $url: string | undefined }>` + width: 32px; + aspect-ratio: 63 / 88; + background-color: #2a2320; + background-image: ${(props) => + props.$url != null ? `url(${props.$url})` : "none"}; + background-size: cover; + background-position: center; +`; + +const ThumbLabel = styled.div` + font-size: 9px; + color: #8fa0b0; + max-width: 90px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +export interface CardbackApplyPromptProps { + /** Toolbar = project-wide canonical copy + a "Not now" skip link; rail = per-slot copy + the + * never-pre-checked trap-guard line instead of a skip link (§C.2's own two-entry split). */ + entry: "toolbar" | "rail"; + /** How many back faces "Apply to all" would touch, including custom ones - the prompt's own + * "Apply to all (N)" count (`countBackFacesAffectedByApplyAll`). */ + affectedCount: number; + /** OWNER AMENDMENT 2/OQ-B - thumbnails (front + current custom back) of every slot "Apply to + * all" would override, rendered ABOVE the count line. Empty when nothing is currently custom. */ + customBackThumbnails: Array; + onApplyAll: () => void; + onSetDefault: () => void; + /** Toolbar only - see this file's own header comment for why the rail entry has no skip link. */ + onDismiss?: () => void; +} + +export function CardbackApplyPrompt({ + entry, + affectedCount, + customBackThumbnails, + onApplyAll, + onSetDefault, + onDismiss, +}: CardbackApplyPromptProps) { + const [applyDone, setApplyDone] = useState(false); + const [defaultDone, setDefaultDone] = useState(false); + + const handleApplyAll = () => { + onApplyAll(); + setApplyDone(true); + }; + const handleSetDefault = () => { + onSetDefault(); + setDefaultDone(true); + }; + + const isPerSlot = entry === "rail"; + + return ( + + ✓ Cardback selected + + {isPerSlot + ? "Applied to this slot’s back only. Two optional next steps — both independent, both skippable:" + : "Set as this project’s cardback. Two optional next steps — both independent, both skippable:"} + + {customBackThumbnails.length > 0 && ( + + {customBackThumbnails.map((thumbnail) => ( + + + + {thumbnail.slotLabel} + + ))} + + )} + + +
Apply to all card backs in this deck
+
+ {isPerSlot + ? "opt-in — nothing changes unless you tap" + : `also overrides ${customBackThumbnails.length} card${ + customBackThumbnails.length === 1 ? "" : "s" + } with a custom back`} +
+
+ + {applyDone ? "Applied to all ✓" : `Apply to all (${affectedCount})`} + +
+ + +
Set as my default cardback
+
used for new projects & new slots
+
+ + {defaultDone ? "Default set ✓" : "Set default"} + +
+ {isPerSlot ? ( + + + + Per-slot pick stays per-slot. "Apply to all" is never + pre-checked — a single-slot choice can't silently rewrite the + deck. + + + ) : ( + onDismiss != null && ( + + + Not now + + + ) + )} +
+ ); +} diff --git a/frontend/src/features/card/CommonCardback.tsx b/frontend/src/features/card/CommonCardback.tsx index d77873c93..d08ae4c58 100644 --- a/frontend/src/features/card/CommonCardback.tsx +++ b/frontend/src/features/card/CommonCardback.tsx @@ -12,12 +12,21 @@ import { useAppDispatch, useAppSelector } from "@/common/types"; import { wrapIndex } from "@/common/utils"; import { RightPaddedIcon } from "@/components/icon"; import { MemoizedEditorCard } from "@/features/card/Card"; +import { + countBackFacesAffectedByApplyAll, + resolveCustomBackSlotThumbnails, +} from "@/features/card/cardbackApply"; +import { CardbackApplyPrompt } from "@/features/card/CardbackApplyPrompt"; +import { setUserDefaultCardback } from "@/features/card/cardbackDefaultPreference"; import { CardFooter } from "@/features/card/CardFooter"; import { GridSelectorModal } from "@/features/gridSelector/GridSelectorModal"; import { selectCardbacks } from "@/store/slices/cardbackSlice"; +import { useCardDocumentsByIdentifier } from "@/store/slices/cardDocumentsSlice"; import { + applyCardbackToAllSlots, bulkReplaceSelectedImage, selectProjectCardback, + selectProjectMembers, setSelectedCardback, } from "@/store/slices/projectSlice"; import { selectSearchSettings } from "@/store/slices/searchSettingsSlice"; @@ -42,12 +51,26 @@ export function CommonCardbackGridSelector({ const dispatch = useAppDispatch(); const projectCardback = useAppSelector(selectProjectCardback); + const projectMembers = useAppSelector(selectProjectMembers); + const cardDocumentsByIdentifier = useCardDocumentsByIdentifier(); const filterCardbacks = useAppSelector( (state) => selectSearchSettings(state).searchTypeSettings.filterCardbacks ); //# endregion + //# region state + + // Cardback flow round (SPEC-cardback-pdfwait.md §C.2) - the toolbar entry is project-wide + // canonical: a pick already bulk-replaces every slot following the OLD project cardback, so the + // apply/default prompt renders inline in THIS SAME modal (never a second stacked one) once a + // pick has been made, rather than closing immediately - `closeOnSelect={false}` below. + const [lastPickedImage, setLastPickedImage] = useState( + undefined + ); + + //# endregion + //# region callbacks const setSelectedImageFromIdentifier = (image: string): void => { @@ -59,9 +82,44 @@ export function CommonCardbackGridSelector({ face: Back, }) ); - dispatch(setSelectedCardback({ selectedImage: image })); + } + dispatch(setSelectedCardback({ selectedImage: image, explicit: true })); + setLastPickedImage(image); + }; + + const handleApplyAll = () => { + if (lastPickedImage != null) { + dispatch(applyCardbackToAllSlots({ selectedImage: lastPickedImage })); } }; + const handleSetDefault = () => { + if (lastPickedImage != null) { + void setUserDefaultCardback(lastPickedImage); + } + }; + const handleModalClose = ( + event?: React.MouseEvent + ) => { + setLastPickedImage(undefined); + if (event != null) { + handleClose(event); + } else { + handleClose(); + } + }; + + //# endregion + + //# region computed constants + + const customBackThumbnails = + lastPickedImage != null + ? resolveCustomBackSlotThumbnails( + projectMembers, + lastPickedImage, + cardDocumentsByIdentifier + ) + : []; //# endregion @@ -72,9 +130,25 @@ export function CommonCardbackGridSelector({ imageIdentifiers={searchResults} selectedImage={projectCardback} show={show} - handleClose={handleClose} + handleClose={handleModalClose} onClick={setSelectedImageFromIdentifier} applySearchSettings={filterCardbacks} + closeOnSelect={false} + footerContent={ + lastPickedImage != null && ( + setLastPickedImage(undefined)} + /> + ) + } /> ); } @@ -121,6 +195,7 @@ export function CommonCardback({ selectedImage }: CommonCardbackProps) { dispatch( setSelectedCardback({ selectedImage: image, + explicit: true, }) ); } @@ -203,6 +278,11 @@ export function CardbackToolbarButton() { size="sm" variant="outline-secondary" onClick={handleShowGridSelector} + // Cardback flow round (SPEC-cardback-pdfwait.md OWNER AMENDMENT 3) - a real testid, not + // just this button's own accessible name, since a sheet slot's "⟲" flip button can now + // ALSO carry "cardback" in its own aria-label (the custom-cardback indicator), which + // otherwise makes a name-based locator for this button ambiguous/fragile. + data-testid="cardback-toolbar-button" > Cardback diff --git a/frontend/src/features/card/cardbackApply.test.ts b/frontend/src/features/card/cardbackApply.test.ts new file mode 100644 index 000000000..dbe4a955f --- /dev/null +++ b/frontend/src/features/card/cardbackApply.test.ts @@ -0,0 +1,104 @@ +import { cardDocument1, cardDocument2 } from "@/common/test-constants"; +import { CardType, SlotProjectMembers } from "@/common/types"; +import { + countBackFacesAffectedByApplyAll, + resolveCustomBackSlotThumbnails, +} from "@/features/card/cardbackApply"; + +function member( + id: string, + frontImage: string | undefined, + backImage: string | undefined +): SlotProjectMembers { + return { + id, + front: + frontImage != null + ? { + query: { query: null, cardType: "CARD" as CardType }, + selectedImage: frontImage, + selected: false, + } + : null, + back: + backImage != null + ? { + query: { query: null, cardType: "CARDBACK" as CardType }, + selectedImage: backImage, + selected: false, + } + : null, + }; +} + +describe("resolveCustomBackSlotThumbnails (OWNER AMENDMENT 2/OQ-B)", () => { + test("returns nothing when there is no project cardback to be 'different' from", () => { + const members = [member("t-0", cardDocument1.identifier, "some-back")]; + expect( + resolveCustomBackSlotThumbnails(members, undefined, {}) + ).toStrictEqual([]); + }); + + test("excludes slots whose back matches the project cardback", () => { + const members = [member("t-0", cardDocument1.identifier, "the-default")]; + expect( + resolveCustomBackSlotThumbnails(members, "the-default", {}) + ).toStrictEqual([]); + }); + + test("excludes slots with no back face at all", () => { + const members = [member("t-0", cardDocument1.identifier, undefined)]; + expect( + resolveCustomBackSlotThumbnails(members, "the-default", {}) + ).toStrictEqual([]); + }); + + test("includes a slot whose back differs, with resolved front/back thumbnail+name pairs", () => { + const members = [ + member("t-0", cardDocument1.identifier, cardDocument2.identifier), + ]; + const cardDocumentsByIdentifier = { + [cardDocument1.identifier]: cardDocument1, + [cardDocument2.identifier]: cardDocument2, + }; + const result = resolveCustomBackSlotThumbnails( + members, + "the-default", + cardDocumentsByIdentifier + ); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + slotLabel: "Slot 1", + frontName: cardDocument1.name, + backName: cardDocument2.name, + }); + }); + + test("1-based slot labelling, and only the custom slots are returned out of several", () => { + const members = [ + member("t-0", cardDocument1.identifier, "the-default"), + member("t-1", cardDocument1.identifier, "a-custom-back"), + member("t-2", cardDocument1.identifier, "the-default"), + ]; + const result = resolveCustomBackSlotThumbnails(members, "the-default", {}); + expect(result.map((thumbnail) => thumbnail.slotLabel)).toStrictEqual([ + "Slot 2", + ]); + }); +}); + +describe("countBackFacesAffectedByApplyAll", () => { + test("counts every slot whose back is not already the new cardback - including custom ones", () => { + const members = [ + member("t-0", undefined, "the-old-default"), + member("t-1", undefined, "already-custom"), + member("t-2", undefined, "the-new-cardback"), + member("t-3", undefined, undefined), + ]; + // "the-new-cardback" is what's being applied - already-matching slots don't count, everything + // else does (2 non-matching backs + 1 slot with no back at all). + expect(countBackFacesAffectedByApplyAll(members, "the-new-cardback")).toBe( + 3 + ); + }); +}); diff --git a/frontend/src/features/card/cardbackApply.ts b/frontend/src/features/card/cardbackApply.ts new file mode 100644 index 000000000..ae975d517 --- /dev/null +++ b/frontend/src/features/card/cardbackApply.ts @@ -0,0 +1,65 @@ +/** + * Cardback flow round (SPEC-cardback-pdfwait.md §C.2, OWNER AMENDMENT 2/OQ-B) - pure helpers + * shared by the two "Apply to all card backs" entries (toolbar = project-wide, rail = per-slot). + * Kept dependency-free (no redux/react) so the thumbnail-preview logic is unit-testable without a + * store. + */ +import { CardDocument, SlotProjectMembers } from "@/common/types"; + +export interface CustomBackSlotThumbnail { + /** 1-based, matching the on-sheet "Slot N" labelling elsewhere in this app. */ + slotLabel: string; + frontThumbnailUrl: string | undefined; + frontName: string | undefined; + backThumbnailUrl: string | undefined; + backName: string | undefined; +} + +/** + * Every slot whose current back-face image differs from `projectCardback` - i.e. a + * deliberately-custom back that "Apply to all" would override, with the front/current-back + * thumbnail pair OWNER AMENDMENT 2 requires the prompt to show above the count line. + * `projectCardback == null` (no project cardback chosen at all yet) means there is nothing to be + * "different" FROM, so nothing counts as custom. + */ +export function resolveCustomBackSlotThumbnails( + members: Array, + projectCardback: string | undefined, + cardDocumentsByIdentifier: { + [identifier: string]: CardDocument | undefined; + } +): Array { + if (projectCardback == null) { + return []; + } + const results: Array = []; + members.forEach((member, index) => { + const backImage = member.back?.selectedImage; + if (backImage == null || backImage === projectCardback) { + return; + } + const frontImage = member.front?.selectedImage; + const frontDoc = + frontImage != null ? cardDocumentsByIdentifier[frontImage] : undefined; + const backDoc = cardDocumentsByIdentifier[backImage]; + results.push({ + slotLabel: `Slot ${index + 1}`, + frontThumbnailUrl: frontDoc?.smallThumbnailUrl, + frontName: frontDoc?.name, + backThumbnailUrl: backDoc?.smallThumbnailUrl, + backName: backDoc?.name, + }); + }); + return results; +} + +/** How many of `members`' back faces would change if `newCardback` were applied to every slot + * (project-wide "Apply to all") - the prompt's own "6 backs" count, independent of the + * custom-back subset above. */ +export function countBackFacesAffectedByApplyAll( + members: Array, + newCardback: string +): number { + return members.filter((member) => member.back?.selectedImage !== newCardback) + .length; +} diff --git a/frontend/src/features/card/cardbackDefaultPreference.ts b/frontend/src/features/card/cardbackDefaultPreference.ts new file mode 100644 index 000000000..fdd34c076 --- /dev/null +++ b/frontend/src/features/card/cardbackDefaultPreference.ts @@ -0,0 +1,35 @@ +/** + * Cardback flow round (SPEC-cardback-pdfwait.md Annex A-2) - "Set as my default cardback" is a + * SEAM, deliberately not designed/built here: the spec's own text is "localStorage for anonymous, + * account-persisted for authenticated (mirroring how favourites/projects already split) - not + * designed here." + * + * This repo's own standing rule (CLAUDE.md, "No localStorage for anything that should survive a + * 'clear site data'/incognito test - state that should be server- or URL-derived has caused real + * bugs here before") means the spec's own suggested anonymous-path storage mechanism can't just be + * implemented as written without a real backend/account-preference endpoint to write the + * authenticated half against too (a localStorage-only implementation would silently diverge + * between the two account states, and would be the exact class of bug that rule exists to + * prevent). Rather than land a real persistence layer nobody has designed the backend contract + * for, this module is the explicit seam boundary the button below calls into - documented, + * intentionally a no-op, easy to swap for a real `POST /2/preferences/defaultCardback/`-shaped + * call (or a deliberately-scoped localStorage write, if the owner rules that's fine for the + * anonymous half specifically) once that contract exists. The UI (`CardbackApplyPrompt.tsx`) + * still renders the full "Set as my default cardback" affordance and its done-state - only the + * actual write is deferred. + */ +export interface SetDefaultCardbackResult { + /** Always `true` today (the seam has nothing that can fail yet) - kept as a real return value, + * not a bare void, so callers don't need to change shape once this seam gains a real + * network/storage write that CAN fail. */ + persisted: boolean; +} + +export async function setUserDefaultCardback( + selectedImage: string +): Promise { + // Intentional no-op - see this module's own header comment (Annex A-2 seam). `selectedImage` + // is unused today; kept in the signature so a real implementation is a body-only change. + void selectedImage; + return { persisted: false }; +} diff --git a/frontend/src/features/display/DisplayPage.tsx b/frontend/src/features/display/DisplayPage.tsx index d7622a2bb..189ff2941 100644 --- a/frontend/src/features/display/DisplayPage.tsx +++ b/frontend/src/features/display/DisplayPage.tsx @@ -265,6 +265,7 @@ import { buildScryfallReferenceUrl, } from "@/features/display/scryfallReference"; import { SlotActionsSection } from "@/features/display/SlotActionsSection"; +import { SlotCardbackControl } from "@/features/display/SlotCardbackControl"; import { SourcesAccordion } from "@/features/display/SourcesAccordion"; import { ProjectDraftSummary, @@ -1850,6 +1851,9 @@ const Rail = ({ : undefined ); const query = projectMember?.query; + // Cardback flow round (SPEC-cardback-pdfwait.md §C.2) - the deck's own project cardback, + // needed by SlotCardbackControl below to tell "follows the deck" from "custom for this slot". + const projectCardback = useAppSelector(selectProjectCardback); // 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 @@ -1912,6 +1916,12 @@ const Rail = ({ } : null; + // Cardback flow round (SPEC-cardback-pdfwait.md §C.2, `PKG1b` rail entry) - the slot's own back + // face, regardless of which face is currently selected for editing (`selectedSlotRef.face` can + // be either Front or Back - the cardback control always concerns the Back one specifically). + const backProjectMember = + selectedSlotRef.face === Back ? projectMember : otherProjectMember; + return ( +
+
+ Cardback (this slot) +
+ +
{/* 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 @@ -2326,6 +2355,14 @@ export function DisplayPage() { flippable: entry.member.front?.selectedImage != null || entry.member.back?.selectedImage != null, + // Cardback flow round (SPEC-cardback-pdfwait.md OWNER AMENDMENT 3) - same gating as + // `flippable` itself (card-holding cells only): a real back selection that differs + // from the deck's own project cardback. `projectCardback == null` (nothing chosen + // yet at all) means there is nothing to be "different" FROM. + hasCustomCardback: + projectCardback != null && + entry.member.back?.selectedImage != null && + entry.member.back.selectedImage !== projectCardback, }; return content; }), @@ -2336,6 +2373,7 @@ export function DisplayPage() { cardDocumentsByIdentifier, searchResultsLoading, flippedPreviewSlots, + projectCardback, ] ); diff --git a/frontend/src/features/display/PrePrintSaveGate.tsx b/frontend/src/features/display/PrePrintSaveGate.tsx index d8d550c45..9d798b699 100644 --- a/frontend/src/features/display/PrePrintSaveGate.tsx +++ b/frontend/src/features/display/PrePrintSaveGate.tsx @@ -24,6 +24,13 @@ * attempt, not as an implicit Skip - the user stays on /display with nothing navigated and * nothing saved, which is the safer default for a modal that isn't itself a forced, * no-cancel-option safety net (unlike LoadSafetyModal, which never offers a plain dismiss). + * + * Cardback flow round (SPEC-cardback-pdfwait.md §C.1, `PKG1a`) - a NEW gate step + * (`useCardbackReminderGate`) now runs FIRST, before this file's own Save/Skip branch: the deck- + * completeness decision (does the export need a cardback?) precedes the persistence decision. + * Unlike this Save gate's own dismiss=cancel rule, the cardback reminder's own dismiss semantics + * are OWNER AMENDMENT 1 (dismiss = "use current & continue", not cancel) - the two gates are + * deliberately NOT symmetric; see that hook's own module comment for why. */ import { useRouter } from "next/router"; import React, { useState } from "react"; @@ -31,6 +38,7 @@ import Button from "react-bootstrap/Button"; import Modal from "react-bootstrap/Modal"; import { useAppSelector } from "@/common/types"; +import { useCardbackReminderGate } from "@/features/display/useCardbackReminderGate"; import { selectIsCurrentProjectDirty } from "@/features/savedDecks/selectors"; import { useSaveDeckFlow } from "@/features/savedDecks/useSaveDeckFlow"; import { useGetWhoamiQuery } from "@/store/api"; @@ -64,6 +72,7 @@ export function usePrePrintSaveGate({ const isAuthenticated = whoami.data?.authenticated === true; const isProjectDirty = useAppSelector(selectIsCurrentProjectDirty); const saveFlow = useSaveDeckFlow(); + const cardbackReminderGate = useCardbackReminderGate(); const [showPrompt, setShowPrompt] = useState(false); @@ -71,12 +80,7 @@ export function usePrePrintSaveGate({ router.push(PRINT_PAGE_ROUTE); }; - const startPrintFlow = () => { - // D9(3)a - flush first, unconditionally, before any branch below. D9(2)'s pre-print - // promotion nudge rides the same moment. - flushDraftNow(); - notifyPromoteDraftPrePrint(); - + const runSaveBranch = () => { if (isAuthenticated && isProjectDirty) { setShowPrompt(true); } else { @@ -87,6 +91,18 @@ export function usePrePrintSaveGate({ } }; + const startPrintFlow = () => { + // D9(3)a - flush first, unconditionally, before any branch below. D9(2)'s pre-print + // promotion nudge rides the same moment. + flushDraftNow(); + notifyPromoteDraftPrePrint(); + + // Cardback flow round (SPEC-cardback-pdfwait.md §C.1) - the deck-completeness decision runs + // BEFORE the persistence decision; `guard` is a no-op (calls `runSaveBranch` immediately) once + // a cardback has been explicitly chosen or the reminder's already been dismissed this session. + cardbackReminderGate.guard(runSaveBranch); + }; + const handleSave = () => { setShowPrompt(false); saveFlow.triggerSave(proceedToPrint); @@ -133,6 +149,7 @@ export function usePrePrintSaveGate({ {saveFlow.element} + {cardbackReminderGate.element} ); diff --git a/frontend/src/features/display/SlotCardbackControl.tsx b/frontend/src/features/display/SlotCardbackControl.tsx new file mode 100644 index 000000000..c6e97f223 --- /dev/null +++ b/frontend/src/features/display/SlotCardbackControl.tsx @@ -0,0 +1,196 @@ +/** + * Cardback flow round (SPEC-cardback-pdfwait.md §C.2, `PKG1b` rail entry) - the left rail's + * per-slot cardback control: a thumbnail of the slot's own resolved back face + a "Choose a + * different back…" button. Unlike the toolbar entry (`CommonCardbackGridSelector`, a + * project-wide `GridSelectorModal` pick), this is a per-slot pick - it dispatches + * `setSelectedImages` for THIS SLOT'S back face only, never `bulkReplaceSelectedImage`/ + * `setSelectedCardback` (those are project-wide concepts). + * + * "No modal, ever" (§C.2's own text: "the rail per-slot picker is already the 'no modal, ever' + * surface") - reuses `GridSelectorResults`'s `"embedded"` variant + `useGridSelectorSearch` + * directly (the exact same pair `SelectVersionResults.tsx` already uses for the rail's own + * front/back art picker), never `GridSelectorModal`. + */ +import styled from "@emotion/styled"; +import React, { useRef, useState } from "react"; +import Button from "react-bootstrap/Button"; + +import { Back } from "@/common/constants"; +import { useAppDispatch, useAppSelector } from "@/common/types"; +import { + countBackFacesAffectedByApplyAll, + resolveCustomBackSlotThumbnails, +} from "@/features/card/cardbackApply"; +import { CardbackApplyPrompt } from "@/features/card/CardbackApplyPrompt"; +import { setUserDefaultCardback } from "@/features/card/cardbackDefaultPreference"; +import { GridSelectorResults } from "@/features/gridSelector/GridSelectorResults"; +import { useGridSelectorSearch } from "@/features/gridSelector/useGridSelectorSearch"; +import { selectCardbacks } from "@/store/slices/cardbackSlice"; +import { useCardDocumentsByIdentifier } from "@/store/slices/cardDocumentsSlice"; +import { + applyCardbackToAllSlots, + selectProjectMembers, + setSelectedImages, +} from "@/store/slices/projectSlice"; + +const Thumb = styled.div<{ $url: string | undefined }>` + flex: 0 0 54px; + width: 54px; + aspect-ratio: 63 / 88; + border: 1px solid rgba(235, 235, 235, 0.15); + position: relative; + background-color: #2a2320; + background-image: ${(props) => + props.$url != null ? `url(${props.$url})` : "none"}; + background-size: cover; + background-position: center; + + .cap { + position: absolute; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + color: #a99; + font-size: 8px; + text-align: center; + padding: 1px; + } +`; + +const Row = styled.div` + display: flex; + gap: 9px; + align-items: flex-start; +`; + +const Meta = styled.div` + flex: 1; + min-width: 0; + + .bname { + font-size: 13px; + color: #ebebeb; + } + + .bsub { + font-size: 11px; + color: #8fa0b0; + } +`; + +export interface SlotCardbackControlProps { + slot: number; + /** The slot's own current back-face image, whatever it resolves to today (following the + * project cardback, or already custom). */ + backImage: string | undefined; + projectCardback: string | undefined; +} + +export function SlotCardbackControl({ + slot, + backImage, + projectCardback, +}: SlotCardbackControlProps) { + const dispatch = useAppDispatch(); + const projectMembers = useAppSelector(selectProjectMembers); + const cardbackSearchResults = useAppSelector(selectCardbacks); + const cardDocumentsByIdentifier = useCardDocumentsByIdentifier(); + + const [pickerOpen, setPickerOpen] = useState(false); + const [lastPickedImage, setLastPickedImage] = useState( + undefined + ); + const focusRef = useRef(null); + + const search = useGridSelectorSearch({ + imageIdentifiers: cardbackSearchResults, + active: pickerOpen, + }); + + const backDocument = + backImage != null ? cardDocumentsByIdentifier[backImage] : undefined; + const isCustom = + backImage != null && + projectCardback != null && + backImage !== projectCardback; + + const handlePick = (image: string) => { + dispatch( + setSelectedImages({ selectedImage: image, slots: [[Back, slot]] }) + ); + setLastPickedImage(image); + setPickerOpen(false); + }; + + const handleApplyAll = () => { + if (lastPickedImage != null) { + dispatch(applyCardbackToAllSlots({ selectedImage: lastPickedImage })); + } + }; + const handleSetDefault = () => { + if (lastPickedImage != null) { + void setUserDefaultCardback(lastPickedImage); + } + }; + + const customBackThumbnails = + lastPickedImage != null + ? resolveCustomBackSlotThumbnails( + projectMembers, + lastPickedImage, + cardDocumentsByIdentifier + ) + : []; + + return ( +
+ + + {isCustom ? "custom back" : "deck back"} + + +
+ {backDocument?.name ?? "Deck default back"} +
+
+ {isCustom ? "custom for this slot" : "follows project cardback"} +
+ + +
+ {pickerOpen && ( +
+ +
+ )} + {lastPickedImage != null && ( + + )} +
+ ); +} diff --git a/frontend/src/features/display/cardbackReminderSuppression.test.ts b/frontend/src/features/display/cardbackReminderSuppression.test.ts new file mode 100644 index 000000000..0649cbdec --- /dev/null +++ b/frontend/src/features/display/cardbackReminderSuppression.test.ts @@ -0,0 +1,48 @@ +import { + hasSuppressedCardbackReminderThisSession, + resetCardbackReminderSuppressionForTests, + suppressCardbackReminderThisSession, + UNSAVED_PROJECT_SUPPRESSION_KEY, +} from "@/features/display/cardbackReminderSuppression"; + +describe("cardback reminder gate suppression (sessionStorage-backed, per-project)", () => { + afterEach(() => { + resetCardbackReminderSuppressionForTests(UNSAVED_PROJECT_SUPPRESSION_KEY); + resetCardbackReminderSuppressionForTests("saved-deck-key-1"); + resetCardbackReminderSuppressionForTests("saved-deck-key-2"); + }); + + test("starts unset for a fresh project", () => { + expect( + hasSuppressedCardbackReminderThisSession(UNSAVED_PROJECT_SUPPRESSION_KEY) + ).toBe(false); + }); + + test("persists across reads once suppressed (CB1 - once per session)", () => { + suppressCardbackReminderThisSession(UNSAVED_PROJECT_SUPPRESSION_KEY); + expect( + hasSuppressedCardbackReminderThisSession(UNSAVED_PROJECT_SUPPRESSION_KEY) + ).toBe(true); + expect( + hasSuppressedCardbackReminderThisSession(UNSAVED_PROJECT_SUPPRESSION_KEY) + ).toBe(true); + }); + + test("is scoped PER PROJECT - suppressing one project's key leaves another's untouched", () => { + suppressCardbackReminderThisSession("saved-deck-key-1"); + expect(hasSuppressedCardbackReminderThisSession("saved-deck-key-1")).toBe( + true + ); + expect(hasSuppressedCardbackReminderThisSession("saved-deck-key-2")).toBe( + false + ); + }); + + test("resets cleanly (test-only helper, mirrors a fresh tab)", () => { + suppressCardbackReminderThisSession(UNSAVED_PROJECT_SUPPRESSION_KEY); + resetCardbackReminderSuppressionForTests(UNSAVED_PROJECT_SUPPRESSION_KEY); + expect( + hasSuppressedCardbackReminderThisSession(UNSAVED_PROJECT_SUPPRESSION_KEY) + ).toBe(false); + }); +}); diff --git a/frontend/src/features/display/cardbackReminderSuppression.ts b/frontend/src/features/display/cardbackReminderSuppression.ts new file mode 100644 index 000000000..c0298a901 --- /dev/null +++ b/frontend/src/features/display/cardbackReminderSuppression.ts @@ -0,0 +1,47 @@ +/** + * Cardback flow round (SPEC-cardback-pdfwait.md §C.1, `CB1`) - the no-cardback reminder gate's + * own "at most once per session, per project" suppression, mirroring + * `postExportContributionPrompt.ts`'s identical sessionStorage-not-localStorage precedent (must + * NOT survive a "clear site data"/incognito test the way a real persisted setting would, but does + * need to survive this tab's own reloads within one print attempt). Per-PROJECT (not one flag for + * the whole session) - keyed by `savedDeckSession`'s `currentDeckKey`, falling back to a fixed + * bucket for an unsaved/anonymous project (see `useCardbackReminderGate.ts`'s own comment on why + * that bucket is an acceptable approximation, not a real per-project key, until every project has + * a stable identity regardless of save state). + */ + +const SESSION_FLAG_PREFIX = "cardbackReminderGateSuppressed:"; + +/** Fixed bucket for a project with no saved-deck key yet (anonymous, or authenticated but never + * saved this project) - see this module's own header comment. */ +export const UNSAVED_PROJECT_SUPPRESSION_KEY = "__unsaved__"; + +function storageKey(projectKey: string): string { + return `${SESSION_FLAG_PREFIX}${projectKey}`; +} + +export function hasSuppressedCardbackReminderThisSession( + projectKey: string +): boolean { + if (typeof window === "undefined") { + return false; + } + return window.sessionStorage.getItem(storageKey(projectKey)) === "true"; +} + +export function suppressCardbackReminderThisSession(projectKey: string): void { + if (typeof window === "undefined") { + return; + } + window.sessionStorage.setItem(storageKey(projectKey), "true"); +} + +/** Test-only reset - a fresh session/tab in real use always starts unset. */ +export function resetCardbackReminderSuppressionForTests( + projectKey: string +): void { + if (typeof window === "undefined") { + return; + } + window.sessionStorage.removeItem(storageKey(projectKey)); +} diff --git a/frontend/src/features/display/useCardbackReminderGate.test.tsx b/frontend/src/features/display/useCardbackReminderGate.test.tsx new file mode 100644 index 000000000..3ec24e60f --- /dev/null +++ b/frontend/src/features/display/useCardbackReminderGate.test.tsx @@ -0,0 +1,170 @@ +/** + * Cardback flow round (SPEC-cardback-pdfwait.md §C.1, `PKG1a`) - hook-level coverage for + * `useCardbackReminderGate`, exercised directly (not only indirectly via + * `PrePrintSaveGate.test.tsx`/an E2E Playwright spec) since it's now composed into TWO + * independent call sites (`usePrePrintSaveGate`'s own `startPrintFlow`, and `PDFGenerator.tsx`'s + * classic direct "Generate PDF"/"Save PDF to Google Drive" buttons - see this hook's own module + * comment) - a single shared unit test is the one place both call sites' shared semantics + * (dismiss-continues, once-per-session suppression) are verified without duplicating a full E2E + * flow for each entry point. + */ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { Provider } from "react-redux"; + +import { CardType, Project, SlotProjectMembers } from "@/common/types"; +import { ClientSearchContextProvider } from "@/features/clientSearch/clientSearchContext"; +import { clientSearchService } from "@/features/clientSearch/clientSearchService"; +import { + resetCardbackReminderSuppressionForTests, + UNSAVED_PROJECT_SUPPRESSION_KEY, +} from "@/features/display/cardbackReminderSuppression"; +import { useCardbackReminderGate } from "@/features/display/useCardbackReminderGate"; +import { setupStore } from "@/store/store"; + +const oneMember: SlotProjectMembers = { + id: "t-0", + front: { + query: { query: "my card", cardType: "CARD" as CardType }, + selectedImage: "front-image", + selected: false, + }, + back: null, +}; + +function baseProject(overrides: Partial = {}): Project { + return { + members: [oneMember], + nextMemberId: 1, + cardback: null, + mostRecentlySelectedSlot: null, + manualOverrides: {}, + cardbackExplicitlySet: false, + ...overrides, + }; +} + +function TestHarness({ project }: { project: Project }) { + const gate = useCardbackReminderGate(); + const [proceeded, setProceeded] = React.useState(0); + return ( + <> + +
{proceeded}
+ {gate.element} + + ); +} + +function renderHarness(project: Project) { + const store = setupStore({ project }); + render( + + undefined, + forceUpdateValue: 0, + }} + > + + + + ); +} + +describe("useCardbackReminderGate (SPEC-cardback-pdfwait.md §C.1)", () => { + afterEach(() => { + resetCardbackReminderSuppressionForTests(UNSAVED_PROJECT_SUPPRESSION_KEY); + }); + + test("does not appear at all once a cardback has been explicitly chosen - guard proceeds straight through", async () => { + const user = userEvent.setup(); + renderHarness( + baseProject({ cardback: "chosen", cardbackExplicitlySet: true }) + ); + + await user.click(screen.getByText("start print flow")); + + expect(screen.queryByTestId("pre-print-cardback-gate")).toBeNull(); + expect(screen.getByTestId("proceeded-count")).toHaveTextContent("1"); + }); + + test("appears for a project still riding the untouched default cardback, and blocks the guarded action until resolved", async () => { + const user = userEvent.setup(); + renderHarness(baseProject()); + + await user.click(screen.getByText("start print flow")); + + expect(screen.getByTestId("pre-print-cardback-gate")).toBeInTheDocument(); + // Blocked - the guarded action hasn't run yet. + expect(screen.getByTestId("proceeded-count")).toHaveTextContent("0"); + }); + + test("'Use current & continue' proceeds and suppresses the gate for the rest of the session (CB1)", async () => { + const user = userEvent.setup(); + renderHarness(baseProject()); + + await user.click(screen.getByText("start print flow")); + await user.click(screen.getByTestId("cardback-gate-use-current")); + + expect(screen.queryByTestId("pre-print-cardback-gate")).toBeNull(); + expect(screen.getByTestId("proceeded-count")).toHaveTextContent("1"); + + // A second print attempt in the same session is silent (CB1 - "at most once per session"). + await user.click(screen.getByText("start print flow")); + expect(screen.queryByTestId("pre-print-cardback-gate")).toBeNull(); + expect(screen.getByTestId("proceeded-count")).toHaveTextContent("2"); + }); + + test("OWNER AMENDMENT 1 - dismissing the gate (Esc/backdrop/✕, all routed through Modal's onHide) proceeds, it does NOT cancel", async () => { + const user = userEvent.setup(); + renderHarness(baseProject()); + + await user.click(screen.getByText("start print flow")); + expect(screen.getByTestId("pre-print-cardback-gate")).toBeInTheDocument(); + + // The header's own close (X) button - react-bootstrap Modal routes this through onHide, + // exactly like Esc/backdrop would. + await user.click(screen.getByLabelText("Close")); + + expect(screen.queryByTestId("pre-print-cardback-gate")).toBeNull(); + // Dismiss is NOT cancel (Amendment 1 supersedes the spec's own OQ-A recommendation) - the + // guarded action still ran. + expect(screen.getByTestId("proceeded-count")).toHaveTextContent("1"); + }); + + test("'Choose a cardback' opens the project-wide grid selector instead of the reminder, and closing it (with or without a pick) proceeds", async () => { + const user = userEvent.setup(); + renderHarness(baseProject()); + + await user.click(screen.getByText("start print flow")); + await user.click(screen.getByTestId("cardback-gate-choose")); + + expect(screen.queryByTestId("pre-print-cardback-gate")).toBeNull(); + expect(screen.getByTestId("cardback-grid-selector")).toBeInTheDocument(); + expect(screen.getByTestId("proceeded-count")).toHaveTextContent("0"); + + // Closing the grid selector (its own header X) resumes the guarded print/export action - + // no genuine cancel path exists anywhere in this gate (Amendment 1). + const grid = screen.getByTestId("cardback-grid-selector"); + await user.click( + grid.querySelector('button[aria-label="Close"]') as HTMLElement + ); + + expect(screen.getByTestId("proceeded-count")).toHaveTextContent("1"); + }); + + test("never fires for an empty project (nothing to print)", async () => { + const user = userEvent.setup(); + renderHarness(baseProject({ members: [] })); + + await user.click(screen.getByText("start print flow")); + + expect(screen.queryByTestId("pre-print-cardback-gate")).toBeNull(); + expect(screen.getByTestId("proceeded-count")).toHaveTextContent("1"); + }); +}); diff --git a/frontend/src/features/display/useCardbackReminderGate.tsx b/frontend/src/features/display/useCardbackReminderGate.tsx new file mode 100644 index 000000000..b2769e61a --- /dev/null +++ b/frontend/src/features/display/useCardbackReminderGate.tsx @@ -0,0 +1,226 @@ +/** + * Cardback flow round (SPEC-cardback-pdfwait.md §C.1, `PKG1a`) - the no-cardback reminder GATE + * STEP, composed into `usePrePrintSaveGate.startPrintFlow` (before the Save/Skip branch) AND the + * classic direct "Generate PDF"/"Save PDF to Google Drive" buttons on `PDFGenerator.tsx` (the + * spec's own coverage note: a user can reach `/print` directly, bypassing the editor's Finish + * footer entirely, so the reminder needs its own independent guard there too - not just inside + * `usePrePrintSaveGate`). + * + * OWNER AMENDMENT 1 (2026-07-24, supersedes the spec's own `OQ-A` recommendation): dismissing the + * gate (✕/Esc/backdrop) means "use current default & continue" - the guarded action still runs. + * There is no cancel path left in this gate at all; "Use current & continue" is a first-class + * button purely so a keyboard/screen-reader user isn't limited to backdrop/Esc to proceed. + * + * Fire condition (`ridingUntouchedDefault`, `CB1`) - at most once per print ATTEMPT, and a + * per-project sessionStorage suppress (`cardbackReminderSuppression.ts`) makes a second guarded + * action in the same session silent, mirroring `usePostExportContributionPrompt`'s identical + * once-per-session shape. + */ +import styled from "@emotion/styled"; +import React, { useState } from "react"; +import Button from "react-bootstrap/Button"; +import Modal from "react-bootstrap/Modal"; + +import { useAppSelector } from "@/common/types"; +import { MemoizedCommonCardbackGridSelector } from "@/features/card/CommonCardback"; +import { + hasSuppressedCardbackReminderThisSession, + suppressCardbackReminderThisSession, + UNSAVED_PROJECT_SUPPRESSION_KEY, +} from "@/features/display/cardbackReminderSuppression"; +import { selectCardbacks } from "@/store/slices/cardbackSlice"; +import { useCardDocumentsByIdentifier } from "@/store/slices/cardDocumentsSlice"; +import { + selectIsRidingUntouchedDefaultCardback, + selectProjectCardback, + selectProjectMembers, +} from "@/store/slices/projectSlice"; +import { selectCurrentSavedDeck } from "@/store/slices/savedDeckSessionSlice"; + +const CurBackThumbnail = styled.div<{ $url: string | undefined }>` + flex: 0 0 88px; + width: 88px; + aspect-ratio: 63 / 88; + border: 1px solid rgba(235, 235, 235, 0.15); + position: relative; + background-color: #2a2320; + background-image: ${(props) => + props.$url != null ? `url(${props.$url})` : "none"}; + background-size: cover; + background-position: center; + + .cap { + position: absolute; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + color: #a99; + font-size: 9px; + text-align: center; + padding: 1px; + } +`; + +const ReminderBody = styled.div` + display: flex; + gap: 14px; + align-items: flex-start; +`; + +const SeamNote = styled.div` + margin-top: 10px; + font-size: 12px; + color: #8fa0b0; + border-top: 1px solid #16202b; + padding-top: 8px; + + .seam { + color: #ffd76a; + } +`; + +interface CardbackReminderGateModalProps { + curBackThumbnailUrl: string | undefined; + onUseCurrentAndContinue: () => void; + onChooseACardback: () => void; +} + +function CardbackReminderGateModal({ + curBackThumbnailUrl, + onUseCurrentAndContinue, + onChooseACardback, +}: CardbackReminderGateModalProps) { + return ( + + + Pick a cardback before printing? + + + + + Current cardback + +
+

+ Your deck is still using the default cardback. Most + printers put a back on every card - choosing your own is quick and + easy to forget. +

+
+ You can keep the default and continue - this only asks once per + print. +
+
+
+ + The site default cardback (which source's cardback document ships + as the fallback) is a{" "} + backend/config seed - Annex A-1, not + designed here. + +
+ + + + +
+ ); +} + +export interface UseCardbackReminderGateResult { + /** Render this once (mirrors `usePrePrintSaveGate`'s own `element` convention) - the reminder + * Modal plus the cardback grid it can open. */ + element: React.ReactElement; + /** Wraps any "about to print/export" action: shows the reminder first if the fire condition + * holds, otherwise runs `proceed` immediately. */ + guard: (proceed: () => void) => void; +} + +export function useCardbackReminderGate(): UseCardbackReminderGateResult { + const projectMembers = useAppSelector(selectProjectMembers); + const ridingUntouchedDefault = useAppSelector( + selectIsRidingUntouchedDefaultCardback + ); + const projectCardback = useAppSelector(selectProjectCardback); + const cardbackSearchResults = useAppSelector(selectCardbacks); + const cardDocumentsByIdentifier = useCardDocumentsByIdentifier(); + const currentSavedDeck = useAppSelector(selectCurrentSavedDeck); + const projectKey = + currentSavedDeck.currentDeckKey ?? UNSAVED_PROJECT_SUPPRESSION_KEY; + + const [showGate, setShowGate] = useState(false); + const [showGridSelector, setShowGridSelector] = useState(false); + const [pendingProceed, setPendingProceed] = useState< + (() => void) | undefined + >(undefined); + + const resolvedCardback = + projectCardback != null + ? cardDocumentsByIdentifier[projectCardback] + : undefined; + + const finishGate = () => { + suppressCardbackReminderThisSession(projectKey); + setShowGate(false); + setShowGridSelector(false); + const proceed = pendingProceed; + setPendingProceed(undefined); + proceed?.(); + }; + + const guard = (proceed: () => void) => { + if ( + !ridingUntouchedDefault || + projectMembers.length === 0 || + hasSuppressedCardbackReminderThisSession(projectKey) + ) { + proceed(); + return; + } + setPendingProceed(() => proceed); + setShowGate(true); + }; + + const element = ( + <> + {showGate && !showGridSelector && ( + setShowGridSelector(true)} + /> + )} + {showGridSelector && ( + + )} + + ); + + return { element, guard }; +} diff --git a/frontend/src/features/gridSelector/GridSelectorModal.tsx b/frontend/src/features/gridSelector/GridSelectorModal.tsx index b79a66bcc..d3c2fe8b1 100644 --- a/frontend/src/features/gridSelector/GridSelectorModal.tsx +++ b/frontend/src/features/gridSelector/GridSelectorModal.tsx @@ -28,6 +28,16 @@ interface GridSelectorProps { searchq?: string; /** When false, ignore project-level search settings and use unconstrained defaults instead. */ applySearchSettings?: boolean; + /** Cardback flow round (SPEC-cardback-pdfwait.md §C.2) - additive, default-unchanged: every + * existing caller keeps closing the modal the instant a card is picked. The cardback toolbar + * entry passes `false` so the apply-all/set-default prompt (§C.2) can render inline in this + * SAME modal, rather than opening a second stacked one (§C.2's own "never stacked" rule) - the + * modal only closes once the user explicitly dismisses it afterwards. */ + closeOnSelect?: boolean; + /** Rendered inside `Modal.Body`, directly below the grid - the cardback apply/default prompt's + * own mount point (only meaningful alongside `closeOnSelect={false}`, where the modal stays + * open long enough for something to render here). */ + footerContent?: React.ReactNode; } export function GridSelectorModal({ @@ -40,6 +50,8 @@ export function GridSelectorModal({ onClick, searchq, applySearchSettings = true, + closeOnSelect = true, + footerContent, }: GridSelectorProps) { //# region queries and hooks @@ -64,7 +76,9 @@ export function GridSelectorModal({ const selectImage = (identifier: string) => { onClick(identifier); - handleClose(); + if (closeOnSelect) { + handleClose(); + } }; //# endregion @@ -115,6 +129,7 @@ export function GridSelectorModal({ focusRef={focusRef} search={search} /> + {footerContent} )} - {(isDownloading || isSavingToDrive) && imageFetchProgress != null && ( -

- {/* Approximate, not exact - see pdf.worker.ts's own comment on why "total" can - undercount a deck with duplicate cards. A large export paced to the image - CDN's shared rate limit can take several minutes - this exists so that wait - reads as "working," not "hung." */} - Fetching images: {imageFetchProgress.completed} of ~ - {imageFetchProgress.total} -

- )} + {/* PDF-wait experience round (SPEC-cardback-pdfwait.md §D.1, PKG2a) - replaces the old + bare "Fetching images: N/M" text with a real determinate/indeterminate/done + ProgressBar; never blocks the buttons above it. */} + {/* Issue #166 - see this component's own hook-usage comment above for the success-detection wiring; shown once per session, dismissible, never blocks the - buttons above it. */} - {contributionPrompt.visible && ( + buttons above it. §D.3/PE1 - suppressed here whenever the game embed's own outro + (right column) is already showing this exact same prompt - one nudge, not two. */} + {contributionPrompt.visible && !showEmbedOutro && (
{ )} - {!scmMode && ( -
- -
- )} - {/* Image-fetch-failure/error detection comes from the real (debounced) - @react-pdf/renderer render via useRenderPDF, which runs unconditionally - regardless of which preview is on screen - these warnings stay visible in fast - mode too, not just exact mode, since generating a PDF full of silently-blank - cards is exactly the mistake this warns against, independent of which preview - the user happened to be looking at. */} - {!showSpinner && error != null && ( - - Couldn't generate a preview:{" "} - {error instanceof Error ? error.message : String(error)} - - )} - {!showSpinner && error == null && failures.length > 0 && ( - - {failures.length} card image{failures.length === 1 ? "" : "s"}{" "} - couldn't be loaded and will appear blank:{" "} - {failures.map((failure) => failure.label).join(", ")} - - )} - {!scmMode && previewMode === "fast" ? ( - until generation has + actually started (2c's own lazy-load constraint - see PDFWaitPanel.tsx). */} + {waitPhase === "fetching" || waitPhase === "assembling" ? ( + + ) : showEmbedOutro ? ( + ) : ( <> - {showSpinner && ( - + {!scmMode && ( +
+ +
+ )} + {/* Image-fetch-failure/error detection comes from the real (debounced) + @react-pdf/renderer render via useRenderPDF, which runs unconditionally + regardless of which preview is on screen - these warnings stay visible in fast + mode too, not just exact mode, since generating a PDF full of silently-blank + cards is exactly the mistake this warns against, independent of which preview + the user happened to be looking at. */} + {!showSpinner && error != null && ( + + Couldn't generate a preview:{" "} + {error instanceof Error ? error.message : String(error)} + + )} + {!showSpinner && error == null && failures.length > 0 && ( + + {failures.length} card image{failures.length === 1 ? "" : "s"}{" "} + couldn't be loaded and will appear blank:{" "} + {failures.map((failure) => failure.label).join(", ")} + + )} + {!scmMode && previewMode === "fast" ? ( + + ) : ( + <> + {showSpinner && ( + + )} + + + + )} - - - )} @@ -1724,6 +1781,7 @@ export const PDFGenerator = ({ heightDelta = 0 }: { heightDelta?: number }) => { setPendingFailureConfirm(null); }} /> + {cardbackReminderGate.element} ); }; diff --git a/frontend/src/features/pdf/PDFWaitPanel.tsx b/frontend/src/features/pdf/PDFWaitPanel.tsx new file mode 100644 index 000000000..484c7772c --- /dev/null +++ b/frontend/src/features/pdf/PDFWaitPanel.tsx @@ -0,0 +1,316 @@ +/** + * PDF-generation wait experience round (SPEC-cardback-pdfwait.md §D, `PKG2`) - two pieces mounted + * from `PDFGenerator.tsx`: + * + * - `PDFProgressBox` (2a) - a real Bootstrap `ProgressBar`, determinate while the real + * `imageFetchProgress {completed,total}` signal exists (`pdfRenderService.onImageProgress`), + * honest indeterminate (`animated striped`) for the `@react-pdf/renderer` layout/encode phase + * that exposes NO progress callback (Annex A-3, `PB1`), and a green "done" bar. Replaces the + * old bare `pdf-image-fetch-progress` text line (kept as the SAME `data-testid` on the + * determinate label, so nothing that greps for it breaks). + * - `PDFWaitGameEmbed` (2b) - the right column while generation runs: a chrome frame around + * `` rendered VERBATIM (no forked component, no new voting mechanic - the exact + * `/whatsthat` funnel, docs/features/printing-tags.md) plus a persistent build-status ribbon + * so the PDF's own progress stays visible while playing. Lazy-loaded via + * `next/dynamic({ssr:false})` and only imported once generation actually starts (memory-safety + * constraint `D.4`/`MS1`/`MS3` - never eagerly bundled/instantiated on the print page, and torn + * down (unmounted) the instant generation finishes - see `PDFGenerator.tsx`'s own + * `waitPhase` derivation for the teardown trigger). + */ +import styled from "@emotion/styled"; +import dynamic from "next/dynamic"; +import React from "react"; +import ProgressBar from "react-bootstrap/ProgressBar"; +import Spinner from "react-bootstrap/Spinner"; + +export type PDFWaitPhase = "idle" | "fetching" | "assembling" | "done"; + +// Lazy-loaded ONLY once a caller actually mounts (which PDFGenerator.tsx only +// does once isDownloading/isSavingToDrive is true) - never eagerly bundled/instantiated while a +// user is still configuring the PDF (D.4/2c's own binding constraint). +const LazyQuestionFeed = dynamic( + () => + import("@/features/questionFeed/QuestionFeed").then((m) => m.QuestionFeed), + { + ssr: false, + loading: () => ( +
+ +
+ ), + } +); + +const ProgressBox = styled.div` + margin-top: 12px; + background: #22303f; + border: 1px solid #16202b; + padding: 10px 12px; +`; + +const ProgressLabel = styled.div` + font-size: 12px; + color: #ebebeb; + display: flex; + justify-content: space-between; + margin-bottom: 6px; + + .pfrac { + font-variant-numeric: tabular-nums; + color: #8fa0b0; + } +`; + +const ProgressSub = styled.div<{ $done?: boolean }>` + font-size: 11px; + color: ${(props) => (props.$done ? "#8fe08f" : "#8fa0b0")}; + margin-top: 6px; +`; + +const SeamTag = styled.span` + display: block; + font-size: 11px; + color: #ffd76a; + margin-top: 6px; +`; + +// §D.1/§G - the assembling phase is genuinely indeterminate (no progress callback exists for +// `@react-pdf/renderer`'s own layout/encode phase). react-bootstrap's always emits +// a real `aria-valuenow` from its `now` prop with no way to suppress it via props (the component +// sets it AFTER spreading incoming props, so it can't be overridden) - passing `now={100}` would +// announce a false "100% complete" to a screen reader mid-assembly, exactly the "no false +// numeric" rule §G is binding on. This is a plain, hand-built indeterminate track instead: +// `aria-busy="true"`, no `aria-valuenow`/`aria-valuemin`/`aria-valuemax` at all. +const IndeterminateTrack = styled.div` + height: 10px; + background: #16202b; + overflow: hidden; +`; + +const IndeterminateFill = styled.div` + height: 100%; + width: 100%; + background-color: #df6919; + background-image: linear-gradient( + 45deg, + rgba(255, 255, 255, 0.18) 25%, + transparent 25%, + transparent 50%, + rgba(255, 255, 255, 0.18) 50%, + rgba(255, 255, 255, 0.18) 75%, + transparent 75%, + transparent + ); + background-size: 1rem 1rem; + animation: pdf-wait-barstripe 1s linear infinite; + + @keyframes pdf-wait-barstripe { + from { + background-position: 1rem 0; + } + to { + background-position: 0 0; + } + } + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +`; + +export interface PDFProgressBoxProps { + phase: PDFWaitPhase; + imageFetchProgress: { completed: number; total: number } | null; +} + +/** §D.1 (2a) - the progress bar `PDFGenerator.tsx` mounts whenever `phase !== "idle"`. */ +export function PDFProgressBox({ + phase, + imageFetchProgress, +}: PDFProgressBoxProps) { + if (phase === "idle") { + return null; + } + + if (phase === "fetching") { + const completed = imageFetchProgress?.completed ?? 0; + const total = imageFetchProgress?.total ?? 0; + // `total` is approximate (undercounts duplicate cards - pdf.worker.ts's own comment), so the + // bar is capped at 99% and never claims a false 100% before the phase genuinely ends. + const percent = total > 0 ? Math.min((completed / total) * 100, 99) : 0; + return ( + + + Fetching images… + + {completed} of ~{total} + + + + + Full-resolution fetches are paced to the image CDN - a large deck can + take a few minutes. + + + seam 2a: "total" is approximate (undercounts duplicate + cards) → shown as "~N", never a false 100%. + + + ); + } + + if (phase === "assembling") { + return ( + + + Assembling PDF… + images done + + {/* Hand-built, not - see IndeterminateTrack's own comment: no + aria-valuenow (no false numeric), aria-busy only. */} + + + + Laying out pages & encoding. + + seam 2a: the assemble/encode phase exposes no progress callback (Annex + A-3) → honest indeterminate bar, not a placeholder number. + + + ); + } + + return ( + + + ✓ PDF ready + cards.pdf + + + Saved to your device. + + ); +} + +const EmbedFrame = styled.div` + border: 1px solid #16202b; + background: #22303f; + height: 100%; + min-height: 420px; + display: flex; + flex-direction: column; +`; + +const EmbedHead = styled.div` + background: #2b3e50; + border-bottom: 1px solid #16202b; + padding: 8px 12px; + display: flex; + align-items: center; + gap: 8px; + + .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #df6919; + } + + .h { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #8fa0b0; + } + + .lz { + margin-left: auto; + font-size: 10px; + color: #8fa0b0; + font-family: "Courier New", monospace; + } +`; + +const BuildRibbon = styled.div<{ $done?: boolean }>` + background: #0b1520; + border-bottom: 1px solid #16202b; + padding: 6px 12px; + font-size: 11px; + color: ${(props) => (props.$done ? "#8fe08f" : "#ebebeb")}; + display: flex; + align-items: center; + gap: 8px; + + .mini { + flex: 1; + height: 6px; + background: #16202b; + overflow: hidden; + } + + .mini .b { + height: 100%; + background: ${(props) => (props.$done ? "#5cb85c" : "#df6919")}; + } +`; + +const EmbedBody = styled.div` + flex: 1; + overflow-y: auto; +`; + +export interface PDFWaitGameEmbedProps { + phase: "fetching" | "assembling"; + imageFetchProgress: { completed: number; total: number } | null; +} + +/** §D.2 (2b) - the right column while generation runs. Renders `` verbatim inside + * a chrome frame with a persistent build-status ribbon. */ +export function PDFWaitGameEmbed({ + phase, + imageFetchProgress, +}: PDFWaitGameEmbedProps) { + const completed = imageFetchProgress?.completed ?? 0; + const total = imageFetchProgress?.total ?? 0; + const percent = total > 0 ? Math.min((completed / total) * 100, 99) : 0; + const ribbonLabel = + phase === "fetching" ? "Building your PDF…" : "Assembling your PDF…"; + return ( + + + + + {ribbonLabel} + + + + + + + +
+ Each answer is submitted the instant you tap — leaving mid-card never + loses a vote. +
+
+ ); +} diff --git a/frontend/src/features/pdf/PagePreview.test.tsx b/frontend/src/features/pdf/PagePreview.test.tsx index b05dd0079..1e77f56bb 100644 --- a/frontend/src/features/pdf/PagePreview.test.tsx +++ b/frontend/src/features/pdf/PagePreview.test.tsx @@ -482,3 +482,93 @@ describe("PagePreview - screenPresentation prop (R7/D17)", () => { expect(page.style.borderRadius).not.toBe(""); }); }); + +// Cardback flow round (SPEC-cardback-pdfwait.md OWNER AMENDMENT 3) - the flip button's own +// non-default-back indicator dot, gated the SAME way the flip button itself is (flippable cells +// only - never rendered on a genuinely empty slot, and never without onSlotFlip wired at all). +describe("PagePreview - custom-cardback indicator dot (OWNER AMENDMENT 3)", () => { + it("renders the dot on a flippable slot whose hasCustomCardback is true", () => { + render( + undefined} + /> + ); + + expect( + screen.getByTestId("page-preview-slot-custom-cardback-indicator") + ).toBeInTheDocument(); + expect(screen.getByTestId("page-preview-slot-flip")).toHaveAccessibleName( + "Preview the other face of this card (custom cardback)" + ); + }); + + it("renders no dot on a flippable slot following the deck default (hasCustomCardback false/undefined)", () => { + render( + undefined} + /> + ); + + expect(screen.getByTestId("page-preview-slot-flip")).toBeInTheDocument(); + expect( + screen.queryByTestId("page-preview-slot-custom-cardback-indicator") + ).toBeNull(); + }); + + it("same gating as the flip button itself - no dot (and no flip button) on a genuinely empty slot, even if hasCustomCardback were somehow true", () => { + render( + undefined} + /> + ); + + expect(screen.queryByTestId("page-preview-slot-flip")).toBeNull(); + expect( + screen.queryByTestId("page-preview-slot-custom-cardback-indicator") + ).toBeNull(); + }); +}); diff --git a/frontend/src/features/pdf/PagePreview.tsx b/frontend/src/features/pdf/PagePreview.tsx index e234e8411..6aa5394c3 100644 --- a/frontend/src/features/pdf/PagePreview.tsx +++ b/frontend/src/features/pdf/PagePreview.tsx @@ -177,6 +177,22 @@ const SlotFlipButton = styled.button` } `; +// Cardback flow round (SPEC-cardback-pdfwait.md OWNER AMENDMENT 3, N) - a small non-default-back +// indicator dot within the flip button's existing footprint, whenever the slot's own back face +// differs from the deck's default cardback. "No new color roles" (the amendment's own binding +// constraint) - reuses the warning token (`#ffc107`) already in the #302 palette, not a new hue. +const CustomCardbackDot = styled.span` + position: absolute; + top: -2px; + right: -2px; + width: 8px; + height: 8px; + border-radius: 50%; + background: #ffc107; + border: 1px solid #0b1520; + pointer-events: none; +`; + // E19 - the lime, rounded, corner-only cut guide: a small L-bracket (two legs) at each of a // card's four trim corners, replacing the full dashed-rectangle trim line the screenPresentation // sheet used to draw. Dimensions match the mockup's own rendered interpretation ("0.6mm stroke + @@ -267,6 +283,11 @@ export interface PagePreviewSlotContent { * (front and/or back); `undefined`/`false` (a genuinely empty slot, nothing on either face) * renders no flip button, same as `onSlotFlip` simply being omitted. */ flippable?: boolean; + /** Cardback flow round (SPEC-cardback-pdfwait.md OWNER AMENDMENT 3, N) - true when this slot's + * own back face is a deliberately-different, custom cardback (not the deck's default) - drawn + * as a small indicator dot on the `⟲` flip button (same gating as the flip button itself: card- + * holding cells only). `undefined`/`false` renders no dot, same as before this round. */ + hasCustomCardback?: boolean; } export interface PagePreviewProps { @@ -714,7 +735,11 @@ function PagePreviewSlotEl({ {onSlotFlip != null && content?.flippable === true && ( { event.stopPropagation(); @@ -722,6 +747,13 @@ function PagePreviewSlotEl({ }} > ⟲ + {content?.hasCustomCardback === true && ( + + )} )}
diff --git a/frontend/src/store/slices/projectSlice.test.ts b/frontend/src/store/slices/projectSlice.test.ts index c3d683a5e..f59f667c9 100644 --- a/frontend/src/store/slices/projectSlice.test.ts +++ b/frontend/src/store/slices/projectSlice.test.ts @@ -1,12 +1,17 @@ +import { Back, Front } from "@/common/constants"; import { computeSearchQueryHashKey } from "@/common/processing"; import { CardType, Project, ThunkStatus } from "@/common/types"; import { + applyCardbackToAllSlots, projectSlice, + selectIsCardbackExplicitlySet, + selectIsRidingUntouchedDefaultCardback, selectManualOverride, selectManualOverrides, selectQueriesWithoutSearchResults, setAllManualOverrides, setManualOverride, + setSelectedCardback, } from "@/store/slices/projectSlice"; import { setupStore } from "@/store/store"; @@ -402,3 +407,102 @@ describe("manual bleed override reducers and selectors (Proposal B PR-2)", () => ); }); }); + +// Cardback flow round (SPEC-cardback-pdfwait.md §C.1/§C.2) - `cardbackExplicitlySet`, +// `setSelectedCardback`'s new `explicit` flag, `applyCardbackToAllSlots`, and the reminder gate's +// own fire-condition selector. +describe("cardback flow round - explicit-choice tracking + apply-all", () => { + test("a fresh project has never explicitly chosen a cardback", () => { + const state = { project: baseProjectState }; + expect(selectIsCardbackExplicitlySet(setupStore(state).getState())).toBe( + false + ); + expect( + selectIsRidingUntouchedDefaultCardback(setupStore(state).getState()) + ).toBe(true); + }); + + test("setSelectedCardback WITHOUT explicit:true (the listenerMiddleware auto-seed path) does not flip cardbackExplicitlySet", () => { + const seeded = projectSlice.reducer( + baseProjectState, + setSelectedCardback({ selectedImage: "auto-seeded-cardback" }) + ); + expect(seeded.cardback).toBe("auto-seeded-cardback"); + // Never explicitly written to `true` - `undefined` reads as `false` everywhere this is + // consulted (see the `Project` type's own field comment), so this asserts via the real + // selector rather than the raw (possibly-still-`undefined`) field value. + expect( + selectIsCardbackExplicitlySet(setupStore({ project: seeded }).getState()) + ).toBe(false); + }); + + test("setSelectedCardback WITH explicit:true (a real user pick) flips cardbackExplicitlySet permanently", () => { + const picked = projectSlice.reducer( + baseProjectState, + setSelectedCardback({ + selectedImage: "user-picked-cardback", + explicit: true, + }) + ); + expect(picked.cardbackExplicitlySet).toBe(true); + + // Never resets back to false, even if the cardback is later cleared. + const cleared = projectSlice.reducer( + picked, + setSelectedCardback({ selectedImage: null }) + ); + expect(cleared.cardback).toBeNull(); + expect(cleared.cardbackExplicitlySet).toBe(true); + }); + + test("selectIsRidingUntouchedDefaultCardback flips false once a cardback has been explicitly set", () => { + const state = { + project: { ...baseProjectState, cardbackExplicitlySet: true }, + }; + expect( + selectIsRidingUntouchedDefaultCardback(setupStore(state).getState()) + ).toBe(false); + }); + + test("applyCardbackToAllSlots overrides EVERY slot's back face, including ones that already differ (custom backs)", () => { + const seeded: Project = { + ...baseProjectState, + members: [ + { + id: "t-0", + front: null, + back: { + query: { query: null, cardType: "CARDBACK" as CardType }, + selectedImage: "old-default", + selected: false, + }, + }, + { + id: "t-1", + front: null, + // A deliberately-custom back, different from the old project default. + back: { + query: { query: null, cardType: "CARDBACK" as CardType }, + selectedImage: "already-custom", + selected: false, + }, + }, + { + // No back face at all yet. + id: "t-2", + front: null, + back: null, + }, + ], + }; + const applied = projectSlice.reducer( + seeded, + applyCardbackToAllSlots({ selectedImage: "new-cardback" }) + ); + expect( + applied.members.map((member) => member[Back]?.selectedImage) + ).toStrictEqual(["new-cardback", "new-cardback", "new-cardback"]); + // Front faces are untouched. + expect(applied.members.every((member) => member[Front] == null)).toBe(true); + }); +}); diff --git a/frontend/src/store/slices/projectSlice.ts b/frontend/src/store/slices/projectSlice.ts index d8240f460..5cae7f84f 100644 --- a/frontend/src/store/slices/projectSlice.ts +++ b/frontend/src/store/slices/projectSlice.ts @@ -36,6 +36,7 @@ const initialState: Project = { cardback: null, mostRecentlySelectedSlot: null, manualOverrides: {}, + cardbackExplicitlySet: false, }; export const projectSlice = createAppSlice({ @@ -142,9 +143,50 @@ export const projectSlice = createAppSlice({ }, setSelectedCardback: ( state, - action: PayloadAction<{ selectedImage: string | null }> + action: PayloadAction<{ + selectedImage: string | null; + /** Cardback flow round (SPEC-cardback-pdfwait.md §C.1, Annex A-1) - true only when a + * real user action chose this image (the toolbar/rail pickers, the reminder gate's own + * "Choose a cardback", an XML import's own opted-in `useXMLCardback`). `listenerMiddleware + * .ts`'s own auto-seed-a-cardback-once-the-catalog-loads effect deliberately omits this + * (defaults `false`) - that's a passive fallback landing in an empty `cardback` slot, not + * a user "choosing" anything, so it must NOT flip `cardbackExplicitlySet` or the reminder + * gate would never fire for a brand-new project. */ + explicit?: boolean; + }> ) => { state.cardback = action.payload.selectedImage; + // Any real, explicit pick flips this permanently true for the project's lifetime (never + // reset back to false by a later clear) - "never chose" only describes a project that has + // NEVER dispatched this reducer with `explicit: true`. + if (action.payload.selectedImage != null && action.payload.explicit) { + state.cardbackExplicitlySet = true; + } + }, + /** + * Cardback flow round (SPEC-cardback-pdfwait.md §C.2, OWNER AMENDMENT 2/OQ-B) - the + * toolbar/project-wide "Apply to all card backs" action. Unlike `bulkReplaceSelectedImage` + * (which only touches slots CURRENTLY carrying the old project cardback), this overrides + * EVERY slot's back face unconditionally - including ones a user deliberately gave a + * different, custom back - per the owner's override-with-count ruling. Never fires on its + * own; only ever dispatched from the apply-all prompt's own explicit, never-pre-checked + * button. + */ + applyCardbackToAllSlots: ( + state, + action: PayloadAction<{ selectedImage: string }> + ) => { + for (const member of state.members) { + if (member[Back] == null) { + member[Back] = { + query: { query: null, cardType: Cardback }, + selectedImage: action.payload.selectedImage, + selected: false, + }; + } else { + member[Back]!.selectedImage = action.payload.selectedImage; + } + } }, /** * ProjectMaxSize (612 cards at time of writing) is primarily enforced at this layer. @@ -380,6 +422,8 @@ export const projectSlice = createAppSlice({ state.cardback = action.payload.cardback; state.manualOverrides = action.payload.manualOverrides; state.mostRecentlySelectedSlot = null; + state.cardbackExplicitlySet = + action.payload.cardbackExplicitlySet ?? action.payload.cardback != null; }, }, }); @@ -390,6 +434,7 @@ export const { setQueries, clearQueries, setSelectedCardback, + applyCardbackToAllSlots, addMembers, toggleMemberSelection, expandSelection, @@ -644,6 +689,20 @@ export const selectAnySelectedImagesFilteredOnPrinting = createSelector( export const selectProjectCardback = (state: RootState): string | undefined => state.project.cardback ?? undefined; +/** Cardback flow round (SPEC-cardback-pdfwait.md §C.1, Annex A-1) - see the `Project` type's own + * `cardbackExplicitlySet` field comment for what this does and doesn't distinguish today. */ +export const selectIsCardbackExplicitlySet = (state: RootState): boolean => + state.project.cardbackExplicitlySet ?? false; + +/** Cardback flow round (SPEC-cardback-pdfwait.md §C.1) - the reminder gate's own fire condition: + * true only while the project cardback is still whatever it started as AND the user has never + * explicitly picked one this project (Annex A-1's "chose the default" vs. "never chose" split - + * see `cardbackExplicitlySet`'s own comment for why null-ness alone can't carry this once a real + * site-default seed exists). */ +export const selectIsRidingUntouchedDefaultCardback = ( + state: RootState +): boolean => !selectIsCardbackExplicitlySet(state); + export const selectManualOverrides = ( state: RootState ): Project["manualOverrides"] => state.project.manualOverrides; diff --git a/frontend/tests/CardbackFlow.spec.ts b/frontend/tests/CardbackFlow.spec.ts new file mode 100644 index 000000000..2b5f0da9b --- /dev/null +++ b/frontend/tests/CardbackFlow.spec.ts @@ -0,0 +1,193 @@ +import { expect } from "@playwright/test"; + +import { cardDocument2, cardDocument3 } from "@/common/test-constants"; +import { + cardbacksThreeResults, + cardDocumentsThreeResults, + defaultHandlers, + searchResultsThreeResults, + sourceDocumentsOneResult, + tagConsensusTwoUnresolvedTags, +} from "@/mocks/handlers"; + +import { test } from "../playwright.setup"; +import { + importTextOnEditorLanding, + loadPageWithDefaultBackend, +} from "./test-utils"; + +// Cardback flow round (SPEC-cardback-pdfwait.md §C, PKG1) - the no-cardback reminder gate (1a) +// and the apply-all/set-default prompt from both entries (1b, toolbar = project-wide, rail = +// per-slot). Reached entirely through the real /editor -> Finish footer flow, same as every +// other post-Proposal-H display suite (the classic /editor route is fully unrouted). + +const threeCardHandlers = [ + cardDocumentsThreeResults, + cardbacksThreeResults, + sourceDocumentsOneResult, + searchResultsThreeResults, + tagConsensusTwoUnresolvedTags, + ...defaultHandlers, +]; + +test.describe("Cardback reminder gate (SPEC-cardback-pdfwait.md §C.1, PKG1a)", () => { + test.describe.configure({ mode: "serial", timeout: 60_000 }); + + test("appears for a project still riding the default cardback; dismissing it (✕) still proceeds (OWNER AMENDMENT 1), and a second attempt this session is silent (CB1)", async ({ + page, + network, + }) => { + network.use(...threeCardHandlers); + await loadPageWithDefaultBackend(page); + await importTextOnEditorLanding(page, "my search query"); + + await page.getByTestId("finish-footer-print-export").click(); + + const gate = page.getByTestId("pre-print-cardback-gate"); + await expect(gate).toBeVisible(); + await expect(gate).toContainText("default cardback"); + await expect(gate.getByTestId("cardback-gate-use-current")).toBeVisible(); + + // OWNER AMENDMENT 1 - dismiss (the header's own ✕) is NOT a cancel; it behaves exactly like + // "Use current & continue" and the print attempt proceeds. + await gate.getByLabel("Close").click(); + await page.waitForURL(/\/print/, { timeout: 30_000 }); + await expect(page.getByRole("tab", { name: "PDF" })).toBeVisible(); + + // Back to the editor (a real reload, same tab/session - sessionStorage survives this even + // though the in-memory project itself doesn't, so re-importing is needed before a second + // print attempt can be made at all). CB1: at most once per session, so no second gate - + // the suppression key is keyed on project identity (unsaved -> one fixed bucket), not + // project CONTENT, so a freshly re-imported project is still covered by it. + await page.goto("/editor?server=http://127.0.0.1:8000", { + waitUntil: "domcontentloaded", + }); + await importTextOnEditorLanding(page, "my search query"); + await expect(page.getByTestId("finish-footer-print-export")).toBeVisible(); + await page.getByTestId("finish-footer-print-export").click(); + await expect(page.getByTestId("pre-print-cardback-gate")).toHaveCount(0); + await page.waitForURL(/\/print/, { timeout: 30_000 }); + }); +}); + +test.describe("Cardback apply-all + set-default prompt (SPEC-cardback-pdfwait.md §C.2, PKG1b)", () => { + test.describe.configure({ timeout: 60_000 }); + + test("rail (per-slot) entry stays per-slot with a never-pre-checked trap-guard, and the sheet's flip icon flags the resulting custom back; the toolbar (project-wide) entry then shows the affected slot's thumbnails + count, and Apply to all/Set default both work independently", async ({ + page, + network, + }) => { + network.use(...threeCardHandlers); + await loadPageWithDefaultBackend(page); + // Two slots, both fronts resolving to cardDocument1 - cardbacksThreeResults' own + // fetchCardbacks.fulfilled listener auto-seeds BOTH slots' backs to its first entry + // (cardDocument1), so both start on the (non-explicit) project default. + await importTextOnEditorLanding(page, "my search query\nmy search query"); + + const sheetSlots = page.getByTestId("page-preview-slot"); + await expect(sheetSlots.nth(0).locator("img")).toBeVisible(); + await expect(sheetSlots.nth(1).locator("img")).toBeVisible(); + + // --- rail entry: give slot 1 a deliberately-custom back (cardDocument2) --- + await sheetSlots.nth(0).click(); + const railControl = page.getByTestId("slot-cardback-control"); + await expect(railControl).toBeVisible(); + await railControl.getByTestId("slot-cardback-choose").click(); + + const railPicker = page.getByTestId("slot-cardback-picker"); + await expect(railPicker).toBeVisible(); + await railPicker.getByAltText(cardDocument2.name).click(); + + const railPrompt = page.getByTestId("cardback-apply-prompt"); + await expect(railPrompt).toBeVisible(); + // Per-slot copy, count = 1 (only slot 2 still differs from the just-picked cardDocument2). + await expect( + railPrompt.getByTestId("cardback-apply-all-button") + ).toHaveText("Apply to all (1)"); + await expect( + railPrompt.getByTestId("cardback-apply-prompt-trapnote") + ).toContainText("never pre-checked"); + // Never pre-checked - the button itself hasn't flipped to a done state. + await expect( + railPrompt.getByTestId("cardback-apply-all-button") + ).not.toHaveText(/✓/); + // No skip link on the rail entry (the rail is already the "no modal, ever" surface). + await expect( + railPrompt.getByTestId("cardback-apply-prompt-not-now") + ).toHaveCount(0); + + // Deliberately DON'T apply-all here - "per-slot pick stays per-slot" is the whole point. + // Slot 1's flip icon now carries the custom-cardback indicator dot; slot 2's does not + // (still following the deck default). + await expect( + sheetSlots + .nth(0) + .getByTestId("page-preview-slot-custom-cardback-indicator") + ).toBeVisible(); + await expect( + sheetSlots + .nth(1) + .getByTestId("page-preview-slot-custom-cardback-indicator") + ).toHaveCount(0); + + // --- toolbar entry: project-wide pick of a THIRD cardback --- + // A dedicated testid, not a name-based locator - a slot's own "⟲" flip button can now ALSO + // carry "cardback" in its accessible name (this same round's OWNER AMENDMENT 3 indicator), + // which makes any Cardback-name-based locator ambiguous/fragile. + await page.getByTestId("cardback-toolbar-button").click(); + const cardbackModal = page.getByTestId("cardback-grid-selector"); + await expect(cardbackModal).toBeVisible(); + await cardbackModal.getByAltText(cardDocument3.name).click(); + + const toolbarPrompt = cardbackModal.getByTestId("cardback-apply-prompt"); + await expect(toolbarPrompt).toBeVisible(); + // OWNER AMENDMENT 2/OQ-B - the affected (still-custom) slot's thumbnails render above the + // count line, and the count names it explicitly. + const thumbnails = toolbarPrompt.getByTestId( + "cardback-apply-prompt-thumbnails" + ); + await expect(thumbnails).toBeVisible(); + await expect(thumbnails).toContainText("Slot 1"); + await expect( + toolbarPrompt.getByTestId("cardback-apply-all-button") + ).toHaveText("Apply to all (1)"); + await expect( + toolbarPrompt.getByTestId("cardback-apply-prompt-not-now") + ).toBeVisible(); + + // Apply to all - overrides slot 1's deliberately-custom back too (override-with-count, OQ-B). + await toolbarPrompt.getByTestId("cardback-apply-all-button").click(); + await expect( + toolbarPrompt.getByTestId("cardback-apply-all-button") + ).toHaveText("Applied to all ✓"); + + // Set as my default cardback - independent of the apply-all choice, seam-mocked (Annex A-2 - + // no real persistence layer exists yet), but the UI's own done-state is real. + await toolbarPrompt.getByTestId("cardback-set-default-button").click(); + await expect( + toolbarPrompt.getByTestId("cardback-set-default-button") + ).toHaveText("Default set ✓"); + + await cardbackModal.getByRole("button", { name: "Close" }).last().click(); + await expect(cardbackModal).not.toBeVisible(); + + // The sheet reflects the override - toggling to the back view shows cardDocument3 on BOTH + // slots now (the button's own current label - "Showing: Fronts" - toggles TO backs on click). + await page.getByText("Showing: Fronts").click(); + await expect(sheetSlots.nth(0).locator("img")).toHaveAttribute( + "alt", + cardDocument3.name + ); + await expect(sheetSlots.nth(1).locator("img")).toHaveAttribute( + "alt", + cardDocument3.name + ); + // Slot 1's custom-cardback indicator is gone - it's no longer different from the deck + // default (the flip icon/indicator rendering is independent of which face is on screen). + await expect( + sheetSlots + .nth(0) + .getByTestId("page-preview-slot-custom-cardback-indicator") + ).toHaveCount(0); + }); +}); diff --git a/frontend/tests/CardbackPdfWaitFidelity.spec.ts b/frontend/tests/CardbackPdfWaitFidelity.spec.ts new file mode 100644 index 000000000..8cbdbadd6 --- /dev/null +++ b/frontend/tests/CardbackPdfWaitFidelity.spec.ts @@ -0,0 +1,220 @@ +import { expect } from "@playwright/test"; +import { readFileSync } from "fs"; +import { http, HttpResponse } from "msw"; +import path from "path"; +import { fileURLToPath } from "url"; + +import { cardDocument2 } from "@/common/test-constants"; +import { + cardbacksThreeResults, + cardDocumentsThreeResults, + defaultHandlers, + questionFeedConfirmSuggestionSingleton, + searchResultsOneResult, + searchResultsThreeResults, + sourceDocumentsOneResult, + tagConsensusTwoUnresolvedTags, +} from "@/mocks/handlers"; + +import { test } from "../playwright.setup"; +import { + ensureDisplayRightRailOpen, + importTextOnEditorLanding, + loadPageWithDefaultBackend, +} from "./test-utils"; + +/** + * CSS-fidelity guard for SPEC-cardback-pdfwait.md's binding token table (§E), self-verification + * for the cardback flow + PDF-wait experience round. Every assertion reads REAL computed styles + * (`toHaveCSS`, backed by `getComputedStyle`), matching this repo's own established discipline + * (DisplayLeftRailFidelity.spec.ts's own module comment). Runs at BOTH 1400px and 390px per the + * spec's own §H verification matrix - layout differs, but the token VALUES (color/border/font) + * are viewport-independent, so most assertions are shared between the two `test()` bodies below, + * not duplicated per-viewport tables. + * + * Not exhaustive against every row in §E - a representative, binding sample of the round's N + * (introduced-this-round) elements across all four subsections (E.1 gate / E.2 grid+prompt / + * E.3 progress bar / E.4 game embed+outro), covering every DISTINCT colour token the round + * introduces at least once. + */ + +const threeCardHandlers = [ + cardDocumentsThreeResults, + cardbacksThreeResults, + sourceDocumentsOneResult, + searchResultsThreeResults, + tagConsensusTwoUnresolvedTags, + ...defaultHandlers, +]; + +const IMAGE_WORKER_URL_PATTERN = /^https:\/\/cdn\.proxyprints\.ca\//; +const IMAGE_BUCKET_URL_PATTERN = /^https:\/\/img\.proxyprints\.ca\//; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const validImageBytes = readFileSync( + path.join(__dirname, "..", "public", "blank.png") +); +const imageBucketFailure = http.get( + IMAGE_BUCKET_URL_PATTERN, + () => new HttpResponse(null, { status: 404 }) +); +const delayedImageWorkerSuccess = http.get( + IMAGE_WORKER_URL_PATTERN, + async () => { + await new Promise((resolve) => setTimeout(resolve, 4_000)); + return new HttpResponse(validImageBytes, { + status: 200, + headers: { "Content-Type": "image/png" }, + }); + } +); + +for (const viewport of [ + { width: 1400, height: 900, label: "1400px" }, + { width: 390, height: 844, label: "390px" }, +]) { + test.describe(`Cardback flow CSS fidelity (SPEC-cardback-pdfwait.md §E) - ${viewport.label}`, () => { + test.describe.configure({ timeout: 60_000 }); + // `test.use({ viewport })` at describe level, NOT `page.setViewportSize()` mid-test - the + // established pattern this repo's own phone-tier coverage relies on (DisplayPage.spec.ts's + // "phone viewport (issue #266)" describe block's own module comment: the chromium project's + // configured viewport is dead config, and a mid-test resize can leave viewport-tier-derived + // component state (`useViewportTier`) stuck at whatever tier it mounted under). + test.use({ viewport: { width: viewport.width, height: viewport.height } }); + + test(`E.1 gate + E.2 grid/prompt tokens resolve real computed values at ${viewport.label}`, async ({ + page, + network, + }) => { + network.use(...threeCardHandlers); + await loadPageWithDefaultBackend(page); + await importTextOnEditorLanding(page, "my search query"); + + await ensureDisplayRightRailOpen(page); + await page.getByTestId("finish-footer-print-export").click(); + const gate = page.getByTestId("pre-print-cardback-gate"); + await expect(gate).toBeVisible(); + + // E.1 `.mdialog` (real react-bootstrap Modal on Superhero) - content bg $gray-600 #4e5d6c. + const modalContent = page.locator(".modal-content").first(); + await expect(modalContent).toHaveCSS( + "background-color", + "rgb(78, 93, 108)" + ); + + // E.1 `.mfoot` primary button - $primary #df6919. + await expect(gate.getByTestId("cardback-gate-choose")).toHaveCSS( + "background-color", + "rgb(223, 105, 25)" + ); + + await gate.getByTestId("cardback-gate-use-current").click(); + await page.waitForURL(/\/print/, { timeout: 30_000 }); + + // --- E.2, both entries: reopen the editor and drive the toolbar apply prompt. --- + await page.goto("/editor?server=http://127.0.0.1:8000", { + waitUntil: "domcontentloaded", + }); + await importTextOnEditorLanding(page, "my search query"); + await ensureDisplayRightRailOpen(page); + await page.getByTestId("cardback-toolbar-button").click(); + const cardbackModal = page.getByTestId("cardback-grid-selector"); + await expect(cardbackModal).toBeVisible(); + await cardbackModal.getByAltText(cardDocument2.name).click(); + + const prompt = cardbackModal.getByTestId("cardback-apply-prompt"); + await expect(prompt).toBeVisible(); + + // E.2 `.cbprompt` panel - #22303f bg, 1px #16202b border, left 3px #df6919. + await expect(prompt).toHaveCSS("background-color", "rgb(34, 48, 63)"); + await expect(prompt).toHaveCSS( + "border-left", + "3px solid rgb(223, 105, 25)" + ); + + // E.2 `.applybtn` (primary-tinted, at rest) - transparent bg, 1px #df6919 border, + // #ffb27d text. + const applyButton = prompt.getByTestId("cardback-apply-all-button"); + await expect(applyButton).toHaveCSS( + "border", + "1px solid rgb(223, 105, 25)" + ); + await expect(applyButton).toHaveCSS("color", "rgb(255, 178, 125)"); + + // E.2 `.defbtn` (info-tinted, at rest) - 1px #5bc0de border, #8fd7ea text. + const defaultButton = prompt.getByTestId("cardback-set-default-button"); + await expect(defaultButton).toHaveCSS( + "border", + "1px solid rgb(91, 192, 222)" + ); + await expect(defaultButton).toHaveCSS("color", "rgb(143, 215, 234)"); + + // Done-state (both buttons share the same green) - #5cb85c border, #8fe08f text. + await applyButton.click(); + await expect(applyButton).toHaveCSS( + "border", + "1px solid rgb(92, 184, 92)" + ); + await expect(applyButton).toHaveCSS("color", "rgb(143, 224, 143)"); + }); + + test(`E.3 progress bar + E.4 game embed tokens resolve real computed values at ${viewport.label}`, async ({ + page, + network, + }) => { + network.use( + ...threeCardHandlers.filter( + (handler) => handler !== searchResultsThreeResults + ), + searchResultsOneResult, + imageBucketFailure, + delayedImageWorkerSuccess, + questionFeedConfirmSuggestionSingleton + ); + await loadPageWithDefaultBackend(page); + await importTextOnEditorLanding(page, "my search query"); + await ensureDisplayRightRailOpen(page); + await page.getByTestId("finish-footer-print-export").click(); + await page + .getByTestId("pre-print-cardback-gate") + .getByTestId("cardback-gate-use-current") + .click(); + await page.waitForURL(/\/print/, { timeout: 30_000 }); + await page.getByRole("tab", { name: "PDF" }).click(); + + await page.getByRole("button", { name: "Generate PDF" }).click(); + + // E.3 `.progressbox` - #22303f bg, 1px #16202b border. + const progressBox = page.getByTestId("pdf-progress"); + await expect(progressBox).toBeVisible({ timeout: 15_000 }); + await expect(progressBox).toHaveCSS( + "background-color", + "rgb(34, 48, 63)" + ); + await expect(progressBox).toHaveCSS( + "border", + "1px solid rgb(22, 32, 43)" + ); + + // E.4 `.gameembed` frame - #22303f bg, 1px #16202b border. + const embed = page.getByTestId("pdf-wait-game"); + await expect(embed).toBeVisible({ timeout: 15_000 }); + await expect(embed).toHaveCSS("background-color", "rgb(34, 48, 63)"); + await expect(embed).toHaveCSS("border", "1px solid rgb(22, 32, 43)"); + + // E.4 `.geband` build ribbon - #0b1520 bg. + await expect(page.getByTestId("pdf-wait-game-ribbon")).toHaveCSS( + "background-color", + "rgb(11, 21, 32)" + ); + + // E.4 `.tbtn.yes` (ThumbButton, QuestionFeed's own shipped idiom, reproduced verbatim) - + // min-height 44px floor. + const yesButton = embed.getByTestId("question-feed-level1-yes"); + await expect(yesButton).toBeVisible({ timeout: 15_000 }); + const yesHeight = await yesButton.evaluate( + (el) => el.getBoundingClientRect().height + ); + expect(yesHeight).toBeGreaterThanOrEqual(44); + }); + }); +} diff --git a/frontend/tests/DisplayFinishFooter.spec.ts b/frontend/tests/DisplayFinishFooter.spec.ts index f5c7da323..203c10d8b 100644 --- a/frontend/tests/DisplayFinishFooter.spec.ts +++ b/frontend/tests/DisplayFinishFooter.spec.ts @@ -77,6 +77,14 @@ test.describe("/display Finish footer (issue #275)", () => { await footer.getByTestId("finish-footer-print-export").click(); + // Cardback flow round (SPEC-cardback-pdfwait.md §C.1) - a fresh project is still "riding + // the untouched default" cardback, so the reminder gate fires before the (absent, for an + // anonymous session) save gate / navigation. "Use current & continue" is the equivalent of + // this test's own old "no save gate, straight through" assertion for the NEW gate. + const cardbackGate = page.getByTestId("pre-print-cardback-gate"); + await expect(cardbackGate).toBeVisible(); + await cardbackGate.getByTestId("cardback-gate-use-current").click(); + // No save gate for an anonymous session (D9(3): "authenticated AND dirty" gates the prompt) - // straight through to the Print page. // Explicit generous timeout, not the default assertion timeout - see this describe block's @@ -134,6 +142,12 @@ test.describe("/display Finish footer (issue #275)", () => { .getByTestId("finish-footer-print-export") .click(); + // Cardback flow round (SPEC-cardback-pdfwait.md §C.1) - the reminder gate runs BEFORE the + // save gate (a deck-completeness decision precedes the persistence one). + const cardbackGate = page.getByTestId("pre-print-cardback-gate"); + await expect(cardbackGate).toBeVisible(); + await cardbackGate.getByTestId("cardback-gate-use-current").click(); + const gate = page.getByTestId("pre-print-save-gate-modal"); await expect(gate).toBeVisible(); await gate.getByTestId("pre-print-save-gate-save").click(); @@ -180,6 +194,12 @@ test.describe("/display Finish footer (issue #275)", () => { .getByTestId("finish-footer-print-export") .click(); + // Cardback flow round (SPEC-cardback-pdfwait.md §C.1) - the reminder gate runs BEFORE the + // save gate (a deck-completeness decision precedes the persistence one). + const cardbackGate = page.getByTestId("pre-print-cardback-gate"); + await expect(cardbackGate).toBeVisible(); + await cardbackGate.getByTestId("cardback-gate-use-current").click(); + const gate = page.getByTestId("pre-print-save-gate-modal"); await expect(gate).toBeVisible(); await gate.getByTestId("pre-print-save-gate-skip").click(); diff --git a/frontend/tests/DisplayPage.spec.ts b/frontend/tests/DisplayPage.spec.ts index 80819d860..4d6e4e6df 100644 --- a/frontend/tests/DisplayPage.spec.ts +++ b/frontend/tests/DisplayPage.spec.ts @@ -459,13 +459,32 @@ test.describe("DisplayPage (Proposal H, Step 1)", () => { cardDocument1.name ); - await page.getByRole("button", { name: "Cardback" }).click(); + // Cardback flow round (SPEC-cardback-pdfwait.md OWNER AMENDMENT 3) - a dedicated testid, not + // a name-based locator, since a sheet slot's own "⟲" flip button can carry an accessible name + // mentioning "cardback" too (the custom-cardback indicator's aria-label). + await page.getByTestId("cardback-toolbar-button").click(); const cardbackModal = page.getByTestId("cardback-grid-selector"); await expect(cardbackModal).toBeVisible(); await cardbackModal.getByAltText(cardDocument2.name).click(); + + // Cardback flow round (SPEC-cardback-pdfwait.md §C.2) - the modal no longer auto-closes on + // pick: the apply-all/set-default prompt now renders inline in this SAME modal (never a + // second stacked one), so a selection round-trips into project state on click, but the modal + // itself stays open until the user explicitly closes it. + await expect( + cardbackModal.getByTestId("cardback-apply-prompt") + ).toBeVisible(); + await expect(backSheetSlot.locator("img")).toHaveAttribute( + "alt", + cardDocument2.name + ); + + // Two elements share the accessible name "Close" here (the header's own btn-close X, and + // this modal's own footer button) - the footer one is the later of the two in DOM order. + await cardbackModal.getByRole("button", { name: "Close" }).last().click(); await expect(cardbackModal).not.toBeVisible(); - // The back-face slot on the sheet now reflects the newly selected cardback, confirming the + // The back-face slot on the sheet still reflects the newly selected cardback, confirming the // selection round-tripped through the shared projectSlice.cardback state, not just the modal's // own local component state. await expect(backSheetSlot.locator("img")).toHaveAttribute( diff --git a/frontend/tests/GridSelectorModal.spec.ts b/frontend/tests/GridSelectorModal.spec.ts index ca5241e1d..137ba8ddd 100644 --- a/frontend/tests/GridSelectorModal.spec.ts +++ b/frontend/tests/GridSelectorModal.spec.ts @@ -346,7 +346,7 @@ test.describe("GridSelectorModal – JumpToVersion", () => { await expect(optionInput).toBeDisabled(); }); - test("submitting a valid option number selects that card and closes the modal", async ({ + test("submitting a valid option number selects that card (cardback flow round: the modal now stays open with the apply/default prompt, closed explicitly)", async ({ page, network, }) => { @@ -365,11 +365,18 @@ test.describe("GridSelectorModal – JumpToVersion", () => { await gridSelector.getByPlaceholder("1", { exact: true }).fill("2"); await gridSelector.getByLabel("jump-to-version-submit").click(); - await expect(gridSelector).not.toBeVisible(); + // Cardback flow round (SPEC-cardback-pdfwait.md §C.2) - the toolbar entry's pick no longer + // auto-closes the modal (the apply-all/set-default prompt renders inline in this SAME modal + // instead); the selection itself still round-trips into project state immediately though. + await expect( + gridSelector.getByTestId("cardback-apply-prompt") + ).toBeVisible(); await expectDisplaySheetSlotState(page, 1, "back", cardDocument2.name); + await gridSelector.getByRole("button", { name: "Close" }).last().click(); + await expect(gridSelector).not.toBeVisible(); }); - test("submitting a valid identifier selects that card and closes the modal", async ({ + test("submitting a valid identifier selects that card (cardback flow round: the modal now stays open with the apply/default prompt, closed explicitly)", async ({ page, network, }) => { @@ -387,8 +394,12 @@ test.describe("GridSelectorModal – JumpToVersion", () => { .fill(cardDocument2.identifier); await gridSelector.getByLabel("jump-to-version-submit").click(); - await expect(gridSelector).not.toBeVisible(); + await expect( + gridSelector.getByTestId("cardback-apply-prompt") + ).toBeVisible(); await expectDisplaySheetSlotState(page, 1, "back", cardDocument2.name); + await gridSelector.getByRole("button", { name: "Close" }).last().click(); + await expect(gridSelector).not.toBeVisible(); }); }); diff --git a/frontend/tests/GridSelectorModalVariants.spec.ts b/frontend/tests/GridSelectorModalVariants.spec.ts index 46fbdccd9..5dec8ea2b 100644 --- a/frontend/tests/GridSelectorModalVariants.spec.ts +++ b/frontend/tests/GridSelectorModalVariants.spec.ts @@ -40,7 +40,7 @@ const threeCardHandlers = [ ]; test.describe("GridSelectorModal - keyboard navigation", () => { - test("Tab reaches a result card and Enter selects it, same as a click would", async ({ + test("Tab reaches a result card and Enter selects it, same as a click would (cardback flow round: the modal now stays open with the apply/default prompt)", async ({ page, network, }) => { @@ -56,7 +56,12 @@ test.describe("GridSelectorModal - keyboard navigation", () => { await expect(targetCard).toBeFocused(); await page.keyboard.press("Enter"); - await expect(gridSelector).not.toBeVisible(); + // Cardback flow round (SPEC-cardback-pdfwait.md §C.2) - the toolbar entry's pick no longer + // auto-closes the modal (the apply-all/set-default prompt renders inline in this SAME modal + // instead), so a keyboard-driven select is confirmed the same way a click-driven one now is. + await expect( + gridSelector.getByTestId("cardback-apply-prompt") + ).toBeVisible(); }); }); diff --git a/frontend/tests/PDFWaitExperience.spec.ts b/frontend/tests/PDFWaitExperience.spec.ts new file mode 100644 index 000000000..899a7b9f9 --- /dev/null +++ b/frontend/tests/PDFWaitExperience.spec.ts @@ -0,0 +1,205 @@ +import { expect } from "@playwright/test"; +import { readFileSync } from "fs"; +import { http, HttpResponse } from "msw"; +import path from "path"; +import { fileURLToPath } from "url"; + +import { + cardDocumentsOneResult, + defaultHandlers, + questionFeedConfirmSuggestionSingleton, + searchResultsOneResult, + sourceDocumentsOneResult, +} from "@/mocks/handlers"; + +import { test } from "../playwright.setup"; +import { + importTextOnEditorLanding, + loadPageWithDefaultBackend, +} from "./test-utils"; + +// PDF-generation wait experience round (SPEC-cardback-pdfwait.md §D, PKG2) - reached via the real +// editor -> Finish footer -> (cardback reminder gate) -> /print flow, not the classic /editor +// route (fully unrouted post-Proposal-H - see PDFGenerator.spec.ts's own module comment). This is +// the ONE live entry point PDFGenerator.tsx has today. + +const IMAGE_WORKER_URL_PATTERN = /^https:\/\/cdn\.proxyprints\.ca\//; +const IMAGE_BUCKET_URL_PATTERN = /^https:\/\/img\.proxyprints\.ca\//; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const validImageBytes = readFileSync( + path.join(__dirname, "..", "public", "blank.png") +); + +const imageBucketFailure = http.get( + IMAGE_BUCKET_URL_PATTERN, + () => new HttpResponse(null, { status: 404 }) +); +// Artificially delayed (not instant) so the "fetching" phase - and thus the game embed - has a +// real window to be observed in, matching the pre-existing precedent for this same need +// (PDFGenerator.spec.ts's own "shows live 'fetching images' progress" test, before that whole +// file was retired by the Proposal H route swap). +const delayedImageWorkerSuccess = http.get( + IMAGE_WORKER_URL_PATTERN, + async () => { + await new Promise((resolve) => setTimeout(resolve, 4_000)); + return new HttpResponse(validImageBytes, { + status: 200, + headers: { "Content-Type": "image/png" }, + }); + } +); + +const reachPDFTabOnPrintPage = async ( + page: import("@playwright/test").Page +) => { + await loadPageWithDefaultBackend(page); + await importTextOnEditorLanding(page, "my search query"); + + await page.getByTestId("finish-footer-print-export").click(); + const cardbackGate = page.getByTestId("pre-print-cardback-gate"); + await expect(cardbackGate).toBeVisible(); + await cardbackGate.getByTestId("cardback-gate-use-current").click(); + + await page.waitForURL(/\/print/, { timeout: 30_000 }); + await page.getByRole("tab", { name: "PDF" }).click(); +}; + +test.describe("PDF-generation wait experience (SPEC-cardback-pdfwait.md §D, PKG2)", () => { + test.describe.configure({ mode: "serial", timeout: 90_000 }); + + test("progress phases: determinate fetching -> indeterminate assembling -> green done, replacing the bare text line", async ({ + page, + network, + }) => { + network.use( + cardDocumentsOneResult, + sourceDocumentsOneResult, + searchResultsOneResult, + imageBucketFailure, + delayedImageWorkerSuccess, + questionFeedConfirmSuggestionSingleton, + ...defaultHandlers + ); + + await reachPDFTabOnPrintPage(page); + + const [download] = await Promise.all([ + page.waitForEvent("download"), + (async () => { + await page.getByRole("button", { name: "Generate PDF" }).click(); + + const progressBox = page.getByTestId("pdf-progress"); + await expect(progressBox).toBeVisible({ timeout: 15_000 }); + await expect(progressBox).toContainText("Fetching images", { + timeout: 3_000, + }); + // react-bootstrap's ProgressBar puts role="progressbar"/aria-valuenow on the INNER bar + // element, not the outer data-testid'd wrapper - scope via role instead. + const bar = progressBox.getByRole("progressbar"); + await expect(bar).toHaveAttribute("aria-valuenow", /\d+/); + + // The determinate bar never claims a false 100% mid-fetch (seam 2a). + const valueNow = await bar.getAttribute("aria-valuenow"); + expect(Number(valueNow)).toBeLessThanOrEqual(99); + })(), + ]); + expect(download.suggestedFilename()).toBe("cards.pdf"); + + // Done phase - green bar, "PDF ready" label. + const progressBox = page.getByTestId("pdf-progress"); + await expect(progressBox).toContainText("✓ PDF ready"); + }); + + test("game embed: lazy-mounts the real QuestionFeed while generating, and tears down to the outro on finish (no standalone duplicate)", async ({ + page, + network, + }) => { + network.use( + cardDocumentsOneResult, + sourceDocumentsOneResult, + searchResultsOneResult, + imageBucketFailure, + delayedImageWorkerSuccess, + questionFeedConfirmSuggestionSingleton, + ...defaultHandlers + ); + + await reachPDFTabOnPrintPage(page); + + // Never mounted before generation starts (2c - lazy-load only once isDownloading is true). + await expect(page.getByTestId("pdf-wait-game")).toHaveCount(0); + await expect(page.getByTestId("question-feed")).toHaveCount(0); + + const [download] = await Promise.all([ + page.waitForEvent("download"), + (async () => { + await page.getByRole("button", { name: "Generate PDF" }).click(); + + const embed = page.getByTestId("pdf-wait-game"); + await expect(embed).toBeVisible({ timeout: 15_000 }); + // The real, unforked QuestionFeed funnel - Level 1 YES/NOT SURE/NO/SKIP. + await expect(embed.getByTestId("question-feed")).toBeVisible({ + timeout: 15_000, + }); + await expect(embed.getByTestId("question-feed-level1")).toBeVisible(); + await expect(embed.getByTestId("pdf-wait-game-ribbon")).toBeVisible(); + })(), + ]); + expect(download.suggestedFilename()).toBe("cards.pdf"); + + // Torn down on finish - the game (and QuestionFeed with it) unmounts entirely. + await expect(page.getByTestId("pdf-wait-game")).toHaveCount(0); + await expect(page.getByTestId("question-feed")).toHaveCount(0); + + // The outro (the shipped PostExportContributionPrompt, unchanged) replaces it in the SAME + // right column. + const outro = page.getByTestId("post-export-contribution-prompt"); + await expect(outro).toBeVisible(); + await expect(outro).toContainText("What's That Card?"); + + // §D.3/PE1 - exactly ONE nudge: the standalone left-column mount is suppressed while the + // embed's own outro is showing (there is only ever one post-export-contribution-prompt + // testid on the page at a time). + await expect( + page.getByTestId("post-export-contribution-prompt") + ).toHaveCount(1); + }); + + test("the classic direct 'Generate PDF' path reachable from /print still respects the cardback reminder guard's own once-per-session suppression", async ({ + page, + network, + }) => { + network.use( + cardDocumentsOneResult, + sourceDocumentsOneResult, + searchResultsOneResult, + imageBucketFailure, + delayedImageWorkerSuccess, + questionFeedConfirmSuggestionSingleton, + ...defaultHandlers + ); + + await reachPDFTabOnPrintPage(page); + + // The editor's own Finish-footer gate already ran (and was suppressed via "Use current & + // continue") earlier in this same session/tab - PDFGenerator.tsx's OWN independent guard + // (usePrePrintSaveGate's sibling call site around the classic direct Generate/Save-to-Drive + // buttons) reads the SAME per-project sessionStorage suppression key, so a click here does + // NOT show a second reminder - straight into the real fetch/assemble/done flow, confirming + // both call sites share one coherent CB1 "once per session" contract rather than each + // maintaining an independent (and possibly nagging-twice) copy. + await Promise.all([ + page.waitForEvent("download"), + (async () => { + await page.getByRole("button", { name: "Generate PDF" }).click(); + await expect(page.getByTestId("pre-print-cardback-gate")).toHaveCount( + 0 + ); + await expect(page.getByTestId("pdf-progress")).toBeVisible({ + timeout: 15_000, + }); + })(), + ]); + }); +}); diff --git a/frontend/tests/UnsavedWorkGuard.spec.ts b/frontend/tests/UnsavedWorkGuard.spec.ts index 7eae663be..c47611119 100644 --- a/frontend/tests/UnsavedWorkGuard.spec.ts +++ b/frontend/tests/UnsavedWorkGuard.spec.ts @@ -55,6 +55,13 @@ test.describe("Unsaved-work guard (priority bug fix)", () => { await page.getByTestId("finish-footer-print-export").click(); + // Cardback flow round (SPEC-cardback-pdfwait.md §C.1) - a fresh project is still riding the + // untouched default cardback, so the reminder gate fires first; "Use current & continue" + // proceeds with the same client-side navigation this test is really about. + const cardbackGate = page.getByTestId("pre-print-cardback-gate"); + await expect(cardbackGate).toBeVisible(); + await cardbackGate.getByTestId("cardback-gate-use-current").click(); + // A real navigation, not just a same-page state change - waitForURL fails outright if the // click never actually left /editor, which is exactly the failure mode a regression here // would produce. Generous explicit timeout - /print's first on-demand dev-mode compile diff --git a/frontend/tests/test-utils.ts b/frontend/tests/test-utils.ts index c99626b70..41c623137 100644 --- a/frontend/tests/test-utils.ts +++ b/frontend/tests/test-utils.ts @@ -846,6 +846,23 @@ export const openDisplayCardbackGridSelector = async (page: Page) => { return gridSelector; }; +// Cardback flow round (SPEC-cardback-pdfwait.md) - same isVisible()-guard gear-open pattern as +// openDisplaySearchSettingsModal/openDisplayCardbackGridSelector above, factored out since the +// Finish footer (FinishFooter.tsx, `finish-footer-print-export`) ALSO lives inside the same +// `display-print-settings-rail` Offcanvas - below `xl`, reachable only via the gear button. Every +// existing caller of `finish-footer-print-export` in this suite runs at the default (unset) +// viewport, which resolves to devices["Desktop Chrome"]'s 1280x720 (above `xl`, rail already +// inline - see openDisplaySearchSettingsModal's own comment on why playwright.config.ts's +// `contextOptions.viewport` override is dead config) - this helper is for any caller that +// deliberately narrows below `xl` (e.g. a phone-width `test.use({ viewport })` block). +export const ensureDisplayRightRailOpen = async (page: Page) => { + const rail = page.getByTestId("display-print-settings-rail"); + if (!(await rail.isVisible())) { + await page.getByTestId("display-gear-button").click(); + await expect(rail).toBeVisible(); + } +}; + /** * Open a StyledDropdownTreeSelect and click an option by its exact label text. * The container should be the `.react-dropdown-tree-select` element (or a From 616455964a2b8bbac1b73446fbb8cbf1ea5eac51 Mon Sep 17 00:00:00 2001 From: wilfordgrimley <2397930+WilfordGrimley@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:51:43 +0000 Subject: [PATCH 2/2] Fix rebase fallout: reminder gate in navigateToPrintPDFTab, stale progress-bar text assertion, .modal-content bg after #425's theme-defaults pass PR #427 (landed after this branch's original base) un-skipped PDFGenerator.spec.ts/PagePreview.spec.ts/PostExportContributionPrompt.spec.ts onto the shared navigateToPrintPDFTab helper, which the new cardback reminder gate now intercepts; PR #425's theme-defaults pass separately re-routed every Modal's content background to $theme-raised-bg. Co-Authored-By: Claude Fable 5 --- frontend/tests/CardbackPdfWaitFidelity.spec.ts | 11 +++++++++-- frontend/tests/PDFGenerator.spec.ts | 5 ++++- frontend/tests/test-utils.ts | 8 ++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/frontend/tests/CardbackPdfWaitFidelity.spec.ts b/frontend/tests/CardbackPdfWaitFidelity.spec.ts index 8cbdbadd6..9d278a93a 100644 --- a/frontend/tests/CardbackPdfWaitFidelity.spec.ts +++ b/frontend/tests/CardbackPdfWaitFidelity.spec.ts @@ -94,11 +94,18 @@ for (const viewport of [ const gate = page.getByTestId("pre-print-cardback-gate"); await expect(gate).toBeVisible(); - // E.1 `.mdialog` (real react-bootstrap Modal on Superhero) - content bg $gray-600 #4e5d6c. + // E.1 `.mdialog` (real react-bootstrap Modal) - the spec's own table cites the stock + // Superhero `$modal-content-bg` ($gray-600 #4e5d6c), but PR #425's theme-defaults pass + // (landed the same day, separately) re-routed EVERY Modal's content bg to + // `$theme-raised-bg` (#22303f) sitewide - a base-theme move this spec's own binding token + // couldn't have anticipated, not a regression introduced here. Asserting the CURRENT real + // shared value (`_theme-tokens.scss`'s `$modal-content-bg: $theme-raised-bg`) - this file's + // own Modal instances (the reminder gate, the cardback grid selector) are unforked, + // sitewide Bootstrap chrome, not something this round overrides. const modalContent = page.locator(".modal-content").first(); await expect(modalContent).toHaveCSS( "background-color", - "rgb(78, 93, 108)" + "rgb(34, 48, 63)" ); // E.1 `.mfoot` primary button - $primary #df6919. diff --git a/frontend/tests/PDFGenerator.spec.ts b/frontend/tests/PDFGenerator.spec.ts index 2a15caea6..c4b90d4d6 100644 --- a/frontend/tests/PDFGenerator.spec.ts +++ b/frontend/tests/PDFGenerator.spec.ts @@ -223,9 +223,12 @@ test.describe("PDFGenerator - export image-fetch progress (rate-limit fix)", () await expect(page.getByTestId("pdf-image-fetch-progress")).toBeVisible({ timeout: 15_000, }); + // PDF-generation wait experience round (SPEC-cardback-pdfwait.md §D.1, PKG2a) - the old + // bare "Fetching images: N/M" text line is now a real Bootstrap ProgressBar's own label + // (PDFWaitPanel.tsx's PDFProgressBox); same testid, new copy. await expect( page.getByTestId("pdf-image-fetch-progress") - ).toContainText("Fetching images:"); + ).toContainText("Fetching images"); })(), ]); expect(download.suggestedFilename()).toBe("cards.pdf"); diff --git a/frontend/tests/test-utils.ts b/frontend/tests/test-utils.ts index 41c623137..3728433f2 100644 --- a/frontend/tests/test-utils.ts +++ b/frontend/tests/test-utils.ts @@ -269,6 +269,14 @@ export const navigateToPrintPDFTab = async (page: Page, query: string) => { await importTextOnEditorLanding(page, query); await expect(async () => { await page.getByTestId("finish-footer-print-export").click(); + // Cardback flow round (SPEC-cardback-pdfwait.md §C.1) - a fresh project is still riding the + // untouched default cardback, so the reminder gate fires before navigation; "Use current & + // continue" proceeds (and suppresses the gate for the rest of this retry loop's session, so a + // `toPass` retry never re-shows it). + const cardbackGate = page.getByTestId("pre-print-cardback-gate"); + if (await cardbackGate.isVisible().catch(() => false)) { + await cardbackGate.getByTestId("cardback-gate-use-current").click(); + } await page.waitForURL(/\/print/, { timeout: 15_000 }); }).toPass({ timeout: 45_000 }); await page.getByRole("tab", { name: "PDF" }).click();