Skip to content

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 — existing worker-src 'self' blob: and script-src 'self' 'wasm-unsafe-eval' already cover a same-origin pdf.js worker + optional WASM codecs.
  • ExportPreview props stay exactly { format: 'cards' | 'worksheet'; cardModel: CardModel; worksheetModel: WorksheetModel }.
  • Preview box height stays 460.
  • pdfjs-dist must be lazy (it rides ExportPreview's already-React.lazy'd chunk — do not import it from ExportDialog or any eagerly-loaded module).
  • Pin pdfjs-dist@^4.10.38 (the v4 page.render({ canvasContext, viewport }) signature the code below relies on; v5 changed it).
  • The canvas preview must reach phones. ExportDialog.tsx on develop currently gates <ExportPreview> behind an isMobile (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 leaves ExportDialog.tsx alone, 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
Expected: 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
Expected: FAIL — 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 couldnt 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
Expected: PASS — both tests green (2 canvases on success; error text on failure).

  • [ ] Step 6: Run type-check, lint, and the export suite

Run (in packages/web/frontend):

npm run build && npm run lint && npm test -- export
Expected: type-check + production build succeed (confirms Vite resolves 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]>"
(Run 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
Expected: FAIL — the gate still renders the "Preview isn't available on mobile" message and does not mount 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';
to:
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'));
Remove those five lines entirely.

(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 ? (
to:
          ) : cardModel && worksheetModel ? (

  • [ ] Step 4: Run the test to verify it passes

Run (in packages/web/frontend):

npm test -- ExportDialog
Expected: PASS — the preview mounts at mobile width; the "not available" message is gone.

  • [ ] Step 5: Type-check, lint, and full export suite

Run (in packages/web/frontend):

npm run build && npm run lint && npm test -- export
Expected: build + lint clean (confirms no dangling 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 to develop, and let it deploy to develop.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 to ExportPreview.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') returns null under happy-dom; the if (!ctx) continue; guard means the success test still appends the <canvas> elements (asserted) without actually painting.