Therapy Pack — Editor Display Consistency + Image-Word Seeding 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's content display consistent with the rest of the site and fix the seeding so packs actually contain picture-card words. From live review (2026-07-20): (1) fix word-card overflow, (2) remove the ⟳ replace feature, (3) match the tools' row/table format, (4) wrap long sentences, (5) prioritize has_image words when seeding, (6) render words with the shared WordImageThumb like the rest of the site.
Architecture: The Editor becomes a working table consistent with the tools (each word a compact row using the shared WordImageThumb); the export stays picture cards (the printable deliverable, unchanged). Seeding runs a has_image: true round first (via the same filter Word Lists' "Has picture card" toggle uses) and backfills without the filter only if a pack would be thin. The ⟳ replace feature (button, dialog) is removed — X (remove) + quick-add cover it.
Tech Stack: React 19, TypeScript, MUI 7, Zustand 5, Vitest 4. Reuses WordImageThumb (src/components/shared/WordImageThumb.tsx), the pack store, and packSeeder.
Global Constraints¶
- Editor = table consistent with the tools; export = picture cards (unchanged). Words render as compact rows using the shared
WordImageThumb(the exact component the tool tables use), NOT a bespoke card. - Remove the ⟳ replace feature entirely — no replace button, no
ReplaceWordDialog. Item vocabulary is: remove (×), reorder (↑/↓), and quick-add (+). The store'sreplaceWordmethod may remain (harmless, tested) but the Editor must not use it. - Seeding prioritizes
has_image. Word seeding requestshas_image: truefirst (same as Word Lists' filter →WordSearchRequest.has_image), then backfills without the filter only to avoid a thin pack. Image-bearing words come first. - Sentences (and contrasts) wrap — no
noWraptruncation; rows grow vertically with controls top-aligned. WordImageThumbprops:{ word, imageFile, provider, size }; it renders/images/words/{provider}/{file}(or R2 for generated) and hides itself on load error. Render it only when bothimageFileandimageProviderare present (matchesWordListTable's guard); otherwise show a fixed-size empty slot so rows stay aligned.- Test commands (from
packages/web/frontend/):npm test -- --run <file>,npm run type-check,npm run lint,npm run build.
File Structure¶
src/lib/seed/packSeeder.ts(modify) —seedWordshas_image-first + backfill.src/lib/seed/packSeeder.test.ts(modify) — image-priority test.src/components/pack/WordRow.tsx(create) — compact word row withWordImageThumb.src/components/pack/WordRow.test.tsx(create).src/components/pack/WordCard.tsx+WordCard.test.tsx(delete) — replaced byWordRow.src/components/pack/ReplaceWordDialog.tsx(delete) — replace feature removed.src/components/pack/PackItemCard.tsx(modify) — wrap text (removenoWrap), top-align controls, drop the replace affordance.src/components/pack/PackItemCard.test.tsx(modify) — drop replace assertions; add a wrap assertion.src/components/pack/PackEditor.tsx(modify) — renderWordRow; remove replace dialog/state/handler +WordCard/ReplaceWordDialogimports.src/components/pack/PackEditor.test.tsx(modify) — drop the replace test; keep add/remove.
Task 1: Seeding prioritizes has_image words (with backfill)¶
Files:
- Modify: src/lib/seed/packSeeder.ts (the seedWords helper)
- Test: src/lib/seed/packSeeder.test.ts
Interfaces: seedWords(target, searchWords, at, total) signature unchanged; behavior now does an image-first round then a backfill round.
- [ ] Step 1: Write the failing test
Add to src/lib/seed/packSeeder.test.ts (in the seedPack orchestrator describe):
it('seeds image-bearing words first, then backfills without the filter', async () => {
const searchWords = vi.fn().mockImplementation((body: { has_image?: boolean; patterns: { phoneme: string }[] }) => {
const phone = body.patterns[0].phoneme;
const items = body.has_image === true
? [{ word: `${phone}-img`, has_image: 1, image_file: `${phone}.svg`, image_provider: 'mulberry' }]
: [{ word: `${phone}-img`, has_image: 1, image_file: `${phone}.svg`, image_provider: 'mulberry' },
{ word: `${phone}-plain`, has_image: 0 }];
return Promise.resolve({ items, total: items.length, offset: 0, limit: 20 });
});
const out = await seedPack({ mode: 'sound', phones: ['s'], position: 'initial' }, {
searchWords,
getMinimalPairs: vi.fn().mockResolvedValue([]),
fetchSentences: vi.fn().mockResolvedValue({ corpus_matches: [], total: 0, elapsed_ms: { corpus: 1, total: 1 } }),
now: () => 1,
});
// requested image words
expect(searchWords).toHaveBeenCalledWith(expect.objectContaining({ has_image: true }));
// image word is first and carries its image ref
expect(out.words[0]).toMatchObject({ word: 's-img', imageFile: 's.svg' });
// backfill pulled in the non-image word too (round 2, no filter), deduped
expect(out.words.map((w) => w.word)).toEqual(['s-img', 's-plain']);
});
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- --run src/lib/seed/packSeeder.test.ts
Expected: FAIL — has_image never requested; only one round.
- [ ] Step 3: Implement
Replace the body of seedWords in src/lib/seed/packSeeder.ts with an image-first + backfill version:
async function seedWords(
target: TargetSpec,
searchWords: SeedDeps['searchWords'],
at: number,
total = 20,
): Promise<PackWord[]> {
const phones = wordPhones(target);
const pos = seedPosition(target);
const per = Math.max(1, Math.ceil(total / phones.length));
const roundFor = async (withImage: boolean, limit: number): Promise<Word[][]> =>
Promise.all(
phones.map((phone) =>
safe(
() => searchWords({
patterns: [{ type: patternTypeFor(pos), phoneme: phone }],
sort_by: 'frequency', sort_order: 'desc',
...(withImage ? { has_image: true } : {}),
limit,
}).then((r) => r.items),
[] as Word[],
),
),
);
const seen = new Set<string>();
const merged: Word[] = [];
const drain = (lists: Word[][]) => {
for (let i = 0; merged.length < total && lists.some((l) => l[i]); i++) {
for (const l of lists) {
const w = l[i];
if (w && !seen.has(w.word.toLowerCase())) {
seen.add(w.word.toLowerCase());
merged.push(w);
if (merged.length >= total) break;
}
}
}
};
drain(await roundFor(true, per)); // image-bearing words first
if (merged.length < total) drain(await roundFor(false, total)); // backfill to avoid a thin pack
return merged.map((w) => mapWordToPackWord(w, at));
}
(WordSearchRequest already accepts has_image?: boolean — no api change.)
- [ ] Step 4: Run the test to verify it passes
Run: npm test -- --run src/lib/seed/packSeeder.test.ts
Expected: PASS (existing + new; the single-phone "sound" test still passes — round 1 returns 1 image word, round 2 backfills the plain one).
- [ ] Step 5: Type-check and commit
npm run type-check
git add src/lib/seed/packSeeder.ts src/lib/seed/packSeeder.test.ts
git commit -m "feat(packs): seed image-bearing words first (has_image), backfill to avoid thin packs (PHON-170)"
Task 2: WordRow — a word as a compact row with WordImageThumb¶
Files:
- Create: src/components/pack/WordRow.tsx
- Test: src/components/pack/WordRow.test.tsx
- Delete: src/components/pack/WordCard.tsx, src/components/pack/WordCard.test.tsx
Interfaces:
- Consumes: PackWord (../../types/therapyPack); WordImageThumb (../shared/WordImageThumb); ClickableWord (../shared/ClickableWord) — the site-wide component that makes a word open the Word Profile dialog (image + norms) and click through to Lookup. The editor is already under WordProfileProvider (main.tsx), so ClickableWord works here with no extra wiring.
- Produces: WordRow props { word: PackWord; index: number; total: number; onRemove: (id: string) => void; onMoveUp: (id: string) => void; onMoveDown: (id: string) => void } (NO onReplace).
- A Paper row (variant outlined) that mirrors WordListTable's row exactly: a fixed 40×40 leading slot containing <WordImageThumb word imageFile provider size={40}> when BOTH imageFile and imageProvider are present, else empty; then the word wrapped in <ClickableWord word={word.word}> (so clicking it opens the profile card + Lookup, like everywhere else on the site) styled color: primary.main + IPA (caption, monospace) in a minWidth: 0 flex box; then reorder ↑/↓ (disabled at bounds) + remove ×. Accessible labels Move <word> up/down, Remove <word>; the clickable word carries ClickableWord's own View profile for <word> label. Do NOT build a new card — compose the two existing shared components.
- [ ] Step 1: Write the failing test
Create src/components/pack/WordRow.test.tsx:
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import WordRow from './WordRow';
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 noImage: PackWord = {
kind: 'word', id: 'w2', word: 'other', ipa: 'ʌ ð ɚ',
provenance: { sourceTool: 'manual', addedAt: 1 },
};
describe('WordRow', () => {
it('renders the shared thumbnail + a ClickableWord (opens profile) + IPA', () => {
render(<WordRow word={withImage} index={0} total={2} onRemove={vi.fn()} onMoveUp={vi.fn()} onMoveDown={vi.fn()} />);
const img = screen.getByRole('img', { name: 'rocket' }) as HTMLImageElement;
expect(img.src).toContain('rocket.svg');
// the word is a ClickableWord (site-wide click-through to the word profile)
expect(screen.getByRole('button', { name: /view profile for rocket/i })).toBeInTheDocument();
expect(screen.getByText('ɹ ɑ k ə t')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /replace/i })).not.toBeInTheDocument();
});
it('renders no image for a word without one', () => {
render(<WordRow word={noImage} index={0} total={1} onRemove={vi.fn()} onMoveUp={vi.fn()} onMoveDown={vi.fn()} />);
expect(screen.queryByRole('img')).not.toBeInTheDocument();
expect(screen.getByText('other')).toBeInTheDocument();
});
it('fires remove and disables move-up at the top', () => {
const onRemove = vi.fn();
render(<WordRow word={withImage} index={0} total={2} onRemove={onRemove} onMoveUp={vi.fn()} onMoveDown={vi.fn()} />);
expect(screen.getByRole('button', { name: /move rocket up/i })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: /remove rocket/i }));
expect(onRemove).toHaveBeenCalledWith('w1');
});
});
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- --run src/components/pack/WordRow.test.tsx
Expected: FAIL — module not found.
- [ ] Step 3: Implement
WordRowand deleteWordCard
Create src/components/pack/WordRow.tsx:
import React from 'react';
import { Box, IconButton, Paper, Tooltip, Typography } from '@mui/material';
import { ArrowUpward as UpIcon, ArrowDownward as DownIcon, Close as RemoveIcon } from '@mui/icons-material';
import type { PackWord } from '../../types/therapyPack';
import WordImageThumb from '../shared/WordImageThumb';
import ClickableWord from '../shared/ClickableWord';
interface WordRowProps {
word: PackWord;
index: number;
total: number;
onRemove: (id: string) => void;
onMoveUp: (id: string) => void;
onMoveDown: (id: string) => void;
}
const WordRow: React.FC<WordRowProps> = ({ word, index, total, onRemove, onMoveUp, onMoveDown }) => (
<Paper variant="outlined" sx={{ display: 'flex', alignItems: 'center', gap: 1.5, px: 1.5, py: 0.75, mb: 0.75 }}>
<Box sx={{ width: 40, height: 40, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{word.imageFile && word.imageProvider && (
<WordImageThumb word={word.word} imageFile={word.imageFile} provider={word.imageProvider} size={40} />
)}
</Box>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<ClickableWord word={word.word}>
<Typography component="span" variant="body2" fontWeight={600} color="primary.main" noWrap sx={{ display: 'block' }}>
{word.word}
</Typography>
</ClickableWord>
{word.ipa && (
<Typography variant="caption" color="text.secondary" fontFamily="monospace" noWrap component="div">
{word.ipa}
</Typography>
)}
</Box>
<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="Remove">
<IconButton size="small" aria-label={`Remove ${word.word}`} onClick={() => onRemove(word.id)}>
<RemoveIcon fontSize="small" />
</IconButton>
</Tooltip>
</Paper>
);
export default WordRow;
Delete src/components/pack/WordCard.tsx and src/components/pack/WordCard.test.tsx:
git rm src/components/pack/WordCard.tsx src/components/pack/WordCard.test.tsx
- [ ] Step 4: Run the test to verify it passes
Run: npm test -- --run src/components/pack/WordRow.test.tsx
Expected: PASS (3/3).
- [ ] Step 5: Commit
npm run type-check
git add src/components/pack/WordRow.tsx src/components/pack/WordRow.test.tsx
git commit -m "feat(packs): WordRow — words as tool-consistent rows with WordImageThumb; drop WordCard (PHON-170)"
Task 3: PackItemCard — wrap text, top-align controls, drop replace¶
Files:
- Modify: src/components/pack/PackItemCard.tsx
- Test: src/components/pack/PackItemCard.test.tsx
Interfaces: PackItemCard (used only for contrasts + sentences now) keeps its props but onReplace becomes vestigial — remove the replace button entirely. Primary/secondary text no longer uses noWrap (wraps); the row's alignItems becomes flex-start so controls sit at the top when text wraps.
- [ ] Step 1: Update the test
In src/components/pack/PackItemCard.test.tsx: remove the test asserting the replace button appears; add/adjust a test that long text is NOT truncated (no noWrap). Replace the "shows the replace button only for words" test with:
it('does not render a replace button', () => {
render(
<PackItemCard item={{ kind: 'sentence', id: 's1', text: 'A very long sentence that should wrap and not truncate at all in the editor row.', targets: [], provenance: { sourceTool: 'manual', addedAt: 1 } }}
index={0} total={1} onRemove={vi.fn()} onMoveUp={vi.fn()} onMoveDown={vi.fn()} />,
);
expect(screen.queryByRole('button', { name: /replace/i })).not.toBeInTheDocument();
// full text present (not truncated)
expect(screen.getByText(/A very long sentence that should wrap and not truncate/)).toBeInTheDocument();
});
onReplace from those renders if present.)
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- --run src/components/pack/PackItemCard.test.tsx
Expected: FAIL — replace button still rendered / test references removed prop.
- [ ] Step 3: Implement
In src/components/pack/PackItemCard.tsx:
- Remove the onReplace? prop from the interface and the replace IconButton/Tooltip block, and remove the Autorenew/ReplaceIcon import.
- Change the Paper row alignItems: 'center' → alignItems: 'flex-start'.
- Remove noWrap from the primary <Typography variant="body1"> and the secondary <Typography variant="caption"> so they wrap.
- Make the contrast words clickable (site-wide consistency). Import ClickableWord from ../shared/ClickableWord. For item.kind === 'contrast', instead of rendering the combined primaryLabel string, render the two words each wrapped in ClickableWord so they open the word profile, e.g.:
{item.kind === 'contrast' ? (
<Typography variant="body1" component="div">
<ClickableWord word={item.a}>{item.a}</ClickableWord>
{' vs '}
<ClickableWord word={item.b}>{item.b}</ClickableWord>
{item.label ? ` (${item.label})` : ''}
</Typography>
) : (
<Typography variant="body1">{primaryLabel(item)}</Typography>
)}
primaryLabel. The primaryLabel/secondaryLabel helpers stay for the sentence/word-fallback cases.)
- [ ] Step 4: Run the test to verify it passes
Run: npm test -- --run src/components/pack/PackItemCard.test.tsx
Expected: PASS.
- [ ] Step 5: Commit
npm run type-check
git add src/components/pack/PackItemCard.tsx src/components/pack/PackItemCard.test.tsx
git commit -m "feat(packs): PackItemCard wraps text + drops replace (contrasts/sentences) (PHON-170)"
Task 4: PackEditor — use WordRow, remove the replace feature; full gate¶
Files:
- Modify: src/components/pack/PackEditor.tsx
- Test: src/components/pack/PackEditor.test.tsx
- Delete: src/components/pack/ReplaceWordDialog.tsx
Interfaces: Words render via WordRow (no onReplace); the ReplaceWordDialog, replaceId state, replacingWord, and the replace onConfirm handler are all removed; WordCard/ReplaceWordDialog imports removed. updateWord/replaceWord store methods stay in the store but the editor no longer calls the replace path.
- [ ] Step 1: Update the test
In src/components/pack/PackEditor.test.tsx: remove the "replaces a word through the dialog" test. Update the "adds a lexicon-linked word card" test if it queried a replace affordance. Keep the manual-add / remove / contrast / sentence add tests. (The word add/remove now render WordRow; the img/word assertions still hold.)
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- --run src/components/pack/PackEditor.test.tsx
Expected: FAIL — references removed replace dialog/handler after implementation edits, or the old replace test fails.
- [ ] Step 3: Implement
In src/components/pack/PackEditor.tsx:
- Replace import WordCard from './WordCard'; with import WordRow from './WordRow';; remove import ReplaceWordDialog from './ReplaceWordDialog';.
- Remove replaceWord from the destructured store actions (and updateWord if it was only used for replace — check; keep it if used elsewhere), remove the replaceId state and replacingWord lookup.
- In the Words section, render <WordRow key={w.id} word={w} index={i} total={activePack.words.length} onRemove={...} onMoveUp={...} onMoveDown={...} /> (drop onReplace).
- Remove the <ReplaceWordDialog ... /> element and its onConfirm handler.
Then delete the dialog file:
git rm src/components/pack/ReplaceWordDialog.tsx
- [ ] 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. (Confirm no dangling imports of WordCard/ReplaceWordDialog remain anywhere.)
- [ ] Step 6: Commit
git add src/components/pack/PackEditor.tsx src/components/pack/PackEditor.test.tsx
git commit -m "feat(packs): editor words use WordRow; remove the replace feature (PHON-170)"
Self-Review notes (coverage vs the 6 review points)¶
- (1) Card overflow →
WordRowis a compact fixed-height row (no oversized card); word/IPA truncate gracefully in aminWidth:0box. ✓ - (2) Remove replace → replace button +
ReplaceWordDialogdeleted; Task 3 + 4. ✓ - (3) Consistency with the tools' table → words render as rows composing the SAME shared components
WordListTableuses (ClickableWord+WordImageThumb); Task 2. ✓ - (4) Sentences wrap →
PackItemCarddropsnoWrap, top-aligns controls; Task 3. ✓ - (5) Prioritize
has_image→seedWordsimage-first round + backfill; Task 1. ✓ - (6) "Click opens a word card + links to Lookup, like the rest of the site" → reuse
ClickableWord(the site-wide component that opens the Word Profile dialog — image + norms — and clicks through to Lookup) for words (Task 2) and contrast words (Task 3). Nothing new built; the editor is already underWordProfileProvider. Combined with seeding image-bearing words (Task 1). ✓ - Deferred / unchanged: the export picture-card renderer (the printable — intentionally still cards); contrast rows keep text form (thumbnails-per-contrast a later nicety); the store
replaceWord/updateWordmethods stay (harmless, still tested) though the editor no longer uses replace.
Decisions resolved at planning¶
- Editor = table (tool-consistent) via
WordImageThumb; export = picture cards. The editor is the working surface; the printable is the card deliverable. has_image-first with backfill — prioritize image words but never ship a thin pack for a rare target.- Replace removed, not hidden — delete the dialog + button; X + quick-add is the model.