Skip to content

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 componentsContrastRow composes WordImageThumb + ClickableWord (from ../shared/*), mirroring ContrastiveGroupsTable's per-word cell. No bespoke image/click logic.
  • PackContrast change is additive/optional — keep a/b as strings (existing consumers: toPackItems, mapPairToPackContrast, buildWorksheetModel, PackItemCard, ContrastAddField). Add optional per-member ipa/imageFile/imageProvider. No PACK_SCHEMA_VERSION bump.
  • Populate lexicon refs where the data already is: seeding (mapPairToPackContrastMinimalPairResult.word1/word2 are Word) and tool-add (toPackItems pairs branch — MinimalPair.word1/word2 are Word). Manual adds resolve both words via resolveWordForPack.
  • 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) — PackContrast per-member optional refs.
  • src/lib/seed/packSeeder.ts (modify) — mapPairToPackContrast populates 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) — render ContrastRow; 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
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/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 formatContrastRow = two cells, each WordImageThumb + ClickableWord + IPA (mirrors ContrastiveGroupsTable's word cell); Tasks 2–3. ✓
  • Data to render itPackContrast carries 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 (toPackItems groups → words, unchanged).

Decisions resolved at planning

  1. Additive optional per-member refs on PackContrast (keep a/b strings) — backward-compatible with every existing consumer; no schema bump.
  2. ContrastRow mirrors the tool's cell (thumbnail + clickable word + IPA), minus the norm chips (data not on the pack item).
  3. Manual contrast add resolves both words via the existing resolveWordForPack, same as word add.