Therapy Pack — Front Page + Wiring (Phase 3c) 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 six-tool WelcomeView info-dump with the outcome-first front page — a hero, a target box that resolves typed input to a live chip, quick-start chips, and a My Packs path — wiring the full flow type/pick a target → seed via existing APIs → open the seeded pack in the Editor.
Architecture: A presentational TargetBox component drives the Phase 3a resolver live as the user types (resolved chip / "did you mean" / hint), and emits a resolved { target, label } on Build. WelcomeView becomes the outcome-first landing surface: hero + TargetBox + quick-starts + My Packs + a demoted "build your own with the tools" escape hatch. Its Build handler runs Phase 3b seeding (seedPack) then the createSeededPack store action, then navigates to the Editor via the existing onSelectTool('editor') callback. A seeding loading state covers the async gap.
Tech Stack: React 19, TypeScript, MUI 7, Zustand 5, Vitest 4 + @testing-library/react. Reuses Phase 3a (resolveTarget, describeTarget), Phase 3b (seedPack, usePackStore.createSeededPack), and the existing App_new.tsx onSelectTool routing.
Global Constraints¶
- No new API surface. Seeding uses Phase 3b's
seedPack(which calls the existing endpoints). The front page adds no direct fetch. - Store is the single mutation surface. The Build handler calls
usePackStore.getState().createSeededPack(...); it never touchespackDb. - Never silently rewrite input. The resolver's
suggestionresult renders as a clickable "Did you mean …?" — accepting it re-resolves that text; it is never auto-applied. Build is enabled only on aresolvedresult. (spec §5.2) - Editor navigation reuses the existing route. Opening the seeded pack =
onSelectTool('editor')(the primary surface wired in Phase 1b). The provisional "Build a Therapy Pack" CTA added toWelcomeViewearlier is replaced by this real flow. - Analytics via existing
track():track('target_resolved', { method })andtrack('pack_seeded')are NOT in the allowlist — reuse existing allowlisted events only, or omit. Usetrack('tool_opened', { tool: 'editor' })if signalling the editor open; do NOT invent new event names or touch the worker allowlist this phase (analytics taxonomy is Phase 6). - Privacy / copy: the trust line states evidence-based methodology + "decision support, not a diagnosis" + local-first. No patient identifiers anywhere.
- Test commands (from
packages/web/frontend/):npm test -- --run <file>,npm run type-check,npm run lint,npm run build.
File Structure¶
src/components/pack/TargetBox.tsx(create) — the target input + live resolve preview + quick-starts + Build button.src/components/pack/TargetBox.test.tsx(create) — RTL tests.src/components/WelcomeView.tsx(modify) — rework into the outcome-first landing page; embedTargetBox; wire seed-and-open; keep tool jump-links as the demoted escape hatch; add My Packs.src/components/WelcomeView.test.tsx(modify) — update for the new flow (seed-and-open).
Task 1: TargetBox — live resolver preview + Build¶
Files:
- Create: src/components/pack/TargetBox.tsx
- Test: src/components/pack/TargetBox.test.tsx
Interfaces:
- Consumes: resolveTarget from ../../lib/resolver/resolveTarget; TargetSpec from ../../types/therapyPack.
- Produces: TargetBox props { onBuild: (target: TargetSpec, label: string) => void; busy?: boolean }.
- Controlled text field; on every change it calls resolveTarget(value) and renders: a resolved chip (label) when resolved; a clickable "Did you mean "X"?" when suggestion (clicking sets the input to X); a muted hint when unresolved and the field is non-empty; nothing when empty.
- Build button enabled only when the current result is resolved and !busy; clicking calls onBuild(target, label).
- A row of quick-start chips (curated strings: /r/, s-blends, k vs t, final s); clicking a chip sets the input to that string (which resolves live).
- Placeholder: Enter a target — /r/, s-blends, k vs t, final /s/….
- [ ] Step 1: Write the failing test
Create src/components/pack/TargetBox.test.tsx:
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import TargetBox from './TargetBox';
describe('TargetBox', () => {
it('shows a resolved chip and enables Build for a valid target', () => {
const onBuild = vi.fn();
render(<TargetBox onBuild={onBuild} />);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: 'final s' } });
expect(screen.getByText('Final /s/')).toBeInTheDocument();
const build = screen.getByRole('button', { name: /build/i });
expect(build).toBeEnabled();
fireEvent.click(build);
expect(onBuild).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'sound', phones: ['s'], position: 'final' }),
'Final /s/',
);
});
it('disables Build for unresolved input', () => {
render(<TargetBox onBuild={vi.fn()} />);
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'qwxz' } });
expect(screen.getByRole('button', { name: /build/i })).toBeDisabled();
});
it('offers a did-you-mean suggestion and applies it on click', () => {
render(<TargetBox onBuild={vi.fn()} />);
const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'arr' } });
const dym = screen.getByRole('button', { name: /did you mean/i });
fireEvent.click(dym);
expect(input.value).toBe('r');
// 'r' resolves to /ɹ/, so Build becomes enabled
expect(screen.getByRole('button', { name: /build/i })).toBeEnabled();
});
it('fills the input from a quick-start chip', () => {
render(<TargetBox onBuild={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'k vs t' }));
expect((screen.getByRole('textbox') as HTMLInputElement).value).toBe('k vs t');
expect(screen.getByText('/k/ vs /t/')).toBeInTheDocument();
});
it('disables Build while busy', () => {
render(<TargetBox onBuild={vi.fn()} busy />);
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'final s' } });
expect(screen.getByRole('button', { name: /build/i })).toBeDisabled();
});
});
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- --run src/components/pack/TargetBox.test.tsx
Expected: FAIL — module not found.
- [ ] Step 3: Implement
TargetBox
Create src/components/pack/TargetBox.tsx:
import React, { useMemo, useState } from 'react';
import { Box, Button, Chip, Stack, TextField, Typography } from '@mui/material';
import { AutoAwesome as BuildIcon } from '@mui/icons-material';
import { resolveTarget } from '../../lib/resolver/resolveTarget';
import type { TargetSpec } from '../../types/therapyPack';
const QUICK_STARTS = ['/r/', 's-blends', 'k vs t', 'final s'];
interface TargetBoxProps {
onBuild: (target: TargetSpec, label: string) => void;
busy?: boolean;
}
const TargetBox: React.FC<TargetBoxProps> = ({ onBuild, busy = false }) => {
const [value, setValue] = useState('');
const result = useMemo(() => resolveTarget(value), [value]);
const resolved = result.status === 'resolved' ? result : null;
return (
<Box sx={{ width: '100%', maxWidth: 560 }}>
<TextField
fullWidth
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Enter a target — /r/, s-blends, k vs t, final /s/…"
inputProps={{ 'aria-label': 'Therapy target' }}
onKeyDown={(e) => {
if (e.key === 'Enter' && resolved && !busy) {
e.preventDefault();
onBuild(resolved.target, resolved.label);
}
}}
/>
{/* Live resolve feedback */}
<Box sx={{ minHeight: 32, mt: 1 }}>
{result.status === 'resolved' && (
<Chip label={result.label} color="primary" />
)}
{result.status === 'suggestion' && (
<Button size="small" variant="text" onClick={() => setValue(result.suggestedInput)}>
{result.note}
</Button>
)}
{result.status === 'unresolved' && (
<Typography variant="body2" color="text.secondary">
Not recognized. Try a sound like <code>/r/</code>, a contrast like <code>k vs t</code>,
or a process like <code>fronting</code>.
</Typography>
)}
</Box>
<Stack direction="row" spacing={1} sx={{ mt: 1, mb: 2, flexWrap: 'wrap', gap: 1 }}>
{QUICK_STARTS.map((q) => (
<Chip key={q} label={q} variant="outlined" onClick={() => setValue(q)} />
))}
</Stack>
<Button
variant="contained"
size="large"
startIcon={<BuildIcon />}
disabled={!resolved || busy}
onClick={() => resolved && onBuild(resolved.target, resolved.label)}
>
{busy ? 'Building your pack…' : 'Build pack'}
</Button>
</Box>
);
};
export default TargetBox;
- [ ] Step 4: Run the test to verify it passes
Run: npm test -- --run src/components/pack/TargetBox.test.tsx
Expected: PASS (5/5).
- [ ] Step 5: Type-check and commit
npm run type-check
git add src/components/pack/TargetBox.tsx src/components/pack/TargetBox.test.tsx
git commit -m "feat(packs): TargetBox — live resolver preview + quick-starts + Build (PHON-170)"
Task 2: Rework WelcomeView into the outcome-first front page + wire seed-and-open¶
Files:
- Modify: src/components/WelcomeView.tsx
- Modify: src/components/WelcomeView.test.tsx
Interfaces:
- Consumes: TargetBox (Task 1); seedPack from ../lib/seed/packSeeder; usePackStore; describeTarget from ../lib/resolver/resolverVocab; TargetSpec from ../types/therapyPack; existing onSelectTool prop.
- Produces: WelcomeView keeps its prop { onSelectTool: (id: string) => void }. New layout:
- Hero: outcome headline ("Therapy-ready materials for any speech sound — in minutes.") + subline naming the deliverable (editable, printable pack).
- TargetBox with a Build handler that: sets a seeding state, await seedPack(target), usePackStore.getState().createSeededPack(target, seeded, label), clears seeding, then onSelectTool('editor').
- My Packs: a button that opens the editor (onSelectTool('editor')) — the editor's empty-state already exposes the My Packs library; a returning user lands there. (A dedicated dialog is a later nicety.)
- Escape hatch: the existing task→tool jump-links, kept under a demoted "Or build your own with the tools" heading.
- Trust line: evidence-based methodology · local-first (packs live in your browser) · decision support, not a diagnosis.
- Remove the provisional "Build a Therapy Pack" CTA (superseded by the target box).
- [ ] Step 1: Update the test
Rewrite src/components/WelcomeView.test.tsx to cover the new flow (mock seedPack and the store):
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import WelcomeView from './WelcomeView';
import { usePackStore } from '../store/packStore';
vi.mock('../lib/seed/packSeeder', () => ({
seedPack: vi.fn(async () => ({ words: [], contrasts: [], sentences: [] })),
}));
import { seedPack } from '../lib/seed/packSeeder';
describe('WelcomeView — outcome-first front page', () => {
beforeEach(() => {
usePackStore.setState({ activePack: null, library: [], saveError: null });
vi.clearAllMocks();
});
it('renders the hero and the target box', () => {
render(<WelcomeView onSelectTool={vi.fn()} />);
expect(screen.getByRole('textbox', { name: /therapy target/i })).toBeInTheDocument();
});
it('builds a seeded pack from a target and opens the editor', async () => {
const onSelectTool = vi.fn();
render(<WelcomeView onSelectTool={onSelectTool} />);
fireEvent.change(screen.getByRole('textbox', { name: /therapy target/i }), { target: { value: 'final s' } });
fireEvent.click(screen.getByRole('button', { name: /build pack/i }));
await waitFor(() => expect(seedPack).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'sound', phones: ['s'], position: 'final' }),
));
await waitFor(() => expect(onSelectTool).toHaveBeenCalledWith('editor'));
// the seeded pack became active
expect(usePackStore.getState().activePack).not.toBeNull();
expect(usePackStore.getState().activePack!.title).toBe('Final /s/');
});
it('still exposes the tool escape-hatch links', () => {
const onSelectTool = vi.fn();
render(<WelcomeView onSelectTool={onSelectTool} />);
fireEvent.click(screen.getByRole('button', { name: /open word lists/i }));
expect(onSelectTool).toHaveBeenCalledWith('wordLists');
});
});
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- --run src/components/WelcomeView.test.tsx
Expected: FAIL — no target box / build-pack flow yet.
- [ ] Step 3: Rework
WelcomeView
Rewrite src/components/WelcomeView.tsx. Keep the existing TOOL_HINTS jump-list and its rendering (the escape hatch); replace the hero/intro/provisional-CTA with the outcome-first hero + TargetBox + trust line + My Packs, and add the Build handler.
/**
* Outcome-first front page (7.0.0 app inversion, PHON-170, spec §5.1).
* Replaces the six-tool info-dump: a target box that resolves a typed
* target and seeds an editable, printable pack, with the tools demoted to
* a "build your own" escape hatch.
*/
import React, { useState } from 'react';
import { Box, Button, Typography, Link as MuiLink, Divider } from '@mui/material';
import TargetBox from './pack/TargetBox';
import { seedPack } from '../lib/seed/packSeeder';
import { describeTarget } from '../lib/resolver/resolverVocab';
import { usePackStore } from '../store/packStore';
import type { TargetSpec } from '../types/therapyPack';
interface ToolHint { prompt: string; tool: string; id: string; }
const TOOL_HINTS: ToolHint[] = [
{ prompt: 'Looking up a word?', tool: 'Lookup', id: 'lookup' },
{ prompt: 'Building a drill word list?', tool: 'Word Lists', id: 'wordLists' },
{ prompt: 'Targeting a contrast?', tool: 'Contrast Sets', id: 'contrastive' },
{ prompt: 'Need sentences for context?', tool: 'Sentences', id: 'governedGeneration' },
{ prompt: 'Analyzing a passage?', tool: 'Text Analysis', id: 'textAnalysis' },
];
interface WelcomeViewProps {
onSelectTool: (id: string) => void;
}
const WelcomeView: React.FC<WelcomeViewProps> = ({ onSelectTool }) => {
const [seeding, setSeeding] = useState(false);
const hasPacks = usePackStore((s) => s.library.length > 0);
const handleBuild = async (target: TargetSpec, label: string) => {
setSeeding(true);
try {
const seeded = await seedPack(target);
usePackStore.getState().createSeededPack(target, seeded, label || describeTarget(target));
} finally {
setSeeding(false);
}
onSelectTool('editor');
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', px: 3, py: { xs: 4, md: 6 }, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700} gutterBottom>
Therapy-ready materials for any speech sound — in minutes.
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 620, mb: 4 }}>
Enter a target and get an editable, printable therapy pack — word lists, contrast pairs,
sentences, and picture cards you can curate and export.
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
<TargetBox onBuild={handleBuild} busy={seeding} />
</Box>
{hasPacks && (
<Button variant="text" sx={{ mt: 1 }} onClick={() => onSelectTool('editor')}>
Open My Packs
</Button>
)}
<Typography variant="caption" color="text.disabled" sx={{ mt: 3, maxWidth: 560, display: 'block' }}>
Evidence-based methodology · packs live in your browser (local-first) · decision support, not a diagnosis.
</Typography>
<Divider sx={{ my: 4, width: '100%', maxWidth: 560 }} />
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Or build your own with the tools
</Typography>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'auto 1fr auto',
columnGap: 1.5, rowGap: 1,
alignItems: 'baseline',
maxWidth: 560, width: '100%', mx: 'auto', textAlign: 'left',
}}
>
{TOOL_HINTS.map(({ prompt, tool, id }) => (
<React.Fragment key={id}>
<Box component="span" sx={{ color: 'text.disabled' }}>→</Box>
<Typography component="span" variant="body2" color="text.secondary">{prompt}</Typography>
<MuiLink
component="button" type="button" underline="hover"
onClick={() => onSelectTool(id)}
sx={{ fontWeight: 600, fontSize: '0.875rem', color: 'primary.main', cursor: 'pointer', justifySelf: 'start' }}
>
Open {tool}
</MuiLink>
</React.Fragment>
))}
</Box>
</Box>
);
};
export default WelcomeView;
- [ ] Step 4: Run the test to verify it passes
Run: npm test -- --run src/components/WelcomeView.test.tsx
Expected: PASS (3/3).
- [ ] 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
git commit -m "feat(packs): outcome-first front page — target box seeds a pack and opens the editor (PHON-170)"
Self-Review notes (coverage vs spec §5.1 / §5.3)¶
- Outcome-first hero + deliverable subline: Task 2. ✓
- Target box as primary action (doubles as router via resolve): Task 1
TargetBox+ Task 2 wiring. ✓ - Live resolved chip / did-you-mean (never silent) / hint: Task 1. ✓
- Quick-starts (mini gallery): Task 1 chips (
/r/,s-blends,k vs t,final s) — resolver-backed, real function without waiting on Phase 4's curated packs. ✓ (the full popular-packs gallery from real demand data is Phase 4.) - Seed via existing APIs → open Editor: Task 2 Build handler (
seedPack→createSeededPack→onSelectTool('editor')) with abusystate. ✓ - Returning users → My Packs: Task 2 "Open My Packs" (routes to the editor's library empty-state) — shown only when packs exist. A dedicated My Packs dialog on the landing page is a later nicety. ✓ (partial)
- Tools demoted to escape hatch: Task 2 keeps the jump-links under "Or build your own with the tools". ✓
- Trust line (methodology · local-first · decision support not diagnosis): Task 2. ✓
- Provisional CTA replaced: Task 2 removes the earlier "Build a Therapy Pack" button. ✓
- Deferred (later phases): the full popular-packs gallery from demand data (Phase 4); the AppSidebar "Advanced" grouping + 7.0.0 version bump + analytics taxonomy (Phase 6); a seeding skeleton/preview of the pack being built (busy text covers it for now); a dedicated landing My-Packs dialog.
Decisions resolved at planning¶
- My Packs from the landing page routes into the editor's existing empty-state library (which already lists/open/rename/duplicate/delete) rather than duplicating a dialog on the front page — smaller surface, one library UI.
- Quick-starts are resolver strings, not Phase-4 curated packs — they exercise the same resolve→seed path and give the gallery real function now; Phase 4 swaps in demand-sourced packs.
- No new analytics events (
target_resolved/pack_seededare Phase 6 taxonomy); the editor-open is observable via the existingtool_opened/editor route if needed.