Skip to content

Therapy Pack — Export Renderer (Phase 2) 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: Turn a Therapy Pack into the owned deliverable — client-side–rendered picture-card and worksheet PDFs plus a CSV, with AAC symbol imagery where available, CC BY-SA attribution in the footer, and two convenience presets (Kids / Adult-medical) — wired to the Editor's Preview/Export buttons.

Architecture: All client-side. A pure export-model layer (fully unit-tested) shapes a pack + resolved word metadata into card/worksheet/CSV models; thin @react-pdf/renderer document components render those models to PDF; a rasterize util converts the app's SVG (same-origin) and WebP (R2, cross-origin) images to PNG data URLs (the only formats @react-pdf/renderer embeds), degrading to word-only cards on any failure; an ExportDialog hosts format tabs, presets, a live preview, and download. Image + CSV-enrichment metadata is fetched once per export via the existing POST /api/words/batch endpoint.

Tech Stack: React 19, TypeScript, MUI 7, Zustand 5, @react-pdf/renderer (new dep), the existing /api/words/batch endpoint, Vitest 4 + @testing-library/react + happy-dom.

Global Constraints

  • Client-side only. No new backend service, no new D1 tables, no new API route. Image/CSV metadata comes from the existing POST /api/words/batch (returns word, ipa, phonemes, syllable_count, cv_shape, has_image, image_file, image_provider). Batch is capped at 1000 words server-side.
  • Store is the read source. Export reads the active pack from usePackStore; it never mutates it and never touches packDb directly.
  • Privacy. Exports carry pack content by design (that's the deliverable), but analytics must not. Reuse the existing allowlisted results_exported event: track('results_exported', { tool: 'therapyPack', format, item_count }) — NO word text, NO notes. Do not add a new analytics event or touch the worker allowlist.
  • Imagery = AAC symbols for all ages (spec §5.8). Mulberry/OpenMoji SVGs are same-origin (/images/words/{provider}/{file}); PhonoLex-generated cards are WebP on R2 via VITE_GENERATED_IMAGE_BASE_URL. Use wordImageUrl(provider, file) from src/lib/wordImages.ts to build URLs and WORD_IMAGE_PROVIDER_LABELS for attribution. Never serve source_file or emulate proprietary symbol art.
  • @react-pdf/renderer embeds PNG/JPEG only — not SVG, not WebP. Every card image MUST be rasterized to a PNG data URL first. Any image that fails to load/rasterize (missing, CORS-tainted, decode error) falls back to a word-only card — never a broken image, never a thrown export.
  • CC BY-SA 4.0 attribution is mandatory in any PDF that embeds images: a footer line naming each distinct provider used (via WORD_IMAGE_PROVIDER_LABELS).
  • Lazy-load the PDF engine. @react-pdf/renderer is heavy — import it with dynamic import() inside the ExportDialog path so it stays out of the main bundle.
  • Test env: @react-pdf/renderer (pdfkit/yoga) must NOT load under happy-dom in unit tests — component tests that would pull it in must vi.mock('@react-pdf/renderer', …). The pure model layer and CSV have no such dependency and are tested directly.
  • Test commands (from packages/web/frontend/): npm test -- --run <file>, npm run type-check, npm run lint, npm run build.
  • Pack types (src/types/therapyPack.ts): TherapyPack, PackWord (word, ipa?, hasImage?), PackContrast (a, b, label?), PackSentence (text, targets[]), TargetSpec.

File Structure

  • packages/web/frontend/package.json (modify) — add @react-pdf/renderer.
  • src/services/apiClient.ts (modify) — add batchWords(words: string[]) method.
  • src/lib/export/rasterize.ts (create) — rasterizeToPng(url) DOM util.
  • src/lib/export/packImages.ts (create) — resolvePackImages(words) (batch fetch) + rasterizePackImages(...).
  • src/lib/export/exportModel.ts (create) — PURE model builders: buildCardModel, buildWorksheetModel, buildPackCsv, buildAttribution, PRESET_OPTIONS.
  • src/lib/export/exportModel.test.ts, rasterize.test.ts, packImages.test.ts (create) — unit tests.
  • src/components/pack/export/PictureCardsDoc.tsx (create) — @react-pdf/renderer cards document.
  • src/components/pack/export/WorksheetDoc.tsx (create) — @react-pdf/renderer worksheet document.
  • src/components/pack/export/ExportDialog.tsx (create) — format tabs + presets + preview + download.
  • src/components/pack/export/ExportDialog.test.tsx (create) — RTL test (react-pdf mocked).
  • src/components/pack/PackHeader.tsx (modify) — enable Preview/Export, add onExport prop.
  • src/components/pack/PackEditor.tsx (modify) — host ExportDialog, pass onExport.
  • src/components/pack/PackEditor.test.tsx (modify) — assert Export opens the dialog.

Task 1: @react-pdf/renderer dependency + batchWords service method

Files: - Modify: packages/web/frontend/package.json - Modify: src/services/apiClient.ts - Test: src/services/apiClient.batchWords.test.ts (create)

Interfaces: - Consumes: the class's existing this.post<T>(path, body) helper (used by searchWords/getWord). - Produces: - export interface BatchWordInfo { word: string; ipa?: string; phonemes_str?: string; syllable_count?: number; cv_shape?: string; has_image?: boolean; image_file?: string; image_provider?: string; } - async batchWords(words: string[]): Promise<BatchWordInfo[]> on the api client — POSTs /api/words/batch with { words }, returns the array (empty array for empty input, no network call).

  • [ ] Step 1: Install the dependency

Run (from packages/web/frontend/):

npm install @react-pdf/renderer
Expected: package.json gains "@react-pdf/renderer": "^4.x" under dependencies; package-lock.json updates.

  • [ ] Step 2: Write the failing test

Create src/services/apiClient.batchWords.test.ts:

import { describe, it, expect, vi, afterEach } from 'vitest';
import { api } from './apiClient';

afterEach(() => vi.restoreAllMocks());

describe('api.batchWords', () => {
  it('returns [] without calling the network for empty input', async () => {
    const spy = vi.spyOn(globalThis, 'fetch');
    expect(await api.batchWords([])).toEqual([]);
    expect(spy).not.toHaveBeenCalled();
  });

  it('POSTs /api/words/batch and returns the array', async () => {
    const rows = [{ word: 'rocket', ipa: 'ɹ ɑ k ə t', image_file: 'rocket.svg', image_provider: 'mulberry' }];
    vi.spyOn(globalThis, 'fetch').mockResolvedValue(
      new Response(JSON.stringify(rows), { status: 200, headers: { 'Content-Type': 'application/json' } }),
    );
    const out = await api.batchWords(['rocket']);
    expect(out).toEqual(rows);
    const call = (globalThis.fetch as unknown as { mock: { calls: unknown[][] } }).mock.calls[0];
    expect(String(call[0])).toContain('/api/words/batch');
    expect((call[1] as RequestInit).method).toBe('POST');
  });
});
  • [ ] Step 3: Run the test to verify it fails

Run: npm test -- --run src/services/apiClient.batchWords.test.ts Expected: FAIL — api.batchWords is not a function.

  • [ ] Step 4: Implement the method + type

In src/services/apiClient.ts, add the type near the other exported types and the method to the api class (next to getWord/searchWords). Use the class's existing post helper (confirm its exact name by reading the file — if it's this.post, use that; the batch route expects a raw JSON array back, so type the post accordingly):

export interface BatchWordInfo {
  word: string;
  ipa?: string;
  phonemes_str?: string;
  syllable_count?: number;
  cv_shape?: string;
  has_image?: boolean;
  image_file?: string;
  image_provider?: string;
}
  async batchWords(words: string[]): Promise<BatchWordInfo[]> {
    if (words.length === 0) return [];
    return this.post<BatchWordInfo[]>('/api/words/batch', { words: words.slice(0, 1000) });
  }

If the api object is a plain object literal rather than a class (read the file to confirm), attach batchWords in the same style as the sibling methods and reuse whatever internal request helper searchWords uses.

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

Run: npm test -- --run src/services/apiClient.batchWords.test.ts Expected: PASS.

  • [ ] Step 6: Type-check and commit
npm run type-check
git add packages/web/frontend/package.json packages/web/frontend/package-lock.json src/services/apiClient.ts src/services/apiClient.batchWords.test.ts
git commit -m "feat(packs): @react-pdf/renderer dep + batchWords service method (PHON-170)"

Task 2: rasterizeToPng — SVG/WebP → PNG data URL

Files: - Create: src/lib/export/rasterize.ts - Test: src/lib/export/rasterize.test.ts

Interfaces: - Produces: export async function rasterizeToPng(url: string, size?: number): Promise<string | null> — loads url into an Image (with crossOrigin='anonymous' so cross-origin R2 WebP can be read back off the canvas), draws it centered/contained onto a size×size (default 256) canvas with a white background, and returns a image/png data URL. Returns null on any load or draw failure (never throws). Injectable seam for tests: accept an optional deps param { createImage?: () => HTMLImageElement; createCanvas?: () => HTMLCanvasElement } defaulting to real DOM factories.

  • [ ] Step 1: Write the failing test

Create src/lib/export/rasterize.test.ts:

import { describe, it, expect } from 'vitest';
import { rasterizeToPng } from './rasterize';

// A fake <img> whose src setter immediately fires onload/onerror.
function fakeImageFactory(mode: 'load' | 'error') {
  return () => {
    const img = {
      crossOrigin: '',
      width: 10, height: 10,
      _src: '',
      onload: null as null | (() => void),
      onerror: null as null | (() => void),
      set src(v: string) { this._src = v; queueMicrotask(() => mode === 'load' ? this.onload?.() : this.onerror?.()); },
      get src() { return this._src; },
    };
    return img as unknown as HTMLImageElement;
  };
}

function fakeCanvasFactory(toDataURL: () => string) {
  return () => ({
    width: 0, height: 0,
    getContext: () => ({ fillStyle: '', fillRect: () => {}, drawImage: () => {} }),
    toDataURL,
  } as unknown as HTMLCanvasElement);
}

describe('rasterizeToPng', () => {
  it('returns a PNG data URL on successful load', async () => {
    const out = await rasterizeToPng('/images/words/mulberry/rocket.svg', 256, {
      createImage: fakeImageFactory('load'),
      createCanvas: fakeCanvasFactory(() => 'data:image/png;base64,AAAA'),
    });
    expect(out).toBe('data:image/png;base64,AAAA');
  });

  it('returns null when the image fails to load', async () => {
    const out = await rasterizeToPng('https://aac.example/bad.webp', 256, {
      createImage: fakeImageFactory('error'),
      createCanvas: fakeCanvasFactory(() => 'data:image/png;base64,AAAA'),
    });
    expect(out).toBeNull();
  });

  it('returns null when toDataURL throws (tainted canvas)', async () => {
    const out = await rasterizeToPng('https://aac.example/tainted.webp', 256, {
      createImage: fakeImageFactory('load'),
      createCanvas: fakeCanvasFactory(() => { throw new Error('tainted'); }),
    });
    expect(out).toBeNull();
  });
});
  • [ ] Step 2: Run the test to verify it fails

Run: npm test -- --run src/lib/export/rasterize.test.ts Expected: FAIL — module not found.

  • [ ] Step 3: Implement the util

Create src/lib/export/rasterize.ts:

/**
 * Rasterize an image URL to a PNG data URL for embedding in a PDF.
 *
 * @react-pdf/renderer embeds PNG/JPEG only — not the app's SVG (Mulberry/
 * OpenMoji, same-origin) or WebP (PhonoLex-generated, cross-origin on R2).
 * The browser decodes both into an <img>; drawing that onto a canvas and
 * reading it back as PNG is the portable conversion. crossOrigin='anonymous'
 * lets us read cross-origin R2 images back off the canvas (requires R2 CORS;
 * without it the canvas is tainted and toDataURL throws → we return null and
 * the caller falls back to a word-only card).
 */
interface RasterizeDeps {
  createImage?: () => HTMLImageElement;
  createCanvas?: () => HTMLCanvasElement;
}

export async function rasterizeToPng(
  url: string,
  size = 256,
  deps: RasterizeDeps = {},
): Promise<string | null> {
  const createImage = deps.createImage ?? (() => new Image());
  const createCanvas = deps.createCanvas ?? (() => document.createElement('canvas'));

  const img = createImage();
  img.crossOrigin = 'anonymous';

  const loaded = await new Promise<boolean>((resolve) => {
    img.onload = () => resolve(true);
    img.onerror = () => resolve(false);
    img.src = url;
  });
  if (!loaded) return null;

  try {
    const canvas = createCanvas();
    canvas.width = size;
    canvas.height = size;
    const ctx = canvas.getContext('2d');
    if (!ctx) return null;
    ctx.fillStyle = '#ffffff';
    ctx.fillRect(0, 0, size, size);
    // Contain the source within the square, preserving aspect ratio.
    const iw = img.width || size;
    const ih = img.height || size;
    const scale = Math.min(size / iw, size / ih);
    const w = iw * scale;
    const h = ih * scale;
    ctx.drawImage(img, (size - w) / 2, (size - h) / 2, w, h);
    return canvas.toDataURL('image/png');
  } catch {
    return null;
  }
}
  • [ ] Step 4: Run the test to verify it passes

Run: npm test -- --run src/lib/export/rasterize.test.ts Expected: PASS (3/3).

  • [ ] Step 5: Commit
git add src/lib/export/rasterize.ts src/lib/export/rasterize.test.ts
git commit -m "feat(packs): rasterizeToPng util (SVG/WebP → PNG for PDF embed) (PHON-170)"

Task 3: packImages — resolve + rasterize pack word imagery

Files: - Create: src/lib/export/packImages.ts - Test: src/lib/export/packImages.test.ts

Interfaces: - Consumes: api.batchWords + BatchWordInfo (Task 1); wordImageUrl from src/lib/wordImages.ts; rasterizeToPng (Task 2). - Produces: - export interface ResolvedWord { word: string; info?: BatchWordInfo; pngDataUrl?: string; provider?: string; } - export async function resolvePackImages(words: string[], rasterize?: (url: string) => Promise<string | null>): Promise<Map<string, ResolvedWord>> — lowercases + de-dupes input, calls api.batchWords, and for each word that has image_file + image_provider, builds the URL via wordImageUrl and rasterizes it (using the injected rasterize, defaulting to rasterizeToPng). Words with no image, or whose rasterize returns null, get a ResolvedWord with no pngDataUrl. Keyed by lowercased word. Never throws — a batch failure resolves to a map of image-less entries.

  • [ ] Step 1: Write the failing test

Create src/lib/export/packImages.test.ts:

import { describe, it, expect, vi, afterEach } from 'vitest';
import { resolvePackImages } from './packImages';
import { api } from '../../services/apiClient';

afterEach(() => vi.restoreAllMocks());

describe('resolvePackImages', () => {
  it('rasterizes words that have an image and leaves others image-less', async () => {
    vi.spyOn(api, 'batchWords').mockResolvedValue([
      { word: 'rocket', image_file: 'rocket.svg', image_provider: 'mulberry', ipa: 'ɹ ɑ k ə t' },
      { word: 'the', ipa: 'ð ə' }, // no image
    ]);
    const rasterize = vi.fn().mockResolvedValue('data:image/png;base64,AAAA');
    const map = await resolvePackImages(['Rocket', 'the'], rasterize);
    expect(map.get('rocket')!.pngDataUrl).toBe('data:image/png;base64,AAAA');
    expect(map.get('rocket')!.provider).toBe('mulberry');
    expect(map.get('the')!.pngDataUrl).toBeUndefined();
    expect(rasterize).toHaveBeenCalledTimes(1);
  });

  it('keeps a word image-less when rasterize returns null', async () => {
    vi.spyOn(api, 'batchWords').mockResolvedValue([
      { word: 'rocket', image_file: 'rocket.webp', image_provider: 'generated' },
    ]);
    const map = await resolvePackImages(['rocket'], vi.fn().mockResolvedValue(null));
    expect(map.get('rocket')!.pngDataUrl).toBeUndefined();
    expect(map.get('rocket')!.info?.image_provider).toBe('generated');
  });

  it('resolves to image-less entries when the batch call fails', async () => {
    vi.spyOn(api, 'batchWords').mockRejectedValue(new Error('network'));
    const map = await resolvePackImages(['rocket'], vi.fn());
    expect(map.get('rocket')!.pngDataUrl).toBeUndefined();
  });
});
  • [ ] Step 2: Run the test to verify it fails

Run: npm test -- --run src/lib/export/packImages.test.ts Expected: FAIL — module not found.

  • [ ] Step 3: Implement the module

Create src/lib/export/packImages.ts:

import { api, type BatchWordInfo } from '../../services/apiClient';
import { wordImageUrl } from '../wordImages';
import { rasterizeToPng } from './rasterize';

export interface ResolvedWord {
  word: string;
  info?: BatchWordInfo;
  pngDataUrl?: string;
  provider?: string;
}

export async function resolvePackImages(
  words: string[],
  rasterize: (url: string) => Promise<string | null> = (u) => rasterizeToPng(u),
): Promise<Map<string, ResolvedWord>> {
  const keys = Array.from(new Set(words.map((w) => w.toLowerCase())));
  const map = new Map<string, ResolvedWord>();
  for (const k of keys) map.set(k, { word: k });

  let rows: BatchWordInfo[] = [];
  try {
    rows = await api.batchWords(keys);
  } catch {
    return map; // image-less entries already seeded
  }

  await Promise.all(
    rows.map(async (info) => {
      const key = info.word.toLowerCase();
      const entry: ResolvedWord = { word: key, info };
      if (info.image_file && info.image_provider) {
        const url = wordImageUrl(info.image_provider, info.image_file);
        const png = await rasterize(url);
        if (png) {
          entry.pngDataUrl = png;
          entry.provider = info.image_provider;
        }
      }
      map.set(key, entry);
    }),
  );
  return map;
}
  • [ ] Step 4: Run the test to verify it passes

Run: npm test -- --run src/lib/export/packImages.test.ts Expected: PASS (3/3).

  • [ ] Step 5: Commit
git add src/lib/export/packImages.ts src/lib/export/packImages.test.ts
git commit -m "feat(packs): resolvePackImages — batch-fetch + rasterize card imagery (PHON-170)"

Task 4: exportModel — pure card/worksheet/CSV builders + presets

Files: - Create: src/lib/export/exportModel.ts - Test: src/lib/export/exportModel.test.ts

Interfaces: - Consumes: TherapyPack, PackWord, PackContrast, PackSentence from ../../types/therapyPack; ResolvedWord from ./packImages; WORD_IMAGE_PROVIDER_LABELS from ../wordImages. - Produces (all pure — no DOM, no network): - export type ExportPreset = 'kids' | 'adult'; - export interface ExportOptions { labelPosition: 'above' | 'below' | 'none'; gridSize: 4 | 6 | 12 | 20; trialColumns: number; includeSentences: boolean; } - export const PRESET_OPTIONS: Record<ExportPreset, ExportOptions>kids: { labelPosition: 'above', gridSize: 12, trialColumns: 10, includeSentences: true }; adult: { labelPosition: 'none', gridSize: 20, trialColumns: 10, includeSentences: true }. - export interface CardModelItem { word: string; ipa?: string; pngDataUrl?: string; } - export interface CardModel { title: string; items: CardModelItem[]; labelPosition: ExportOptions['labelPosition']; gridSize: ExportOptions['gridSize']; attribution: string[]; } - export function buildCardModel(pack: TherapyPack, images: Map<string, ResolvedWord>, opts: ExportOptions): CardModel - export interface WorksheetModel { title: string; targetSummary: string; words: { word: string; ipa?: string }[]; contrasts: { a: string; b: string; label?: string }[]; sentences: string[]; notes: string; trialColumns: number; attribution: string[]; } - export function buildWorksheetModel(pack: TherapyPack, images: Map<string, ResolvedWord>, opts: ExportOptions): WorksheetModel - export function buildPackCsv(pack: TherapyPack, images: Map<string, ResolvedWord>): string - export function buildAttribution(images: Map<string, ResolvedWord>): string[] — distinct provider labels for providers actually used by an embedded (rasterized) image.

  • [ ] Step 1: Write the failing test

Create src/lib/export/exportModel.test.ts:

import { describe, it, expect } from 'vitest';
import {
  buildCardModel, buildWorksheetModel, buildPackCsv, buildAttribution, PRESET_OPTIONS,
} from './exportModel';
import type { ResolvedWord } from './packImages';
import { createEmptyPack, type TherapyPack } from '../../types/therapyPack';

function pack(): TherapyPack {
  const p = createEmptyPack('p1', 1);
  p.title = 'Initial /r/';
  p.target = { mode: 'sound', phones: ['ɹ'], position: 'initial' };
  p.words = [
    { kind: 'word', id: 'w1', word: 'rocket', ipa: 'ɹ ɑ k ə t', provenance: { sourceTool: 'guided', addedAt: 1 } },
    { kind: 'word', id: 'w2', word: 'rabbit', ipa: 'ɹ æ b ə t', provenance: { sourceTool: 'guided', addedAt: 2 } },
  ];
  p.contrasts = [{ kind: 'contrast', id: 'c1', a: 'rake', b: 'wake', label: 'r vs w', provenance: { sourceTool: 'contrastive', addedAt: 3 } }];
  p.sentences = [{ kind: 'sentence', id: 's1', text: 'The rocket is red.', targets: ['rocket'], provenance: { sourceTool: 'sentences', addedAt: 4 } }];
  p.notes = 'Cue level: moderate.';
  return p;
}

const images = new Map<string, ResolvedWord>([
  ['rocket', { word: 'rocket', pngDataUrl: 'data:image/png;base64,AAAA', provider: 'mulberry', info: { word: 'rocket', syllable_count: 2, cv_shape: 'CVCVC', phonemes_str: '|ɹ|ɑ|k|ə|t|' } }],
  ['rabbit', { word: 'rabbit', info: { word: 'rabbit', syllable_count: 2, cv_shape: 'CVCVC' } }], // no image
]);

describe('exportModel', () => {
  it('buildCardModel maps words + images and carries grid/label opts', () => {
    const m = buildCardModel(pack(), images, PRESET_OPTIONS.kids);
    expect(m.title).toBe('Initial /r/');
    expect(m.items.map((i) => i.word)).toEqual(['rocket', 'rabbit']);
    expect(m.items[0].pngDataUrl).toBe('data:image/png;base64,AAAA');
    expect(m.items[1].pngDataUrl).toBeUndefined();
    expect(m.labelPosition).toBe('above');
    expect(m.gridSize).toBe(12);
    expect(m.attribution).toContain('Mulberry Symbols (CC BY-SA 4.0)');
  });

  it('buildWorksheetModel includes target summary, contrasts, sentences, notes', () => {
    const m = buildWorksheetModel(pack(), images, PRESET_OPTIONS.kids);
    expect(m.words.map((w) => w.word)).toEqual(['rocket', 'rabbit']);
    expect(m.contrasts[0]).toMatchObject({ a: 'rake', b: 'wake', label: 'r vs w' });
    expect(m.sentences).toEqual(['The rocket is red.']);
    expect(m.notes).toBe('Cue level: moderate.');
    expect(m.targetSummary).toContain('ɹ');
  });

  it('buildWorksheetModel drops sentences when opts.includeSentences is false', () => {
    const m = buildWorksheetModel(pack(), images, { ...PRESET_OPTIONS.kids, includeSentences: false });
    expect(m.sentences).toEqual([]);
  });

  it('buildPackCsv emits a header + one row per word with enrichment columns', () => {
    const csv = buildPackCsv(pack(), images);
    const lines = csv.trim().split('\n');
    expect(lines[0]).toBe('word,ipa,syllable_count,cv_shape,has_image');
    expect(lines[1]).toContain('rocket');
    expect(lines[1]).toContain('CVCVC');
    expect(lines).toHaveLength(3); // header + 2 words
  });

  it('buildPackCsv quotes fields containing commas', () => {
    const p = pack();
    p.words = [{ kind: 'word', id: 'w', word: 'a,b', ipa: 'x', provenance: { sourceTool: 'manual', addedAt: 1 } }];
    const csv = buildPackCsv(p, new Map());
    expect(csv.split('\n')[1]).toContain('"a,b"');
  });

  it('buildAttribution lists only providers of embedded images', () => {
    expect(buildAttribution(images)).toEqual(['Mulberry Symbols (CC BY-SA 4.0)']);
    expect(buildAttribution(new Map())).toEqual([]);
  });
});
  • [ ] Step 2: Run the test to verify it fails

Run: npm test -- --run src/lib/export/exportModel.test.ts Expected: FAIL — module not found.

  • [ ] Step 3: Implement the model builders

Create src/lib/export/exportModel.ts:

import type { TherapyPack } from '../../types/therapyPack';
import type { ResolvedWord } from './packImages';
import { WORD_IMAGE_PROVIDER_LABELS } from '../wordImages';

export type ExportPreset = 'kids' | 'adult';

export interface ExportOptions {
  labelPosition: 'above' | 'below' | 'none';
  gridSize: 4 | 6 | 12 | 20;
  trialColumns: number;
  includeSentences: boolean;
}

export const PRESET_OPTIONS: Record<ExportPreset, ExportOptions> = {
  kids: { labelPosition: 'above', gridSize: 12, trialColumns: 10, includeSentences: true },
  adult: { labelPosition: 'none', gridSize: 20, trialColumns: 10, includeSentences: true },
};

export interface CardModelItem { word: string; ipa?: string; pngDataUrl?: string; }
export interface CardModel {
  title: string;
  items: CardModelItem[];
  labelPosition: ExportOptions['labelPosition'];
  gridSize: ExportOptions['gridSize'];
  attribution: string[];
}

export interface WorksheetModel {
  title: string;
  targetSummary: string;
  words: { word: string; ipa?: string }[];
  contrasts: { a: string; b: string; label?: string }[];
  sentences: string[];
  notes: string;
  trialColumns: number;
  attribution: string[];
}

function targetSummary(pack: TherapyPack): string {
  const t = pack.target;
  if (!t) return '';
  const phones = t.phones.map((p) => `/${p}/`).join(', ');
  const pos = t.position && t.position !== 'any' ? ` (${t.position})` : '';
  return `${phones}${pos}`;
}

export function buildAttribution(images: Map<string, ResolvedWord>): string[] {
  const providers = new Set<string>();
  for (const r of images.values()) {
    if (r.pngDataUrl && r.provider) providers.add(r.provider);
  }
  return Array.from(providers).map((p) => WORD_IMAGE_PROVIDER_LABELS[p] ?? p);
}

export function buildCardModel(
  pack: TherapyPack,
  images: Map<string, ResolvedWord>,
  opts: ExportOptions,
): CardModel {
  const items: CardModelItem[] = pack.words.map((w) => {
    const r = images.get(w.word.toLowerCase());
    return { word: w.word, ipa: w.ipa ?? r?.info?.ipa, pngDataUrl: r?.pngDataUrl };
  });
  return {
    title: pack.title,
    items,
    labelPosition: opts.labelPosition,
    gridSize: opts.gridSize,
    attribution: buildAttribution(images),
  };
}

export function buildWorksheetModel(
  pack: TherapyPack,
  images: Map<string, ResolvedWord>,
  opts: ExportOptions,
): WorksheetModel {
  return {
    title: pack.title,
    targetSummary: targetSummary(pack),
    words: pack.words.map((w) => ({ word: w.word, ipa: w.ipa ?? images.get(w.word.toLowerCase())?.info?.ipa })),
    contrasts: pack.contrasts.map((c) => ({ a: c.a, b: c.b, label: c.label })),
    sentences: opts.includeSentences ? pack.sentences.map((s) => s.text) : [],
    notes: pack.notes,
    trialColumns: opts.trialColumns,
    attribution: buildAttribution(images),
  };
}

function csvCell(v: string | number | boolean | undefined): string {
  if (v === undefined || v === null) return '';
  const s = String(v);
  return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}

export function buildPackCsv(pack: TherapyPack, images: Map<string, ResolvedWord>): string {
  const header = ['word', 'ipa', 'syllable_count', 'cv_shape', 'has_image'];
  const rows = pack.words.map((w) => {
    const info = images.get(w.word.toLowerCase())?.info;
    return [
      csvCell(w.word),
      csvCell(w.ipa ?? info?.ipa),
      csvCell(info?.syllable_count),
      csvCell(info?.cv_shape),
      csvCell(info?.has_image ? 'yes' : (w.hasImage ? 'yes' : 'no')),
    ].join(',');
  });
  return [header.join(','), ...rows].join('\n') + '\n';
}
  • [ ] Step 4: Run the test to verify it passes

Run: npm test -- --run src/lib/export/exportModel.test.ts Expected: PASS (6/6).

  • [ ] Step 5: Commit
git add src/lib/export/exportModel.ts src/lib/export/exportModel.test.ts
git commit -m "feat(packs): pure export-model builders (cards/worksheet/CSV/attribution) (PHON-170)"

Task 5: @react-pdf/renderer document components

Files: - Create: src/components/pack/export/PictureCardsDoc.tsx - Create: src/components/pack/export/WorksheetDoc.tsx

Interfaces: - Consumes: CardModel, WorksheetModel from ../../../lib/export/exportModel; @react-pdf/renderer primitives (Document, Page, View, Text, Image, StyleSheet). - Produces: - export function PictureCardsDoc({ model }: { model: CardModel }): JSX.Element — a Document with a grid of cells; columns from gridSize (4→2col, 6→2col, 12→3col, 20→4col); each cell shows the PNG (via <Image src={pngDataUrl} />) when present else a word-only cell; label above/below/none per model.labelPosition; a footer Text with the attribution lines when model.attribution is non-empty. - export function WorksheetDoc({ model }: { model: WorksheetModel }): JSX.Element — a Document with a header block (Name / Date / Goal=targetSummary / Clinician / Cue level fields), a words list with trialColumns trial boxes + __/__=__%, a two-column contrast block, a sentences block (target bolded is out of scope — plain text), a notes block, and the attribution footer.

NOTE: These components are excluded from unit tests@react-pdf/renderer (pdfkit/yoga) does not run under happy-dom, and their output is binary PDF. They are exercised only by the ExportDialog integration (Task 6, which mocks the engine) and by manual npm run build + in-app preview. A header comment in each file records this. No test file for this task.

  • [ ] Step 1: Implement PictureCardsDoc.tsx

Create src/components/pack/export/PictureCardsDoc.tsx:

/**
 * Picture-cards PDF (spec §5.8). NOT unit-tested: @react-pdf/renderer's
 * pdfkit/yoga stack doesn't run under happy-dom and the output is binary.
 * Exercised via ExportDialog (engine mocked) + manual preview.
 */
import React from 'react';
import { Document, Page, View, Text, Image, StyleSheet } from '@react-pdf/renderer';
import type { CardModel } from '../../../lib/export/exportModel';

const COLS: Record<number, number> = { 4: 2, 6: 2, 12: 3, 20: 4 };

const s = StyleSheet.create({
  page: { padding: 24, fontSize: 10 },
  title: { fontSize: 14, marginBottom: 12, fontWeight: 700 },
  grid: { flexDirection: 'row', flexWrap: 'wrap' },
  label: { textAlign: 'center', marginVertical: 4 },
  img: { width: '80%', height: '80%', objectFit: 'contain', alignSelf: 'center' },
  wordOnly: { textAlign: 'center', fontSize: 16, marginTop: 'auto', marginBottom: 'auto' },
  footer: { position: 'absolute', bottom: 16, left: 24, right: 24, fontSize: 7, color: '#666', textAlign: 'center' },
});

export function PictureCardsDoc({ model }: { model: CardModel }): React.JSX.Element {
  const cols = COLS[model.gridSize] ?? 3;
  const cellW = `${100 / cols}%`;
  const cellH = 720 / (Math.ceil(model.gridSize / cols) + 0.5);
  return (
    <Document>
      <Page size="LETTER" style={s.page}>
        <Text style={s.title}>{model.title}</Text>
        <View style={s.grid}>
          {model.items.map((it, i) => (
            <View key={i} style={{ width: cellW, height: cellH, borderWidth: 1, borderColor: '#333', padding: 6, justifyContent: 'center' }}>
              {model.labelPosition === 'above' && <Text style={s.label}>{it.word}</Text>}
              {it.pngDataUrl ? <Image style={s.img} src={it.pngDataUrl} /> : <Text style={s.wordOnly}>{it.word}</Text>}
              {model.labelPosition === 'below' && <Text style={s.label}>{it.word}</Text>}
            </View>
          ))}
        </View>
        {model.attribution.length > 0 && (
          <Text style={s.footer} fixed>Images: {model.attribution.join(' · ')}. Made with PhonoLex.</Text>
        )}
      </Page>
    </Document>
  );
}
  • [ ] Step 2: Implement WorksheetDoc.tsx

Create src/components/pack/export/WorksheetDoc.tsx:

/**
 * Worksheet/list PDF (spec §5.8). NOT unit-tested (see PictureCardsDoc note).
 */
import React from 'react';
import { Document, Page, View, Text, StyleSheet } from '@react-pdf/renderer';
import type { WorksheetModel } from '../../../lib/export/exportModel';

const s = StyleSheet.create({
  page: { padding: 32, fontSize: 11, lineHeight: 1.4 },
  title: { fontSize: 16, fontWeight: 700, marginBottom: 8 },
  header: { flexDirection: 'row', flexWrap: 'wrap', marginBottom: 12, fontSize: 10, color: '#333' },
  headerField: { width: '50%', marginBottom: 4 },
  sectionTitle: { fontSize: 12, fontWeight: 700, marginTop: 12, marginBottom: 6, borderBottomWidth: 1, borderBottomColor: '#ccc' },
  row: { flexDirection: 'row', alignItems: 'center', marginBottom: 4 },
  word: { width: '40%' },
  boxes: { flexDirection: 'row', flexGrow: 1 },
  box: { width: 12, height: 12, borderWidth: 1, borderColor: '#999', marginRight: 3 },
  score: { marginLeft: 8, color: '#666' },
  contrastRow: { flexDirection: 'row', marginBottom: 3 },
  contrastCell: { width: '50%' },
  notes: { marginTop: 8 },
  footer: { position: 'absolute', bottom: 20, left: 32, right: 32, fontSize: 7, color: '#666', textAlign: 'center' },
});

export function WorksheetDoc({ model }: { model: WorksheetModel }): React.JSX.Element {
  return (
    <Document>
      <Page size="LETTER" style={s.page}>
        <Text style={s.title}>{model.title}</Text>
        <View style={s.header}>
          <Text style={s.headerField}>Name: ____________________</Text>
          <Text style={s.headerField}>Date: ____________________</Text>
          <Text style={s.headerField}>Goal / Target: {model.targetSummary || '____________'}</Text>
          <Text style={s.headerField}>Clinician: ____________________</Text>
          <Text style={s.headerField}>Cue level (I / min / mod / max): __________</Text>
        </View>

        <Text style={s.sectionTitle}>Words</Text>
        {model.words.map((w, i) => (
          <View key={i} style={s.row}>
            <Text style={s.word}>{w.word}{w.ipa ? `  ${w.ipa}` : ''}</Text>
            <View style={s.boxes}>
              {Array.from({ length: model.trialColumns }).map((_, j) => <View key={j} style={s.box} />)}
            </View>
            <Text style={s.score}>__/{model.trialColumns} = __%</Text>
          </View>
        ))}

        {model.contrasts.length > 0 && (
          <>
            <Text style={s.sectionTitle}>Contrast pairs</Text>
            {model.contrasts.map((c, i) => (
              <View key={i} style={s.contrastRow}>
                <Text style={s.contrastCell}>{c.a}</Text>
                <Text style={s.contrastCell}>{c.b}</Text>
              </View>
            ))}
          </>
        )}

        {model.sentences.length > 0 && (
          <>
            <Text style={s.sectionTitle}>Sentences</Text>
            {model.sentences.map((t, i) => <Text key={i}> {t}</Text>)}
          </>
        )}

        {model.notes && (
          <>
            <Text style={s.sectionTitle}>Notes</Text>
            <Text style={s.notes}>{model.notes}</Text>
          </>
        )}

        {model.attribution.length > 0 && (
          <Text style={s.footer} fixed>Images: {model.attribution.join(' · ')}. Made with PhonoLex.</Text>
        )}
      </Page>
    </Document>
  );
}
  • [ ] Step 3: Type-check (no test to run)

Run: npm run type-check Expected: clean. (These components are not imported anywhere yet — that happens in Task 6. If tsc reports them as unused, that's fine; there is no unused-file rule.)

  • [ ] Step 4: Commit
git add src/components/pack/export/PictureCardsDoc.tsx src/components/pack/export/WorksheetDoc.tsx
git commit -m "feat(packs): @react-pdf/renderer picture-card + worksheet documents (PHON-170)"

Task 6: ExportDialog — format tabs, presets, preview, download

Files: - Create: src/components/pack/export/ExportDialog.tsx - Test: src/components/pack/export/ExportDialog.test.tsx

Interfaces: - Consumes: usePackStore (activePack); resolvePackImages (Task 3); buildCardModel/buildWorksheetModel/buildPackCsv/PRESET_OPTIONS/ExportPreset/ExportOptions (Task 4); PictureCardsDoc/WorksheetDoc (Task 5); @react-pdf/renderer's pdf() (dynamic import) for blob generation; track from ../../../lib/analytics. - Produces: export default function ExportDialog({ open, onClose }: { open: boolean; onClose: () => void }): JSX.Element — MUI Dialog. State: current format ('cards' | 'worksheet' | 'csv'), preset ('kids' | 'adult'), resolved images (loaded on open via resolvePackImages over the pack's word list), loading flag. Choosing a preset sets the working ExportOptions. A Download button: for csv, builds the CSV string and triggers a text download; for cards/worksheet, dynamically imports @react-pdf/renderer, calls pdf(<Doc model=… />).toBlob(), and triggers a download. Emits track('results_exported', { tool: 'therapyPack', format, item_count }) on a successful download. (A live <PDFViewer> preview may be added but is optional; the required deliverable is Download.)

  • [ ] Step 1: Write the failing test

Create src/components/pack/export/ExportDialog.test.tsx:

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import ExportDialog from './ExportDialog';
import { usePackStore } from '../../../store/packStore';

// Keep the heavy PDF engine out of the test environment entirely.
vi.mock('@react-pdf/renderer', () => ({
  pdf: () => ({ toBlob: async () => new Blob(['%PDF'], { type: 'application/pdf' }) }),
  Document: () => null, Page: () => null, View: () => null, Text: () => null,
  Image: () => null, StyleSheet: { create: (x: unknown) => x },
}));
vi.mock('../../../lib/export/packImages', () => ({
  resolvePackImages: vi.fn(async () => new Map()),
}));
const track = vi.fn();
vi.mock('../../../lib/analytics', () => ({ track: (...a: unknown[]) => track(...a) }));

function seedPack() {
  usePackStore.setState({ activePack: null, library: [], saveError: null });
  usePackStore.getState().newPack();
  usePackStore.getState().setTitle('Initial /r/');
  usePackStore.getState().addItem({
    kind: 'word', id: 'w1', word: 'rocket', ipa: 'ɹ ɑ k ə t',
    provenance: { sourceTool: 'manual', addedAt: 1 },
  });
}

describe('ExportDialog', () => {
  beforeEach(() => { track.mockClear(); seedPack(); });

  it('exports CSV and tracks the event', async () => {
    // jsdom/happy-dom: stub URL + anchor click used by the download helper.
    const createUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:x');
    vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
    render(<ExportDialog open onClose={vi.fn()} />);
    fireEvent.click(screen.getByRole('tab', { name: /csv/i }));
    fireEvent.click(screen.getByRole('button', { name: /download/i }));
    await waitFor(() => expect(track).toHaveBeenCalledWith(
      'results_exported', expect.objectContaining({ tool: 'therapyPack', format: 'csv', item_count: 1 }),
    ));
    expect(createUrl).toHaveBeenCalled();
  });

  it('generates a PDF blob for the cards format', async () => {
    vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:x');
    vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
    render(<ExportDialog open onClose={vi.fn()} />);
    // default format is cards
    fireEvent.click(screen.getByRole('button', { name: /download/i }));
    await waitFor(() => expect(track).toHaveBeenCalledWith(
      'results_exported', expect.objectContaining({ tool: 'therapyPack', format: 'cards' }),
    ));
  });
});
  • [ ] Step 2: Run the test to verify it fails

Run: npm test -- --run src/components/pack/export/ExportDialog.test.tsx Expected: FAIL — module not found.

  • [ ] Step 3: Implement the dialog

Create src/components/pack/export/ExportDialog.tsx:

import React, { useEffect, useMemo, useState } from 'react';
import {
  Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
  Tab, Tabs, ToggleButton, ToggleButtonGroup, Typography, CircularProgress,
} from '@mui/material';
import { usePackStore } from '../../../store/packStore';
import { resolvePackImages, type ResolvedWord } from '../../../lib/export/packImages';
import {
  buildCardModel, buildWorksheetModel, buildPackCsv, PRESET_OPTIONS, type ExportPreset,
} from '../../../lib/export/exportModel';
import { PictureCardsDoc } from './PictureCardsDoc';
import { WorksheetDoc } from './WorksheetDoc';
import { track } from '../../../lib/analytics';

type Format = 'cards' | 'worksheet' | 'csv';

function triggerDownload(blob: Blob, filename: string) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  a.remove();
  URL.revokeObjectURL(url);
}

const ExportDialog: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
  const pack = usePackStore((s) => s.activePack);
  const [format, setFormat] = useState<Format>('cards');
  const [preset, setPreset] = useState<ExportPreset>('kids');
  const [images, setImages] = useState<Map<string, ResolvedWord>>(new Map());
  const [busy, setBusy] = useState(false);

  useEffect(() => {
    if (!open || !pack) return;
    let cancelled = false;
    setBusy(true);
    resolvePackImages(pack.words.map((w) => w.word))
      .then((m) => { if (!cancelled) setImages(m); })
      .finally(() => { if (!cancelled) setBusy(false); });
    return () => { cancelled = true; };
  }, [open, pack]);

  const opts = useMemo(() => PRESET_OPTIONS[preset], [preset]);
  const safeTitle = (pack?.title || 'therapy-pack').replace(/[^\w-]+/g, '_');

  const handleDownload = async () => {
    if (!pack) return;
    setBusy(true);
    try {
      if (format === 'csv') {
        const csv = buildPackCsv(pack, images);
        triggerDownload(new Blob([csv], { type: 'text/csv' }), `${safeTitle}.csv`);
        track('results_exported', { tool: 'therapyPack', format: 'csv', item_count: pack.words.length });
        return;
      }
      const { pdf } = await import('@react-pdf/renderer');
      const doc = format === 'cards'
        ? <PictureCardsDoc model={buildCardModel(pack, images, opts)} />
        : <WorksheetDoc model={buildWorksheetModel(pack, images, opts)} />;
      const blob = await pdf(doc).toBlob();
      triggerDownload(blob, `${safeTitle}-${format}.pdf`);
      track('results_exported', {
        tool: 'therapyPack', format,
        item_count: format === 'cards' ? pack.words.length
          : pack.words.length + pack.contrasts.length + pack.sentences.length,
      });
    } finally {
      setBusy(false);
    }
  };

  return (
    <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
      <DialogTitle>Export pack</DialogTitle>
      <DialogContent>
        <Tabs value={format} onChange={(_, v) => setFormat(v)} sx={{ mb: 2 }}>
          <Tab label="Picture cards" value="cards" />
          <Tab label="Worksheet" value="worksheet" />
          <Tab label="CSV" value="csv" />
        </Tabs>

        {format !== 'csv' && (
          <Box sx={{ mb: 2 }}>
            <Typography variant="body2" sx={{ mb: 1 }}>Preset</Typography>
            <ToggleButtonGroup exclusive size="small" value={preset}
              onChange={(_, v) => v && setPreset(v)}>
              <ToggleButton value="kids">Kids</ToggleButton>
              <ToggleButton value="adult">Adult / medical</ToggleButton>
            </ToggleButtonGroup>
          </Box>
        )}

        <Typography variant="caption" color="text.secondary">
          {busy ? 'Preparing imagery…' : `${pack?.words.length ?? 0} words · ${pack?.contrasts.length ?? 0} contrasts · ${pack?.sentences.length ?? 0} sentences`}
        </Typography>
      </DialogContent>
      <DialogActions>
        <Button onClick={onClose}>Close</Button>
        <Button variant="contained" onClick={handleDownload} disabled={busy || !pack}
          startIcon={busy ? <CircularProgress size={16} /> : undefined}>
          Download
        </Button>
      </DialogActions>
    </Dialog>
  );
};

export default ExportDialog;
  • [ ] Step 4: Run the test to verify it passes

Run: npm test -- --run src/components/pack/export/ExportDialog.test.tsx Expected: PASS (2/2).

  • [ ] Step 5: Commit
git add src/components/pack/export/ExportDialog.tsx src/components/pack/export/ExportDialog.test.tsx
git commit -m "feat(packs): ExportDialog — format tabs, presets, PDF/CSV download (PHON-170)"

Task 7: Enable Preview/Export in PackHeader + host ExportDialog in PackEditor

Files: - Modify: src/components/pack/PackHeader.tsx - Modify: src/components/pack/PackHeader.test.tsx - Modify: src/components/pack/PackEditor.tsx - Modify: src/components/pack/PackEditor.test.tsx

Interfaces: - PackHeader gains one prop: onExport: () => void. The Preview and Export buttons become enabled and both call onExport (Preview and Export open the same dialog this phase; the dialog's tabs cover both needs). Remove the "Coming soon" tooltips. - PackEditor renders <ExportDialog open={exportOpen} onClose={…} />, holds exportOpen state, and passes onExport={() => setExportOpen(true)} to PackHeader.

  • [ ] Step 1: Update the PackHeader test (add onExport, drop disabled-Export assertion)

In src/components/pack/PackHeader.test.tsx: add onExport={noop} to every <PackHeader … /> render, and replace the "disables Preview and Export" test with:

  it('enables Export and fires onExport', () => {
    const onExport = vi.fn();
    render(
      <PackHeader pack={makePack()} saveError={null}
        onTitleChange={noop} onOpenMyPacks={noop} onNewPack={noop}
        onStartOver={noop} onDuplicate={noop} onExport={onExport} />,
    );
    const btn = screen.getByRole('button', { name: /^export$/i });
    expect(btn).toBeEnabled();
    fireEvent.click(btn);
    expect(onExport).toHaveBeenCalled();
  });

Ensure fireEvent is imported in the test file (it already imports from @testing-library/react).

  • [ ] Step 2: Run the PackHeader test to verify it fails

Run: npm test -- --run src/components/pack/PackHeader.test.tsx Expected: FAIL — onExport unused/Export still disabled.

  • [ ] Step 3: Update PackHeader

In src/components/pack/PackHeader.tsx: add onExport: () => void; to PackHeaderProps, destructure it, and replace the disabled Preview/Export block:

        <Button size="small" variant="outlined" onClick={onExport}>Preview</Button>
        <Button size="small" variant="contained" onClick={onExport}>Export</Button>

(Remove the two <Tooltip title="Coming soon">…</Tooltip> wrappers and their <span>s.)

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

Run: npm test -- --run src/components/pack/PackHeader.test.tsx Expected: PASS.

  • [ ] Step 5: Wire ExportDialog into PackEditor

In src/components/pack/PackEditor.tsx: import the dialog, add state, pass the prop, render it.

Add import:

import ExportDialog from './export/ExportDialog';
Add state near the other useStates:
  const [exportOpen, setExportOpen] = useState(false);
Pass the prop on <PackHeader … />:
        onExport={() => setExportOpen(true)}
Render the dialog next to the others (near <MyPacksDialog … />):
      <ExportDialog open={exportOpen} onClose={() => setExportOpen(false)} />

  • [ ] Step 6: Add a PackEditor integration assertion

In src/components/pack/PackEditor.test.tsx, mock the export engine at the top (so importing PackEditor→ExportDialog doesn't pull in @react-pdf/renderer), and add a test that Export opens the dialog:

Add mocks near the existing vi.mock('../../lib/analytics', …):

vi.mock('@react-pdf/renderer', () => ({
  pdf: () => ({ toBlob: async () => new Blob(['%PDF']) }),
  Document: () => null, Page: () => null, View: () => null, Text: () => null,
  Image: () => null, StyleSheet: { create: (x: unknown) => x },
}));
vi.mock('./export/packImages', () => ({ resolvePackImages: vi.fn(async () => new Map()) }));
Add the test:
  it('opens the export dialog from the header', async () => {
    usePackStore.getState().newPack();
    render(<PackEditor />);
    fireEvent.click(screen.getByRole('button', { name: /^export$/i }));
    await waitFor(() => expect(screen.getByRole('dialog', { name: /export pack/i })).toBeInTheDocument());
  });

  • [ ] Step 7: Run the full gate

Run (from packages/web/frontend/):

npm test -- --run
npm run type-check
npm run lint
npm run build
Expected: all pass (the one known pre-existing unrelated MetadataErrorAlert flake aside); build succeeds. Verify the @react-pdf/renderer chunk is code-split (dynamic import) — the main bundle should not balloon; the build's chunk list should show a separate lazy chunk for the PDF engine.

  • [ ] Step 8: Commit
git add src/components/pack/PackHeader.tsx src/components/pack/PackHeader.test.tsx src/components/pack/PackEditor.tsx src/components/pack/PackEditor.test.tsx
git commit -m "feat(packs): enable Preview/Export → ExportDialog in the editor (PHON-170)"

Self-Review notes (coverage vs spec §5.8 / §10.1)

  • Client-side PDF renderer (@react-pdf/renderer): Tasks 1, 5, 6. Decision §10.1 resolved to react-pdf. ✓
  • Picture cards (grid, label above/below/none, grid sizes, cut borders): Task 5 PictureCardsDoc + Task 4 model. Grid-ladder presets 4/6/12/20 present; contrast adjacent-cell layout is a follow-up (noted below). ✓ (partial)
  • Worksheet/list (header fields, per-item trial boxes + __/__=__%, words→sentences, contrast two-column, notes, caregiver footer): Task 5 WorksheetDoc + Task 4 model. Cue-level column + whole-page score-box variant + nonword drill row are follow-ups (noted). ✓ (partial)
  • CSV (word, IPA, syllable_count, CV-shape, has_image): Task 4 buildPackCsv. Contrast-partner + carrier-sentence columns are a follow-up (the spec's full CSV superset) — noted. ✓ (core)
  • AAC imagery from R2/public, word-only fallback: Tasks 2–3 (rasterize + resolve, graceful null). ✓
  • CC BY-SA attribution in footer: Task 4 buildAttribution → Task 5 footers. ✓
  • Two presets (Kids / Adult-medical) that only preset options: Task 4 PRESET_OPTIONS, Task 6 toggle. ✓
  • Analytics export event: mapped to the existing allowlisted results_exported (tool='therapyPack') — no worker change. ✓
  • Preview/Export enablement: Task 7. Preview + Export both open ExportDialog this phase (the dialog serves both). ✓
  • Deferred to follow-up phases/tickets (explicitly, not silently): live <PDFViewer> preview pane; contrast-adjacent card pairing (24-card convention); worksheet cue-level column + whole-page score box + CVCV/nonword drill row; CSV contrast-partner/carrier-sentence columns; R2 CORS so cross-origin generated WebP cards rasterize (until configured, generated cards degrade to word-only in exports — Mulberry/OpenMoji SVGs are same-origin and unaffected). These do not block the phase's deliverable (cards + worksheet + CSV with imagery + attribution + presets).

Decisions resolved at planning

  1. PDF library: @react-pdf/renderer (React fit, flexbox layout, page breaks, PNG embedding; dynamic-imported to stay out of the main bundle). Spec §10.1 leading option.
  2. Image embedding: rasterize SVG/WebP → PNG via canvas (rasterizeToPng), with graceful word-only fallback on any failure; images + CSV-enrichment resolved once per export via the existing POST /api/words/batch.
  3. R2 CORS (deploy follow-up, non-blocking): cross-origin generated WebP cards need R2 to send Access-Control-Allow-Origin so the canvas isn't tainted. Until set, generated cards fall back to word-only in exports; same-origin SVG cards embed fine. Flagged for a deploy ticket, not built here.
  4. Analytics: reuse results_exported (already allowlisted) rather than a new export event.