diff --git a/.github/scripts/docs_lint.py b/.github/scripts/docs_lint.py
index 8c328f1e0..1fd48c048 100644
--- a/.github/scripts/docs_lint.py
+++ b/.github/scripts/docs_lint.py
@@ -47,19 +47,6 @@
"architecture (publish_wiki.py/publish_site.py) — not a live "
"reference, not a forward-reference to something not yet built"
),
- "frontend/src/features/display/useProjectDraftBackup.ts": (
- "docs/proposals/proposal-h-display-layout-spec.md's ADDENDUM D9/F1 — "
- "a forward reference to a proposed-but-not-yet-built file (the deck "
- "auto-backup hook), explicitly flagged in that same doc's §A2 as "
- "needing its own future issue; issue #267's implementation (this "
- "repo's most recent /display PR) deliberately did not build it"
- ),
- "frontend/src/pages/print.tsx": (
- "docs/proposals/proposal-h-display-layout-spec.md's ADDENDUM D10/F5 — "
- "a forward reference to a proposed-but-not-yet-built thin route "
- "wrapper (the Print-page rehoming), tracked under issue #272 item 3; "
- "issue #267's implementation deliberately did not build it"
- ),
}
PATH_EXTENSIONS = (
diff --git a/docs/features/pdf-generator.md b/docs/features/pdf-generator.md
index dcbafc84d..519760870 100644
--- a/docs/features/pdf-generator.md
+++ b/docs/features/pdf-generator.md
@@ -164,8 +164,13 @@ because the two hooks return differently:
`await saveToDrive()` already gives the real success/cancelled value
directly.
-The same hook/component pair is also mounted from `frontend/src/features/ display/DisplayPage.tsx`'s own inline export (Proposal H, item 2) — one
-implementation shared by both real export surfaces, not two. See
+This used to also be mounted from `DisplayPage.tsx`'s own inline export
+(Proposal H, item 2) — issue #275 retired that pipeline entirely (the
+memory-heavy Generate PDF/Save-to-Drive operations now live solely here,
+reached from `/display`'s Finish footer via a pre-print save gate; see
+`docs/proposals/proposal-h-display-layout-spec.md`'s ADDENDUM D9/D10), so
+this component's mounts are now: this tab, `PDFGeneratorModal.tsx`, and
+`ProjectEditor.tsx` — one implementation, not a forked second copy. See
`docs/features/printing-tags.md`'s own entry for the full detail (why
`/whatsthat` and not a new route, the `sessionStorage`-backed "never
repeats within a session" rule) and `docs/features/print-export-page.md`
diff --git a/docs/features/print-export-page.md b/docs/features/print-export-page.md
index e516a85ff..827eb370a 100644
--- a/docs/features/print-export-page.md
+++ b/docs/features/print-export-page.md
@@ -29,14 +29,19 @@ requiring hand-rolled SVG specifically.
## Post-export contribution prompt (issue #166)
-The `PDFGenerator.tsx` mounted inside this tab's own "PDF" sub-tab carries the
-same post-export contribution prompt the unified `/display` page's inline
-export does — a dismissible `Alert` shown once per session after a genuine
-"Generate PDF"/"Save PDF to Google Drive" success, linking to `/whatsthat`.
-One shared implementation (`frontend/src/features/export/ usePostExportContributionPrompt.ts` + `PostExportContributionPrompt.tsx`),
+The `PDFGenerator.tsx` mounted inside this tab's own "PDF" sub-tab carries a
+dismissible `Alert` shown once per session after a genuine "Generate
+PDF"/"Save PDF to Google Drive" success, linking to `/whatsthat`. One shared
+implementation (`frontend/src/features/export/ usePostExportContributionPrompt.ts` + `PostExportContributionPrompt.tsx`),
mounted from `PDFGenerator.tsx` itself so every real caller of that
-component — this tab, `PDFGeneratorModal.tsx`, `ProjectEditor.tsx` — gets it
-for free, rather than wiring it into `FinishedMyProject.tsx` separately. See
+component — this tab (reachable both via the classic editor's "Print!" tab
+and, since issue #275, standalone at `pages/print.tsx`), `PDFGeneratorModal.tsx`,
+`ProjectEditor.tsx` — gets it for free, rather than wiring it into
+`FinishedMyProject.tsx` separately. (Issue #275 also retired the unified
+`/display` page's OWN separate inline export pipeline and its own mount of
+this same prompt — PDF generation now lives solely here, reached from
+`/display`'s Finish footer via a pre-print save gate; see
+`docs/proposals/proposal-h-display-layout-spec.md`'s ADDENDUM D9/D10.) See
`docs/features/printing-tags.md`'s own entry for the full detail (session-
scoped `sessionStorage` flag, success-detection mechanism, why it's a
funnel entry point rather than a parallel one) and
@@ -48,6 +53,9 @@ of the wiring.
- `frontend/src/features/export/FinishedMyProject.tsx`
- `frontend/src/components/flags.tsx`
- `frontend/public/*.svg` (vendored flag icons)
+- `frontend/src/pages/print.tsx` (issue #275, D10/F5) — thin standalone route
+ wrapper mounting `FinishedMyProject` unchanged, mirroring `pages/myDecks.tsx`;
+ the funnel destination `/display`'s Finish footer navigates to
## Status
diff --git a/docs/features/printing-tags.md b/docs/features/printing-tags.md
index 378b2f50b..c22fedbf4 100644
--- a/docs/features/printing-tags.md
+++ b/docs/features/printing-tags.md
@@ -406,12 +406,15 @@ printings, artists, tags, and moderation from one screen.
after a genuinely successful PDF export (either "Generate PDF" or "Save
PDF to Google Drive"), linking straight to `/whatsthat` via the exact
same route `Navbar.tsx`/`HomepagePanel.tsx` already use. Mounted from
- both real export surfaces — `frontend/src/features/display/DisplayPage.tsx`'s
- own inline export (Proposal H, item 2) and `PDFGenerator.tsx` itself
- (so the classic "Print!" tab / `PDFGeneratorModal.tsx` / `ProjectEditor.tsx`
- mounts get it too, since they all render the same component) — one
+ `PDFGenerator.tsx` itself (so the classic "Print!" tab / standalone
+ `pages/print.tsx` (issue #275) / `PDFGeneratorModal.tsx` / `ProjectEditor.tsx`
+ mounts all get it, since they render the same component) — one
`usePostExportContributionPrompt`/`PostExportContributionPrompt.tsx`
- pair (`frontend/src/features/export/`), not two copies. "Never repeats
+ pair (`frontend/src/features/export/`), not two copies. This used to
+ ALSO be mounted from `DisplayPage.tsx`'s own inline export (Proposal H,
+ item 2) — issue #275 retired that pipeline entirely, so PDF generation
+ (and this prompt) now lives solely on the Print page, reached from
+ `/display`'s Finish footer via a pre-print save gate. "Never repeats
within a session" (this is the "separate post-export contribution
toast, task #31" the unified-display-page proposal's own §4.4′ footnote
references) — a `sessionStorage` flag set the moment the prompt is
diff --git a/docs/proposals/proposal-h-display-layout-spec.md b/docs/proposals/proposal-h-display-layout-spec.md
index ff3528516..e536171aa 100644
--- a/docs/proposals/proposal-h-display-layout-spec.md
+++ b/docs/proposals/proposal-h-display-layout-spec.md
@@ -38,8 +38,26 @@ removed — a soft warning (never a hard clamp) now surfaces when the current bl
selected profile's D6-table cap for a 4-column sheet, computed dynamically
(`maxBleedForFourColumns`) rather than copying the table's numbers verbatim. At these shipped
defaults, /display's sheet now renders the spec's own 4×2 grid (D4) exactly, not the 4×1 this
-doc previously reported as the interim state before D5/D6 landed. Deliberately NOT built here
-(this PR, and not by #266/#267/#268/#284 above either): D9–D11/D14/D16 (own future issues, per
+doc previously reported as the interim state before D5/D6 landed. **D9/D10 shipped (this PR —
+issue #275):** the right-rail Finish footer (`FinishFooter.tsx`) now holds `Save Deck` and
+`Print / Export →` as co-equal `btn-primary` buttons plus the unchanged `Export ▾`, replacing
+the old three-button "Prepare Print" stack; the memory-heavy Generate PDF/Save PDF to Google
+Drive operations (and this page's own item-2 inline export pipeline that drove them) are
+removed from `/display` outright, not merely hidden — PDF generation now lives solely on the new
+`pages/print.tsx` (D10/F5, a thin wrapper mounting the unchanged `FinishedMyProject`, mirroring
+`pages/myDecks.tsx`). `useProjectDraftBackup.ts` (F1) mirrors the working project to
+`localStorage` (indexes/settings only) on a debounce, offers a restore nudge on the empty-project
+landing, and fires D9(2)'s promotion nudge post-import; `PrePrintSaveGate.tsx` (F3) runs the
+D9(3) flush-then-optionally-prompt-then-navigate sequence the footer's `Print / Export →` button
+triggers. Deliberately NOT built by this PR: the Print page's own tab REORDER (owner order PDF ·
+MakePlayingCards · NotMPC · PringlePrints, PDF default) and its PDF tab's preview removal (D10's
+own owner addendum, both tracked as their own follow-up), and a genuine, out-of-scope gap this
+PR leaves documented rather than silently accepted — `/display`'s own Page Setup controls (paper
+size/bleed edge/guides, plain component state, never persisted) don't carry over to the Print
+page's classic `PDFGenerator`, which has its own separate settings and doesn't read this page's
+margin-profile/card-spacing redux slices either; a future issue, not D9/D10's own scope (save-
+vs-print ordering and route linkage, not settings portability). Deliberately NOT built here
+(this PR, and not by #266/#267/#268/#284 above either): D11/D14/D16 (own future issues, per
§A2's own issue mapping).
Issue mapping (explicit):
@@ -955,12 +973,13 @@ Card spacing control (D19) — **SHIPPED** (this PR, alongside R7/D17/D18):
item 3 → D10 Print-page rehoming (F5); item 4 → D11 FinishSettings (F6); item
5 → D12 browse (F9/F10); item 7 → D16 cardback swatch (F7); item 1 → D15
import variety (F13).
-- **NEW issue needed** — **D9 finish footer + deck auto-backup** (F1–F4). The
- owner named this in #272's comment as polish-round scope but it has no issue
- number of its own; it is the one genuinely-new surface here (local-draft
- persistence + co-equal Save/Print + pre-print save gate) and should be filed
- as its own issue at implementation time. D10's `pages/print.tsx` route
- (F5) can ride #272 item 3 or the same new issue.
+- **#275 (filed, shipped)** — **D9 finish footer + deck auto-backup** (F1–F4),
+ plus D10's `pages/print.tsx` route (F5) — the owner named this in #272's
+ comment as polish-round scope; it was filed as its own issue and shipped
+ (see this doc's own "Implementation status" line above). The tab
+ REORDER/PDF-tab-preview-removal half of D10's own owner addendum was
+ deliberately NOT built by #275 — tracked as its own follow-up, not silently
+ dropped.
## A3. Conflicts / tensions (new, honest)
diff --git a/docs/upstreaming/extractable-primitives.md b/docs/upstreaming/extractable-primitives.md
index 9dcebe795..c29b51f17 100644
--- a/docs/upstreaming/extractable-primitives.md
+++ b/docs/upstreaming/extractable-primitives.md
@@ -87,22 +87,23 @@ coupling to the vote system is.
## Frontend — PDF / export
-| Primitive | File(s) | Problem solved | Candidate consumers | Entanglement | License note |
-| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| PDF render core | `frontend/src/features/pdf/useRenderPDF.ts`, `pdfRenderService.ts`, `pdf.worker.ts`, `PDF.tsx` | Off-main-thread PDF generation via a comlink-wrapped Web Worker + `@react-pdf/renderer` document tree | upstream, proxies-at-home | CLEAN | — |
-| Eager-WASM lazy-mount fix | `frontend/src/features/pdf/PDFGeneratorModal.tsx`, `frontend/src/features/export/FinishedMyProject.tsx`, `frontend/src/components/ProjectEditor.tsx` | `next/dynamic({ssr:false})` + `mountOnEnter` on the PDF tab so `@react-pdf/renderer`'s WASM doesn't eagerly load (and phantom-download) before the PDF tab is opened | upstream, proxies-at-home | CLEAN (pattern-level — the call sites live in larger entangled files, so this is a pattern to replicate, not a file to lift) | — |
-| Page-layout math | `frontend/src/features/pdf/layout.ts` | Pure page/card/bleed/margin/spacing geometry, page-absolute slot rects out; zero imports | upstream, proxies-at-home | CLEAN | — |
-| PDF canvas preview | `frontend/src/features/pdf/PDFCanvasPreview.tsx` | pdf.js canvas-based render preview | upstream, proxies-at-home | CLEAN | — |
-| Generic concurrency helpers | `frontend/src/common/semaphore.ts`, `frontend/src/common/concurrencyLimit.ts` | Bounded-concurrency gate (`Semaphore`) and a `mapWithConcurrencyLimit` helper | upstream, proxies-at-home | CLEAN | — |
-| Paced/retrying image fetch | `frontend/src/features/pdf/pdfImage.ts` | Semaphore-gated, exponential-backoff full-resolution image fetch for PDF export | upstream, proxies-at-home (with their own CDN) | entangled-with-image-cdn-infra | — |
-| Bleed-prior vote resolution | `frontend/src/features/pdf/bleedPriorResolution.ts` | Derives a per-card bleed lean from the weighted-vote consensus system | — (fork-only by design) | entangled-with-vote-consensus | — |
-| Ordering-service flag icons | `frontend/src/components/flags.tsx` | Small flag-icon wrappers for print-service country badges | upstream, proxies-at-home | CLEAN | SVGs vendored from `lipis/flag-icons` (MIT) |
-| Ordering-service link components | `frontend/src/components/MakePlayingCardsLink.tsx`, `NotMPCLink.tsx`, `PringlePrintsLink.tsx` | Thin link-out wrappers around a name+URL constant | upstream, proxies-at-home | CLEAN (constants are product-specific — expected substitution, not entanglement) | — |
-| Sheet pagination helper | `frontend/src/features/display/displayPagination.ts` | Chunks project members into per-sheet slot groups for the print-sheet preview | upstream, proxies-at-home | CLEAN | — |
-| Responsive-tier `matchMedia` hook | `frontend/src/features/display/useViewportTier.ts` | Resolves the current viewport into one of four named Bootstrap-breakpoint tiers via `matchMedia` listeners, for driving a component's placement/behavior beyond what CSS alone (or react-bootstrap `Offcanvas`'s own single `responsive` breakpoint) can express | upstream, proxies-at-home | CLEAN | — |
-| Linked/unlinked axis-pair control | `frontend/src/features/display/CardSpacingControl.tsx` | Horizontal/Vertical numeric inputs with a link/unlink toggle (linked ⇒ one value drives both axes); plain props in/callbacks out, no redux/slice coupling in the component itself | upstream, proxies-at-home | CLEAN | Behavior emulated from an owner-provided screenshot of an AGPL tool — patterns only, no source consulted (see proposal-h-display-layout-spec.md's D19 provenance note) |
-| Named-preset margin-profile control | `frontend/src/features/display/MarginProfileControl.tsx` | `Form.Select` of named margin presets + a soft (never hard-clamped) cap warning derived from the current bleed value; plain props in/callback out, no redux/slice coupling in the component itself | upstream, proxies-at-home | CLEAN | — |
-| 4-column bleed-cap math | `frontend/src/features/display/marginProfiles.ts` | `maxBleedForFourColumns` — the largest bleed edge a fixed column count can carry given page width/margins/card width/spacing, mirroring `layout.ts`'s own `fitCardsInDimension` boundary inverted for a fixed count; pure data + math, no redux/vote coupling | upstream, proxies-at-home | CLEAN | — |
+| Primitive | File(s) | Problem solved | Candidate consumers | Entanglement | License note |
+| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| PDF render core | `frontend/src/features/pdf/useRenderPDF.ts`, `pdfRenderService.ts`, `pdf.worker.ts`, `PDF.tsx` | Off-main-thread PDF generation via a comlink-wrapped Web Worker + `@react-pdf/renderer` document tree | upstream, proxies-at-home | CLEAN | — |
+| Eager-WASM lazy-mount fix | `frontend/src/features/pdf/PDFGeneratorModal.tsx`, `frontend/src/features/export/FinishedMyProject.tsx`, `frontend/src/components/ProjectEditor.tsx` | `next/dynamic({ssr:false})` + `mountOnEnter` on the PDF tab so `@react-pdf/renderer`'s WASM doesn't eagerly load (and phantom-download) before the PDF tab is opened | upstream, proxies-at-home | CLEAN (pattern-level — the call sites live in larger entangled files, so this is a pattern to replicate, not a file to lift) | — |
+| Page-layout math | `frontend/src/features/pdf/layout.ts` | Pure page/card/bleed/margin/spacing geometry, page-absolute slot rects out; zero imports | upstream, proxies-at-home | CLEAN | — |
+| PDF canvas preview | `frontend/src/features/pdf/PDFCanvasPreview.tsx` | pdf.js canvas-based render preview | upstream, proxies-at-home | CLEAN | — |
+| Generic concurrency helpers | `frontend/src/common/semaphore.ts`, `frontend/src/common/concurrencyLimit.ts` | Bounded-concurrency gate (`Semaphore`) and a `mapWithConcurrencyLimit` helper | upstream, proxies-at-home | CLEAN | — |
+| Paced/retrying image fetch | `frontend/src/features/pdf/pdfImage.ts` | Semaphore-gated, exponential-backoff full-resolution image fetch for PDF export | upstream, proxies-at-home (with their own CDN) | entangled-with-image-cdn-infra | — |
+| Bleed-prior vote resolution | `frontend/src/features/pdf/bleedPriorResolution.ts` | Derives a per-card bleed lean from the weighted-vote consensus system | — (fork-only by design) | entangled-with-vote-consensus | — |
+| Ordering-service flag icons | `frontend/src/components/flags.tsx` | Small flag-icon wrappers for print-service country badges | upstream, proxies-at-home | CLEAN | SVGs vendored from `lipis/flag-icons` (MIT) |
+| Ordering-service link components | `frontend/src/components/MakePlayingCardsLink.tsx`, `NotMPCLink.tsx`, `PringlePrintsLink.tsx` | Thin link-out wrappers around a name+URL constant | upstream, proxies-at-home | CLEAN (constants are product-specific — expected substitution, not entanglement) | — |
+| Sheet pagination helper | `frontend/src/features/display/displayPagination.ts` | Chunks project members into per-sheet slot groups for the print-sheet preview | upstream, proxies-at-home | CLEAN | — |
+| Responsive-tier `matchMedia` hook | `frontend/src/features/display/useViewportTier.ts` | Resolves the current viewport into one of four named Bootstrap-breakpoint tiers via `matchMedia` listeners, for driving a component's placement/behavior beyond what CSS alone (or react-bootstrap `Offcanvas`'s own single `responsive` breakpoint) can express | upstream, proxies-at-home | CLEAN | — |
+| Linked/unlinked axis-pair control | `frontend/src/features/display/CardSpacingControl.tsx` | Horizontal/Vertical numeric inputs with a link/unlink toggle (linked ⇒ one value drives both axes); plain props in/callbacks out, no redux/slice coupling in the component itself | upstream, proxies-at-home | CLEAN | Behavior emulated from an owner-provided screenshot of an AGPL tool — patterns only, no source consulted (see proposal-h-display-layout-spec.md's D19 provenance note) |
+| Named-preset margin-profile control | `frontend/src/features/display/MarginProfileControl.tsx` | `Form.Select` of named margin presets + a soft (never hard-clamped) cap warning derived from the current bleed value; plain props in/callback out, no redux/slice coupling in the component itself | upstream, proxies-at-home | CLEAN | — |
+| 4-column bleed-cap math | `frontend/src/features/display/marginProfiles.ts` | `maxBleedForFourColumns` — the largest bleed edge a fixed column count can carry given page width/margins/card width/spacing, mirroring `layout.ts`'s own `fitCardsInDimension` boundary inverted for a fixed count; pure data + math, no redux/vote coupling | upstream, proxies-at-home | CLEAN | — |
+| Local-only project draft auto-backup | `frontend/src/features/display/useProjectDraftBackup.ts` | Debounced `localStorage` mirror of the working project (indexes/settings only, never image pixels) with a restore-on-return nudge; reuses `deckPayload.ts`'s plaintext `buildDeckPayload`/`projectFromDeckPayload` (not its encryption path) — no crypto session, no server round-trip, no auth query anywhere in the file | upstream, proxies-at-home | CLEAN | — |
## Backend
diff --git a/frontend/src/features/display/DisplayPage.tsx b/frontend/src/features/display/DisplayPage.tsx
index e23a45948..4b31bc50d 100644
--- a/frontend/src/features/display/DisplayPage.tsx
+++ b/frontend/src/features/display/DisplayPage.tsx
@@ -48,10 +48,13 @@
*
* Issue #166 (post-export contribution prompt) - after a genuinely successful "Generate PDF" or
* "Save PDF to Google Drive", a dismissible prompt points the user at the existing "What's That
- * Card?" vote-queue funnel (docs/features/printing-tags.md). See
- * features/export/postExportContributionPrompt.ts + usePostExportContributionPrompt.ts for the
- * success-detection and show-once-per-session logic - the same hook/component pair is also
- * mounted from PDFGenerator.tsx itself, so the classic "Print!" tab gets it too.
+ * Card?" vote-queue funnel (docs/features/printing-tags.md), via
+ * features/export/postExportContributionPrompt.ts + usePostExportContributionPrompt.ts's
+ * success-detection and show-once-per-session logic. Originally mounted from BOTH this page's
+ * own inline export (item 2, below) and PDFGenerator.tsx itself; issue #275 removed this page's
+ * inline export entirely (see that issue's own module comment further down), so PDFGenerator.tsx
+ * - now the sole place PDF generation happens, reached via the Print page (D10, pages/print.tsx)
+ * - is this feature's only remaining mount.
*
* Issue #238 (deck-input landing, design doc §4.1) - the `isProjectEmpty` early return used to
* render only a plain "head to /editor" link, meaning this page could never start a project
@@ -77,9 +80,8 @@
* three toolbar-parity findings from the same audit. DisplayExportMenu.tsx composes the same
* unchanged ExportXML/ExportImages/ExportDecklist Dropdown.Items Export.tsx already mounts on the
* classic editor's own "Download" dropdown - same hooks, same gating selectors. ExportPDF.tsx's
- * own item is deliberately excluded, since this page's Generate PDF button already reuses
- * useDownloadPDF directly rather than opening the classic PDFGenerator modal that item dispatches
- * to (see the inline-export region comment below).
+ * own item is deliberately excluded, since PDF generation itself lives on the Print page (D10,
+ * pages/print.tsx) now, not this one - see issue #275's own module comment further down.
*
* Issue #266 (mobile responsive shell - docs/proposals' /display layout spec, owner-approved
* 2026-07-21, §2/§4/§6 rows R1/R2/R4/R5/R6) replaced the single always-rendered `RailWrapper`
@@ -158,6 +160,37 @@
* empty shell) for an anonymous session or one with zero saved rows ever created; see
* SavedDecksLandingPanel.tsx's own module comment for the full two-tier visibility rationale,
* including why the locked-session unlock prompt is deliberately NOT suppressed here.
+ *
+ * Issue #275 (proposal-h-display-layout-spec.md ADDENDUM D9/D10) replaces the old "Prepare Print
+ * footer" three-button stack (Export ▾/Save PDF to Google Drive/Generate PDF) with the new
+ * FinishFooter.tsx: `Save Deck` and `Print / Export →` as CO-EQUAL `btn-primary` buttons, plus
+ * the same unchanged `Export ▾` below them. Per D9's own hard owner constraint ("save deck
+ * should come before PDF completes because we have to rely on clients available mem for the
+ * PDF"), the memory-heavy Generate PDF / Save PDF to Google Drive operations - and this page's
+ * entire item-2 inline export pipeline that drove them (useDownloadPDF/useSaveToDrivePDF/
+ * ImageFailureConfirmModal/the fetch-progress bar/the post-export contribution prompt) - are
+ * REMOVED from this page outright, not merely hidden: the Print page (D10, `pages/print.tsx`)
+ * now owns PDF generation exclusively, via its own unchanged `FinishedMyProject`/`PDFGenerator`,
+ * which already mounts `PostExportContributionPrompt` itself (so the /whatsthat funnel still
+ * fires there, for free - no regression). `useProjectDraftBackup.ts` (F1) mirrors the working
+ * project to `localStorage` (indexes/settings only, governing premise "we index, we do not
+ * store images") on a debounce, offers a restore nudge on `DeckInputLanding` when a prior
+ * session's draft outlives an emptied project, and fires D9(2)'s promotion nudge post-import;
+ * `PrePrintSaveGate.tsx` (F3) runs the D9(3) flush-then-optionally-prompt-then-navigate sequence
+ * the Finish footer's "Print / Export →" button triggers, landing on the new `pages/print.tsx`
+ * (D10/F5) - a thin wrapper mirroring `pages/myDecks.tsx`'s own `MyDecksPage` pattern,
+ * `FinishedMyProject.tsx` itself UNCHANGED. Deliberately NOT built here (D10's own owner
+ * addendum, tracked as its own follow-up): the Print page's tab REORDER/new PDF default, and the
+ * PDF tab's own preview removal - see `pages/print.tsx`'s own module comment.
+ *
+ * Known, deliberately-out-of-scope gap this leaves (documented, not silently accepted): this
+ * page's own Page Setup controls (paper size/bleed edge/guides - plain `DisplaySheetSettings`
+ * component state, never persisted) don't carry over to the Print page's classic `PDFGenerator`,
+ * which has always had its own separate settings and doesn't read this page's margin-profile/
+ * card-spacing redux slices either. A user who configures those here and then prints lands on a
+ * PDFGenerator with its own unrelated defaults - a genuine settings-parity gap, out of scope for
+ * this issue (D9/D10 resolve the SAVE-vs-PRINT ordering and the route linkage, not settings
+ * portability), left for a future issue.
*/
import styled from "@emotion/styled";
import React, {
@@ -172,7 +205,6 @@ import Button from "react-bootstrap/Button";
import Col from "react-bootstrap/Col";
import Form from "react-bootstrap/Form";
import Offcanvas, { OffcanvasPlacement } from "react-bootstrap/Offcanvas";
-import ProgressBar from "react-bootstrap/ProgressBar";
import Row from "react-bootstrap/Row";
import ToggleButton from "react-bootstrap/ToggleButton";
import ToggleButtonGroup from "react-bootstrap/ToggleButtonGroup";
@@ -189,30 +221,29 @@ import {
import { AutofillCollapse } from "@/components/AutofillCollapse";
import { RightPaddedIcon } from "@/components/icon";
import { RenderIfVisible } from "@/components/RenderIfVisible";
-import { Spinner } from "@/components/Spinner";
import { CardbackToolbarButton } from "@/features/card/CommonCardback";
import { DeckbuilderConfirmAffordance } from "@/features/card/DeckbuilderConfirmAffordance";
import { RequestedPrintingBadge } from "@/features/card/RequestedPrintingBadge";
-import { useClientSearchContext } from "@/features/clientSearch/clientSearchContext";
import { ArtistSection } from "@/features/display/ArtistSection";
import { AttributesSection } from "@/features/display/AttributesSection";
import { CardSpacingControl } from "@/features/display/CardSpacingControl";
import { CatalogBrowseResults } from "@/features/display/CatalogBrowseResults";
import { paginateSlotsForDisplay } from "@/features/display/displayPagination";
+import { FinishFooter } from "@/features/display/FinishFooter";
import { MarginProfileControl } from "@/features/display/MarginProfileControl";
import { MARGIN_PROFILES } from "@/features/display/marginProfiles";
+import { usePrePrintSaveGate } from "@/features/display/PrePrintSaveGate";
import { PrintOptionsSection } from "@/features/display/PrintOptionsSection";
import {
SavedDecksLandingPanel,
useHasSavedDecksForLanding,
} from "@/features/display/SavedDecksLandingPanel";
import { SlotActionsSection } from "@/features/display/SlotActionsSection";
+import {
+ ProjectDraftSummary,
+ useProjectDraftBackup,
+} from "@/features/display/useProjectDraftBackup";
import { useViewportTier } from "@/features/display/useViewportTier";
-import { DisplayExportMenu } from "@/features/export/DisplayExportMenu";
-import { PostExportContributionPrompt } from "@/features/export/PostExportContributionPrompt";
-import { wasLatestCardsPdfDownloadSuccessful } from "@/features/export/postExportContributionPrompt";
-import { usePostExportContributionPrompt } from "@/features/export/usePostExportContributionPrompt";
-import { isGoogleDriveAppConfigured } from "@/features/googleDrive/googleDriveConfig";
import { SelectVersionResults } from "@/features/gridSelector/SelectVersionResults";
import { useGridSelectorSearch } from "@/features/gridSelector/useGridSelectorSearch";
import { Import } from "@/features/import/Import";
@@ -227,13 +258,7 @@ import {
PagePreview,
PagePreviewSlotContent,
} from "@/features/pdf/PagePreview";
-import { getPageSizeMM, PageSize, PDFProps } from "@/features/pdf/PDF";
-import {
- ImageFailureConfirmModal,
- useDownloadPDF,
- useSaveToDrivePDF,
-} from "@/features/pdf/PDFGenerator";
-import { ImageFetchFailure } from "@/features/pdf/pdfImage";
+import { getPageSizeMM, PageSize } from "@/features/pdf/PDF";
import { SavedDeckPanel } from "@/features/savedDecks/SavedDeckPanel";
import { SearchSettings } from "@/features/searchSettings/SearchSettings";
import { selectRemoteBackendURL } from "@/store/slices/backendSlice";
@@ -249,7 +274,6 @@ import {
} from "@/store/slices/marginProfileSlice";
import {
selectIsProjectEmpty,
- selectManualOverrides,
selectProjectCardback,
selectProjectMember,
selectProjectMembers,
@@ -572,10 +596,53 @@ const ImportColumns = () => (
>
);
-const DeckInputLanding = () => {
+interface DeckInputLandingProps {
+ // Issue #275 (design doc ADDENDUM D9(1)/F1) - useProjectDraftBackup's own restore nudge:
+ // non-null only when a prior session's draft outlives this one's now-empty project. Passed in
+ // rather than a second hook instance here, since DisplayPage already owns the one instance
+ // actually driving the debounced writes (see this file's own module comment).
+ restorableDraft: ProjectDraftSummary | null;
+ onRestoreDraft: () => void;
+ onDismissDraft: () => void;
+}
+
+const DeckInputLanding = ({
+ restorableDraft,
+ onRestoreDraft,
+ onDismissDraft,
+}: DeckInputLandingProps) => {
const hasSavedDecks = useHasSavedDecksForLanding();
return (
+ {restorableDraft != null && (
+
+
+ Restore your unsaved work? A local backup of{" "}
+ {restorableDraft.memberCount} card
+ {restorableDraft.memberCount !== 1 ? "s" : ""} from this browser is
+ still here.
+
+
+
+
+ )}
{hasSavedDecks ? (
@@ -873,6 +940,17 @@ export function DisplayPage() {
const frontsVisible = useAppSelector(selectFrontsVisible);
const cardDocumentsByIdentifier = useCardDocumentsByIdentifier();
+ // Issue #275 (design doc ADDENDUM D9) - the silent local draft auto-backup (F1) and the
+ // pre-print save gate (F3). ONE instance of each, here - both FinishFooter and
+ // DeckInputLanding below are handed the relevant pieces as props rather than each mounting
+ // their own hook instance (see useProjectDraftBackup.ts's own module comment on why a second
+ // instance would duplicate the debounced-write effect).
+ const draftBackup = useProjectDraftBackup();
+ const prePrintSaveGate = usePrePrintSaveGate({
+ flushDraftNow: draftBackup.flushDraftNow,
+ notifyPromoteDraftPrePrint: draftBackup.notifyPromoteDraftPrePrint,
+ });
+
const [settings, setSettings] = useState(
DEFAULT_SHEET_SETTINGS
);
@@ -999,142 +1077,10 @@ export function DisplayPage() {
// the on-screen sheet and the exported PDF in lockstep with no extra plumbing.
const spacing = useAppSelector(selectCardSpacing);
- //# region inline export (item 2, owner's hands-on review) - the real export pipeline, run
- // in-page rather than navigating to the classic PDF tab. Reuses PDFGenerator.tsx's own
- // useDownloadPDF/useSaveToDrivePDF/ImageFailureConfirmModal verbatim (exported for this, not
- // forked) - same #81 paced-fetcher/retry machinery, same in-app failure-confirm modal, same
- // Google Drive upload path. Only this page's own settings feed it (paper size, bleed edge,
- // guides, the sheet's current fronts/backs view) - every other PDFProps field neither exposed
- // here nor meaningful for this page's default "export what you see" use case (card selection
- // mode, cut-line geometry, quality/DPI, spacing/margins, SCM mode) takes PDFGenerator's own
- // documented default, matching its classic-tab behavior exactly for anyone who hasn't touched
- // those settings there either.
-
- const { clientSearchService } = useClientSearchContext();
+ // Still needed below (ChooseImageSection's own backendURL prop) - issue #275 removed every
+ // OTHER consumer this used to have (the inline export pipeline, see this file's own module
+ // comment for where that pipeline moved).
const backendURL = useAppSelector(selectRemoteBackendURL);
- const manualOverrides = useAppSelector(selectManualOverrides);
-
- const [isDownloading, setIsDownloading] = useState(false);
- const [isSavingToDrive, setIsSavingToDrive] = useState(false);
- const [imageFetchProgress, setImageFetchProgress] = useState<{
- completed: number;
- total: number;
- } | null>(null);
- // Determinate while images are still being fetched (a real count, "N of ~M"); indeterminate
- // once every image has resolved but @react-pdf/renderer is still assembling the file itself -
- // pdfRenderService/pdf.worker.ts only report per-image progress, so "assembling" is inferred
- // the moment completed reaches total, not a separate signal the worker sends.
- const [exportPhase, setExportPhase] = useState<
- "fetching" | "assembling" | null
- >(null);
- const [pendingFailureConfirm, setPendingFailureConfirm] = useState<{
- failures: Array;
- resolve: (value: boolean) => void;
- } | null>(null);
- const confirmDespiteFailures = (
- failures: Array
- ): Promise =>
- new Promise((resolve) => setPendingFailureConfirm({ failures, resolve }));
-
- const setExportProgress = (
- progress: { completed: number; total: number } | null
- ) => {
- setImageFetchProgress(progress);
- if (progress == null) {
- setExportPhase(null);
- } else {
- setExportPhase(
- progress.total > 0 && progress.completed >= progress.total
- ? "assembling"
- : "fetching"
- );
- }
- };
-
- // CUSTOM + explicit width/height, not the named pageSize alone - PDF.tsx's getPageSizeMM only
- // honours pageWidth/pageHeight when pageSize is "CUSTOM" (otherwise it returns that name's own
- // portrait dimensions), so this is what makes the exported file match this page's own
- // landscape sheet (sheetWidthMM/sheetHeightMM, computed just below) rather than silently
- // reverting to portrait.
- const exportPdfProps: Omit = {
- cardSelectionMode: "frontsAndDistinctBacks",
- pageSize: "CUSTOM",
- pageWidth: sheetWidthMM,
- pageHeight: sheetHeightMM,
- bleedEdgeMM: settings.bleedEdgeMM,
- roundCorners: false,
- drawCardCutLines: settings.showCutLines,
- drawPageCutLines: true,
- cutLineLengthMM: 2,
- cutLineOffsetMM: 0,
- cutLineThicknessMM: 0.2,
- cutLineColor: "#FF0000",
- cutLinePlacement: "Inside",
- cutLineShape: "InsideOnly",
- cardSpacingRowMM: spacing.row,
- cardSpacingColMM: spacing.col,
- pageMarginTopMM: margins.top,
- pageMarginBottomMM: margins.bottom,
- pageMarginLeftMM: margins.left,
- pageMarginRightMM: margins.right,
- cardDocumentsByIdentifier: cardDocumentsByIdentifier,
- projectMembers: projectMembers,
- projectCardback: projectCardback,
- bleedOverrides: manualOverrides,
- scmMode: false,
- scmPaperSize: "letter",
- scmVariant: "default",
- scmRegistration: 3,
- scmDuplex: true,
- scmOffsetXMM: 0,
- scmOffsetYMM: 0,
- scmOffsetAngleDeg: 0,
- imageQuality: "full-resolution",
- imageDPI: 600,
- jpgQuality: 100,
- };
-
- const generatePdf = useDownloadPDF(
- exportPdfProps,
- clientSearchService,
- dispatch,
- setIsDownloading,
- backendURL,
- setExportProgress,
- confirmDespiteFailures
- );
- const saveToDrive = useSaveToDrivePDF(
- exportPdfProps,
- clientSearchService,
- dispatch,
- setIsSavingToDrive,
- backendURL,
- setExportProgress,
- confirmDespiteFailures
- );
-
- // Issue #166 - post-export contribution prompt. useDownloadPDF's returned promise resolves
- // void (its own useDoFileDownload wrapper swallows the inner success boolean - see
- // postExportContributionPrompt.ts's own comment), so success is read back out of the same
- // fileDownloads redux slice the download manager UI already populates, once this exact click's
- // download has finished. useSaveToDrivePDF has no such wrapper - .finally() passes its
- // .then()'s resolved boolean straight through, so awaiting saveToDrive() directly already
- // gives the real success/cancelled value.
- const contributionPrompt = usePostExportContributionPrompt();
- const onGeneratePdfClick = async () => {
- await generatePdf();
- if (wasLatestCardsPdfDownloadSuccessful()) {
- contributionPrompt.notifyExportSucceeded();
- }
- };
- const onSaveToDriveClick = async () => {
- const succeeded = await saveToDrive();
- if (succeeded === true) {
- contributionPrompt.notifyExportSucceeded();
- }
- };
-
- //# endregion
const layout = useMemo(
// Mirrors PagePreview's own computeLayout() call so cardsPerPage here matches exactly
@@ -1268,36 +1214,15 @@ export function DisplayPage() {
);
if (isProjectEmpty) {
- return ;
+ return (
+
+ );
}
- // Issue #266 (design doc §2/§4/§6 R4) - the export fetch-progress bar, extracted so both its
- // old top-of-page placement's markup and its new right-rail-footer placement below render the
- // exact same element rather than two hand-copied blocks that could drift.
- const exportProgressBar = exportPhase != null && (
-
{/* Issue #266 (design doc §3/§6 R4) - identity + the gear that opens the right rail; the
@@ -1408,22 +1333,6 @@ export function DisplayPage() {
- {/* Issue #166 - shown once per session, immediately after this button's first genuine
- export success (see usePostExportContributionPrompt.ts) - never blocks the export
- result above it, dismissible, and never re-fires again this session per its own
- show-once logic. */}
- {contributionPrompt.visible && (
-
-
-
- )}
-
{/* Issue #266 (design doc §4.1) - ONE node, all widths: inline sticky 380px column at
`lg`+, `placement="start"` drawer on tablet, `placement="bottom"` 72vh sheet on phone
@@ -1753,36 +1662,15 @@ export function DisplayPage() {
{/* Design doc §4.2's "Prepare Print footer - pinned, always visible at the rail's
- bottom (flex column: body scrolls, footer doesn't)". */}
-
- {exportProgressBar}
- {/* Issue #241 (design doc §5's export-beyond-PDF row) - XML/Card Images/Decklist,
- relocated unmodified from the classic editor's own "Download" dropdown. */}
-
- {isGoogleDriveAppConfigured() && (
-
- )}
-
+ bottom (flex column: body scrolls, footer doesn't)". Issue #275 (ADDENDUM D9/F2)
+ replaces the old three-button stack with FinishFooter's own co-equal Save
+ Deck/Print - Export pair + the unchanged Export dropdown - see this file's own
+ module comment for the full rationale. */}
+
);
}
diff --git a/frontend/src/features/display/FinishFooter.tsx b/frontend/src/features/display/FinishFooter.tsx
new file mode 100644
index 000000000..164b87876
--- /dev/null
+++ b/frontend/src/features/display/FinishFooter.tsx
@@ -0,0 +1,116 @@
+/**
+ * Proposal H ADDENDUM D9/F2 (docs/proposals/proposal-h-display-layout-spec.md, issue #275) - the
+ * right-rail pinned Finish footer: `Save Deck` and `Print / Export →` as CO-EQUAL `btn-primary`
+ * buttons of equal width side by side, a secondary `Export ▾` (`DisplayExportMenu.tsx`,
+ * lightweight XML/Card Images/Decklist only) below them, and the compact "✓ Draft backed up
+ * locally" note. Replaces the old three-button "Prepare Print footer" stack (Export ▾/Save PDF
+ * to Google Drive/Generate PDF) - the memory-heavy Generate PDF and Save PDF to Google Drive
+ * operations move OUT of this footer entirely, to the Print page (D10/pages/print.tsx), so this
+ * footer itself can never trigger the OOM D9's own hard constraint warns about ("save deck
+ * should come before PDF completes because we have to rely on clients available mem for the
+ * PDF").
+ *
+ * `Save Deck` reuses useSaveDeckFlow.ts's own passphrase-setup/unlock/save modal chain (the same
+ * one SavedDeckPanel.tsx's toolbar Save button already drives) - this component owns its OWN
+ * hook instance (a second, independent one from the toolbar's), so both buttons work
+ * independently and neither can leave the other's modal state stuck open.
+ *
+ * Anonymous sessions: D9(2)'s "anonymous users' nudge routes through sign-in first" (server save
+ * is authenticated-only by construction) applies here too, not just to the promotion toast - the
+ * button becomes a direct sign-in link (the same backendURL+loginUrl+`?next=` construction
+ * AuthWidget.tsx already uses) labeled "Sign in to Save" rather than a disabled/dead control, so
+ * an anonymous user always has somewhere to go from this footer, never a no-op button.
+ */
+import React, { useEffect, useState } from "react";
+import Button from "react-bootstrap/Button";
+
+import { useAppSelector } from "@/common/types";
+import { DisplayExportMenu } from "@/features/export/DisplayExportMenu";
+import { useSaveDeckFlow } from "@/features/savedDecks/useSaveDeckFlow";
+import { useGetWhoamiQuery } from "@/store/api";
+import { selectRemoteBackendURL } from "@/store/slices/backendSlice";
+
+interface FinishFooterProps {
+ /** useProjectDraftBackup's own `hasBackedUpThisSession` - drives the compact note below the
+ * buttons. Passed in rather than re-instantiated here, since DisplayPage already owns the one
+ * hook instance actually driving the debounced writes. */
+ hasBackedUpThisSession: boolean;
+ /** usePrePrintSaveGate's own `startPrintFlow` - runs the D9(3) persist-before-navigate sequence
+ * before any PDF render begins. */
+ onPrintClick: () => void;
+}
+
+export function FinishFooter({
+ hasBackedUpThisSession,
+ onPrintClick,
+}: FinishFooterProps) {
+ const { element, triggerSave, isAuthenticated, isProjectEmpty } =
+ useSaveDeckFlow();
+ const backendURL = useAppSelector(selectRemoteBackendURL);
+ const whoami = useGetWhoamiQuery();
+
+ // window isn't available during the static export build - resolved client-only, mirroring
+ // AuthWidget.tsx's own identical pattern for the exact same `?next=` round-trip.
+ const [currentHref, setCurrentHref] = useState(null);
+ useEffect(() => {
+ setCurrentHref(window.location.href);
+ }, []);
+
+ const loginHref =
+ backendURL != null && whoami.data?.loginUrl != null && currentHref != null
+ ? `${backendURL}${whoami.data.loginUrl}?next=${encodeURIComponent(
+ currentHref
+ )}`
+ : undefined;
+
+ return (
+
+
+ {isAuthenticated ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Issue #241 (design doc §5's export-beyond-PDF row) - XML/Card Images/Decklist, unchanged
+ and unforked; the ONLY export surface this footer still owns directly, per D9's own
+ "memory-heavy operations move OUT" line. */}
+
+ {hasBackedUpThisSession && (
+
+
+ Draft backed up locally
+
+ )}
+ {element}
+
+ );
+}
diff --git a/frontend/src/features/display/PrePrintSaveGate.tsx b/frontend/src/features/display/PrePrintSaveGate.tsx
new file mode 100644
index 000000000..d8d550c45
--- /dev/null
+++ b/frontend/src/features/display/PrePrintSaveGate.tsx
@@ -0,0 +1,140 @@
+/**
+ * Proposal H ADDENDUM D9(3)/F3 (docs/proposals/proposal-h-display-layout-spec.md, issue #275) -
+ * the pre-print save gate. Pressing the Finish footer's "Print / Export →" runs a persist step
+ * FIRST, before any navigation (and therefore any PDF render) begins:
+ * (a) flush the local draft synchronously (useProjectDraftBackup's `flushDraftNow`) - never
+ * debounced, so the crash/OOM safety net is guaranteed current the instant before whatever
+ * happens next;
+ * (b) if authenticated AND the project is dirty (savedDeckSessionSlice's own dirty-check,
+ * selectIsCurrentProjectDirty), show a lightweight "Save before printing?" prompt - Save
+ * (opens useSaveDeckFlow.ts's own passphrase-setup/unlock/save chain, the same one the
+ * Finish footer's own Save Deck button and SavedDeckPanel's toolbar Save button use) or
+ * Skip - mirroring LoadSafetyModal.tsx's existing "always take a safety copy before a
+ * destructive step" pattern, here applied to the PDF-render step instead of a deck-load
+ * step;
+ * (c) only after persistence resolves (Save completes, or Skip/no-save-needed) does
+ * client-side navigation to the Print page (D10, pages/print.tsx) begin.
+ *
+ * "Saving gates PDF; PDF never gates saving" (D9's own summary line) - this hook never blocks on
+ * anything PDF-related, only on the save choice itself, and an anonymous or clean (non-dirty)
+ * session skips the prompt entirely and navigates immediately - the gate only ever appears when
+ * there is genuinely something unsaved to decide about.
+ *
+ * Dismissing the prompt (close button/Escape/backdrop) is treated as cancelling the WHOLE print
+ * 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).
+ */
+import { useRouter } from "next/router";
+import React, { useState } from "react";
+import Button from "react-bootstrap/Button";
+import Modal from "react-bootstrap/Modal";
+
+import { useAppSelector } from "@/common/types";
+import { selectIsCurrentProjectDirty } from "@/features/savedDecks/selectors";
+import { useSaveDeckFlow } from "@/features/savedDecks/useSaveDeckFlow";
+import { useGetWhoamiQuery } from "@/store/api";
+
+/** The Print page's own route (D10/F5 - pages/print.tsx, a thin wrapper mounting
+ * FinishedMyProject/PrintPanel). */
+const PRINT_PAGE_ROUTE = "/print";
+
+export interface UsePrePrintSaveGateOptions {
+ /** useProjectDraftBackup's own `flushDraftNow` - D9(3)a, always run first. */
+ flushDraftNow: () => void;
+ /** useProjectDraftBackup's own `notifyPromoteDraftPrePrint` - D9(2)'s promotion nudge,
+ * pre-print half, fired alongside the flush. */
+ notifyPromoteDraftPrePrint: () => void;
+}
+
+export interface UsePrePrintSaveGateResult {
+ /** Render this once - the "Save before printing?" prompt plus whatever useSaveDeckFlow.ts's
+ * own modal chain needs, all in one place. */
+ element: React.ReactElement;
+ /** The Finish footer's "Print / Export →" `onClick` - runs the full D9(3) sequence. */
+ startPrintFlow: () => void;
+}
+
+export function usePrePrintSaveGate({
+ flushDraftNow,
+ notifyPromoteDraftPrePrint,
+}: UsePrePrintSaveGateOptions): UsePrePrintSaveGateResult {
+ const router = useRouter();
+ const whoami = useGetWhoamiQuery();
+ const isAuthenticated = whoami.data?.authenticated === true;
+ const isProjectDirty = useAppSelector(selectIsCurrentProjectDirty);
+ const saveFlow = useSaveDeckFlow();
+
+ const [showPrompt, setShowPrompt] = useState(false);
+
+ const proceedToPrint = () => {
+ 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();
+
+ if (isAuthenticated && isProjectDirty) {
+ setShowPrompt(true);
+ } else {
+ // Nothing dirty to offer saving (or no account to save to at all) - navigate immediately.
+ // "PDF never gates saving" cuts both ways: saving never gates a print attempt that has
+ // nothing new to save either.
+ proceedToPrint();
+ }
+ };
+
+ const handleSave = () => {
+ setShowPrompt(false);
+ saveFlow.triggerSave(proceedToPrint);
+ };
+
+ const handleSkip = () => {
+ setShowPrompt(false);
+ proceedToPrint();
+ };
+
+ const element = (
+ <>
+ setShowPrompt(false)}
+ data-testid="pre-print-save-gate-modal"
+ >
+
+ Save before printing?
+
+
+
+ You have unsaved changes. Printing can use a lot of memory, so
+ it's safest to save your deck first - your local draft is
+ already backed up, but a real saved deck can be reached from any
+ device.
+
+
+
+
+
+
+
+ {saveFlow.element}
+ >
+ );
+
+ return { element, startPrintFlow };
+}
diff --git a/frontend/src/features/display/useProjectDraftBackup.test.tsx b/frontend/src/features/display/useProjectDraftBackup.test.tsx
new file mode 100644
index 000000000..cf1c62402
--- /dev/null
+++ b/frontend/src/features/display/useProjectDraftBackup.test.tsx
@@ -0,0 +1,210 @@
+/**
+ * Proposal H ADDENDUM D9(1)/F1 - useProjectDraftBackup.ts's own test suite. Exercises the hook
+ * through a real component tree (Harness) against a real redux store (setupStore), mirroring
+ * SavedDeckPanel.test.tsx/useConsentToast.test.tsx's own precedent for testing a stateful hook
+ * this way rather than a bare renderHook call - restoreDraft/dismissRestoreDraft both need to be
+ * driven by real clicks against the hook's own returned callbacks.
+ */
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import React from "react";
+import { Provider } from "react-redux";
+
+import { localBackend, projectSelectedImage1 } from "@/common/test-constants";
+import {
+ clearStoredProjectDraftForTests,
+ useProjectDraftBackup,
+} from "@/features/display/useProjectDraftBackup";
+import { loadProject } from "@/store/slices/projectSlice";
+import { selectToastsNotifications } from "@/store/slices/toastsSlice";
+import { setupStore } from "@/store/store";
+
+function Harness() {
+ const backup = useProjectDraftBackup();
+ return (
+
+ );
+}
+
+function renderHarness(preloadedState: Parameters[0]) {
+ const store = setupStore(preloadedState);
+ render(
+
+
+
+ );
+ return store;
+}
+
+const DRAFT_STORAGE_KEY = "mpc-autofill-project-draft";
+
+afterEach(() => {
+ clearStoredProjectDraftForTests();
+});
+
+test("does not write a draft while the project is empty", () => {
+ renderHarness({ backend: localBackend });
+
+ fireEvent.click(screen.getByTestId("flush"));
+
+ expect(window.localStorage.getItem(DRAFT_STORAGE_KEY)).toBeNull();
+ expect(screen.getByTestId("has-backed-up")).toHaveTextContent("false");
+});
+
+test("flushDraftNow writes indexes/settings only, never image pixels, and flips hasBackedUpThisSession", () => {
+ renderHarness({ backend: localBackend, project: projectSelectedImage1 });
+
+ fireEvent.click(screen.getByTestId("flush"));
+
+ expect(screen.getByTestId("has-backed-up")).toHaveTextContent("true");
+ const raw = window.localStorage.getItem(DRAFT_STORAGE_KEY);
+ expect(raw).not.toBeNull();
+ const stored = JSON.parse(raw as string);
+ expect(stored.draftVersion).toBe(1);
+ expect(stored.payload.members).toHaveLength(1);
+ expect(stored.payload.members[0].front.query.query).toBe("my search query");
+ expect(stored.payload.members[0].front.selectedImage).toBe(
+ projectSelectedImage1.members[0].front?.selectedImage
+ );
+ // Governing premise (CLAUDE.md "we index, we do not store images") - the serialized draft
+ // never contains anything resembling image byte data, only the identifier/query index.
+ expect(raw).not.toMatch(/data:image/);
+});
+
+test("the debounced auto-write eventually persists without an explicit flush", async () => {
+ renderHarness({ backend: localBackend, project: projectSelectedImage1 });
+
+ expect(window.localStorage.getItem(DRAFT_STORAGE_KEY)).toBeNull();
+
+ await waitFor(
+ () => expect(window.localStorage.getItem(DRAFT_STORAGE_KEY)).not.toBeNull(),
+ { timeout: 2_000 }
+ );
+});
+
+test("a restorable draft from a prior session surfaces once the project is empty again", async () => {
+ // First "session": a populated project backs itself up.
+ renderHarness({ backend: localBackend, project: projectSelectedImage1 });
+ fireEvent.click(screen.getByTestId("flush"));
+ expect(window.localStorage.getItem(DRAFT_STORAGE_KEY)).not.toBeNull();
+
+ // Second "session": a fresh mount against an EMPTY project (localStorage persists across
+ // mounts in a real browser, and jsdom's own localStorage is shared across renders in this
+ // same test file the same way).
+ renderHarness({ backend: localBackend });
+
+ await waitFor(() =>
+ expect(screen.getAllByTestId("restorable").at(-1)).not.toHaveTextContent(
+ "none"
+ )
+ );
+ expect(screen.getAllByTestId("restorable").at(-1)).toHaveTextContent(
+ '"memberCount":1'
+ );
+});
+
+test("restoreDraft rehydrates the project from the stored draft", async () => {
+ renderHarness({ backend: localBackend, project: projectSelectedImage1 });
+ fireEvent.click(screen.getByTestId("flush"));
+
+ const store = renderHarness({ backend: localBackend });
+ await waitFor(() =>
+ expect(screen.getAllByTestId("restorable").at(-1)).not.toHaveTextContent(
+ "none"
+ )
+ );
+
+ fireEvent.click(screen.getAllByTestId("restore").at(-1)!);
+
+ expect(store.getState().project.members).toHaveLength(1);
+ expect(store.getState().project.members[0].front?.selectedImage).toBe(
+ projectSelectedImage1.members[0].front?.selectedImage
+ );
+ await waitFor(() =>
+ expect(screen.getAllByTestId("restorable").at(-1)).toHaveTextContent("none")
+ );
+});
+
+test("dismissRestoreDraft hides the banner without deleting the underlying draft", async () => {
+ renderHarness({ backend: localBackend, project: projectSelectedImage1 });
+ fireEvent.click(screen.getByTestId("flush"));
+
+ renderHarness({ backend: localBackend });
+ await waitFor(() =>
+ expect(screen.getAllByTestId("restorable").at(-1)).not.toHaveTextContent(
+ "none"
+ )
+ );
+
+ fireEvent.click(screen.getAllByTestId("dismiss").at(-1)!);
+
+ await waitFor(() =>
+ expect(screen.getAllByTestId("restorable").at(-1)).toHaveTextContent("none")
+ );
+ // The safety net itself is untouched - only this session's banner was hidden.
+ expect(window.localStorage.getItem(DRAFT_STORAGE_KEY)).not.toBeNull();
+});
+
+test("the post-import promotion nudge fires once, the moment the project flips from empty to populated", async () => {
+ const store = renderHarness({ backend: localBackend });
+
+ expect(
+ Object.values(selectToastsNotifications(store.getState()))
+ ).toHaveLength(0);
+
+ act(() => {
+ store.dispatch(loadProject(projectSelectedImage1));
+ });
+
+ await waitFor(() => {
+ const notifications = selectToastsNotifications(store.getState());
+ expect(
+ Object.values(notifications).some((n) => n.name === "Backed up locally")
+ ).toBe(true);
+ });
+});
+
+test("notifyPromoteDraftPrePrint dispatches the same promotion toast on demand", async () => {
+ const store = renderHarness({
+ backend: localBackend,
+ project: projectSelectedImage1,
+ });
+
+ fireEvent.click(screen.getByTestId("notify-pre-print"));
+
+ await waitFor(() => {
+ const notifications = selectToastsNotifications(store.getState());
+ expect(
+ Object.values(notifications).some((n) => n.name === "Backed up locally")
+ ).toBe(true);
+ });
+});
diff --git a/frontend/src/features/display/useProjectDraftBackup.ts b/frontend/src/features/display/useProjectDraftBackup.ts
new file mode 100644
index 000000000..6cd542bac
--- /dev/null
+++ b/frontend/src/features/display/useProjectDraftBackup.ts
@@ -0,0 +1,306 @@
+/**
+ * Proposal H ADDENDUM D9(1)/F1 (docs/proposals/proposal-h-display-layout-spec.md) - the silent
+ * local draft auto-backup, the first of D9's three save-before-PDF layers. HARD OWNER CONSTRAINT
+ * (verbatim, D9): "save deck should come before PDF completes because we have to rely on clients
+ * available mem for the PDF" - PDF generation is the client's most memory-hungry step and can
+ * OOM/crash the tab, so the working project is mirrored to `localStorage` on every mutation,
+ * independent of (and strictly before) any PDF render.
+ *
+ * GOVERNING PREMISE (CLAUDE.md "we index, we do not store images"): this mirrors `buildDeckPayload`'s
+ * plaintext CONTENT shape - decklist identifiers, per-slot queries/overrides, and finish/page
+ * settings - never image pixels. Exactly the same invariant deckPayload.ts's own encrypted saved
+ * decks already honour, applied to this browser's own disk as strictly as to the network.
+ *
+ * Deliberately `localStorage`, not a saved-deck row: this is the ANONYMOUS-safe, account-free
+ * safety net (no crypto session, no server round-trip) - the "the local draft is the anonymous
+ * user's only persistence, and that is fine" line from D9(2). It is NOT a replacement for a real
+ * saved deck - see the promotion nudge below and PrePrintSaveGate.tsx for the two paths that
+ * invite promoting a draft into one.
+ *
+ * Serialization reuses deckPayload.ts's `buildDeckPayload` plaintext CONTENT shape (no version
+ * tag of its own - this hook stamps its own small `draftVersion` wrapper instead, so a future
+ * shape change can upgrade forward the same way `parseDeckPayload` already does for real saved
+ * decks, without coupling to DECK_PAYLOAD_VERSION itself).
+ *
+ * Two other responsibilities live here, both keyed off the exact same `isProjectEmpty`
+ * true->false/false->true transition this hook already has to watch for the debounced-write
+ * gate, rather than each growing its own separate effect:
+ * - The restore nudge (F1's own second half): when `/display` mounts (or the project is cleared)
+ * with an EMPTY project and a non-empty draft already sitting in `localStorage`, `restorableDraft`
+ * is populated so DisplayPage's `DeckInputLanding` can offer a one-line "Restore your unsaved
+ * work?" affordance - the draft is a genuine crash/OOM safety net, so this never auto-restores
+ * without the user's say-so, and dismissing it only hides the banner for this session, it does
+ * NOT delete the underlying draft.
+ * - `notifyPromoteDraftPrePrint` - D9(2)'s promotion nudge ("draft backed up - name and save it?")
+ * at the PRE-PRINT moment; PrePrintSaveGate.tsx calls this once per print attempt. The POST-IMPORT
+ * half of the same nudge fires automatically, right here, off the empty->populated transition -
+ * the same shape as SavedDeckPanel.tsx's own anonymous-to-login "adopt your project" toast,
+ * reusing the same plain-informational Toasts system (no action button - the message points at
+ * the Finish footer's "Save Deck" button, exactly like that existing precedent).
+ */
+import { useCallback, useEffect, useRef, useState } from "react";
+
+import { useAppDispatch, useAppSelector } from "@/common/types";
+import {
+ buildDeckPayload,
+ DeckPayloadContent,
+ projectFromDeckPayload,
+} from "@/features/savedDecks/deckPayload";
+import {
+ loadFinishSettings,
+ selectFinishSettings,
+} from "@/store/slices/finishSettingsSlice";
+import {
+ loadProject,
+ selectIsProjectEmpty,
+ selectManualOverrides,
+ selectProjectCardback,
+ selectProjectMembers,
+} from "@/store/slices/projectSlice";
+import { setNotification } from "@/store/slices/toastsSlice";
+import { RootState } from "@/store/store";
+
+/** Not `deckPayload.ts`'s own `DECK_PAYLOAD_VERSION` - a separate, small counter for this hook's
+ * own wrapper shape, per this file's own module comment. Bump alongside a new upgrade branch in
+ * `parseStoredDraft` below if `StoredDraft`'s own shape ever changes. */
+const DRAFT_FORMAT_VERSION = 1;
+
+const DRAFT_STORAGE_KEY = "mpc-autofill-project-draft";
+
+/** Debounce window between the last project mutation and the actual `localStorage` write - long
+ * enough that a burst of rapid edits (typing a decklist, dragging several slots) coalesces into
+ * one write, short enough that a crash moments after the last edit still has something recent to
+ * recover. */
+const DEBOUNCE_MS = 800;
+
+interface StoredDraft {
+ draftVersion: typeof DRAFT_FORMAT_VERSION;
+ savedAt: string;
+ payload: DeckPayloadContent;
+}
+
+export interface ProjectDraftSummary {
+ memberCount: number;
+ savedAt: string;
+}
+
+export interface UseProjectDraftBackupResult {
+ /** True once this hook has actually written a draft THIS session - drives the Finish footer's
+ * compact "✓ Draft backed up locally" note (D9's footer copy). Never true for an anonymous
+ * empty project - there is nothing to back up yet. */
+ hasBackedUpThisSession: boolean;
+ /** Non-null only while the project is empty AND a real, non-empty draft is sitting in
+ * `localStorage` from an earlier session - DeckInputLanding's own restore-nudge banner reads
+ * this directly. */
+ restorableDraft: ProjectDraftSummary | null;
+ /** Rehydrates the current project + finish settings from the stored draft. No-op if there is
+ * nothing restorable. */
+ restoreDraft: () => void;
+ /** Hides the restore-nudge banner for the rest of this session WITHOUT deleting the underlying
+ * draft - it stays as a genuine safety net in case the user changes their mind, or a real crash
+ * still needs it. */
+ dismissRestoreDraft: () => void;
+ /** Synchronous, non-debounced write - PrePrintSaveGate's own "flush the draft first" step
+ * (D9(3)a), so the safety net is guaranteed current the instant before any PDF render begins,
+ * rather than racing the debounce window above. */
+ flushDraftNow: () => void;
+ /** D9(2)'s promotion nudge, PRE-PRINT half - PrePrintSaveGate calls this once per print
+ * attempt, right before the persist step it gates on. The POST-IMPORT half fires automatically
+ * from this hook's own empty->populated transition effect, below. */
+ notifyPromoteDraftPrePrint: () => void;
+}
+
+function readStoredDraft(): StoredDraft | null {
+ if (typeof window === "undefined") {
+ return null;
+ }
+ try {
+ const raw = window.localStorage.getItem(DRAFT_STORAGE_KEY);
+ if (raw == null) {
+ return null;
+ }
+ const parsed = JSON.parse(raw);
+ if (
+ parsed?.draftVersion !== DRAFT_FORMAT_VERSION ||
+ !Array.isArray(parsed?.payload?.members) ||
+ parsed.payload.members.length === 0
+ ) {
+ return null;
+ }
+ return parsed as StoredDraft;
+ } catch {
+ // Corrupted/foreign localStorage content - treat exactly like "no draft", this hook's own
+ // best-effort contract (see the module comment: it's a safety net, never a hard dependency).
+ return null;
+ }
+}
+
+const PROMOTE_NUDGE_MESSAGE =
+ "Your project is backed up in this browser only - use Save Deck below to keep it permanently.";
+
+export function useProjectDraftBackup(): UseProjectDraftBackupResult {
+ const dispatch = useAppDispatch();
+ const isProjectEmpty = useAppSelector(selectIsProjectEmpty);
+ const projectMembers = useAppSelector(selectProjectMembers);
+ const projectCardback = useAppSelector(selectProjectCardback);
+ const manualOverrides = useAppSelector(selectManualOverrides);
+ const finishSettings = useAppSelector(selectFinishSettings);
+ const cardDocuments = useAppSelector(
+ (state: RootState) => state.cardDocuments.cardDocuments
+ );
+
+ const [hasBackedUpThisSession, setHasBackedUpThisSession] = useState(false);
+ const [dismissed, setDismissed] = useState(false);
+ const [restorableDraft, setRestorableDraft] =
+ useState(null);
+
+ const writeDraftNow = useCallback(() => {
+ if (isProjectEmpty || typeof window === "undefined") {
+ return;
+ }
+ const content = buildDeckPayload(
+ "",
+ {
+ members: projectMembers,
+ nextMemberId: 0,
+ cardback: projectCardback ?? null,
+ mostRecentlySelectedSlot: null,
+ manualOverrides,
+ },
+ finishSettings,
+ cardDocuments
+ );
+ const draft: StoredDraft = {
+ draftVersion: DRAFT_FORMAT_VERSION,
+ savedAt: new Date().toISOString(),
+ payload: content,
+ };
+ try {
+ window.localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft));
+ setHasBackedUpThisSession(true);
+ } catch {
+ // Storage quota/private-mode denial - this is a best-effort safety net, never a hard
+ // dependency (see module comment); silently skipping a write here is the correct
+ // degradation, not a crash.
+ }
+ }, [
+ isProjectEmpty,
+ projectMembers,
+ projectCardback,
+ manualOverrides,
+ finishSettings,
+ cardDocuments,
+ ]);
+
+ // D9(1) - the debounced auto-write. Coalesces a burst of rapid project mutations into one
+ // write per DEBOUNCE_MS of quiet, per this file's own module comment.
+ useEffect(() => {
+ if (isProjectEmpty) {
+ return;
+ }
+ const timeout = setTimeout(writeDraftNow, DEBOUNCE_MS);
+ return () => clearTimeout(timeout);
+ }, [isProjectEmpty, writeDraftNow]);
+
+ // The restore-nudge check: only ever relevant while the project is EMPTY (a populated project
+ // has nothing to "restore" into) and not already dismissed this session.
+ useEffect(() => {
+ if (!isProjectEmpty || dismissed) {
+ setRestorableDraft(null);
+ return;
+ }
+ const stored = readStoredDraft();
+ setRestorableDraft(
+ stored != null
+ ? {
+ memberCount: stored.payload.members.length,
+ savedAt: stored.savedAt,
+ }
+ : null
+ );
+ }, [isProjectEmpty, dismissed]);
+
+ // D9(2)'s promotion nudge, POST-IMPORT half: fires once per session the moment the project
+ // flips from empty to populated - the exact same transition-detection shape as
+ // SavedDeckPanel.tsx's own anonymous->authenticated "adopt your project" toast, reusing the
+ // same plain-informational Toasts system (no action button in that system at all - see
+ // Toasts.tsx - so, like that existing precedent, the message points at the footer's own Save
+ // Deck button rather than embedding an action here).
+ const firedPostImportNudge = useRef(false);
+ const previousIsProjectEmpty = useRef(isProjectEmpty);
+ useEffect(() => {
+ const was = previousIsProjectEmpty.current;
+ if (
+ was === true &&
+ isProjectEmpty === false &&
+ !firedPostImportNudge.current
+ ) {
+ firedPostImportNudge.current = true;
+ dispatch(
+ setNotification([
+ "draft-backup-promote-post-import",
+ {
+ name: "Backed up locally",
+ message: PROMOTE_NUDGE_MESSAGE,
+ level: "info",
+ },
+ ])
+ );
+ }
+ previousIsProjectEmpty.current = isProjectEmpty;
+ }, [isProjectEmpty, dispatch]);
+
+ const notifyPromoteDraftPrePrint = useCallback(() => {
+ dispatch(
+ setNotification([
+ "draft-backup-promote-pre-print",
+ {
+ name: "Backed up locally",
+ message: PROMOTE_NUDGE_MESSAGE,
+ level: "info",
+ },
+ ])
+ );
+ }, [dispatch]);
+
+ const restoreDraft = useCallback(() => {
+ const stored = readStoredDraft();
+ if (stored == null) {
+ return;
+ }
+ const { project, finishSettings: restoredFinishSettings } =
+ projectFromDeckPayload({
+ ...stored.payload,
+ version: 2,
+ revision: 0,
+ modifiedAt: stored.savedAt,
+ });
+ dispatch(loadProject(project));
+ dispatch(loadFinishSettings(restoredFinishSettings));
+ setRestorableDraft(null);
+ }, [dispatch]);
+
+ const dismissRestoreDraft = useCallback(() => {
+ setDismissed(true);
+ setRestorableDraft(null);
+ }, []);
+
+ return {
+ hasBackedUpThisSession,
+ restorableDraft,
+ restoreDraft,
+ dismissRestoreDraft,
+ flushDraftNow: writeDraftNow,
+ notifyPromoteDraftPrePrint,
+ };
+}
+
+/** Test-only escape hatch - a fresh session/tab in real use always starts with no stored draft;
+ * this exists so a single jest process can exercise the restore path without polluting other
+ * tests' own `localStorage` (mirrors postExportContributionPrompt.ts's own reset-for-tests
+ * precedent). */
+export function clearStoredProjectDraftForTests(): void {
+ if (typeof window !== "undefined") {
+ window.localStorage.removeItem(DRAFT_STORAGE_KEY);
+ }
+}
diff --git a/frontend/src/features/savedDecks/SavedDeckPanel.tsx b/frontend/src/features/savedDecks/SavedDeckPanel.tsx
index 0466ae881..9cef0337b 100644
--- a/frontend/src/features/savedDecks/SavedDeckPanel.tsx
+++ b/frontend/src/features/savedDecks/SavedDeckPanel.tsx
@@ -13,23 +13,22 @@
* this component). The component itself is entirely route-agnostic (reads/writes projectSlice +
* savedDeckSessionSlice only), so the only thing that differs between callers is spacing - hence
* the className prop rather than a second, forked component.
+ *
+ * Issue #275 (proposal-h-display-layout-spec.md ADDENDUM D9) extracted the passphrase-setup/
+ * unlock/save modal-chain orchestration below into useSaveDeckFlow.ts, so the new Finish footer's
+ * own "Save Deck" button (FinishFooter.tsx) and PrePrintSaveGate.tsx's "Save" choice can trigger
+ * the exact same flow without forking it - this component is its first, unchanged caller.
*/
-import React, { useEffect, useRef, useState } from "react";
+import React, { useEffect, useRef } from "react";
import Button from "react-bootstrap/Button";
import { useAppDispatch, useAppSelector } from "@/common/types";
-import { useCryptoSession } from "@/features/savedDecks/cryptoSession";
-import { PassphraseSetupModal } from "@/features/savedDecks/PassphraseSetupModal";
-import { SaveDeckModal } from "@/features/savedDecks/SaveDeckModal";
-import { UnlockModal } from "@/features/savedDecks/UnlockModal";
+import { useSaveDeckFlow } from "@/features/savedDecks/useSaveDeckFlow";
import { useGetWhoamiQuery } from "@/store/api";
-import { selectIsProjectEmpty } from "@/store/slices/projectSlice";
import { selectCurrentSavedDeck } from "@/store/slices/savedDeckSessionSlice";
import { setNotification } from "@/store/slices/toastsSlice";
-type PendingModal = "passphrase-setup" | "unlock" | "save" | null;
-
interface SavedDeckPanelProps {
// Defaults to the original ProjectEditor placement's own spacing (a block stacked under the
// panels above it in a vertical column) - DisplayPage.tsx passes "" instead, since there this
@@ -40,12 +39,16 @@ interface SavedDeckPanelProps {
export function SavedDeckPanel({ className = "pt-2" }: SavedDeckPanelProps) {
const dispatch = useAppDispatch();
+ // Kept as its own query (RTK Query dedupes against useSaveDeckFlow's own identical call) rather
+ // than reading `isAuthenticated` back out of the hook below - this effect needs the RAW
+ // `whoami.data?.authenticated` (`undefined` while the query is still loading), not the hook's
+ // already-coerced boolean, so an already-authenticated user's initial undefined->true
+ // resolution can't be mistaken for a genuine live sign-in transition (see the `was === false`
+ // check below - `undefined !== false`, so only a real anonymous->authenticated flip fires it).
const whoami = useGetWhoamiQuery();
- const isAuthenticated = whoami.data?.authenticated === true;
- const session = useCryptoSession();
const currentSavedDeck = useAppSelector(selectCurrentSavedDeck);
- const isProjectEmpty = useAppSelector(selectIsProjectEmpty);
- const [pendingModal, setPendingModal] = useState(null);
+ const { element, triggerSave, isAuthenticated, isProjectEmpty } =
+ useSaveDeckFlow();
// Anonymous -> login adopt-by-save toast: surfaced once, right at the moment whoami flips to
// authenticated, only if there's actually a non-empty in-memory project worth offering to
@@ -76,16 +79,6 @@ export function SavedDeckPanel({ className = "pt-2" }: SavedDeckPanelProps) {
return null;
}
- const handleSaveClick = () => {
- if (session.status === "no-profile") {
- setPendingModal("passphrase-setup");
- } else if (session.status === "locked") {
- setPendingModal("unlock");
- } else if (session.status === "unlocked") {
- setPendingModal("save");
- }
- };
-
return (
<>
- setPendingModal(null)}
- onComplete={() => setPendingModal("save")}
- />
- setPendingModal(null)}
- onUnlocked={() => setPendingModal("save")}
- />
- setPendingModal(null)}
- onSaved={() => setPendingModal(null)}
- />
+ {element}
>
);
}
diff --git a/frontend/src/features/savedDecks/useSaveDeckFlow.tsx b/frontend/src/features/savedDecks/useSaveDeckFlow.tsx
new file mode 100644
index 000000000..4016137f1
--- /dev/null
+++ b/frontend/src/features/savedDecks/useSaveDeckFlow.tsx
@@ -0,0 +1,100 @@
+/**
+ * Extracted from SavedDeckPanel.tsx's own `handleSaveClick`/`pendingModal` orchestration
+ * (proposal-h-display-layout-spec.md ADDENDUM D9, issue #275) so a SECOND call site - the
+ * Finish footer's own "Save Deck" button (FinishFooter.tsx) and PrePrintSaveGate.tsx's "Save"
+ * choice - can trigger the exact same passphrase-setup/unlock/save modal chain without forking
+ * it, mirroring useLoadSavedDeck.ts's own precedent (extracted from MyDecksPage.tsx for the same
+ * reason). SavedDeckPanel.tsx itself is refactored to use this hook too, so there is exactly ONE
+ * place this three-modal sequencing lives - not the original plus a near-duplicate.
+ *
+ * Behaviour preserved verbatim from SavedDeckPanel.tsx: `no-profile` -> PassphraseSetupModal ->
+ * `unlocked` (session flips) -> SaveDeckModal shown next; `locked` -> UnlockModal -> same;
+ * `unlocked` already -> SaveDeckModal directly. `triggerSave` accepts an optional `onSaved`
+ * callback so a caller that needs to do something AFTER a successful save (PrePrintSaveGate's
+ * "navigate to the Print page only once persistence resolves", D9(3)c) can hook it, while
+ * SavedDeckPanel's own plain "just close the modal" caller can omit it.
+ */
+import React, { useCallback, useState } from "react";
+
+import { useAppSelector } from "@/common/types";
+import { useCryptoSession } from "@/features/savedDecks/cryptoSession";
+import { PassphraseSetupModal } from "@/features/savedDecks/PassphraseSetupModal";
+import { SaveDeckModal } from "@/features/savedDecks/SaveDeckModal";
+import { UnlockModal } from "@/features/savedDecks/UnlockModal";
+import { useGetWhoamiQuery } from "@/store/api";
+import { selectIsProjectEmpty } from "@/store/slices/projectSlice";
+
+type PendingModal = "passphrase-setup" | "unlock" | "save" | null;
+
+export interface UseSaveDeckFlowResult {
+ /** Render this once, near wherever `triggerSave` is called from - the whole modal surface
+ * (PassphraseSetupModal/UnlockModal/SaveDeckModal) this hook needs, and renders nothing until
+ * it's actually needed. */
+ element: React.ReactElement;
+ /** Starts (or continues) the save flow: passphrase-setup/unlock first if the crypto session
+ * isn't ready, then SaveDeckModal. `onSaved` (optional) fires once the deck has actually been
+ * persisted - omit it for a plain "just save" caller. */
+ triggerSave: (onSaved?: () => void) => void;
+ /** True once a real, non-authenticated session is confirmed - callers that need to branch on
+ * this themselves (the Finish footer's anonymous "Save Deck" state, D9's own "anonymous users'
+ * nudge routes through sign-in first") read it directly rather than re-querying whoami. */
+ isAuthenticated: boolean;
+ /** Whether the current project has anything worth saving - SavedDeckPanel's own existing
+ * `disabled={isProjectEmpty}` gate on its Save button, exposed here so a second caller
+ * (the Finish footer) can apply the identical gate without re-deriving it. */
+ isProjectEmpty: boolean;
+}
+
+export function useSaveDeckFlow(): UseSaveDeckFlowResult {
+ const whoami = useGetWhoamiQuery();
+ const isAuthenticated = whoami.data?.authenticated === true;
+ const session = useCryptoSession();
+ const isProjectEmpty = useAppSelector(selectIsProjectEmpty);
+
+ const [pendingModal, setPendingModal] = useState(null);
+ const [onSavedCallback, setOnSavedCallback] = useState<
+ (() => void) | undefined
+ >(undefined);
+
+ const triggerSave = useCallback(
+ (onSaved?: () => void) => {
+ setOnSavedCallback(() => onSaved);
+ if (session.status === "no-profile") {
+ setPendingModal("passphrase-setup");
+ } else if (session.status === "locked") {
+ setPendingModal("unlock");
+ } else if (session.status === "unlocked") {
+ setPendingModal("save");
+ }
+ },
+ [session.status]
+ );
+
+ const handleSaved = useCallback(() => {
+ setPendingModal(null);
+ onSavedCallback?.();
+ setOnSavedCallback(undefined);
+ }, [onSavedCallback]);
+
+ const element = (
+ <>
+ setPendingModal(null)}
+ onComplete={() => setPendingModal("save")}
+ />
+ setPendingModal(null)}
+ onUnlocked={() => setPendingModal("save")}
+ />
+ setPendingModal(null)}
+ onSaved={handleSaved}
+ />
+ >
+ );
+
+ return { element, triggerSave, isAuthenticated, isProjectEmpty };
+}
diff --git a/frontend/src/pages/print.tsx b/frontend/src/pages/print.tsx
new file mode 100644
index 000000000..a4fe30fc5
--- /dev/null
+++ b/frontend/src/pages/print.tsx
@@ -0,0 +1,74 @@
+import Head from "next/head";
+import Link from "next/link";
+import React from "react";
+
+import { ProjectName } from "@/common/constants";
+import { useAppSelector } from "@/common/types";
+import { NoBackendDefault } from "@/components/NoBackendDefault";
+import { FinishedMyProject } from "@/features/export/FinishedMyProject";
+import Footer from "@/features/ui/Footer";
+import { ProjectContainer } from "@/features/ui/Layout";
+import {
+ useAnyBackendConfigured,
+ useProjectName,
+} from "@/store/slices/backendSlice";
+import { selectIsProjectEmpty } from "@/store/slices/projectSlice";
+require("bootstrap-icons/font/bootstrap-icons.css");
+
+/**
+ * Proposal H ADDENDUM D10/F5 (docs/proposals/proposal-h-display-layout-spec.md, issue #275) - a
+ * thin route wrapper mounting `FinishedMyProject` (the MakePlayingCards/NotMPC/PringlePrints
+ * supplier tabs + the PDF sub-tab), mirroring `pages/myDecks.tsx`'s own
+ * `MyDecksPage`/`pages/shared.tsx`'s `SharedDeckPage` wrapper pattern - compose, don't fork.
+ * `FinishedMyProject.tsx` itself is UNCHANGED; this file only gives it a standalone route so the
+ * Finish footer's "Print / Export →" button (FinishFooter.tsx, via PrePrintSaveGate.tsx) has
+ * somewhere to client-side-navigate to (D9's pre-print persist step runs BEFORE this navigation,
+ * never after). The classic /editor "Print!" tab keeps mounting the same component unchanged too
+ * (ProjectEditor.tsx's own `PrintPanel`) - both /display and /editor now funnel here.
+ *
+ * Deliberately NOT built here (D10's own owner addendum, explicitly out of THIS issue's scope per
+ * the task that shipped this file): the tab REORDER (owner order: PDF · MakePlayingCards ·
+ * NotMPC · PringlePrints, PDF default - today's array order/default is unchanged) and the PDF
+ * tab's own preview removal (`showPreview={false}` prop plumbing so /display's own center sheet
+ * region becomes the sole preview). Both are tracked as their own follow-up against this same
+ * D10 addendum, not silently dropped - see that doc's own change inventory.
+ */
+function PrintPageOrDefault() {
+ const anyBackendConfigured = useAnyBackendConfigured();
+ const isProjectEmpty = useAppSelector(selectIsProjectEmpty);
+
+ if (!anyBackendConfigured) {
+ return ;
+ }
+
+ // A direct/bookmarked nav here with nothing in the project yet has nothing for
+ // FinishedMyProject to usefully show (no cards to export) - point back at the funnel's own
+ // entry point rather than rendering an empty PDF/supplier-instructions surface.
+ if (isProjectEmpty) {
+ return (
+
+
Your project is empty - there's nothing to print yet.
+ Head to Display to add some cards
+
+ );
+ }
+
+ return ;
+}
+
+export default function Print() {
+ const projectName = useProjectName();
+ return (
+
+
+ {`${projectName} Print`}
+
+
+
+
+
+ );
+}
diff --git a/frontend/tests/DisplayFinishFooter.spec.ts b/frontend/tests/DisplayFinishFooter.spec.ts
new file mode 100644
index 000000000..01cb5353a
--- /dev/null
+++ b/frontend/tests/DisplayFinishFooter.spec.ts
@@ -0,0 +1,209 @@
+import { expect, Page } from "@playwright/test";
+import { http, HttpResponse } from "msw";
+
+import { createCryptoProfile } from "@/common/savedDeckCrypto";
+import {
+ existingProfileHandler,
+ getSavedDecksHandler,
+} from "@/features/savedDecks/cryptoTestHandlers";
+import {
+ cardDocumentsOneResult,
+ defaultHandlers,
+ searchResultsOneResult,
+ sourceDocumentsOneResult,
+ whoamiAnonymous,
+ whoamiSignedInNotModerator,
+} from "@/mocks/handlers";
+
+import { test } from "../playwright.setup";
+import { importText, loadPageWithDefaultBackend } from "./test-utils";
+
+// Issue #275 (proposal-h-display-layout-spec.md ADDENDUM D9/D10) - the /display Finish footer
+// (FinishFooter.tsx: co-equal "Save Deck"/"Print / Export ->", the draft-backed-up note) and its
+// D9(3) pre-print save gate (PrePrintSaveGate.tsx). These are real, new user journeys - not just
+// selector renames of the old three-button "Prepare Print" stack these replace (that stack's own
+// dedicated coverage, DisplayPageExport.spec.ts, is retired alongside it - its subject no longer
+// exists on this page; PDFGenerator.spec.ts already covers the underlying pipeline mechanics
+// unchanged, since PDF generation now lives solely on the Print page via the same unforked
+// PDFGenerator.tsx).
+
+const TEST_ITERATIONS = 100;
+const PASSPHRASE = "the real one";
+
+const oneCardHandlers = [
+ cardDocumentsOneResult,
+ sourceDocumentsOneResult,
+ searchResultsOneResult,
+ ...defaultHandlers,
+];
+
+const goToDisplay = async (page: Page) => {
+ await loadPageWithDefaultBackend(page);
+ await importText(page, "my search query");
+ await page.getByRole("link", { name: "Display (beta)" }).click();
+ await expect(page.getByTestId("display-page")).toBeVisible();
+};
+
+test.describe("/display Finish footer (issue #275)", () => {
+ test("anonymous: shows a sign-in link in place of Save Deck, and Print / Export navigates straight to the Print page", async ({
+ page,
+ network,
+ }) => {
+ network.use(whoamiAnonymous, ...oneCardHandlers);
+ await goToDisplay(page);
+
+ const footer = page.getByTestId("display-finish-footer");
+ await expect(
+ footer.getByTestId("finish-footer-save-deck-signin")
+ ).toBeVisible();
+ await expect(footer.getByTestId("finish-footer-save-deck")).toHaveCount(0);
+
+ await footer.getByTestId("finish-footer-print-export").click();
+
+ // No save gate for an anonymous session (D9(3): "authenticated AND dirty" gates the prompt) -
+ // straight through to the Print page.
+ await expect(page).toHaveURL(/\/print/);
+ await expect(page.getByRole("tab", { name: "PDF" })).toBeVisible();
+ });
+
+ test("authenticated: shows the Save Deck button and, once a draft has backed up, the compact note", async ({
+ page,
+ network,
+ }) => {
+ const profile = await createCryptoProfile(PASSPHRASE, TEST_ITERATIONS);
+ network.use(
+ whoamiSignedInNotModerator,
+ existingProfileHandler(profile),
+ getSavedDecksHandler([]),
+ ...oneCardHandlers
+ );
+ await goToDisplay(page);
+
+ const footer = page.getByTestId("display-finish-footer");
+ await expect(footer.getByTestId("finish-footer-save-deck")).toBeVisible();
+ await expect(
+ footer.getByTestId("finish-footer-save-deck-signin")
+ ).toHaveCount(0);
+
+ // F1's debounced auto-backup (800ms) - the compact note only appears once a write has
+ // actually happened this session.
+ await expect(footer.getByTestId("finish-footer-draft-note")).toBeVisible({
+ timeout: 5_000,
+ });
+ });
+
+ test("authenticated + dirty: Print / Export shows the save gate; choosing Save unlocks, saves, and lands on the Print page", async ({
+ page,
+ network,
+ }) => {
+ const profile = await createCryptoProfile(PASSPHRASE, TEST_ITERATIONS);
+ const saveDeckRequests: Array = [];
+ network.use(
+ whoamiSignedInNotModerator,
+ existingProfileHandler(profile),
+ getSavedDecksHandler([]),
+ http.post("http://127.0.0.1:8000/2/saveDeck/", async ({ request }) => {
+ saveDeckRequests.push(await request.json());
+ return HttpResponse.json({ key: "new-deck-key" }, { status: 200 });
+ }),
+ ...oneCardHandlers
+ );
+ await goToDisplay(page);
+
+ await page
+ .getByTestId("display-finish-footer")
+ .getByTestId("finish-footer-print-export")
+ .click();
+
+ const gate = page.getByTestId("pre-print-save-gate-modal");
+ await expect(gate).toBeVisible();
+ await gate.getByTestId("pre-print-save-gate-save").click();
+
+ // Crypto session starts locked this "session" (a fresh page load) - Save routes through
+ // Unlock first, exactly like the toolbar's own Save button would.
+ await page.getByLabel("unlock-passphrase").fill(PASSPHRASE);
+ await page.getByRole("button", { name: "Unlock" }).click();
+
+ const saveModal = page.getByTestId("save-deck-modal");
+ await expect(saveModal).toBeVisible();
+ await page.getByLabel("save-deck-name").fill("My Print Test Deck");
+ await saveModal.getByRole("button", { name: "Save", exact: true }).click();
+
+ // Persistence resolves -> THEN navigation - D9(3)c, "saving gates PDF; PDF never gates
+ // saving" the other way around.
+ await expect(page).toHaveURL(/\/print/);
+ await expect(page.getByRole("tab", { name: "PDF" })).toBeVisible();
+ expect(saveDeckRequests).toHaveLength(1);
+ });
+
+ test("authenticated + dirty: Skip on the save gate navigates to the Print page without saving", async ({
+ page,
+ network,
+ }) => {
+ const profile = await createCryptoProfile(PASSPHRASE, TEST_ITERATIONS);
+ let saveDeckCalls = 0;
+ network.use(
+ whoamiSignedInNotModerator,
+ existingProfileHandler(profile),
+ getSavedDecksHandler([]),
+ http.post("http://127.0.0.1:8000/2/saveDeck/", () => {
+ saveDeckCalls += 1;
+ return HttpResponse.json({ key: "unused" }, { status: 200 });
+ }),
+ ...oneCardHandlers
+ );
+ await goToDisplay(page);
+
+ await page
+ .getByTestId("display-finish-footer")
+ .getByTestId("finish-footer-print-export")
+ .click();
+
+ const gate = page.getByTestId("pre-print-save-gate-modal");
+ await expect(gate).toBeVisible();
+ await gate.getByTestId("pre-print-save-gate-skip").click();
+
+ await expect(page).toHaveURL(/\/print/);
+ expect(saveDeckCalls).toBe(0);
+ });
+});
+
+test.describe("/display local draft auto-backup + restore nudge (issue #275)", () => {
+ test("emptying the project resurfaces a restore nudge for the just-backed-up draft, and Restore rehydrates it", async ({
+ page,
+ network,
+ }) => {
+ network.use(whoamiAnonymous, ...oneCardHandlers);
+ await goToDisplay(page);
+
+ // Wait for F1's debounced write, then empty the project via the rail's own Delete action -
+ // no reload needed: the restore-nudge check re-runs the moment isProjectEmpty flips true,
+ // same session.
+ await page
+ .getByTestId("display-finish-footer")
+ .getByTestId("finish-footer-draft-note")
+ .waitFor({ timeout: 5_000 });
+
+ await page.getByTestId("page-preview-slot").first().click();
+ await page
+ .getByRole("heading", { name: "Slot Actions", exact: true })
+ .click();
+ await page.getByTestId("display-slot-action-delete").click();
+
+ await expect(page.getByTestId("display-empty-state")).toBeVisible();
+ const banner = page.getByTestId("display-restore-draft-banner");
+ await expect(banner).toBeVisible();
+ await expect(banner).toContainText("1 card");
+
+ await banner.getByTestId("display-restore-draft-accept").click();
+
+ await expect(page.getByTestId("display-empty-state")).toHaveCount(0);
+ // Every grid position still renders a `page-preview-slot` placeholder (8, this page's own
+ // default Letter/Borderless/3.175mm-bleed 4x2 capacity) regardless of how many are filled -
+ // only the resolved `` count reflects the actually-restored member (DisplayPage.spec.ts's
+ // own established pattern for this same distinction).
+ await expect(
+ page.getByTestId("page-preview-slot").locator("img")
+ ).toHaveCount(1);
+ });
+});
diff --git a/frontend/tests/DisplayPageExport.spec.ts b/frontend/tests/DisplayPageExport.spec.ts
deleted file mode 100644
index c58fba678..000000000
--- a/frontend/tests/DisplayPageExport.spec.ts
+++ /dev/null
@@ -1,250 +0,0 @@
-import { expect, Page } from "@playwright/test";
-import { readFileSync } from "fs";
-import { http, HttpResponse } from "msw";
-import path from "path";
-import { fileURLToPath } from "url";
-
-import { cardDocument1, cardDocument2 } from "@/common/test-constants";
-import {
- cardDocumentsOneResult,
- cardDocumentsSixResults,
- defaultHandlers,
- searchResultsOneResult,
- searchResultsSixResults,
- sourceDocumentsOneResult,
-} from "@/mocks/handlers";
-
-import { test } from "../playwright.setup";
-import { importText, loadPageWithDefaultBackend } from "./test-utils";
-
-// Proposal H, item 2 (owner's hands-on review): "Generate PDF" on /display now runs the REAL
-// export pipeline in-page - the exact same useDownloadPDF/useSaveToDrivePDF/
-// ImageFailureConfirmModal PDFGenerator.tsx itself uses (exported for this, not forked), fed by
-// this page's own toolbar settings instead of a navigation to the classic PDF tab. These tests
-// mirror PDFGenerator.spec.ts's own proven coverage for that shared pipeline (warn/cancel/
-// continue/success, live fetch progress) scoped to this page's own entry point, plus this page's
-// own non-default-settings and progress-bar-specific assertions.
-
-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 imageWorkerFailure = http.get(
- IMAGE_WORKER_URL_PATTERN,
- () => new HttpResponse(null, { status: 500 })
-);
-const imageBucketFailure = http.get(
- IMAGE_BUCKET_URL_PATTERN,
- () => new HttpResponse(null, { status: 404 })
-);
-const imageWorkerSuccess = http.get(
- IMAGE_WORKER_URL_PATTERN,
- () =>
- new HttpResponse(validImageBytes, {
- status: 200,
- headers: { "Content-Type": "image/png" },
- })
-);
-const imageBucketSuccess = http.get(
- IMAGE_BUCKET_URL_PATTERN,
- () =>
- new HttpResponse(validImageBytes, {
- status: 200,
- headers: { "Content-Type": "image/png" },
- })
-);
-
-const oneCardHandlers = [
- cardDocumentsOneResult,
- sourceDocumentsOneResult,
- searchResultsOneResult,
- ...defaultHandlers,
-];
-
-const goToDisplay = async (page: Page) => {
- await loadPageWithDefaultBackend(page);
- await importText(page, "my search query");
- await page.getByRole("link", { name: "Display (beta)" }).click();
- await expect(page.getByTestId("display-page")).toBeVisible();
-};
-
-test.describe("DisplayPage inline export (Proposal H, item 2)", () => {
- test("exports with this page's own current, non-default settings and downloads cards.pdf - no navigation to a classic tab", async ({
- page,
- network,
- }) => {
- network.use(imageBucketSuccess, imageWorkerSuccess, ...oneCardHandlers);
- await goToDisplay(page);
-
- // Non-default settings: a distinct bleed edge from the default, and Guides off - these feed
- // the real export via exportPdfProps (see DisplayPage.tsx), not a separate settings store.
- await page.getByLabel("Bleed edge (mm)").fill("5");
- await page.getByLabel("Guides").uncheck();
-
- // The real pipeline fetches full-resolution images over the network (the #81 paced fetcher,
- // not a stub) - capturing that request is what proves this page's current settings actually
- // drove a real export rather than a no-op button.
- const fullResolutionFetch = page.waitForRequest(IMAGE_WORKER_URL_PATTERN);
-
- const [download] = await Promise.all([
- page.waitForEvent("download"),
- fullResolutionFetch,
- page.getByTestId("display-generate-pdf").click(),
- ]);
- expect(download.suggestedFilename()).toBe("cards.pdf");
-
- // Stayed on /display throughout - the whole point of item 2 is no navigation to the classic
- // tab for this.
- await expect(page).toHaveURL(/\/display/);
- });
-
- test("shows a real determinate progress bar while images are fetched, then clears once the render settles", async ({
- page,
- network,
- }) => {
- // Longer than the file's default 30s - the staggered second image below deliberately adds
- // real wall-clock delay on top of this test's own dev-server first-compile cost (whichever
- // test in this file runs first pays that, same as DisplayPage.spec.ts's own documented
- // "first hit" cost).
- test.setTimeout(60_000);
- // Two distinct cards (not duplicate slots of the same one, which the export pipeline
- // dedupes by identifier down to a single fetch) - a single-image export can complete its
- // one and only progress callback already at completed===total, jumping straight to the
- // "Assembling PDF…" phase with no visibly distinct "fetching, not yet done" moment.
- // Staggered, not equally delayed - the Semaphore(3) paced fetcher (pdfImage.ts) issues both
- // requests concurrently, so two equally-delayed responses land back to back and can still
- // race past the assertion's poll before it ever observes completed < total. Resolving the
- // first request fast and the second slowly guarantees a long, stable window where exactly
- // one of two images has finished.
- let requestCount = 0;
- const staggeredImageWorkerSuccess = http.get(
- IMAGE_WORKER_URL_PATTERN,
- async () => {
- requestCount += 1;
- await new Promise((resolve) =>
- setTimeout(resolve, requestCount === 1 ? 0 : 3_000)
- );
- return new HttpResponse(validImageBytes, {
- status: 200,
- headers: { "Content-Type": "image/png" },
- });
- }
- );
- network.use(
- cardDocumentsSixResults,
- sourceDocumentsOneResult,
- searchResultsSixResults,
- imageBucketFailure, // bucket miss, falls through to the (staggered) worker
- staggeredImageWorkerSuccess,
- ...defaultHandlers
- );
- await loadPageWithDefaultBackend(page);
- await importText(page, "1 query 1\n1 query 2");
- await page.getByRole("link", { name: "Display (beta)" }).click();
- await expect(page.getByTestId("display-page")).toBeVisible();
-
- // Wait for BOTH cards' documents to have resolved into state before exporting - otherwise
- // exportPdfProps' cardDocumentsByIdentifier can still only have 1 entry at click time (the
- // second card's fetch still in flight), making the pipeline's own total 1 instead of 2 and
- // collapsing the "fetching" phase to nothing observable, independent of this mock's own
- // stagger. The alt text is set from the resolved CardDocument's own name - a reliable
- // readiness signal that doesn't depend on mediumThumbnailUrl, which every mock CardDocument
- // in test-constants.ts deliberately leaves as an empty string.
- const sheetSlots = page.getByTestId("page-preview-slot");
- await expect(sheetSlots.nth(0).locator("img")).toHaveAttribute(
- "alt",
- cardDocument1.name
- );
- await expect(sheetSlots.nth(1).locator("img")).toHaveAttribute(
- "alt",
- cardDocument2.name
- );
-
- // A MutationObserver, not a polled Playwright assertion - the "fetching" phase's own
- // window can be narrower than an assertion's poll interval, especially for a 2-image export
- // where the whole phase might only last as long as this test's own stagger. The observer
- // fires synchronously on every DOM change, so it can't miss a value between polls the way an
- // expect().toContainText() retry loop can.
- await page.evaluate(() => {
- (
- window as unknown as { __progressTexts: Array }
- ).__progressTexts = [];
- const observer = new MutationObserver(() => {
- const el = document.querySelector(
- '[data-testid="display-export-progress-bar"]'
- );
- if (el != null) {
- (
- window as unknown as { __progressTexts: Array }
- ).__progressTexts.push(el.textContent);
- }
- });
- observer.observe(document.body, {
- childList: true,
- subtree: true,
- characterData: true,
- });
- });
-
- const [download] = await Promise.all([
- page.waitForEvent("download"),
- page.getByTestId("display-generate-pdf").click(),
- ]);
- expect(download.suggestedFilename()).toBe("cards.pdf");
-
- const progressTexts = await page.evaluate(
- () =>
- (window as unknown as { __progressTexts: Array })
- .__progressTexts
- );
- expect(
- progressTexts.some((text) => text?.includes("Fetching images:"))
- ).toBe(true);
-
- await expect(page.getByTestId("display-export-progress")).not.toBeVisible();
- });
-
- test("blocks the download behind the in-app failure-confirm modal on a dead image link, and cancelling actually prevents the download", async ({
- page,
- network,
- }) => {
- network.use(imageBucketFailure, imageWorkerFailure, ...oneCardHandlers);
- await goToDisplay(page);
-
- const downloadPromise = page
- .waitForEvent("download", { timeout: 3_000 })
- .catch(() => undefined);
- await page.getByTestId("display-generate-pdf").click();
-
- const modal = page.getByTestId("image-failure-confirm-modal");
- await expect(modal).toBeVisible({ timeout: 15_000 });
- await expect(modal).toContainText(cardDocument1.name);
-
- await page.getByTestId("image-failure-confirm-cancel").click();
- await expect(modal).not.toBeVisible();
- await expect(downloadPromise).resolves.toBeUndefined();
- });
-
- test("downloads anyway once the user confirms despite the failed image - same in-app modal PDFGenerator.tsx uses, not forked", async ({
- page,
- network,
- }) => {
- network.use(imageBucketFailure, imageWorkerFailure, ...oneCardHandlers);
- await goToDisplay(page);
-
- await page.getByTestId("display-generate-pdf").click();
- await expect(page.getByTestId("image-failure-confirm-modal")).toBeVisible({
- timeout: 15_000,
- });
-
- const [download] = await Promise.all([
- page.waitForEvent("download"),
- page.getByTestId("image-failure-confirm-continue").click(),
- ]);
- expect(download.suggestedFilename()).toBe("cards.pdf");
- });
-});
diff --git a/frontend/tests/PostExportContributionPrompt.spec.ts b/frontend/tests/PostExportContributionPrompt.spec.ts
index 06cf0f2c2..d58e65d06 100644
--- a/frontend/tests/PostExportContributionPrompt.spec.ts
+++ b/frontend/tests/PostExportContributionPrompt.spec.ts
@@ -1,4 +1,4 @@
-import { expect, Page } from "@playwright/test";
+import { expect } from "@playwright/test";
import { readFileSync } from "fs";
import { http, HttpResponse } from "msw";
import path from "path";
@@ -14,11 +14,13 @@ import {
import { test } from "../playwright.setup";
import { importText, loadPageWithDefaultBackend } from "./test-utils";
-// Issue #166 - the post-export contribution prompt. Mounted from both real export surfaces
-// (docs/features/print-export-page.md, docs/features/pdf-generator.md): DisplayPage.tsx's own
-// inline export (Proposal H, item 2) and PDFGenerator.tsx itself (so the classic "Print!" tab
-// gets it too, since it mounts the same component). These tests mirror DisplayPageExport.spec.ts
-// and PDFGenerator.spec.ts's own proven mock/network setup, scoped to this new prompt.
+// Issue #166 - the post-export contribution prompt. Mounted from PDFGenerator.tsx (so the
+// classic "Print!" tab / Print page gets it). It used to ALSO be mounted from DisplayPage.tsx's
+// own inline export (Proposal H, item 2) - issue #275 removed that inline pipeline entirely (PDF
+// generation now lives solely on the Print page, D10/pages/print.tsx), so that describe block
+// (and its own `display-generate-pdf` coverage) is retired alongside it; this file's remaining
+// coverage - PDFGenerator.tsx's own, unchanged mount - still exercises the real component this
+// prompt is.
const IMAGE_WORKER_URL_PATTERN = /^https:\/\/cdn\.proxyprints\.ca\//;
const IMAGE_BUCKET_URL_PATTERN = /^https:\/\/img\.proxyprints\.ca\//;
@@ -28,14 +30,6 @@ const validImageBytes = readFileSync(
path.join(__dirname, "..", "public", "blank.png")
);
-const imageWorkerFailure = http.get(
- IMAGE_WORKER_URL_PATTERN,
- () => new HttpResponse(null, { status: 500 })
-);
-const imageBucketFailure = http.get(
- IMAGE_BUCKET_URL_PATTERN,
- () => new HttpResponse(null, { status: 404 })
-);
const imageWorkerSuccess = http.get(
IMAGE_WORKER_URL_PATTERN,
() =>
@@ -60,86 +54,6 @@ const oneCardHandlers = [
...defaultHandlers,
];
-const goToDisplay = async (page: Page) => {
- await loadPageWithDefaultBackend(page);
- await importText(page, "my search query");
- await page.getByRole("link", { name: "Display (beta)" }).click();
- await expect(page.getByTestId("display-page")).toBeVisible();
-};
-
-test.describe("Post-export contribution prompt (issue #166) - /display", () => {
- test("appears after a successful export, links to /whatsthat, and is dismissible", async ({
- page,
- network,
- }) => {
- network.use(imageBucketSuccess, imageWorkerSuccess, ...oneCardHandlers);
- await goToDisplay(page);
-
- const prompt = page.getByTestId("post-export-contribution-prompt");
- await expect(prompt).not.toBeVisible();
-
- const [download] = await Promise.all([
- page.waitForEvent("download"),
- page.getByTestId("display-generate-pdf").click(),
- ]);
- expect(download.suggestedFilename()).toBe("cards.pdf");
-
- await expect(prompt).toBeVisible();
- await expect(prompt).toContainText("What's That Card?");
-
- const link = page.getByTestId("post-export-contribution-prompt-link");
- await expect(link).toHaveAttribute("href", "/whatsthat");
- await link.click();
- await expect(page).toHaveURL(/\/whatsthat/);
- });
-
- test("dismissing it hides it, and it never re-appears again this session even after another successful export", async ({
- page,
- network,
- }) => {
- network.use(imageBucketSuccess, imageWorkerSuccess, ...oneCardHandlers);
- await goToDisplay(page);
-
- const prompt = page.getByTestId("post-export-contribution-prompt");
- await Promise.all([
- page.waitForEvent("download"),
- page.getByTestId("display-generate-pdf").click(),
- ]);
- await expect(prompt).toBeVisible();
-
- await prompt.getByRole("button", { name: /close/i }).click();
- await expect(prompt).not.toBeVisible();
-
- // A second successful export in the same session must not bring it back - "never repeats
- // within a session" (design doc's own §4.4′ footnote, task #31).
- await Promise.all([
- page.waitForEvent("download"),
- page.getByTestId("display-generate-pdf").click(),
- ]);
- await page.waitForTimeout(1_000);
- await expect(prompt).not.toBeVisible();
- });
-
- test("does NOT appear when the export is cancelled due to an image failure", async ({
- page,
- network,
- }) => {
- network.use(imageBucketFailure, imageWorkerFailure, ...oneCardHandlers);
- await goToDisplay(page);
-
- await page.getByTestId("display-generate-pdf").click();
- const modal = page.getByTestId("image-failure-confirm-modal");
- await expect(modal).toBeVisible({ timeout: 15_000 });
- await page.getByTestId("image-failure-confirm-cancel").click();
- await expect(modal).not.toBeVisible();
-
- await page.waitForTimeout(1_000);
- await expect(
- page.getByTestId("post-export-contribution-prompt")
- ).not.toBeVisible();
- });
-});
-
test.describe("Post-export contribution prompt (issue #166) - classic Print! tab", () => {
test("also appears after a successful export from PDFGenerator.tsx's own classic tab", async ({
page,