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 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 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 seedPack → 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) — 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.