Skip to content

Therapy Pack — Editor Cards + Word-Linking + Quick-Add 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: Make the Editor render target words as picture cards (spec §5.5), link words to the lexicon at add-time (store IPA + image on the pack item), and give every section a quick-add — words → resolved card, contrasts → two-word pair, sentences → free text with target auto-highlight (spec §15 amendment).

Architecture: Additive PackWord fields (imageFile/imageProvider) keep the pack self-contained so cards render instantly and export needs no re-fetch. Two pure-ish async resolvers over the existing /api/words/batch turn a typed word into a lexicon-linked pack item and a typed sentence into highlighted targets. A WordCard presentational component renders the picture (via the existing wordImageUrl) with word-only fallback. Quick-add fields per section feed the store; the guided seeder (Phase 3b) is updated to persist image refs so seeded and pre-built packs get cards for free.

Tech Stack: React 19, TypeScript, MUI 7, Zustand 5, Vitest 4 + @testing-library/react. Reuses src/lib/wordImages.ts (wordImageUrl, WORD_IMAGE_PROVIDER_LABELS), api.batchWords (Phase 2), the pack store (Phase 1), and the seeder (Phase 3b).

Global Constraints

  • Store is the single mutation surface. Components resolve words then call store actions; never touch packDb.
  • Self-contained packs. Word lexicon data (IPA, image file/provider) is resolved at add-time and stored on the PackWord. No render-time or export-time re-fetch is required for a card. (spec §15)
  • PackWord change is additive/optional. Add imageFile?: string and imageProvider?: string. Do NOT bump PACK_SCHEMA_VERSION (optional fields need no migration; existing packs stay valid).
  • Image URLs via the existing helper. Build card image src with wordImageUrl(provider, file) from src/lib/wordImages.ts. Word-only fallback when the word has no image (missing fields OR load error). Never serve source_file; attribution posture unchanged (CC BY-SA).
  • Graceful resolution. A word not in the lexicon, or a failed batch call, yields a word-only pack item — never a thrown add. (spec §14 seeding-quality posture)
  • No new API surface / no new analytics events. Reuse /api/words/batch; editor mutations keep emitting the existing pack_edited.
  • Quick-add is additive, not a replacement. It sits alongside the Phase-(tools) "+ Add from [tool]" affordance, which is a later phase.
  • Types (src/types/therapyPack.ts): PackWord, PackContrast, PackSentence, PackItem, TherapyPack. BatchWordInfo (src/services/apiClient.ts) has word, ipa?, phonemes_str?, syllable_count?, cv_shape?, has_image?, image_file?, image_provider?.
  • Test commands (from packages/web/frontend/): npm test -- --run <file>, npm run type-check, npm run lint, npm run build.

File Structure

  • src/types/therapyPack.ts (modify) — add imageFile?/imageProvider? to PackWord.
  • src/lib/seed/packSeeder.ts (modify) — mapWordToPackWord persists image_file/image_provider.
  • src/store/packStore.ts (modify) — add updateWord(id, patch) action.
  • src/lib/pack/resolveWord.ts (create) — resolveWordForPack(word, deps?) + resolveSentenceTargets(text, targetPhones, deps?).
  • src/lib/pack/resolveWord.test.ts (create).
  • src/components/pack/WordCard.tsx (create) — picture card for a PackWord.
  • src/components/pack/WordCard.test.tsx (create).
  • src/components/pack/ContrastAddField.tsx (create) — two-word quick-add.
  • src/components/pack/SentenceAddField.tsx (create) — free-text quick-add.
  • src/components/pack/QuickAddFields.test.tsx (create) — tests for both add fields.
  • src/components/pack/PackEditor.tsx (modify) — render WordCard grid, wire async word add + resolved replace, add contrast/sentence quick-add.
  • src/components/pack/PackEditor.test.tsx (modify) — integration assertions.

Task 1: PackWord image fields + seeder persists images + updateWord store action

Files: - Modify: src/types/therapyPack.ts - Modify: src/lib/seed/packSeeder.ts - Modify: src/store/packStore.ts - Test: src/store/packStore.test.ts, src/lib/seed/packSeeder.test.ts

Interfaces: - Consumes: existing PackWord, store commit. - Produces: - PackWord gains imageFile?: string; imageProvider?: string;. - mapWordToPackWord(w, at) now also sets imageFile/imageProvider from w.image_file/w.image_provider when present. - Store: updateWord: (id: string, patch: Partial<Pick<PackWord, 'word' | 'ipa' | 'hasImage' | 'imageFile' | 'imageProvider' | 'provenance'>>) => void — merges patch into the matching word and commits (no-op if id not a word).

  • [ ] Step 1: Write the failing tests

Add to src/store/packStore.test.ts:

  it('updateWord merges a patch into the matching word', () => {
    store.getState().newPack();
    store.getState().addItem({
      kind: 'word', id: 'w1', word: 'rocket',
      provenance: { sourceTool: 'manual', addedAt: 1 },
    });
    store.getState().updateWord('w1', { ipa: 'ɹ ɑ k ə t', imageFile: 'rocket.svg', imageProvider: 'mulberry', hasImage: true });
    const w = store.getState().activePack!.words[0];
    expect(w).toMatchObject({ word: 'rocket', ipa: 'ɹ ɑ k ə t', imageFile: 'rocket.svg', imageProvider: 'mulberry', hasImage: true });
  });

  it('updateWord is a no-op when the id is not a word', () => {
    store.getState().newPack();
    const before = store.getState().activePack!.updatedAt;
    store.getState().updateWord('nope', { ipa: 'x' });
    expect(store.getState().activePack!.updatedAt).toBe(before);
  });

Add to src/lib/seed/packSeeder.test.ts (in the packSeeder pure helpers describe):

  it('mapWordToPackWord persists image_file/image_provider', () => {
    const w = { word: 'rocket', ipa: 'ɹ', has_image: 1, image_file: 'rocket.svg', image_provider: 'mulberry' } as never;
    const pw = mapWordToPackWord(w, 1);
    expect(pw.imageFile).toBe('rocket.svg');
    expect(pw.imageProvider).toBe('mulberry');
  });
  • [ ] Step 2: Run the tests to verify they fail

Run: npm test -- --run src/store/packStore.test.ts src/lib/seed/packSeeder.test.ts Expected: FAIL — updateWord undefined; imageFile undefined.

  • [ ] Step 3: Implement

In src/types/therapyPack.ts, extend PackWord:

export interface PackWord {
  kind: 'word';
  id: string;
  word: string;
  ipa?: string;
  hasImage?: boolean;
  imageFile?: string;
  imageProvider?: string;
  provenance: PackItemProvenance;
}

In src/lib/seed/packSeeder.ts, update mapWordToPackWord:

export function mapWordToPackWord(w: Word, at: number): PackWord {
  return {
    kind: 'word',
    id: newId(),
    word: w.word,
    ipa: w.ipa ?? undefined,
    hasImage: w.has_image ? true : (w.image_file ? true : undefined),
    imageFile: w.image_file ?? undefined,
    imageProvider: w.image_provider ?? undefined,
    provenance: { sourceTool: 'guided', addedAt: at, reason: 'seeded target word' },
  };
}
(Word — from ../../types/phonology — has image_file?/image_provider?/has_image?. If TS complains they're not on the imported Word, they are declared there per phonology.ts:114-116.)

In src/store/packStore.ts, add to the PackState interface:

  updateWord: (id: string, patch: Partial<Pick<PackWord, 'word' | 'ipa' | 'hasImage' | 'imageFile' | 'imageProvider' | 'provenance'>>) => void;
and implement near replaceWord:

    updateWord(id, patch) {
      const p = get().activePack;
      if (!p) return;
      if (!p.words.some((w) => w.id === id)) return;
      commit({ ...p, words: p.words.map((w) => (w.id === id ? { ...w, ...patch } : w)) });
    },
  • [ ] Step 4: Run the tests to verify they pass

Run: npm test -- --run src/store/packStore.test.ts src/lib/seed/packSeeder.test.ts Expected: PASS.

  • [ ] Step 5: Type-check and commit
npm run type-check
git add src/types/therapyPack.ts src/lib/seed/packSeeder.ts src/store/packStore.ts src/store/packStore.test.ts src/lib/seed/packSeeder.test.ts
git commit -m "feat(packs): PackWord image fields + seeder persists them + updateWord action (PHON-170)"

Task 2: resolveWordForPack + resolveSentenceTargets

Files: - Create: src/lib/pack/resolveWord.ts - Test: src/lib/pack/resolveWord.test.ts

Interfaces: - Consumes: api.batchWords + BatchWordInfo (../../services/apiClient); newId (../id); PackWord (../../types/therapyPack). - Produces: - export interface ResolveWordDeps { batchWords: typeof api.batchWords; now: () => number; } - export async function resolveWordForPack(word: string, deps?: Partial<ResolveWordDeps>): Promise<PackWord> — builds a PackWord (fresh id, sourceTool: 'manual') for the trimmed word; batch-resolves it and fills ipa/hasImage/imageFile/imageProvider when the lexicon has it; on no-match or failure returns a word-only item. Never throws. - export async function resolveSentenceTargets(text: string, targetPhones: string[], deps?: Partial<ResolveWordDeps>): Promise<string[]> — returns the distinct lowercased surface words of text whose phonemes_str contains one of targetPhones (pipe-delimited, e.g. |s|). Empty array when targetPhones is empty or on failure.

  • [ ] Step 1: Write the failing test

Create src/lib/pack/resolveWord.test.ts:

import { describe, it, expect, vi } from 'vitest';
import { resolveWordForPack, resolveSentenceTargets } from './resolveWord';

describe('resolveWordForPack', () => {
  it('links a known word to its lexicon data', async () => {
    const batchWords = vi.fn().mockResolvedValue([
      { word: 'rocket', ipa: 'ɹ ɑ k ə t', has_image: 1, image_file: 'rocket.svg', image_provider: 'mulberry' },
    ]);
    const pw = await resolveWordForPack('Rocket', { batchWords, now: () => 5 });
    expect(pw).toMatchObject({
      kind: 'word', word: 'Rocket', ipa: 'ɹ ɑ k ə t', hasImage: true,
      imageFile: 'rocket.svg', imageProvider: 'mulberry',
    });
    expect(pw.provenance).toMatchObject({ sourceTool: 'manual', addedAt: 5 });
  });

  it('returns a word-only item for an unknown word', async () => {
    const pw = await resolveWordForPack('zzxq', { batchWords: vi.fn().mockResolvedValue([]), now: () => 1 });
    expect(pw.word).toBe('zzxq');
    expect(pw.imageFile).toBeUndefined();
    expect(pw.ipa).toBeUndefined();
  });

  it('returns a word-only item when the batch call throws', async () => {
    const pw = await resolveWordForPack('rocket', { batchWords: vi.fn().mockRejectedValue(new Error('down')), now: () => 1 });
    expect(pw.word).toBe('rocket');
    expect(pw.imageFile).toBeUndefined();
  });
});

describe('resolveSentenceTargets', () => {
  it('highlights surface words whose phonemes contain a target phone', async () => {
    const batchWords = vi.fn().mockResolvedValue([
      { word: 'the', phonemes_str: '|ð|ə|' },
      { word: 'sun', phonemes_str: '|s|ʌ|n|' },
      { word: 'is', phonemes_str: '|ɪ|z|' },
      { word: 'hot', phonemes_str: '|h|ɑ|t|' },
    ]);
    const t = await resolveSentenceTargets('The sun is hot', ['s'], { batchWords, now: () => 1 });
    expect(t).toEqual(['sun']);
  });

  it('returns [] when there are no target phones', async () => {
    expect(await resolveSentenceTargets('The sun', [], { batchWords: vi.fn(), now: () => 1 })).toEqual([]);
  });

  it('returns [] on batch failure', async () => {
    const t = await resolveSentenceTargets('The sun', ['s'], { batchWords: vi.fn().mockRejectedValue(new Error('x')), now: () => 1 });
    expect(t).toEqual([]);
  });
});
  • [ ] Step 2: Run the test to verify it fails

Run: npm test -- --run src/lib/pack/resolveWord.test.ts Expected: FAIL — module not found.

  • [ ] Step 3: Implement

Create src/lib/pack/resolveWord.ts:

/**
 * Link a typed word to the lexicon (spec §15): resolve IPA + image at add-time
 * so the pack item is self-contained (instant card, no export re-fetch). Never
 * throws — a word not in the lexicon stays word-only.
 */
import { api } from '../../services/apiClient';
import { newId } from '../id';
import type { PackWord } from '../../types/therapyPack';

export interface ResolveWordDeps {
  batchWords: typeof api.batchWords;
  now: () => number;
}

export async function resolveWordForPack(
  word: string,
  deps: Partial<ResolveWordDeps> = {},
): Promise<PackWord> {
  const batchWords = deps.batchWords ?? api.batchWords.bind(api);
  const now = deps.now ?? Date.now;
  const trimmed = word.trim();
  const item: PackWord = {
    kind: 'word',
    id: newId(),
    word: trimmed,
    provenance: { sourceTool: 'manual', addedAt: now() },
  };
  try {
    const rows = await batchWords([trimmed.toLowerCase()]);
    const info = rows.find((r) => r.word.toLowerCase() === trimmed.toLowerCase());
    if (info) {
      item.ipa = info.ipa ?? undefined;
      item.imageFile = info.image_file ?? undefined;
      item.imageProvider = info.image_provider ?? undefined;
      item.hasImage = info.has_image || info.image_file ? true : undefined;
    }
  } catch {
    // keep word-only
  }
  return item;
}

export async function resolveSentenceTargets(
  text: string,
  targetPhones: string[],
  deps: Partial<ResolveWordDeps> = {},
): Promise<string[]> {
  if (!targetPhones.length) return [];
  const batchWords = deps.batchWords ?? api.batchWords.bind(api);
  const surfaces = (text.toLowerCase().match(/[a-z']+/g) ?? []);
  const uniq = Array.from(new Set(surfaces));
  if (!uniq.length) return [];
  try {
    const rows = await batchWords(uniq);
    const hits = new Set<string>();
    for (const r of rows) {
      const ps = r.phonemes_str ?? '';
      if (targetPhones.some((p) => ps.includes(`|${p}|`))) hits.add(r.word.toLowerCase());
    }
    return Array.from(new Set(surfaces.filter((w) => hits.has(w))));
  } catch {
    return [];
  }
}
  • [ ] Step 4: Run the test to verify it passes

Run: npm test -- --run src/lib/pack/resolveWord.test.ts Expected: PASS (6/6).

  • [ ] Step 5: Type-check and commit
npm run type-check
git add src/lib/pack/resolveWord.ts src/lib/pack/resolveWord.test.ts
git commit -m "feat(packs): resolveWordForPack + resolveSentenceTargets (lexicon linking) (PHON-170)"

Task 3: WordCard — picture card for a pack word

Files: - Create: src/components/pack/WordCard.tsx - Test: src/components/pack/WordCard.test.tsx

Interfaces: - Consumes: PackWord (../../types/therapyPack); wordImageUrl (../../lib/wordImages). - Produces: WordCard props { word: PackWord; index: number; total: number; onRemove: (id: string) => void; onMoveUp: (id: string) => void; onMoveDown: (id: string) => void; onReplace: (id: string) => void }. - Renders a card: the picture (<img src={wordImageUrl(imageProvider, imageFile)} alt={word}>) when BOTH imageFile and imageProvider are present, else a word-only tile; the word label; the IPA beneath (when present); and control buttons — move up (disabled at index 0), move down (disabled at index total-1), replace (), remove (×) — each with an accessible label matching the pattern Move <word> up/down, Replace <word>, Remove <word>. On image load error, fall back to word-only (hide the img).

  • [ ] Step 1: Write the failing test

Create src/components/pack/WordCard.test.tsx:

import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import WordCard from './WordCard';
import type { PackWord } from '../../types/therapyPack';

const withImage: PackWord = {
  kind: 'word', id: 'w1', word: 'rocket', ipa: 'ɹ ɑ k ə t',
  hasImage: true, imageFile: 'rocket.svg', imageProvider: 'mulberry',
  provenance: { sourceTool: 'guided', addedAt: 1 },
};
const wordOnly: PackWord = {
  kind: 'word', id: 'w2', word: 'zzxq',
  provenance: { sourceTool: 'manual', addedAt: 1 },
};

describe('WordCard', () => {
  it('renders the picture, word, and IPA for a word with an image', () => {
    render(<WordCard word={withImage} index={0} total={2}
      onRemove={vi.fn()} onMoveUp={vi.fn()} onMoveDown={vi.fn()} onReplace={vi.fn()} />);
    const img = screen.getByRole('img', { name: 'rocket' }) as HTMLImageElement;
    expect(img.src).toContain('mulberry');
    expect(img.src).toContain('rocket.svg');
    expect(screen.getByText('rocket')).toBeInTheDocument();
    expect(screen.getByText('ɹ ɑ k ə t')).toBeInTheDocument();
  });

  it('renders word-only (no img) when there is no image', () => {
    render(<WordCard word={wordOnly} index={0} total={1}
      onRemove={vi.fn()} onMoveUp={vi.fn()} onMoveDown={vi.fn()} onReplace={vi.fn()} />);
    expect(screen.queryByRole('img')).not.toBeInTheDocument();
    expect(screen.getByText('zzxq')).toBeInTheDocument();
  });

  it('fires remove and replace', () => {
    const onRemove = vi.fn(); const onReplace = vi.fn();
    render(<WordCard word={withImage} index={0} total={1}
      onRemove={onRemove} onMoveUp={vi.fn()} onMoveDown={vi.fn()} onReplace={onReplace} />);
    fireEvent.click(screen.getByRole('button', { name: /remove rocket/i }));
    fireEvent.click(screen.getByRole('button', { name: /replace rocket/i }));
    expect(onRemove).toHaveBeenCalledWith('w1');
    expect(onReplace).toHaveBeenCalledWith('w1');
  });

  it('disables move-up at the top and move-down at the bottom', () => {
    const { rerender } = render(<WordCard word={withImage} index={0} total={2}
      onRemove={vi.fn()} onMoveUp={vi.fn()} onMoveDown={vi.fn()} onReplace={vi.fn()} />);
    expect(screen.getByRole('button', { name: /move rocket up/i })).toBeDisabled();
    rerender(<WordCard word={withImage} index={1} total={2}
      onRemove={vi.fn()} onMoveUp={vi.fn()} onMoveDown={vi.fn()} onReplace={vi.fn()} />);
    expect(screen.getByRole('button', { name: /move rocket down/i })).toBeDisabled();
  });
});
  • [ ] Step 2: Run the test to verify it fails

Run: npm test -- --run src/components/pack/WordCard.test.tsx Expected: FAIL — module not found.

  • [ ] Step 3: Implement

Create src/components/pack/WordCard.tsx:

import React, { useState } from 'react';
import { Box, IconButton, Paper, Tooltip, Typography } from '@mui/material';
import {
  ArrowUpward as UpIcon, ArrowDownward as DownIcon,
  Autorenew as ReplaceIcon, Close as RemoveIcon,
} from '@mui/icons-material';
import type { PackWord } from '../../types/therapyPack';
import { wordImageUrl } from '../../lib/wordImages';

interface WordCardProps {
  word: PackWord;
  index: number;
  total: number;
  onRemove: (id: string) => void;
  onMoveUp: (id: string) => void;
  onMoveDown: (id: string) => void;
  onReplace: (id: string) => void;
}

const WordCard: React.FC<WordCardProps> = ({
  word, index, total, onRemove, onMoveUp, onMoveDown, onReplace,
}) => {
  const [imgFailed, setImgFailed] = useState(false);
  const showImage = !!word.imageFile && !!word.imageProvider && !imgFailed;

  return (
    <Paper variant="outlined" sx={{ width: 140, p: 1, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
      <Box sx={{ width: 96, height: 96, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
        {showImage ? (
          <img
            src={wordImageUrl(word.imageProvider!, word.imageFile!)}
            alt={word.word}
            width={96} height={96}
            onError={() => setImgFailed(true)}
            style={{ objectFit: 'contain' }}
          />
        ) : (
          <Typography variant="h6" color="text.secondary" sx={{ textAlign: 'center' }}>{word.word}</Typography>
        )}
      </Box>
      <Typography variant="body2" fontWeight={600} noWrap sx={{ maxWidth: '100%', mt: 0.5 }}>{word.word}</Typography>
      {word.ipa && (
        <Typography variant="caption" color="text.secondary" noWrap sx={{ maxWidth: '100%' }}>{word.ipa}</Typography>
      )}
      <Box sx={{ display: 'flex', mt: 0.5 }}>
        <Tooltip title="Move up"><span>
          <IconButton size="small" aria-label={`Move ${word.word} up`} disabled={index === 0} onClick={() => onMoveUp(word.id)}>
            <UpIcon fontSize="small" />
          </IconButton>
        </span></Tooltip>
        <Tooltip title="Move down"><span>
          <IconButton size="small" aria-label={`Move ${word.word} down`} disabled={index === total - 1} onClick={() => onMoveDown(word.id)}>
            <DownIcon fontSize="small" />
          </IconButton>
        </span></Tooltip>
        <Tooltip title="Replace">
          <IconButton size="small" aria-label={`Replace ${word.word}`} onClick={() => onReplace(word.id)}>
            <ReplaceIcon fontSize="small" />
          </IconButton>
        </Tooltip>
        <Tooltip title="Remove">
          <IconButton size="small" aria-label={`Remove ${word.word}`} onClick={() => onRemove(word.id)}>
            <RemoveIcon fontSize="small" />
          </IconButton>
        </Tooltip>
      </Box>
    </Paper>
  );
};

export default WordCard;
  • [ ] Step 4: Run the test to verify it passes

Run: npm test -- --run src/components/pack/WordCard.test.tsx Expected: PASS (4/4).

  • [ ] Step 5: Commit
git add src/components/pack/WordCard.tsx src/components/pack/WordCard.test.tsx
git commit -m "feat(packs): WordCard — picture card with word-only fallback (PHON-170)"

Task 4: ContrastAddField + SentenceAddField quick-add

Files: - Create: src/components/pack/ContrastAddField.tsx - Create: src/components/pack/SentenceAddField.tsx - Test: src/components/pack/QuickAddFields.test.tsx

Interfaces: - Produces: - ContrastAddField props { onAdd: (a: string, b: string) => void } — two small text fields (labels "Word A" / "Word B") + "Add pair" button; submits on button click when both are non-empty (trimmed); clears both after add. - SentenceAddField props { onAdd: (text: string) => void } — one text field (placeholder "Type a sentence…") + "Add" button; submits trimmed non-empty text on click or Enter; clears after add.

  • [ ] Step 1: Write the failing test

Create src/components/pack/QuickAddFields.test.tsx:

import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import ContrastAddField from './ContrastAddField';
import SentenceAddField from './SentenceAddField';

describe('ContrastAddField', () => {
  it('adds a pair from two words and clears', () => {
    const onAdd = vi.fn();
    render(<ContrastAddField onAdd={onAdd} />);
    const a = screen.getByRole('textbox', { name: /word a/i }) as HTMLInputElement;
    const b = screen.getByRole('textbox', { name: /word b/i }) as HTMLInputElement;
    fireEvent.change(a, { target: { value: ' rake ' } });
    fireEvent.change(b, { target: { value: 'wake' } });
    fireEvent.click(screen.getByRole('button', { name: /add pair/i }));
    expect(onAdd).toHaveBeenCalledWith('rake', 'wake');
    expect(a.value).toBe('');
    expect(b.value).toBe('');
  });

  it('does not add when a field is empty', () => {
    const onAdd = vi.fn();
    render(<ContrastAddField onAdd={onAdd} />);
    fireEvent.change(screen.getByRole('textbox', { name: /word a/i }), { target: { value: 'rake' } });
    fireEvent.click(screen.getByRole('button', { name: /add pair/i }));
    expect(onAdd).not.toHaveBeenCalled();
  });
});

describe('SentenceAddField', () => {
  it('adds a trimmed sentence and clears', () => {
    const onAdd = vi.fn();
    render(<SentenceAddField onAdd={onAdd} />);
    const input = screen.getByRole('textbox') as HTMLInputElement;
    fireEvent.change(input, { target: { value: '  The sun is hot.  ' } });
    fireEvent.click(screen.getByRole('button', { name: /add/i }));
    expect(onAdd).toHaveBeenCalledWith('The sun is hot.');
    expect(input.value).toBe('');
  });

  it('ignores an empty submit', () => {
    const onAdd = vi.fn();
    render(<SentenceAddField onAdd={onAdd} />);
    fireEvent.click(screen.getByRole('button', { name: /add/i }));
    expect(onAdd).not.toHaveBeenCalled();
  });
});
  • [ ] Step 2: Run the test to verify it fails

Run: npm test -- --run src/components/pack/QuickAddFields.test.tsx Expected: FAIL — modules not found.

  • [ ] Step 3: Implement

Create src/components/pack/ContrastAddField.tsx:

import React, { useState } from 'react';
import { Box, Button, TextField } from '@mui/material';
import { Add as AddIcon } from '@mui/icons-material';

interface ContrastAddFieldProps {
  onAdd: (a: string, b: string) => void;
}

const ContrastAddField: React.FC<ContrastAddFieldProps> = ({ onAdd }) => {
  const [a, setA] = useState('');
  const [b, setB] = useState('');
  const submit = () => {
    const ta = a.trim(); const tb = b.trim();
    if (!ta || !tb) return;
    onAdd(ta, tb);
    setA(''); setB('');
  };
  return (
    <Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
      <TextField size="small" label="Word A" value={a} onChange={(e) => setA(e.target.value)} sx={{ maxWidth: 140 }} />
      <TextField size="small" label="Word B" value={b} onChange={(e) => setB(e.target.value)} sx={{ maxWidth: 140 }} />
      <Button size="small" variant="outlined" startIcon={<AddIcon />} onClick={submit}>Add pair</Button>
    </Box>
  );
};

export default ContrastAddField;

Create src/components/pack/SentenceAddField.tsx:

import React, { useState } from 'react';
import { Box, Button, TextField } from '@mui/material';
import { Add as AddIcon } from '@mui/icons-material';

interface SentenceAddFieldProps {
  onAdd: (text: string) => void;
}

const SentenceAddField: React.FC<SentenceAddFieldProps> = ({ onAdd }) => {
  const [value, setValue] = useState('');
  const submit = () => {
    const t = value.trim();
    if (!t) return;
    onAdd(t);
    setValue('');
  };
  return (
    <Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
      <TextField
        size="small" fullWidth placeholder="Type a sentence…" value={value}
        onChange={(e) => setValue(e.target.value)}
        onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); submit(); } }}
        sx={{ maxWidth: 420 }}
      />
      <Button size="small" variant="outlined" startIcon={<AddIcon />} onClick={submit}>Add</Button>
    </Box>
  );
};

export default SentenceAddField;
  • [ ] Step 4: Run the test to verify it passes

Run: npm test -- --run src/components/pack/QuickAddFields.test.tsx Expected: PASS (4/4).

  • [ ] Step 5: Commit
git add src/components/pack/ContrastAddField.tsx src/components/pack/SentenceAddField.tsx src/components/pack/QuickAddFields.test.tsx
git commit -m "feat(packs): ContrastAddField + SentenceAddField quick-add (PHON-170)"

Task 5: Rewire PackEditor — card grid, lexicon-linked add, resolved replace, per-section quick-add

Files: - Modify: src/components/pack/PackEditor.tsx - Modify: src/components/pack/PackEditor.test.tsx

Interfaces: - Consumes: WordCard, ContrastAddField, SentenceAddField (Tasks 3–4); resolveWordForPack, resolveSentenceTargets (Task 2); store updateWord (Task 1); existing store actions; newId; describeTarget is not needed here. - Behavior changes: - Words section renders a flex-wrap grid of WordCards (replacing the PackItemCard rows for words); the word quick-add (ManualAddField) handler becomes async: const pw = await resolveWordForPack(word); edit(() => addItem(pw));. - Replace (ReplaceWordDialog onConfirm) becomes async: const pw = await resolveWordForPack(newWord); edit(() => updateWord(replaceId, { word: pw.word, ipa: pw.ipa, hasImage: pw.hasImage, imageFile: pw.imageFile, imageProvider: pw.imageProvider })); then close. (Replaces the old replaceWord call.) - Contrasts section gains a ContrastAddField whose handler adds a PackContrast (a, b, label from the pack target's describe or blank, newId, provenance { sourceTool: 'manual', addedAt: Date.now() }). - Sentences section gains a SentenceAddField whose handler resolves targets then adds a PackSentence: const targets = await resolveSentenceTargets(text, activePack.target?.phones ?? []); edit(() => addItem({ kind: 'sentence', id: newId(), text, targets, provenance: { sourceTool: 'manual', addedAt: Date.now() } }));. - Contrasts and Sentences keep rendering as PackItemCard rows.

  • [ ] Step 1: Update the PackEditor test

In src/components/pack/PackEditor.test.tsx, add mocks + assertions. Add near the existing mocks:

vi.mock('../../lib/pack/resolveWord', () => ({
  resolveWordForPack: vi.fn(async (word: string) => ({
    kind: 'word', id: `id-${word}`, word,
    ipa: 'ɹ', hasImage: true, imageFile: `${word}.svg`, imageProvider: 'mulberry',
    provenance: { sourceTool: 'manual', addedAt: 1 },
  })),
  resolveSentenceTargets: vi.fn(async () => ['sun']),
}));

Add tests (the existing add/remove/replace tests may need the word-add assertion updated to await the async resolve):

  it('adds a lexicon-linked word card via quick-add', async () => {
    usePackStore.getState().newPack();
    render(<PackEditor />);
    fireEvent.change(screen.getByPlaceholderText(/add a word/i), { target: { value: 'rocket' } });
    fireEvent.click(screen.getByRole('button', { name: 'Add' }));
    await waitFor(() => expect(screen.getByRole('img', { name: 'rocket' })).toBeInTheDocument());
    expect(usePackStore.getState().activePack!.words[0].imageFile).toBe('rocket.svg');
  });

  it('adds a contrast pair via quick-add', async () => {
    usePackStore.getState().newPack();
    render(<PackEditor />);
    fireEvent.change(screen.getByRole('textbox', { name: /word a/i }), { target: { value: 'rake' } });
    fireEvent.change(screen.getByRole('textbox', { name: /word b/i }), { target: { value: 'wake' } });
    fireEvent.click(screen.getByRole('button', { name: /add pair/i }));
    await waitFor(() => expect(usePackStore.getState().activePack!.contrasts).toHaveLength(1));
    expect(usePackStore.getState().activePack!.contrasts[0]).toMatchObject({ a: 'rake', b: 'wake' });
  });

  it('adds a sentence with resolved targets via quick-add', async () => {
    usePackStore.getState().newPack();
    render(<PackEditor />);
    fireEvent.change(screen.getByPlaceholderText(/type a sentence/i), { target: { value: 'The sun is hot.' } });
    fireEvent.click(screen.getByRole('button', { name: /^add$/i }));
    await waitFor(() => expect(usePackStore.getState().activePack!.sentences).toHaveLength(1));
    expect(usePackStore.getState().activePack!.sentences[0].targets).toEqual(['sun']);
  });

(Note: there are now two "Add" buttons in the tree — the word ManualAddField "Add" and the sentence SentenceAddField "Add". Disambiguate by placeholder/section in the queries: the word test targets the word add via its flow; the sentence test uses the sentence placeholder + /^add$/i. If getByRole('button', { name: 'Add' }) is ambiguous in the word test, query within the words section or use getAllByRole and pick the first. Adjust queries so each test is unambiguous while asserting the same behavior.)

  • [ ] Step 2: Run the test to verify it fails

Run: npm test -- --run src/components/pack/PackEditor.test.tsx Expected: FAIL — no img/card, no contrast/sentence quick-add yet.

  • [ ] Step 3: Rewire PackEditor

Edit src/components/pack/PackEditor.tsx:

  1. Imports:

    import WordCard from './WordCard';
    import ContrastAddField from './ContrastAddField';
    import SentenceAddField from './SentenceAddField';
    import { resolveWordForPack, resolveSentenceTargets } from '../../lib/pack/resolveWord';
    
    Destructure updateWord from the store (add to the existing usePackStore.getState() destructure).

  2. Words section — replace the PackItemCard map with a WordCard grid and make the add handler async:

          <PackSection
            title="Target words"
            count={activePack.words.length}
            emptyHint="No words yet. Type a word below or add from Word Lists."
            action={<ManualAddField onAdd={(word) =>
              resolveWordForPack(word).then((pw) => edit(() => addItem(pw)))
            } />}
          >
            <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
              {activePack.words.map((w, i) => (
                <WordCard key={w.id} word={w} index={i} total={activePack.words.length}
                  onRemove={(id) => edit(() => removeItem(id))}
                  onMoveUp={(id) => edit(() => reorderItem('word', id, -1))}
                  onMoveDown={(id) => edit(() => reorderItem('word', id, 1))}
                  onReplace={(id) => setReplaceId(id)} />
              ))}
            </Box>
          </PackSection>
    
    (Ensure Box is imported — it already is.)

  3. Contrasts section — add the quick-add action:

          <PackSection title="Contrast pairs" count={activePack.contrasts.length}
            emptyHint="No contrast pairs yet. Add a pair below or from Contrast Sets."
            action={<ContrastAddField onAdd={(a, b) => edit(() => addItem({
              kind: 'contrast', id: newId(), a, b,
              provenance: { sourceTool: 'manual', addedAt: Date.now() },
            }))} />}
          >
            {activePack.contrasts.map((c, i) => ( /* unchanged PackItemCard rows */
              <PackItemCard key={c.id} item={c} index={i} total={activePack.contrasts.length}
                onRemove={(id) => edit(() => removeItem(id))}
                onMoveUp={(id) => edit(() => reorderItem('contrast', id, -1))}
                onMoveDown={(id) => edit(() => reorderItem('contrast', id, 1))} />
            ))}
          </PackSection>
    

  4. Sentences section — add the quick-add action (async target resolve):

          <PackSection title="Sentences" count={activePack.sentences.length}
            emptyHint="No sentences yet. Add one below or from Sentences."
            action={<SentenceAddField onAdd={(text) =>
              resolveSentenceTargets(text, activePack.target?.phones ?? []).then((targets) =>
                edit(() => addItem({
                  kind: 'sentence', id: newId(), text, targets,
                  provenance: { sourceTool: 'manual', addedAt: Date.now() },
                })))
            } />}
          >
            {activePack.sentences.map((s, i) => ( /* unchanged PackItemCard rows */
              <PackItemCard key={s.id} item={s} index={i} total={activePack.sentences.length}
                onRemove={(id) => edit(() => removeItem(id))}
                onMoveUp={(id) => edit(() => reorderItem('sentence', id, -1))}
                onMoveDown={(id) => edit(() => reorderItem('sentence', id, 1))} />
            ))}
          </PackSection>
    

  5. Replace handler — resolve then updateWord (replacing the old replaceWord):

          <ReplaceWordDialog
            open={replaceId !== null}
            currentWord={replacingWord?.word ?? ''}
            onClose={() => setReplaceId(null)}
            onConfirm={(newWord) => {
              const id = replaceId;
              setReplaceId(null);
              if (id) resolveWordForPack(newWord).then((pw) => edit(() => updateWord(id, {
                word: pw.word, ipa: pw.ipa, hasImage: pw.hasImage,
                imageFile: pw.imageFile, imageProvider: pw.imageProvider,
              })));
            }}
          />
    
    (The store's replaceWord is now unused by the editor; leave it in the store — still covered by its own tests — or remove in a later simplify pass.)

  6. [ ] Step 4: Run the test to verify it passes

Run: npm test -- --run src/components/pack/PackEditor.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/pack/PackEditor.tsx src/components/pack/PackEditor.test.tsx
git commit -m "feat(packs): editor picture-card grid + lexicon-linked add/replace + per-section quick-add (PHON-170)"

Self-Review notes (coverage vs spec §5.5 / §15)

  • Target words render as picture cards: Task 3 WordCard + Task 5 grid. ✓
  • Words link to the lexicon (IPA + image stored on the item): Task 1 (PackWord fields + seeder) + Task 2 (resolveWordForPack) + Task 5 (add/replace wire through it). ✓
  • Quick-add on every section: Words (Task 5 async add), Contrasts (Task 4 ContrastAddField), Sentences (Task 4 SentenceAddField + Task 2 target resolve). ✓
  • Sentence target auto-highlight: resolveSentenceTargets (Task 2) over the pack target's phones. ✓
  • Word-only fallback preserved: WordCard (no image or load error → word tile); resolveWordForPack (unknown word → word-only). ✓
  • Self-contained pack / no export re-fetch: image refs stored on the item; export's resolvePackImages still works (it resolves by word, and now items also carry the refs). ✓
  • Deferred (later phases, unchanged): the per-section "+ Add from [tool]" control + the tool-side "Add to Editor" pack-picker dropdown (Tools phase); pre-built packs (next phase — now inherit cards via the seeder change); IA/nav + 7.0.0 bump. Export's resolvePackImages could later prefer the stored imageFile over a re-fetch (minor optimization, not required).

Decisions resolved at planning

  1. Store image refs on the item at add-time (not resolve-on-render) — self-contained packs, instant cards, offline-capable export. PackWord gains optional imageFile/imageProvider; no schema-version bump.
  2. Contrast quick-add = two word fields (not a parsed single field) — unambiguous; matches the approved mock.
  3. Sentence targets resolved via /api/words/batch phonemes — highlight surface words whose phonemes_str contains a target phone; [] when no target or on failure.
  4. Replace routes through resolveWordForPack + updateWord so a replaced word gets its new card; the old replaceWord store method is left in place (tested) pending a later cleanup.