Therapy Pack — Contrast Display Consistency 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 Contrast Pairs section match the Contrast Sets tool's format — each pair shown as two word cells, each a WordImageThumb + ClickableWord + IPA (the exact composition ContrastiveGroupsTable uses) — instead of a flat "a vs b" text row.
Architecture: Reuse the site's shared components (WordImageThumb, ClickableWord) in a ContrastRow, the same way WordRow was built. The blocker is data: PackContrast currently stores only the two word strings, so there's nothing to render a thumbnail/IPA from. Extend PackContrast with each member's lexicon refs (additive, optional), captured at seed/add time from the Word objects that seeding and tool-add already have, and resolved (via resolveWordForPack) for manual adds.
Tech Stack: React 19, TypeScript, MUI 7, Vitest 4. Reuses WordImageThumb, ClickableWord, resolveWordForPack, the seeder, and toPackItems.
Global Constraints¶
- Reuse the site's shared components —
ContrastRowcomposesWordImageThumb+ClickableWord(from../shared/*), mirroringContrastiveGroupsTable's per-word cell. No bespoke image/click logic. PackContrastchange is additive/optional — keepa/bas strings (existing consumers:toPackItems,mapPairToPackContrast,buildWorksheetModel,PackItemCard,ContrastAddField). Add optional per-memberipa/imageFile/imageProvider. NoPACK_SCHEMA_VERSIONbump.- Populate lexicon refs where the data already is: seeding (
mapPairToPackContrast—MinimalPairResult.word1/word2areWord) and tool-add (toPackItemspairs branch —MinimalPair.word1/word2areWord). Manual adds resolve both words viaresolveWordForPack. - Editor = tool-consistent working surface; export unchanged. The worksheet/CSV export keeps using
a/b. - 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) —PackContrastper-member optional refs.src/lib/seed/packSeeder.ts(modify) —mapPairToPackContrastpopulates refs.src/lib/seed/packSeeder.test.ts(modify).src/lib/pack/toPackItems.ts(modify) — pairs branch populates refs.src/lib/pack/toPackItems.test.ts(modify).src/components/pack/ContrastRow.tsx(create) — two-cell contrast row.src/components/pack/ContrastRow.test.tsx(create).src/components/pack/PackEditor.tsx(modify) — renderContrastRow; manual contrast add resolves both words.src/components/pack/PackEditor.test.tsx(modify).src/components/pack/PackItemCard.tsx(modify) — drop the now-unused contrast branch (PackItemCard renders sentences only after this).
Task 1: PackContrast per-member lexicon refs + populate at seed/tool-add¶
Files:
- Modify: src/types/therapyPack.ts, src/lib/seed/packSeeder.ts, src/lib/pack/toPackItems.ts
- Test: src/lib/seed/packSeeder.test.ts, src/lib/pack/toPackItems.test.ts
Interfaces:
- PackContrast gains optional: aIpa?: string; aImageFile?: string; aImageProvider?: string; bIpa?: string; bImageFile?: string; bImageProvider?: string;.
- mapPairToPackContrast(p, at) and toPackItems' pairs branch populate them from p.word1/p.word2 (.ipa, .image_file, .image_provider).
- [ ] Step 1: Write the failing tests
Add to src/lib/seed/packSeeder.test.ts (in the pure-helpers describe):
it('mapPairToPackContrast captures both members\' image + ipa', () => {
const p = {
word1: { word: 'key', ipa: 'k iː', image_file: 'key.svg', image_provider: 'mulberry' },
word2: { word: 'tea', ipa: 't iː', image_file: 'tea.svg', image_provider: 'openmoji' },
phoneme1: 'k', phoneme2: 't',
} as never;
const pc = mapPairToPackContrast(p, 1);
expect(pc).toMatchObject({
a: 'key', b: 'tea',
aImageFile: 'key.svg', aImageProvider: 'mulberry', aIpa: 'k iː',
bImageFile: 'tea.svg', bImageProvider: 'openmoji', bIpa: 't iː',
});
});
Add to src/lib/pack/toPackItems.test.ts:
it('maps pair members with their image refs', () => {
const data = [{
word1: { word: 'key', ipa: 'k iː', image_file: 'key.svg', image_provider: 'mulberry' },
word2: { word: 'tea', image_file: 'tea.svg', image_provider: 'openmoji' },
phoneme1: 'k', phoneme2: 't',
}] as never;
const items = toPackItems(data, 'pairs', null, 'contrastive', 1);
expect(items[0]).toMatchObject({ a: 'key', b: 'tea', aImageFile: 'key.svg', bImageProvider: 'openmoji' });
});
- [ ] Step 2: Run the tests to verify they fail
Run: npm test -- --run src/lib/seed/packSeeder.test.ts src/lib/pack/toPackItems.test.ts
Expected: FAIL — new fields undefined.
- [ ] Step 3: Implement
In src/types/therapyPack.ts, extend PackContrast:
export interface PackContrast {
kind: 'contrast';
id: string;
a: string;
b: string;
label?: string;
aIpa?: string;
aImageFile?: string;
aImageProvider?: string;
bIpa?: string;
bImageFile?: string;
bImageProvider?: string;
provenance: PackItemProvenance;
}
In src/lib/seed/packSeeder.ts, update mapPairToPackContrast:
export function mapPairToPackContrast(p: MinimalPairResult, at: number): PackContrast {
return {
kind: 'contrast',
id: newId(),
a: p.word1.word,
b: p.word2.word,
label: `${p.phoneme1} vs ${p.phoneme2}`,
aIpa: p.word1.ipa ?? undefined,
aImageFile: p.word1.image_file ?? undefined,
aImageProvider: p.word1.image_provider ?? undefined,
bIpa: p.word2.ipa ?? undefined,
bImageFile: p.word2.image_file ?? undefined,
bImageProvider: p.word2.image_provider ?? undefined,
provenance: { sourceTool: 'guided', addedAt: at, reason: 'seeded minimal pair' },
};
}
In src/lib/pack/toPackItems.ts, update the pairs branch:
if (dataType === 'pairs') {
return pick(data as MinimalPair[], indices).map((p) => ({
kind: 'contrast' as const,
id: newId(),
a: p.word1.word,
b: p.word2.word,
label: `${p.phoneme1} vs ${p.phoneme2}`,
aIpa: p.word1.ipa ?? undefined,
aImageFile: p.word1.image_file ?? undefined,
aImageProvider: p.word1.image_provider ?? undefined,
bIpa: p.word2.ipa ?? undefined,
bImageFile: p.word2.image_file ?? undefined,
bImageProvider: p.word2.image_provider ?? undefined,
provenance: { sourceTool, addedAt: at },
}));
}
- [ ] Step 4: Run the tests to verify they pass
Run: npm test -- --run src/lib/seed/packSeeder.test.ts src/lib/pack/toPackItems.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/lib/pack/toPackItems.ts src/lib/seed/packSeeder.test.ts src/lib/pack/toPackItems.test.ts
git commit -m "feat(packs): PackContrast carries both members' image/ipa; seed + tool-add populate them (PHON-170)"
Task 2: ContrastRow — two word cells matching the Contrast Sets table¶
Files:
- Create: src/components/pack/ContrastRow.tsx
- Test: src/components/pack/ContrastRow.test.tsx
Interfaces:
- Consumes: PackContrast (../../types/therapyPack); WordImageThumb, ClickableWord (../shared/*).
- Produces: ContrastRow props { contrast: PackContrast; index: number; total: number; onRemove: (id: string) => void; onMoveUp: (id: string) => void; onMoveDown: (id: string) => void }.
- A Paper row with two member cells side by side, each mirroring ContrastiveGroupsTable's word cell: a WordImageThumb (when that member's imageFile+imageProvider are present) + the word wrapped in ClickableWord + the member's IPA (caption, monospace). A "vs" separator between the two cells. Then reorder ↑/↓ (disabled at bounds) + remove ×. Accessible labels Move <a> vs <b> up/down, Remove <a> vs <b> (use the label or "a vs b" for the control names).
- [ ] Step 1: Write the failing test
Create src/components/pack/ContrastRow.test.tsx:
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import ContrastRow from './ContrastRow';
import type { PackContrast } from '../../types/therapyPack';
const pair: PackContrast = {
kind: 'contrast', id: 'c1', a: 'key', b: 'tea', label: 'k vs t',
aIpa: 'k iː', aImageFile: 'key.svg', aImageProvider: 'mulberry',
bIpa: 't iː', bImageFile: 'tea.svg', bImageProvider: 'openmoji',
provenance: { sourceTool: 'guided', addedAt: 1 },
};
describe('ContrastRow', () => {
it('renders both members as clickable words with thumbnails + IPA', () => {
render(<ContrastRow contrast={pair} index={0} total={2} onRemove={vi.fn()} onMoveUp={vi.fn()} onMoveDown={vi.fn()} />);
expect(screen.getByRole('img', { name: 'key' })).toBeInTheDocument();
expect(screen.getByRole('img', { name: 'tea' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /view profile for key/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /view profile for tea/i })).toBeInTheDocument();
expect(screen.getByText('k iː')).toBeInTheDocument();
expect(screen.getByText('t iː')).toBeInTheDocument();
});
it('renders a member without an image as word-only', () => {
const noImg: PackContrast = { ...pair, id: 'c2', bImageFile: undefined, bImageProvider: undefined };
render(<ContrastRow contrast={noImg} index={0} total={1} onRemove={vi.fn()} onMoveUp={vi.fn()} onMoveDown={vi.fn()} />);
expect(screen.getByRole('img', { name: 'key' })).toBeInTheDocument();
expect(screen.queryByRole('img', { name: 'tea' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /view profile for tea/i })).toBeInTheDocument();
});
it('fires remove and disables move-up at the top', () => {
const onRemove = vi.fn();
render(<ContrastRow contrast={pair} index={0} total={2} onRemove={onRemove} onMoveUp={vi.fn()} onMoveDown={vi.fn()} />);
expect(screen.getByRole('button', { name: /move .* up/i })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: /remove/i }));
expect(onRemove).toHaveBeenCalledWith('c1');
});
});
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- --run src/components/pack/ContrastRow.test.tsx
Expected: FAIL — module not found.
- [ ] Step 3: Implement
Create src/components/pack/ContrastRow.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 { PackContrast } from '../../types/therapyPack';
import WordImageThumb from '../shared/WordImageThumb';
import ClickableWord from '../shared/ClickableWord';
interface ContrastRowProps {
contrast: PackContrast;
index: number;
total: number;
onRemove: (id: string) => void;
onMoveUp: (id: string) => void;
onMoveDown: (id: string) => void;
}
function Member({ word, ipa, imageFile, imageProvider }: {
word: string; ipa?: string; imageFile?: string; imageProvider?: string;
}) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flex: 1 }}>
<Box sx={{ width: 36, height: 36, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{imageFile && imageProvider && (
<WordImageThumb word={word} imageFile={imageFile} provider={imageProvider} size={36} />
)}
</Box>
<Box sx={{ minWidth: 0 }}>
<ClickableWord word={word}>
<Typography component="span" variant="body2" fontWeight={600} color="primary.main" noWrap sx={{ display: 'block' }}>
{word}
</Typography>
</ClickableWord>
{ipa && (
<Typography variant="caption" color="text.secondary" fontFamily="monospace" noWrap component="div">{ipa}</Typography>
)}
</Box>
</Box>
);
}
const ContrastRow: React.FC<ContrastRowProps> = ({ contrast, index, total, onRemove, onMoveUp, onMoveDown }) => {
const name = contrast.label ?? `${contrast.a} vs ${contrast.b}`;
return (
<Paper variant="outlined" sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.75, mb: 0.75 }}>
<Member word={contrast.a} ipa={contrast.aIpa} imageFile={contrast.aImageFile} imageProvider={contrast.aImageProvider} />
<Typography variant="caption" color="text.disabled" sx={{ flexShrink: 0 }}>vs</Typography>
<Member word={contrast.b} ipa={contrast.bIpa} imageFile={contrast.bImageFile} imageProvider={contrast.bImageProvider} />
<Tooltip title="Move up"><span>
<IconButton size="small" aria-label={`Move ${name} up`} disabled={index === 0} onClick={() => onMoveUp(contrast.id)}>
<UpIcon fontSize="small" />
</IconButton>
</span></Tooltip>
<Tooltip title="Move down"><span>
<IconButton size="small" aria-label={`Move ${name} down`} disabled={index === total - 1} onClick={() => onMoveDown(contrast.id)}>
<DownIcon fontSize="small" />
</IconButton>
</span></Tooltip>
<Tooltip title="Remove">
<IconButton size="small" aria-label={`Remove ${name}`} onClick={() => onRemove(contrast.id)}>
<RemoveIcon fontSize="small" />
</IconButton>
</Tooltip>
</Paper>
);
};
export default ContrastRow;
- [ ] Step 4: Run the test to verify it passes
Run: npm test -- --run src/components/pack/ContrastRow.test.tsx
Expected: PASS (3/3).
- [ ] Step 5: Commit
npm run type-check
git add src/components/pack/ContrastRow.tsx src/components/pack/ContrastRow.test.tsx
git commit -m "feat(packs): ContrastRow — two word cells matching the Contrast Sets table (PHON-170)"
Task 3: PackEditor uses ContrastRow; manual contrast add resolves both words; gate¶
Files:
- Modify: src/components/pack/PackEditor.tsx
- Modify: src/components/pack/PackItemCard.tsx (drop the contrast branch — sentences only now)
- Test: src/components/pack/PackEditor.test.tsx
Interfaces:
- Contrasts render via <ContrastRow> (not PackItemCard). Sentences still render via PackItemCard.
- The manual contrast add (ContrastAddField onAdd) becomes async: resolve both words via resolveWordForPack to capture their image/ipa, then addItem a PackContrast with the refs:
action={<ContrastAddField onAdd={async (a, b) => {
const [wa, wb] = await Promise.all([resolveWordForPack(a), resolveWordForPack(b)]);
edit(() => addItem({
kind: 'contrast', id: newId(), a: wa.word, b: wb.word,
aIpa: wa.ipa, aImageFile: wa.imageFile, aImageProvider: wa.imageProvider,
bIpa: wb.ipa, bImageFile: wb.imageFile, bImageProvider: wb.imageProvider,
provenance: { sourceTool: 'manual', addedAt: Date.now() },
}));
}} />}
- [ ] Step 1: Update the test
In src/components/pack/PackEditor.test.tsx: the existing "adds a contrast pair via quick-add" test should still pass (asserts contrasts[0] matches { a: 'rake', b: 'wake' }) — but the add is now async and resolves words. The existing mock of ../../lib/pack/resolveWord (from the editor-cards phase) returns a PackWord with word, so resolveWordForPack('rake') → { word: 'rake', ... }. Ensure the mock returns word equal to its input so a: 'rake' holds (adjust the mock if it hardcodes 'rocket': make it echo the input word). Keep the assertion; add that the contrast renders as a ContrastRow (e.g., getByRole('button', { name: /view profile for rake/i })).
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- --run src/components/pack/PackEditor.test.tsx
Expected: FAIL until ContrastRow + async add are wired.
- [ ] Step 3: Implement
In src/components/pack/PackEditor.tsx:
- Import ContrastRow from './ContrastRow'.
- In the Contrasts section, render <ContrastRow key={c.id} contrast={c} index={i} total={activePack.contrasts.length} onRemove={...} onMoveUp={(id)=>edit(()=>reorderItem('contrast',id,-1))} onMoveDown={(id)=>edit(()=>reorderItem('contrast',id,1))} /> instead of PackItemCard.
- Change the ContrastAddField onAdd to the async resolve-both version above (resolveWordForPack is already imported for words).
In src/components/pack/PackItemCard.tsx: remove the item.kind === 'contrast' branch + the ClickableWord import if now unused (PackItemCard renders sentences only — the primary label is primaryLabel(item) which still handles sentences/word-fallback). Confirm primaryLabel/secondaryLabel no longer need the contrast case (leave them harmless if they do).
- [ ] 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/PackItemCard.tsx src/components/pack/PackEditor.test.tsx
git commit -m "feat(packs): editor contrasts use ContrastRow (tool-consistent); manual add resolves both words (PHON-170)"
Self-Review notes¶
- Contrast section matches the tool's format —
ContrastRow= two cells, eachWordImageThumb+ClickableWord+ IPA (mirrorsContrastiveGroupsTable's word cell); Tasks 2–3. ✓ - Data to render it —
PackContrastcarries both members' image/ipa, populated at seed (mapPairToPackContrast), tool-add (toPackItems), and manual-add (resolveWordForPack); Tasks 1, 3. ✓ - Reuse, not reinvent — composes the shared
WordImageThumb/ClickableWord; no new image/click logic. ✓ - Export unchanged — worksheet/CSV still read
a/b. ✓ - Deferred: WCM/AoA/position chips the tool shows (need norm data not carried on the pack item — a later nicety); multiple-opposition sets still add as loose words (
toPackItemsgroups → words, unchanged).
Decisions resolved at planning¶
- Additive optional per-member refs on
PackContrast(keepa/bstrings) — backward-compatible with every existing consumer; no schema bump. ContrastRowmirrors the tool's cell (thumbnail + clickable word + IPA), minus the norm chips (data not on the pack item).- Manual contrast add resolves both words via the existing
resolveWordForPack, same as word add.