Skip to content

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 seedPackcreateSeededPack 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 target is a valid TargetSpec (src/types/therapyPack.ts): { mode: 'sound'|'contrast'|'process'; phones: string[]; position?; processPreset?; level? }. Phones are IPA (ASCII gɡ, 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 does seedPackcreateSeededPackonSelectTool('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) — PrebuiltPack type + PREBUILT_PACKS data.
  • 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 through handleBuild.
  • 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)"

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
Expected: all pass (the one known pre-existing unrelated 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 of handleBuild'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 via resolveWordForPack); 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

  1. 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.
  2. Reuse WelcomeView.handleBuild for the pick — one seed path, error handling already there.
  3. Gallery replaces the TargetBox quick-start chips — one popular-packs surface instead of two overlapping ones.