Mobile PDF Preview via pdf.js Canvas Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make the Therapy Pack export preview render on iPhone and iPad by replacing @react-pdf/renderer's <PDFViewer> (a blob iframe WebKit won't render) with a pdf.js canvas renderer used for all users.
Architecture: ExportPreview.tsx builds the same react-pdf document, produces a blob, and rasterizes every page to a stacked <canvas> via pdfjs-dist in a scrollable box. One engine-agnostic code path; no device detection. ExportDialog.tsx is untouched (same props, same lazy import, same 460px slot).
Tech Stack: React + TypeScript + MUI, @react-pdf/renderer (blob generation only), pdfjs-dist v4 (rasterization), Vite (bundles the pdf.js worker as a same-origin asset), Vitest + happy-dom.
Global Constraints¶
- Package/component scope only:
packages/web/frontend/src/components/pack/export/. - No
_headers/CSP change — existingworker-src 'self' blob:andscript-src 'self' 'wasm-unsafe-eval'already cover a same-origin pdf.js worker + optional WASM codecs. ExportPreviewprops stay exactly{ format: 'cards' | 'worksheet'; cardModel: CardModel; worksheetModel: WorksheetModel }.- Preview box height stays
460. pdfjs-distmust be lazy (it ridesExportPreview's already-React.lazy'd chunk — do not import it fromExportDialogor any eagerly-loaded module).- Pin
pdfjs-dist@^4.10.38(the v4page.render({ canvasContext, viewport })signature the code below relies on; v5 changed it). - The canvas preview must reach phones.
ExportDialog.tsxondevelopcurrently gates<ExportPreview>behind anisMobile(useMediaQuery(breakpoints.down('sm'))) check that shows a "Preview isn't available on mobile" message on phone-width viewports — a stopgap for the old iframe bug. Task 2 removes that gate so the new canvas preview mounts on all viewports; otherwise the fix only reaches iPad (>sm), not iPhone. (Task 1's brief still says "ExportDialog untouched"; that constraint is superseded by Task 2 — Task 1 legitimately leavesExportDialog.tsxalone, Task 2 owns the gate removal.)
Task 1: Replace <PDFViewer> with a pdf.js canvas renderer¶
Files:
- Modify: packages/web/frontend/package.json (add pdfjs-dist dependency)
- Rewrite: packages/web/frontend/src/components/pack/export/ExportPreview.tsx
- Create: packages/web/frontend/src/components/pack/export/ExportPreview.test.tsx
Interfaces:
- Consumes: pdf (named export) from @react-pdf/renderer; PictureCardsDoc from ./PictureCardsDoc; WorksheetDoc from ./WorksheetDoc; CardModel, WorksheetModel from ../../../lib/export/exportModel.
- Produces: default-export React component ExportPreview with unchanged props (consumed by ExportDialog.tsx's existing React.lazy(() => import('./ExportPreview'))).
- [ ] Step 1: Install the dependency
Run (in packages/web/frontend):
npm install pdfjs-dist@^4.10.38
package.json gains "pdfjs-dist": "^4.10.38" under dependencies; no peer-dep errors.
- [ ] Step 2: Write the failing test
Create packages/web/frontend/src/components/pack/export/ExportPreview.test.tsx:
import { render, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { CardModel, WorksheetModel } from '../../../lib/export/exportModel';
// pdf.js: mock the module and the Vite ?url worker import (happy-dom can't run the worker).
const getDocumentMock = vi.fn();
vi.mock('pdfjs-dist', () => ({
GlobalWorkerOptions: { workerSrc: '' },
getDocument: (...a: unknown[]) => getDocumentMock(...a),
}));
vi.mock('pdfjs-dist/build/pdf.worker.min.mjs?url', () => ({ default: 'worker.js' }));
// react-pdf: only pdf().toBlob() is used; the Doc components are irrelevant to rendering here.
const toBlobMock = vi.fn();
vi.mock('@react-pdf/renderer', () => ({ pdf: () => ({ toBlob: toBlobMock }) }));
vi.mock('./PictureCardsDoc', () => ({ PictureCardsDoc: () => null }));
vi.mock('./WorksheetDoc', () => ({ WorksheetDoc: () => null }));
import ExportPreview from './ExportPreview';
const cardModel = {} as CardModel;
const worksheetModel = {} as WorksheetModel;
function mockPdfDoc(numPages: number) {
return {
numPages,
getPage: vi.fn().mockResolvedValue({
getViewport: ({ scale }: { scale: number }) => ({ width: 100 * scale, height: 130 * scale }),
render: () => ({ promise: Promise.resolve() }),
}),
destroy: vi.fn(),
};
}
describe('ExportPreview', () => {
beforeEach(() => {
getDocumentMock.mockReset();
toBlobMock.mockReset();
});
it('rasterizes each page to a canvas on success', async () => {
toBlobMock.mockResolvedValue(new Blob(['%PDF']));
getDocumentMock.mockReturnValue({ promise: Promise.resolve(mockPdfDoc(2)) });
const { container } = render(
<ExportPreview format="cards" cardModel={cardModel} worksheetModel={worksheetModel} />,
);
await waitFor(() => expect(container.querySelectorAll('canvas').length).toBe(2));
});
it('shows an error message when the build fails', async () => {
toBlobMock.mockRejectedValue(new Error('boom'));
const { findByText } = render(
<ExportPreview format="worksheet" cardModel={cardModel} worksheetModel={worksheetModel} />,
);
expect(await findByText(/couldn.t be generated/i)).toBeTruthy();
});
});
- [ ] Step 3: Run the test to verify it fails
Run (in packages/web/frontend):
npm test -- ExportPreview
ExportPreview.tsx still exports the old <PDFViewer> component, so no <canvas> is produced and there is no error text.
- [ ] Step 4: Rewrite the component
Replace the entire contents of packages/web/frontend/src/components/pack/export/ExportPreview.tsx with:
import React, { useEffect, useRef, useState } from 'react';
import { Box, CircularProgress, Typography } from '@mui/material';
import { pdf } from '@react-pdf/renderer';
import * as pdfjsLib from 'pdfjs-dist';
import workerSrc from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
import { PictureCardsDoc } from './PictureCardsDoc';
import { WorksheetDoc } from './WorksheetDoc';
import type { CardModel, WorksheetModel } from '../../../lib/export/exportModel';
// Same-origin worker asset (Vite emits it); satisfies CSP worker-src 'self'.
pdfjsLib.GlobalWorkerOptions.workerSrc = workerSrc;
interface ExportPreviewProps {
format: 'cards' | 'worksheet';
cardModel: CardModel;
worksheetModel: WorksheetModel;
}
const PREVIEW_HEIGHT = 460;
// Rasterize the export PDF to stacked canvases instead of @react-pdf/renderer's <PDFViewer>,
// whose blob <iframe> WebKit (iOS/iPadOS Safari) refuses to render inline. A <canvas> is
// engine-agnostic pixels, so the preview works identically on desktop, iPhone, and iPad — no
// device detection (iPadOS Safari masquerades as desktop Mac, so detection is unreliable anyway).
const ExportPreview: React.FC<ExportPreviewProps> = ({ format, cardModel, worksheetModel }) => {
const containerRef = useRef<HTMLDivElement>(null);
// Monotonic token: a render that finishes after props changed is stale and must not paint.
const renderToken = useRef(0);
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
useEffect(() => {
const token = ++renderToken.current;
const container = containerRef.current;
if (!container) return;
setStatus('loading');
(async () => {
try {
const doc = format === 'cards'
? <PictureCardsDoc model={cardModel} />
: <WorksheetDoc model={worksheetModel} />;
const blob = await pdf(doc).toBlob();
const data = await blob.arrayBuffer();
if (token !== renderToken.current) return;
const pdfDoc = await pdfjsLib.getDocument({ data }).promise;
if (token !== renderToken.current) { pdfDoc.destroy(); return; }
container.replaceChildren();
const cssWidth = container.clientWidth || 600;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
for (let i = 1; i <= pdfDoc.numPages; i += 1) {
if (token !== renderToken.current) { pdfDoc.destroy(); return; }
const page = await pdfDoc.getPage(i);
const base = page.getViewport({ scale: 1 });
const viewport = page.getViewport({ scale: (cssWidth / base.width) * dpr });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = '100%';
canvas.style.height = 'auto';
canvas.style.display = 'block';
canvas.style.marginBottom = '8px';
container.appendChild(canvas);
const ctx = canvas.getContext('2d');
if (!ctx) continue;
await page.render({ canvasContext: ctx, viewport }).promise;
}
pdfDoc.destroy();
if (token !== renderToken.current) return;
setStatus('ready');
} catch {
if (token === renderToken.current) setStatus('error');
}
})();
// Invalidate any in-flight render when props change or the component unmounts.
return () => { renderToken.current += 1; };
}, [format, cardModel, worksheetModel]);
return (
<Box sx={{ position: 'relative', height: PREVIEW_HEIGHT }}>
<Box
ref={containerRef}
sx={{ height: PREVIEW_HEIGHT, overflow: 'auto', bgcolor: 'action.hover', borderRadius: 1, p: 1 }}
/>
{status === 'loading' && (
<Box sx={{ position: 'absolute', inset: 0, display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<CircularProgress />
</Box>
)}
{status === 'error' && (
<Box sx={{ position: 'absolute', inset: 0, display: 'flex', justifyContent: 'center', alignItems: 'center', p: 2 }}>
<Typography variant="caption" color="error">
Preview couldn’t be generated. You can still use the Download button.
</Typography>
</Box>
)}
</Box>
);
};
export default ExportPreview;
- [ ] Step 5: Run the test to verify it passes
Run (in packages/web/frontend):
npm test -- ExportPreview
- [ ] Step 6: Run type-check, lint, and the export suite
Run (in packages/web/frontend):
npm run build && npm run lint && npm test -- export
pdfjs-dist/build/pdf.worker.min.mjs?url and emits a pdf.worker asset); lint clean; ExportDialog tests still pass (they mock ./ExportPreview, so they are unaffected).
- [ ] Step 7: Commit
Run (in packages/web/frontend):
git add packages/web/frontend/package.json packages/web/frontend/package-lock.json \
packages/web/frontend/src/components/pack/export/ExportPreview.tsx \
packages/web/frontend/src/components/pack/export/ExportPreview.test.tsx
git commit -m "fix(export): render PDF preview to canvas so it works on iOS/iPadOS (PHON-170, 7.0.3)
Co-Authored-By: Claude Opus 4.8 <[email protected]>"
git add from the repo root, or adjust paths — the exact paths above are repo-relative.)
Task 2: Remove the isMobile preview gate in ExportDialog.tsx¶
Files:
- Modify: packages/web/frontend/src/components/pack/export/ExportDialog.tsx
- Modify: packages/web/frontend/src/components/pack/export/ExportDialog.test.tsx
Interfaces:
- Consumes: ExportPreview (default export, unchanged from Task 1) via the existing React.lazy(() => import('./ExportPreview')).
- Produces: nothing new — ExportDialog keeps the same props { open: boolean; onClose: () => void }.
Background: on develop, ExportDialog.tsx special-cases phone-width viewports (isMobile = useMediaQuery(theme.breakpoints.down('sm'))) and renders a "Preview isn't available on mobile" message instead of <ExportPreview>. That was a stopgap for the old iframe bug Task 1 just fixed. Since the canvas preview works in-page on every engine, the gate must go, or iPhone-width viewports never see the preview. The download path is untouched.
- [ ] Step 1: Update the failing test to the new behavior
In packages/web/frontend/src/components/pack/export/ExportDialog.test.tsx, replace the existing test that begins it('skips the PDF iframe preview on mobile' (it asserts the message is shown and the preview is absent) with this test, which asserts the preview now mounts even at phone width:
it('renders the PDF preview at phone width (canvas works in-page, unlike an iframe)', async () => {
// Force MUI useMediaQuery to report a mobile viewport; the preview must still mount.
const orig = window.matchMedia;
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: true, media: query, onchange: null,
addEventListener: vi.fn(), removeEventListener: vi.fn(),
addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(),
})) as unknown as typeof window.matchMedia;
try {
render(<ExportDialog open onClose={vi.fn()} />);
await waitFor(() => expect(screen.getByRole('button', { name: /download/i })).toBeEnabled());
expect(await screen.findByTestId('pdf-preview')).toBeInTheDocument();
expect(screen.queryByText(/preview isn't available on mobile/i)).not.toBeInTheDocument();
} finally {
window.matchMedia = orig;
}
});
- [ ] Step 2: Run the test to verify it fails
Run (in packages/web/frontend):
npm test -- ExportDialog
pdf-preview at mobile width.
- [ ] Step 3: Remove the gate from
ExportDialog.tsx
Three edits in packages/web/frontend/src/components/pack/export/ExportDialog.tsx:
(a) Drop the now-unused imports. Change:
import {
Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
Tab, Tabs, Typography, CircularProgress, useMediaQuery, useTheme,
} from '@mui/material';
import {
Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
Tab, Tabs, Typography, CircularProgress,
} from '@mui/material';
(b) Delete the comment and the two hooks (the block that reads):
// Mobile browsers (Chrome Android, iOS WebKit) refuse to render a PDF inside an <iframe>,
// which is what react-pdf's <PDFViewer> does — they show "This content is blocked" instead.
// So on mobile we skip the live PDF preview and point the user at Download.
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
(c) Delete the isMobile branch in the preview <Box>. Change:
) : isMobile ? (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center', gap: 1, py: 6, px: 2, bgcolor: 'action.hover', borderRadius: 1 }}>
<Typography variant="body2" color="text.secondary">
Preview isn't available on mobile.
</Typography>
<Typography variant="caption" color="text.secondary">
Tap Download to save the {format === 'cards' ? 'picture cards' : 'worksheet'} PDF and open it.
</Typography>
</Box>
) : cardModel && worksheetModel ? (
) : cardModel && worksheetModel ? (
- [ ] Step 4: Run the test to verify it passes
Run (in packages/web/frontend):
npm test -- ExportDialog
- [ ] Step 5: Type-check, lint, and full export suite
Run (in packages/web/frontend):
npm run build && npm run lint && npm test -- export
useMediaQuery/useTheme/theme/isMobile references); the full export suite passes. If npm run build complains about a missing VITE_API_URL (a build-guard on this branch), re-run it as VITE_API_URL=https://staging-api.phonolex.com npm run build — env-only, no code change.
- [ ] Step 6: Commit
Run (from the repo root, or adjust paths):
git add packages/web/frontend/src/components/pack/export/ExportDialog.tsx \
packages/web/frontend/src/components/pack/export/ExportDialog.test.tsx
git commit -m "fix(export): mount the canvas PDF preview on phones (drop isMobile gate) (PHON-170, 7.0.3)
Co-Authored-By: Claude Opus 4.8 <[email protected]>"
Task 3: Device verification on staging (manual gate)¶
Files: none (verification only).
This bug is invisible to build/type-check/unit tests — the real gate is a physical device.
- [ ] Step 1: Push
fix/mobile-pdf-preview-7-0-3, open a PR todevelop, and let it deploy todevelop.phonolex.pages.dev. - [ ] Step 2: On an actual iPhone (Safari), open a therapy pack → Export → confirm both the Picture cards and Worksheet tabs render page images in the preview pane (not a blank box).
- [ ] Step 3: Repeat on an actual iPad (Safari) — the device the old iframe silently failed on despite its desktop-like viewport.
- [ ] Step 4: On desktop, confirm the preview still renders and the Download button still produces the real PDF for both formats.
- [ ] Step 5: In DevTools on staging, confirm no CSP violation for the pdf.js worker (it must load same-origin under
worker-src 'self').
Notes for the implementer¶
- Do not touch
ExportDialog.tsx,_headers, or the Doc components. The change is contained toExportPreview.tsx+ its test + the new dependency. - Version bump: this is the 7.0.3 line. Follow the project's version-bump checklist (AppHeader chip + drawers) if the PR is the version-carrying change; if a sibling 7.0.x PR carries the bump, skip it here and note that in the PR.
- happy-dom canvas:
canvas.getContext('2d')returnsnullunder happy-dom; theif (!ctx) continue;guard means the success test still appends the<canvas>elements (asserted) without actually painting.