Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/coverage-acks.txt
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,11 @@ coverage-ack: frontend/tests/ProjectEditorMobileScroll.spec.ts::* — owner-rule
# PR #337 established for this file's title staleness pattern. The new title carries the
# unchanged behavior forward (un-skipped, not removed) at a new, non-stale title.
coverage-ack: frontend/tests/PostExportContributionPrompt.spec.ts::Post-export contribution prompt (issue #166) - classic Print! tab > also appears after a successful export from PDFGenerator.tsx's own classic tab — renamed + un-skipped in the same change, see the file's own new title

# Cardback flow round (SPEC-cardback-pdfwait.md §C.2) - the toolbar cardback picker no longer
# auto-closes on select (the apply-all/set-default prompt renders inline instead), so these 3
# titles changed to describe the new stays-open behaviour; the underlying selection coverage is
# preserved, not dropped - see this PR's own body for the full before/after.
coverage-ack: frontend/tests/GridSelectorModal.spec.ts::GridSelectorModal – JumpToVersion > submitting a valid option number selects that card and closes the modal — cardback flow round (SPEC-cardback-pdfwait.md §C.2): the toolbar cardback picker no longer auto-closes on select (the apply-all/set-default prompt renders inline instead), so the title changed to describe the new behaviour; the underlying selection coverage is preserved, not dropped.
coverage-ack: frontend/tests/GridSelectorModal.spec.ts::GridSelectorModal – JumpToVersion > submitting a valid identifier selects that card and closes the modal — same rename as the sibling "option number" test above, same reason.
coverage-ack: frontend/tests/GridSelectorModalVariants.spec.ts::GridSelectorModal - keyboard navigation > Tab reaches a result card and Enter selects it, same as a click would — cardback flow round (SPEC-cardback-pdfwait.md §C.2): same auto-close-removed rename as GridSelectorModal.spec.ts's JumpToVersion tests.
47 changes: 43 additions & 4 deletions docs/features/pdf-generator.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,12 @@ not just Proposal B's bleed-normalized cards):
- **Live progress**: a large export paced to 3 req/s can now take several minutes (honestly
reported, not hidden) - `PDFProps.reportImageProgress` (mirroring the existing
`reportImageFailure` pattern, threaded through `pdf.worker.ts` → comlink's `onImageProgress` →
`pdfRenderService` → `PDFGenerator.tsx`) drives a "Fetching images: N/M" indicator so the wait
reads as working, not hung. `total` is an approximation (unique card count, not slot count - a
duplicate card in the deck fetches once per slot, so `completed` can end up slightly ahead of
it), intentionally not presented as an exact fraction for that reason.
`pdfRenderService` → `PDFGenerator.tsx`) drives progress feedback so the wait reads as working,
not hung. `total` is an approximation (unique card count, not slot count - a duplicate card in
the deck fetches once per slot, so `completed` can end up slightly ahead of it), intentionally
not presented as an exact fraction for that reason. See "PDF-generation wait experience" below
for the current UI this drives (a real `ProgressBar`, not the bare text line this originally
shipped as).
- **In-app confirm modal, not `window.confirm()`**: the incident's own screenshot showed
Firefox's "allow notifications?" anti-spam chrome sitting next to the native confirm dialog -
a browser can silently start auto-suppressing FUTURE `window.confirm()` calls on an origin once
Expand Down Expand Up @@ -140,6 +142,43 @@ Full spec + approval record: `docs/proposals/proposal-b-bleed-normalization.md`.

**A real crash caught only by running `tests/PDFGenerator.spec.ts`, not by `tsc`/`jest`**: the first version skipped the old proportional rescale by setting `transform: "none"` when normalized. `@react-pdf/renderer`'s own stylesheet parser (`@react-pdf/stylesheet`) has a bug where any single-token transform value throws deep inside its internals (see `docs/lessons.md`'s entry for the exact mechanism) - and their custom reconciler doesn't propagate that as a rejection anywhere, so `pdf(...).toBlob()` just hangs forever with zero console/page error. All 3 download-path Playwright tests hung at their timeout; a stashed pre-Proposal-B baseline confirmed they pass cleanly with no other changes. Fixed by using `transform: undefined` (omitting the key) instead of `"none"` - all 4 tests pass afterward, matching baseline timing.

## PDF-generation wait experience (SPEC-cardback-pdfwait.md §D, PKG2)

`PDFGenerator.tsx` derives a `waitPhase` (`"idle" | "fetching" | "assembling" | "done"`) from the
existing `isDownloading`/`isSavingToDrive` + `imageFetchProgress` state, rather than tracking it as
independent state - fewer places that can drift out of sync. `imageFetchProgress == null` reads as
`"fetching"` (nothing reported yet, not `"assembling"` - a real bug caught by this round's own
Playwright coverage: treating the brief pre-first-callback window as null-means-assembling flashed
"Assembling PDF…" before any fetch had actually started).

- **2a - progress bar** (`PDFWaitPanel.tsx`'s `PDFProgressBox`, replaces the old bare
`pdf-image-fetch-progress` text line): a real Bootstrap `ProgressBar`, determinate
(`now={min(completed/total,1)*100}`, capped at 99% - never a false 100% before the phase
genuinely ends) while fetching, honest indeterminate (`animated striped`, `aria-busy`) while
assembling (no progress callback exists for `@react-pdf/renderer`'s own layout/encode phase -
Annex A-3/`PB1`: swap to determinate if a future render seam exposes one), and a green `done`
bar once the export genuinely succeeds.
- **2b - embedded "What's That Card?" game** (`PDFWaitPanel.tsx`'s `PDFWaitGameEmbed`): the right
column (normally the live PDF preview) becomes a chrome frame around `<QuestionFeed>` rendered
**verbatim** (`next/dynamic({ssr:false})`, imported only once `isDownloading`/`isSavingToDrive`
flips true - never eagerly bundled while a user is still configuring the PDF, mirroring bug 1's
own lazy-WASM posture above) plus a persistent build-status ribbon. No forked component, no new
voting mechanic - the exact `/whatsthat` funnel (`docs/features/printing-tags.md`). Torn down
(unmounted) the instant generation finishes, replaced by the existing
`PostExportContributionPrompt` as the embed's own outro (context-aware "one nudge, not two" -
the standalone bottom-of-settings mount, described below, is suppressed whenever this outro is
already showing the same prompt in the right column).

## Cardback reminder gate on the classic direct "Generate PDF"/"Save PDF to Google Drive" buttons

A user can reach `/print` directly (bookmark, refresh, any entry that skips the editor's Finish
footer/`usePrePrintSaveGate` entirely) - so `PDFGenerator.tsx`'s own Generate/Save-to-Drive click
handlers wrap themselves in `useCardbackReminderGate` (`frontend/src/features/display/ useCardbackReminderGate.tsx`) independently of `PrePrintSaveGate.tsx`'s own composition of the
same hook. Both call sites read the same per-project `sessionStorage` suppression key
(`cardbackReminderSuppression.ts`), so passing through the reminder once (from either entry) is
enough for the rest of that session - see `docs/features/printing-tags.md`'s neighbour,
`SPEC-cardback-pdfwait.md` §C.1, for the gate's own full design.

## Post-export contribution prompt (issue #166)

`useDownloadPDF`/`useSaveToDrivePDF` (this file) are the shared success
Expand Down
36 changes: 32 additions & 4 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,38 @@ cloud-download counter beside it (nav+footer redesign, 2026-07-22 - this
used to sit in the top navbar; it now lives next to the exports it counts,
here and again on the Print page). Your project is
also quietly backed up to this browser as you work — a small "Draft
backed up locally" note under the buttons confirms it — and pressing
**Print / Export →** while signed in with unsaved changes offers to save
your deck first, since the print/PDF step can use a lot of your
browser's memory and you don't want to lose your work if it struggles.
backed up locally" note under the buttons confirms it.

Pressing **Print / Export →** first checks whether your deck is still
riding the site's default cardback and, if so, asks once: **Choose a
cardback** (opens the same picker described below) or **Use current &
continue** — dismissing the prompt any other way (✕, Esc, clicking
outside it) is treated the same as **Use current & continue**, not as a
cancel, so it never blocks you from printing. It only asks once per
print session either way. Only after that does it (while signed in with
unsaved changes) offer to save your deck first, since the print/PDF step
can use a lot of your browser's memory and you don't want to lose your
work if it struggles.

**Choosing a cardback**: the toolbar's **Cardback** button picks one for
the whole project — the picker also offers to **Apply to all card
backs** (this also overwrites any individual slot you'd already given a
different, custom back — the count and thumbnails make that explicit
before you tap it) and to **Set as my default cardback** for future
projects, both optional and never pre-selected for you. Picking a back
for a single card instead (the slot's own "Choose a different back…"
control) never touches any other slot unless you separately opt in to
its own "Apply to all" — and a slot with a custom back gets a small dot
on its own flip (⟲) icon on the print sheet, so you can spot which cards
diverge from the deck default at a glance.

**While your PDF builds**, the Print page shows a real progress bar
(fetching your images, then laying out/encoding the file) and, beside
it, an embedded round of **What's That Card?** so waiting on a big
export isn't dead time — answering (or skipping) a card there is
optional and never blocks the export. Once the PDF is ready, the game
is replaced by a one-time nudge pointing you at the full **What's That
Card?** page if you'd like to keep going.

## Saving and re-using a project

Expand Down
11 changes: 11 additions & 0 deletions frontend/src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,17 @@ export type Project = {
* exactly; an entry is only ever present for a non-"auto" choice - see projectSlice's
* `setManualOverride`. */
manualOverrides: { [identifier: string]: ManualOverride };
/** Cardback flow round (SPEC-cardback-pdfwait.md §C.1, Annex A-1) - whether the user has ever
* explicitly chosen a project cardback this project (toolbar/rail "Apply to all"/the reminder
* gate's own "Choose a cardback" all count - anything that dispatches `setSelectedCardback`
* with a real image), as distinct from `cardback` simply being non-null. Null-ness alone can't
* carry this distinction once a real site-default seed exists (Annex A-1's own note: "a
* default always resolves a non-null projectCardback"); today, with no seed wired up yet, this
* collapses to "has `cardback` ever been set", but the flag is tracked as its own field so the
* `usePrePrintSaveGate`/reminder-gate fire condition (`ridingUntouchedDefault`) doesn't need to
* change again once Annex A-1's seed lands. Optional (not every existing `Project`-shaped
* fixture/test object sets it) - `undefined` reads as `false` everywhere this is consulted. */
cardbackExplicitlySet?: boolean;
};

export interface DFCPairs {
Expand Down
155 changes: 155 additions & 0 deletions frontend/src/features/card/CardbackApplyPrompt.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* Cardback flow round (SPEC-cardback-pdfwait.md §C.2, OWNER AMENDMENT 2/OQ-B) - component-level
* coverage for the shared apply-all/set-default prompt: the two entries' distinct copy/chrome
* (toolbar's "Not now" skip link vs. rail's never-pre-checked trap-guard line), the done-state
* flip on each button, and the OWNER AMENDMENT 2 thumbnail grid.
*/
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";

import { CardbackApplyPrompt } from "@/features/card/CardbackApplyPrompt";

describe("CardbackApplyPrompt", () => {
test("toolbar entry: project-wide copy, a 'Not now' skip link, no trap-guard note", () => {
const onDismiss = jest.fn();
render(
<CardbackApplyPrompt
entry="toolbar"
affectedCount={3}
customBackThumbnails={[]}
onApplyAll={jest.fn()}
onSetDefault={jest.fn()}
onDismiss={onDismiss}
/>
);

expect(
screen.getByText(/Set as this project’s cardback/)
).toBeInTheDocument();
expect(screen.getByTestId("cardback-apply-all-button")).toHaveTextContent(
"Apply to all (3)"
);
expect(screen.queryByTestId("cardback-apply-prompt-trapnote")).toBeNull();
expect(
screen.getByTestId("cardback-apply-prompt-not-now")
).toBeInTheDocument();
});

test("rail entry: per-slot copy, the never-pre-checked trap-guard note, no skip link", () => {
render(
<CardbackApplyPrompt
entry="rail"
affectedCount={6}
customBackThumbnails={[]}
onApplyAll={jest.fn()}
onSetDefault={jest.fn()}
/>
);

expect(
screen.getByText(/Applied to this slot’s back only/)
).toBeInTheDocument();
expect(
screen.getByTestId("cardback-apply-prompt-trapnote")
).toHaveTextContent(/never pre-checked/);
expect(screen.queryByTestId("cardback-apply-prompt-not-now")).toBeNull();
});

test("apply-all button is never pre-checked/done, and flips to a done state only after a real click", async () => {
const user = userEvent.setup();
const onApplyAll = jest.fn();
render(
<CardbackApplyPrompt
entry="rail"
affectedCount={6}
customBackThumbnails={[]}
onApplyAll={onApplyAll}
onSetDefault={jest.fn()}
/>
);

const applyButton = screen.getByTestId("cardback-apply-all-button");
expect(applyButton).toHaveTextContent("Apply to all (6)");
expect(onApplyAll).not.toHaveBeenCalled();

await user.click(applyButton);

expect(onApplyAll).toHaveBeenCalledTimes(1);
expect(applyButton).toHaveTextContent("Applied to all ✓");
});

test("set-default button flips to a done state independently of apply-all (two distinct, individually-skippable actions)", async () => {
const user = userEvent.setup();
const onSetDefault = jest.fn();
render(
<CardbackApplyPrompt
entry="toolbar"
affectedCount={1}
customBackThumbnails={[]}
onApplyAll={jest.fn()}
onSetDefault={onSetDefault}
onDismiss={jest.fn()}
/>
);

const defaultButton = screen.getByTestId("cardback-set-default-button");
await user.click(defaultButton);

expect(onSetDefault).toHaveBeenCalledTimes(1);
expect(defaultButton).toHaveTextContent("Default set ✓");
// The apply-all button is untouched - the two choices are independent.
expect(screen.getByTestId("cardback-apply-all-button")).toHaveTextContent(
"Apply to all (1)"
);
});

test("OWNER AMENDMENT 2 - renders a thumbnail (front + current custom back) for every affected slot, above the count line", () => {
render(
<CardbackApplyPrompt
entry="toolbar"
affectedCount={2}
customBackThumbnails={[
{
slotLabel: "Slot 2",
frontThumbnailUrl: "https://example.com/front-2.png",
frontName: "Front 2",
backThumbnailUrl: "https://example.com/back-2.png",
backName: "Back 2",
},
{
slotLabel: "Slot 5",
frontThumbnailUrl: undefined,
frontName: undefined,
backThumbnailUrl: "https://example.com/back-5.png",
backName: "Back 5",
},
]}
onApplyAll={jest.fn()}
onSetDefault={jest.fn()}
onDismiss={jest.fn()}
/>
);

const thumbnails = screen.getByTestId("cardback-apply-prompt-thumbnails");
expect(thumbnails).toHaveTextContent("Slot 2");
expect(thumbnails).toHaveTextContent("Slot 5");
// also overrides copy names the count.
expect(screen.getByText(/also overrides 2 cards/)).toBeInTheDocument();
});

test("renders no thumbnail grid when nothing is currently custom", () => {
render(
<CardbackApplyPrompt
entry="toolbar"
affectedCount={0}
customBackThumbnails={[]}
onApplyAll={jest.fn()}
onSetDefault={jest.fn()}
onDismiss={jest.fn()}
/>
);

expect(screen.queryByTestId("cardback-apply-prompt-thumbnails")).toBeNull();
});
});
Loading
Loading