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(returnsword, 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 touchespackDbdirectly. - Privacy. Exports carry pack content by design (that's the deliverable), but analytics must not. Reuse the existing allowlisted
results_exportedevent: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 viaVITE_GENERATED_IMAGE_BASE_URL. UsewordImageUrl(provider, file)fromsrc/lib/wordImages.tsto build URLs andWORD_IMAGE_PROVIDER_LABELSfor attribution. Never servesource_fileor emulate proprietary symbol art. @react-pdf/rendererembeds 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/rendereris heavy — import it with dynamicimport()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 mustvi.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) — addbatchWords(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/renderercards document.src/components/pack/export/WorksheetDoc.tsx(create) —@react-pdf/rendererworksheet 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, addonExportprop.src/components/pack/PackEditor.tsx(modify) — host ExportDialog, passonExport.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
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';
useStates:
const [exportOpen, setExportOpen] = useState(false);
<PackHeader … />:
onExport={() => setExportOpen(true)}
<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()) }));
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
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 5WorksheetDoc+ 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
exportevent: mapped to the existing allowlistedresults_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¶
- 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. - 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 existingPOST /api/words/batch. - R2 CORS (deploy follow-up, non-blocking): cross-origin generated WebP cards need R2 to send
Access-Control-Allow-Originso 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. - Analytics: reuse
results_exported(already allowlisted) rather than a newexportevent.