Skip to content

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 touches packDb.
  • Never silently rewrite input. The resolver's suggestion result renders as a clickable "Did you mean …?" — accepting it re-resolves that text; it is never auto-applied. Build is enabled only on a resolved result. (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 to WelcomeView earlier is replaced by this real flow.
  • Analytics via existing track(): track('target_resolved', { method }) and track('pack_seeded') are NOT in the allowlist — reuse existing allowlisted events only, or omit. Use track('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; embed TargetBox; 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
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
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 (seedPackcreateSeededPackonSelectTool('editor')) with a busy state. ✓
  • 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

  1. 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.
  2. 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.
  3. No new analytics events (target_resolved/pack_seeded are Phase 6 taxonomy); the editor-open is observable via the existing tool_opened/editor route if needed.