Therapy Pack — Pre-built Packs 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: Replace the front page's four resolver-string quick-starts with a popular-packs gallery — a demand-sourced starter set of named packs; clicking one seeds it (via the existing seed path) and opens the Editor with picture cards.
Architecture: A pre-built pack is a saved target spec (TargetSpec) plus display metadata (title, description) — spec §5.4's recommended model: opening it runs the same seedPack → createSeededPack path the target box uses, so content stays fresh and consistent with the lexicon (no duplicated static content). Curated word overrides are deferred (§5.4 optional). A pure PREBUILT_PACKS data module feeds a PopularPacksGallery presentational component; WelcomeView renders the gallery and routes a pick through its existing handleBuild(target, label).
Tech Stack: React 19, TypeScript, MUI 7, Vitest 4 + @testing-library/react. Reuses the Phase 3a resolver types, the Phase 3b/editor-cards seed path, and WelcomeView.handleBuild.
Global Constraints¶
- Pre-built pack = target spec + metadata, materialized via the live seed path (spec §5.4). No static content duplication; no new API. Curated word/sentence overrides are deferred (optional per §5.4).
- Every pack's
targetis a validTargetSpec(src/types/therapyPack.ts):{ mode: 'sound'|'contrast'|'process'; phones: string[]; position?; processPreset?; level? }. Phones are IPA (ASCIIg→ɡ,r→ɹ). - Demand-sourced starter set (spec §2, §5.4): /r/ dominates, then /s/, minimal pairs, stopping, vocalic /r/. Keep the set to ~6.
- Reuse
WelcomeView.handleBuild(target, label)for the pick — it already doesseedPack→createSeededPack→onSelectTool('editor')with error handling. Do NOT add a second seed path. - No new analytics events. (Analytics taxonomy is a later phase.)
- Test commands (from
packages/web/frontend/):npm test -- --run <file>,npm run type-check,npm run lint,npm run build.
File Structure¶
src/lib/packs/prebuiltPacks.ts(create) —PrebuiltPacktype +PREBUILT_PACKSdata.src/lib/packs/prebuiltPacks.test.ts(create) — data-integrity tests.src/components/pack/PopularPacksGallery.tsx(create) — gallery of pack cards.src/components/pack/PopularPacksGallery.test.tsx(create).src/components/WelcomeView.tsx(modify) — render the gallery; route a pick throughhandleBuild.src/components/WelcomeView.test.tsx(modify) — assert a gallery pick seeds + opens the editor.src/components/pack/TargetBox.tsx(modify) — remove the now-redundant quick-start chips (the gallery supersedes them).src/components/pack/TargetBox.test.tsx(modify) — drop the quick-start chip test.
Task 1: PREBUILT_PACKS data¶
Files:
- Create: src/lib/packs/prebuiltPacks.ts
- Test: src/lib/packs/prebuiltPacks.test.ts
Interfaces:
- Consumes: TargetSpec from ../../types/therapyPack; describeTarget + resolveTarget from ../resolver/* (for the test's integrity check only).
- Produces:
- export interface PrebuiltPack { id: string; title: string; description: string; target: TargetSpec; }
- export const PREBUILT_PACKS: PrebuiltPack[] — the demand-sourced starter set (6 packs).
- [ ] Step 1: Write the failing test
Create src/lib/packs/prebuiltPacks.test.ts:
import { describe, it, expect } from 'vitest';
import { PREBUILT_PACKS } from './prebuiltPacks';
describe('PREBUILT_PACKS', () => {
it('has a demand-sourced starter set with unique ids', () => {
expect(PREBUILT_PACKS.length).toBeGreaterThanOrEqual(5);
expect(PREBUILT_PACKS.length).toBeLessThanOrEqual(8);
const ids = PREBUILT_PACKS.map((p) => p.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('every pack has a title, description, and a valid target', () => {
for (const p of PREBUILT_PACKS) {
expect(p.title.length).toBeGreaterThan(0);
expect(p.description.length).toBeGreaterThan(0);
expect(['sound', 'contrast', 'process']).toContain(p.target.mode);
expect(p.target.phones.length).toBeGreaterThan(0);
if (p.target.mode === 'contrast') expect(p.target.phones.length).toBe(2);
}
});
it('covers the top-demand targets (/r/, /s/, a contrast)', () => {
const titles = PREBUILT_PACKS.map((p) => p.title.toLowerCase()).join(' | ');
expect(titles).toMatch(/\/r\/|r\b|rhotic|\bɹ/);
expect(titles).toMatch(/\/s\/|\bs\b/);
expect(PREBUILT_PACKS.some((p) => p.target.mode === 'contrast')).toBe(true);
});
});
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- --run src/lib/packs/prebuiltPacks.test.ts
Expected: FAIL — module not found.
- [ ] Step 3: Implement the data
Create src/lib/packs/prebuiltPacks.ts:
/**
* Pre-built pack gallery (spec §5.4). Each pack is a saved target spec +
* display copy; opening it runs the same live seed path as the target box, so
* content stays fresh (no static duplication). Set is demand-sourced (spec §2:
* /r/ dominates, then /s/, minimal pairs, stopping, vocalic /r/). Curated word
* overrides are deferred (§5.4 optional).
*/
import type { TargetSpec } from '../../types/therapyPack';
export interface PrebuiltPack {
id: string;
title: string;
description: string;
target: TargetSpec;
}
export const PREBUILT_PACKS: PrebuiltPack[] = [
{
id: 'initial-r',
title: 'Initial /r/',
description: 'The most-requested sound. Word-initial /r/ with picture cards, ordered easiest-first.',
target: { mode: 'sound', phones: ['ɹ'], position: 'initial' },
},
{
id: 'vocalic-r',
title: 'Vocalic /r/',
description: 'R-controlled vowels (er, ir, ur) for the second-hardest /r/ context.',
target: { mode: 'process', phones: ['ɚ', 'ɝ'], processPreset: 'vocalic-r', position: 'final' },
},
{
id: 'final-s',
title: 'Final /s/',
description: 'Word-final /s/ — plurals, possessives, and third-person verbs.',
target: { mode: 'sound', phones: ['s'], position: 'final' },
},
{
id: 'k-vs-t',
title: 'k vs t (fronting)',
description: 'Velar fronting: /k/–/t/ minimal pairs plus target words and sentences.',
target: { mode: 'contrast', phones: ['k', 't'] },
},
{
id: 'stopping',
title: 'Stopping',
description: 'Fricatives (f, v, s, z, sh, th) for children who stop continuants.',
target: { mode: 'process', phones: ['f', 'v', 's', 'z', 'ʃ', 'θ'], processPreset: 'stopping' },
},
{
id: 'gliding',
title: 'Gliding of liquids',
description: '/r/ and /l/ for children who glide liquids to w/y.',
target: { mode: 'process', phones: ['ɹ', 'l'], processPreset: 'gliding' },
},
];
- [ ] Step 4: Run the test to verify it passes
Run: npm test -- --run src/lib/packs/prebuiltPacks.test.ts
Expected: PASS (3/3).
- [ ] Step 5: Type-check and commit
npm run type-check
git add src/lib/packs/prebuiltPacks.ts src/lib/packs/prebuiltPacks.test.ts
git commit -m "feat(packs): demand-sourced pre-built pack definitions (PHON-170)"
Task 2: PopularPacksGallery component¶
Files:
- Create: src/components/pack/PopularPacksGallery.tsx
- Test: src/components/pack/PopularPacksGallery.test.tsx
Interfaces:
- Consumes: PREBUILT_PACKS + PrebuiltPack (../../lib/packs/prebuiltPacks).
- Produces: PopularPacksGallery props { onPick: (pack: PrebuiltPack) => void; disabled?: boolean } — renders each pack as a clickable card (title + description); clicking (when not disabled) calls onPick(pack). The whole card is a button with accessible name including the title.
- [ ] Step 1: Write the failing test
Create src/components/pack/PopularPacksGallery.test.tsx:
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import PopularPacksGallery from './PopularPacksGallery';
import { PREBUILT_PACKS } from '../../lib/packs/prebuiltPacks';
describe('PopularPacksGallery', () => {
it('renders a card per pre-built pack', () => {
render(<PopularPacksGallery onPick={vi.fn()} />);
for (const p of PREBUILT_PACKS) {
expect(screen.getByRole('button', { name: new RegExp(p.title.replace(/[/()]/g, '\\$&'), 'i') })).toBeInTheDocument();
}
});
it('fires onPick with the pack when a card is clicked', () => {
const onPick = vi.fn();
render(<PopularPacksGallery onPick={onPick} />);
const first = PREBUILT_PACKS[0];
fireEvent.click(screen.getByRole('button', { name: new RegExp(first.title.replace(/[/()]/g, '\\$&'), 'i') }));
expect(onPick).toHaveBeenCalledWith(first);
});
it('does not fire when disabled', () => {
const onPick = vi.fn();
render(<PopularPacksGallery onPick={onPick} disabled />);
const first = PREBUILT_PACKS[0];
fireEvent.click(screen.getByRole('button', { name: new RegExp(first.title.replace(/[/()]/g, '\\$&'), 'i') }));
expect(onPick).not.toHaveBeenCalled();
});
});
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- --run src/components/pack/PopularPacksGallery.test.tsx
Expected: FAIL — module not found.
- [ ] Step 3: Implement the gallery
Create src/components/pack/PopularPacksGallery.tsx:
import React from 'react';
import { Box, Card, CardActionArea, CardContent, Typography } from '@mui/material';
import { PREBUILT_PACKS, type PrebuiltPack } from '../../lib/packs/prebuiltPacks';
interface PopularPacksGalleryProps {
onPick: (pack: PrebuiltPack) => void;
disabled?: boolean;
}
const PopularPacksGallery: React.FC<PopularPacksGalleryProps> = ({ onPick, disabled = false }) => (
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' },
gap: 1.5, width: '100%', maxWidth: 720,
}}
>
{PREBUILT_PACKS.map((pack) => (
<Card key={pack.id} variant="outlined" sx={{ height: '100%' }}>
<CardActionArea
disabled={disabled}
onClick={() => onPick(pack)}
sx={{ height: '100%', alignItems: 'flex-start' }}
>
<CardContent sx={{ textAlign: 'left' }}>
<Typography variant="subtitle2" fontWeight={700} gutterBottom>{pack.title}</Typography>
<Typography variant="caption" color="text.secondary">{pack.description}</Typography>
</CardContent>
</CardActionArea>
</Card>
))}
</Box>
);
export default PopularPacksGallery;
- [ ] Step 4: Run the test to verify it passes
Run: npm test -- --run src/components/pack/PopularPacksGallery.test.tsx
Expected: PASS (3/3).
- [ ] Step 5: Commit
git add src/components/pack/PopularPacksGallery.tsx src/components/pack/PopularPacksGallery.test.tsx
git commit -m "feat(packs): PopularPacksGallery — pre-built pack cards (PHON-170)"
Task 3: Wire the gallery into WelcomeView; drop the redundant TargetBox chips¶
Files:
- Modify: src/components/WelcomeView.tsx
- Modify: src/components/WelcomeView.test.tsx
- Modify: src/components/pack/TargetBox.tsx
- Modify: src/components/pack/TargetBox.test.tsx
Interfaces:
- WelcomeView renders <PopularPacksGallery onPick={...} disabled={seeding} /> under a "Popular packs" heading (between the target box / My Packs and the trust line). The pick handler reuses handleBuild: onPick={(pack) => handleBuild(pack.target, pack.title)}.
- TargetBox loses its QUICK_STARTS chip row (the gallery supersedes it); the input, live resolve preview, and Build button stay.
- [ ] Step 1: Update the tests
In src/components/pack/TargetBox.test.tsx, remove the fills the input from a quick-start chip test (the chips are gone). Keep the resolved/unresolved/did-you-mean/busy tests.
In src/components/WelcomeView.test.tsx, add (keep existing tests):
import { PREBUILT_PACKS } from '../lib/packs/prebuiltPacks';
it('seeds and opens the editor when a popular pack is picked', async () => {
const onSelectTool = vi.fn();
render(<WelcomeView onSelectTool={onSelectTool} />);
const pack = PREBUILT_PACKS[0]; // Initial /r/
fireEvent.click(screen.getByRole('button', { name: new RegExp(pack.title.replace(/[/()]/g, '\\$&'), 'i') }));
await waitFor(() => expect(seedPack).toHaveBeenCalledWith(pack.target));
await waitFor(() => expect(onSelectTool).toHaveBeenCalledWith('editor'));
expect(usePackStore.getState().activePack!.title).toBe(pack.title);
});
- [ ] Step 2: Run the tests to verify they fail
Run: npm test -- --run src/components/WelcomeView.test.tsx src/components/pack/TargetBox.test.tsx
Expected: WelcomeView new test FAILS (no gallery); TargetBox FAILS only if the removed test still references chips — remove it so the file compiles.
- [ ] Step 3: Implement
In src/components/pack/TargetBox.tsx: delete the QUICK_STARTS constant and the <Stack>...quick-start chips...</Stack> block (and any now-unused Stack/Chip imports if they're unused elsewhere in the file — keep Chip if the resolved-chip still uses it).
In src/components/WelcomeView.tsx: import the gallery and render it. Add the import:
import PopularPacksGallery from './pack/PopularPacksGallery';
Add a "Popular packs" section after the hasPacks "Open My Packs" button (and before the trust-line Typography):
<Box sx={{ mt: 4, width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Popular packs
</Typography>
<PopularPacksGallery onPick={(pack) => handleBuild(pack.target, pack.title)} disabled={seeding} />
</Box>
- [ ] Step 4: Run the tests to verify they pass
Run: npm test -- --run src/components/WelcomeView.test.tsx src/components/pack/TargetBox.test.tsx
Expected: PASS.
- [ ] Step 5: 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.
- [ ] Step 6: Commit
git add src/components/WelcomeView.tsx src/components/WelcomeView.test.tsx src/components/pack/TargetBox.tsx src/components/pack/TargetBox.test.tsx
git commit -m "feat(packs): popular-packs gallery on the front page; retire TargetBox quick-start chips (PHON-170)"
Self-Review notes (coverage vs spec §5.4)¶
- Pre-built pack = target spec + metadata, materialized via live APIs: Task 1
PrebuiltPack(target-spec-only) + reuse ofhandleBuild's live seed. ✓ - Demand-sourced starter set (~6): Task 1 (Initial /r/, Vocalic /r/, Final /s/, k vs t fronting, Stopping, Gliding). ✓
- Gallery on the front page; pick seeds + opens the Editor: Tasks 2–3. ✓ (packs display cards automatically via the editor-cards phase.)
- Supersedes the resolver-string quick-starts: Task 3 removes the TargetBox chips. ✓
- Deferred (spec §5.4 optional / later phases): curated word/sentence overrides per pack (the type can gain
curatedWords?later without rework; the seed path would resolve them viaresolveWordForPack); advisor final-trim of the set to a chosen 3–6; the "empty/focused target box renders the gallery" collapse (§5.2) — here the gallery is a distinct section, simpler and equally discoverable.
Decisions resolved at planning¶
- Target-spec-only pre-built packs (no curated overrides in v1) — avoids inventing/duplicating content; the seeder is the content source per §5.4's recommended model. Overrides remain an additive follow-up.
- Reuse
WelcomeView.handleBuildfor the pick — one seed path, error handling already there. - Gallery replaces the TargetBox quick-start chips — one popular-packs surface instead of two overlapping ones.