Landing Target Tiles Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the front-page free-text target box with a 6-tile grid (5 featured targets + a "More targets…" menu), and delete the command resolver.
Architecture: PREBUILT_PACKS gains a featured flag. A new TargetTiles component renders the featured packs as one-click seed tiles plus a "More targets…" tile that opens a new MoreTargetsMenu dialog (remaining packs as a filterable row list + "Start blank"). WelcomeView swaps TargetBox/PopularPacksGallery for TargetTiles. The TargetBox + lib/resolver/* + PopularPacksGallery sources are deleted.
Tech Stack: React 19 + TypeScript + MUI 7; Vitest 4 + @testing-library/react + happy-dom.
Global Constraints¶
- Spec:
docs/superpowers/specs/2026-07-20-landing-target-tiles-design.md. - Featured five, in this order:
initial-r,final-s,k-vs-t,stopping,vocalic-r. Non-featured:gliding. - Seed path is unchanged and reused verbatim:
seedPack(target)→usePackStore.getState().createSeededPack(target, seeded, title)→onSelectTool('editor'), withseeding/seedErrorstate andtrack('target_resolved')+track('pack_seeded'). "Start blank" =usePackStore.getState().newPack()→onSelectTool('editor')(no seed, no analytics change). - Keep
PhonemePickerDialog(shared by 6 tools). Delete onlyTargetBox,lib/resolver/resolveTarget.ts,lib/resolver/resolverVocab.ts,PopularPacksGallery, and their tests. - The More menu must be a filterable list of pack rows (one array → rows) so a future incremental search field can drop in; do NOT build the search field now. "Start blank" is pinned below the pack rows.
- Do not touch the seeder, editor, or export renderer. No em dashes in user-facing copy.
- Gate each task:
npm run type-check,npm run lint, and the task's tests. Final task alsonpm run buildand the full suite.
Task 1: PrebuiltPack.featured flag + reorder¶
Files:
- Modify: src/lib/packs/prebuiltPacks.ts
- Test: src/lib/packs/prebuiltPacks.test.ts
Interfaces:
- Produces: PrebuiltPack gains featured: boolean. PREBUILT_PACKS ordered featured-first: initial-r, final-s, k-vs-t, stopping, vocalic-r, gliding.
- [ ] Step 1: Write failing tests
Add to prebuiltPacks.test.ts:
it('has exactly five featured packs in demand order', () => {
const featured = PREBUILT_PACKS.filter((p) => p.featured);
expect(featured.map((p) => p.id)).toEqual(['initial-r', 'final-s', 'k-vs-t', 'stopping', 'vocalic-r']);
});
it('marks gliding as not featured', () => {
expect(PREBUILT_PACKS.find((p) => p.id === 'gliding')!.featured).toBe(false);
});
-
[ ] Step 2: Run — expect FAIL (
featuredmissing). Run:npx vitest run src/lib/packs/prebuiltPacks.test.ts -
[ ] Step 3: Implement
In prebuiltPacks.ts: add featured: boolean; to the PrebuiltPack interface. Set featured: true on initial-r, final-s, k-vs-t, stopping, vocalic-r and featured: false on gliding. Reorder the array so those five come first in that order, gliding last. (The demand order moves vocalic-r after stopping.)
-
[ ] Step 4: Run — expect PASS (full file, so the existing count/shape tests still hold). Run:
npx vitest run src/lib/packs/prebuiltPacks.test.ts && npm run type-check -
[ ] Step 5: Commit —
feat(packs): featured flag + demand-order prebuilt packs (PHON-170)
Task 2: MoreTargetsMenu dialog¶
Files:
- Create: src/components/pack/MoreTargetsMenu.tsx
- Test: src/components/pack/MoreTargetsMenu.test.tsx
Interfaces:
- Consumes: PREBUILT_PACKS (renders the !featured rows).
- Produces:
interface MoreTargetsMenuProps {
open: boolean;
onClose: () => void;
onPickPack: (pack: PrebuiltPack) => void; // a non-featured pack row was clicked
onStartBlank: () => void; // "Start blank" was clicked
disabled?: boolean; // seeding in flight
}
- [ ] Step 1: Write failing tests
import { render, screen, fireEvent } from '@testing-library/react';
import MoreTargetsMenu from './MoreTargetsMenu';
it('lists non-featured packs and fires onPickPack', () => {
const onPickPack = vi.fn();
render(<MoreTargetsMenu open onClose={vi.fn()} onPickPack={onPickPack} onStartBlank={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: /gliding/i }));
expect(onPickPack).toHaveBeenCalledWith(expect.objectContaining({ id: 'gliding' }));
});
it('fires onStartBlank from the Start blank row', () => {
const onStartBlank = vi.fn();
render(<MoreTargetsMenu open onClose={vi.fn()} onPickPack={vi.fn()} onStartBlank={onStartBlank} />);
fireEvent.click(screen.getByRole('button', { name: /start blank/i }));
expect(onStartBlank).toHaveBeenCalled();
});
it('does not render featured packs in the menu', () => {
render(<MoreTargetsMenu open onClose={vi.fn()} onPickPack={vi.fn()} onStartBlank={vi.fn()} />);
expect(screen.queryByRole('button', { name: /initial \/ɹ\//i })).not.toBeInTheDocument();
});
-
[ ] Step 2: Run — expect FAIL (module missing). Run:
npx vitest run src/components/pack/MoreTargetsMenu.test.tsx -
[ ] Step 3: Implement
MUI Dialog (open, onClose, maxWidth="xs", fullWidth), DialogTitle "More targets". DialogContent:
- Map PREBUILT_PACKS.filter((p) => !p.featured) to a List of ListItemButton rows (primary = pack.title, secondary = pack.description), each onClick={() => onPickPack(pack)}, disabled={disabled}. Keep this as a single mapped array so a filter can wrap it later.
- A short caption below the rows: More packs coming soon. (community/sponsored anchor — leave a {/* future: community/sponsored packs */} comment).
- A Divider, then a pinned ListItemButton "Start blank" (secondary "Build from the tools") → onStartBlank, disabled={disabled}.
-
[ ] Step 4: Run — expect PASS + type-check + lint. Run:
npx vitest run src/components/pack/MoreTargetsMenu.test.tsx && npm run type-check && npm run lint -
[ ] Step 5: Commit —
feat(packs): MoreTargetsMenu dialog (PHON-170)
Task 3: TargetTiles grid¶
Files:
- Create: src/components/pack/TargetTiles.tsx
- Test: src/components/pack/TargetTiles.test.tsx
Interfaces:
- Consumes: PREBUILT_PACKS (featured), MoreTargetsMenu (Task 2).
- Produces:
interface TargetTilesProps {
onPickPack: (pack: PrebuiltPack) => void; // any pack (featured tile OR menu row)
onStartBlank: () => void;
disabled?: boolean;
}
- [ ] Step 1: Write failing tests
import { render, screen, fireEvent } from '@testing-library/react';
import TargetTiles from './TargetTiles';
it('renders the five featured tiles', () => {
render(<TargetTiles onPickPack={vi.fn()} onStartBlank={vi.fn()} />);
['Initial /ɹ/', 'Final /s/', 'k vs t', 'Stopping', 'Vocalic /ɹ/'].forEach((t) =>
expect(screen.getByText(new RegExp(t.replace(/[/()]/g, '\\$&'), 'i'))).toBeInTheDocument());
});
it('seeds a featured pack on tile click', () => {
const onPickPack = vi.fn();
render(<TargetTiles onPickPack={onPickPack} onStartBlank={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: /initial \/ɹ\//i }));
expect(onPickPack).toHaveBeenCalledWith(expect.objectContaining({ id: 'initial-r' }));
});
it('opens the More menu and forwards a menu pick', () => {
const onPickPack = vi.fn();
render(<TargetTiles onPickPack={onPickPack} onStartBlank={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: /more targets/i }));
fireEvent.click(screen.getByRole('button', { name: /gliding/i }));
expect(onPickPack).toHaveBeenCalledWith(expect.objectContaining({ id: 'gliding' }));
});
-
[ ] Step 2: Run — expect FAIL. Run:
npx vitest run src/components/pack/TargetTiles.test.tsx -
[ ] Step 3: Implement
Responsive grid (reuse the Card/CardActionArea/CardContent pattern from the old PopularPacksGallery: gridTemplateColumns { xs:'1fr', sm:'1fr 1fr', md:'1fr 1fr 1fr' }, gap:1.5, maxWidth:720). Render PREBUILT_PACKS.filter((p) => p.featured) as tiles (title subtitle2 bold + description caption), each CardActionArea onClick={() => onPickPack(pack)}, disabled. Add a final "More targets…" tile (same Card shape, distinct copy, AddIcon or similar) that sets menuOpen=true. Render <MoreTargetsMenu open={menuOpen} onClose={() => setMenuOpen(false)} onPickPack={(p) => { setMenuOpen(false); onPickPack(p); }} onStartBlank={() => { setMenuOpen(false); onStartBlank(); }} disabled={disabled} />.
-
[ ] Step 4: Run — expect PASS + type-check + lint.
-
[ ] Step 5: Commit —
feat(packs): TargetTiles featured grid + More tile (PHON-170)
Task 4: WelcomeView integration + delete resolver/TargetBox/PopularPacksGallery¶
Files:
- Modify: src/components/WelcomeView.tsx, src/components/WelcomeView.test.tsx
- Delete: src/components/pack/TargetBox.tsx, src/components/pack/TargetBox.test.tsx, src/components/pack/PopularPacksGallery.tsx, src/lib/resolver/resolveTarget.ts, src/lib/resolver/resolveTarget.test.ts, src/lib/resolver/resolverVocab.ts (+ any resolverVocab test)
Interfaces:
- Consumes: TargetTiles (Task 3).
- [ ] Step 1: Update WelcomeView tests
Replace the TargetBox/gallery-specific tests. Keep/retarget:
it('seeds and opens the editor when a featured tile is picked', async () => {
const onSelectTool = vi.fn();
render(<WelcomeView onSelectTool={onSelectTool} />);
fireEvent.click(screen.getByRole('button', { name: /initial \/ɹ\//i }));
await waitFor(() => expect(seedPack).toHaveBeenCalled());
await waitFor(() => expect(onSelectTool).toHaveBeenCalledWith('editor'));
});
it('opens the editor blank via More targets → Start blank', () => {
const onSelectTool = vi.fn();
render(<WelcomeView onSelectTool={onSelectTool} />);
fireEvent.click(screen.getByRole('button', { name: /more targets/i }));
fireEvent.click(screen.getByRole('button', { name: /start blank/i }));
expect(usePackStore.getState().activePack).not.toBeNull();
expect(onSelectTool).toHaveBeenCalledWith('editor');
});
it('has no free-text target box', () => {
render(<WelcomeView onSelectTool={vi.fn()} />);
expect(screen.queryByRole('textbox', { name: /therapy target/i })).not.toBeInTheDocument();
});
-
[ ] Step 2: Run — expect FAIL. Run:
npx vitest run src/components/WelcomeView.test.tsx -
[ ] Step 3: Implement WelcomeView
-
Remove imports of
TargetBox,PopularPacksGallery,describeTarget. ImportTargetTiles. - Keep
handleBuild(target, label)(seed path) as-is; it becomesonPickPack={(pack) => handleBuild(pack.target, pack.title)}. AddhandleStartBlank = () => { usePackStore.getState().newPack(); onSelectTool('editor'); }. - Replace the
<TargetBox …/>block and the separate "Popular materials" gallery section with a single<TargetTiles onPickPack={(p) => handleBuild(p.target, p.title)} onStartBlank={handleStartBlank} disabled={seeding} />. -
Keep hero headline/subline,
seedErroralert, "Open My Materials", trust line, and the sidebar-tools escape hatch line. -
[ ] Step 4: Delete the dead sources + their tests
git rm src/components/pack/TargetBox.tsx src/components/pack/TargetBox.test.tsx \
src/components/pack/PopularPacksGallery.tsx \
src/lib/resolver/resolveTarget.ts src/lib/resolver/resolveTarget.test.ts \
src/lib/resolver/resolverVocab.ts
resolverVocab.test.ts exists, remove it too. If src/lib/resolver/ is now empty, it's fine to leave.)
- [ ] Step 5: Gate
Run: grep -rn "TargetBox\|resolveTarget\|resolverVocab\|PopularPacksGallery\|describeTarget" src → expect no non-deleted references. Then npm run type-check && npm run lint && npx vitest run && npm run build. Full suite green (except the known unrelated MetadataErrorAlert flake, which passes in isolation).
- [ ] Step 6: Commit —
feat(packs): landing = target tiles; remove free-text resolver (PHON-170)