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) PackWordchange is additive/optional. AddimageFile?: stringandimageProvider?: string. Do NOT bumpPACK_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)fromsrc/lib/wordImages.ts. Word-only fallback when the word has no image (missing fields OR load error). Never servesource_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 existingpack_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) hasword, 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) — addimageFile?/imageProvider?toPackWord.src/lib/seed/packSeeder.ts(modify) —mapWordToPackWordpersistsimage_file/image_provider.src/store/packStore.ts(modify) — addupdateWord(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 aPackWord.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) — renderWordCardgrid, 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;
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:
-
Imports:
Destructureimport WordCard from './WordCard'; import ContrastAddField from './ContrastAddField'; import SentenceAddField from './SentenceAddField'; import { resolveWordForPack, resolveSentenceTargets } from '../../lib/pack/resolveWord';updateWordfrom the store (add to the existingusePackStore.getState()destructure). -
Words section — replace the
PackItemCardmap with aWordCardgrid and make the add handler async:(Ensure<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>Boxis imported — it already is.) -
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> -
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> -
Replace handler — resolve then
updateWord(replacing the oldreplaceWord):(The store's<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, }))); }} />replaceWordis now unused by the editor; leave it in the store — still covered by its own tests — or remove in a later simplify pass.) -
[ ] 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
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 (
PackWordfields + 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 4SentenceAddField+ 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
resolvePackImagesstill 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
resolvePackImagescould later prefer the storedimageFileover a re-fetch (minor optimization, not required).
Decisions resolved at planning¶
- Store image refs on the item at add-time (not resolve-on-render) — self-contained packs, instant cards, offline-capable export.
PackWordgains optionalimageFile/imageProvider; no schema-version bump. - Contrast quick-add = two word fields (not a parsed single field) — unambiguous; matches the approved mock.
- Sentence targets resolved via
/api/words/batchphonemes — highlight surface words whosephonemes_strcontains a target phone;[]when no target or on failure. - Replace routes through
resolveWordForPack+updateWordso a replaced word gets its new card; the oldreplaceWordstore method is left in place (tested) pending a later cleanup.