Skip to content

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'), with seeding/seedError state and track('target_resolved') + track('pack_seeded'). "Start blank" = usePackStore.getState().newPack()onSelectTool('editor') (no seed, no analytics change).
  • Keep PhonemePickerDialog (shared by 6 tools). Delete only TargetBox, 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 also npm run build and 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 (featured missing). 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: Commitfeat(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: Commitfeat(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;
}
Owns the More-menu open/close state internally.

  • [ ] 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: Commitfeat(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();
});
Keep the existing seeding-error test but drive it from a tile click. Keep the "Open My Materials" test.

  • [ ] Step 2: Run — expect FAIL. Run: npx vitest run src/components/WelcomeView.test.tsx

  • [ ] Step 3: Implement WelcomeView

  • Remove imports of TargetBox, PopularPacksGallery, describeTarget. Import TargetTiles.

  • Keep handleBuild(target, label) (seed path) as-is; it becomes onPickPack={(pack) => handleBuild(pack.target, pack.title)}. Add handleStartBlank = () => { 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, seedError alert, "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
(If a 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: Commitfeat(packs): landing = target tiles; remove free-text resolver (PHON-170)