Skip to content

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's replaceWord method may remain (harmless, tested) but the Editor must not use it.
  • Seeding prioritizes has_image. Word seeding requests has_image: true first (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 noWrap truncation; rows grow vertically with controls top-aligned.
  • WordImageThumb props: { word, imageFile, provider, size }; it renders /images/words/{provider}/{file} (or R2 for generated) and hides itself on load error. Render it only when both imageFile and imageProvider are present (matches WordListTable'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) — seedWords has_image-first + backfill.
  • src/lib/seed/packSeeder.test.ts (modify) — image-priority test.
  • src/components/pack/WordRow.tsx (create) — compact word row with WordImageThumb.
  • src/components/pack/WordRow.test.tsx (create).
  • src/components/pack/WordCard.tsx + WordCard.test.tsx (delete) — replaced by WordRow.
  • src/components/pack/ReplaceWordDialog.tsx (delete) — replace feature removed.
  • src/components/pack/PackItemCard.tsx (modify) — wrap text (remove noWrap), 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) — render WordRow; remove replace dialog/state/handler + WordCard/ReplaceWordDialog imports.
  • 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 WordRow and delete WordCard

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();
  });
(Keep the existing remove + move-boundary tests. Drop 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>
)}
(Sentences keep plain wrapping text via 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
Expected: all pass (the one known pre-existing unrelated 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 overflowWordRow is a compact fixed-height row (no oversized card); word/IPA truncate gracefully in a minWidth:0 box. ✓
  • (2) Remove replace → replace button + ReplaceWordDialog deleted; Task 3 + 4. ✓
  • (3) Consistency with the tools' table → words render as rows composing the SAME shared components WordListTable uses (ClickableWord + WordImageThumb); Task 2. ✓
  • (4) Sentences wrapPackItemCard drops noWrap, top-aligns controls; Task 3. ✓
  • (5) Prioritize has_imageseedWords image-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 under WordProfileProvider. 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/updateWord methods stay (harmless, still tested) though the editor no longer uses replace.

Decisions resolved at planning

  1. Editor = table (tool-consistent) via WordImageThumb; export = picture cards. The editor is the working surface; the printable is the card deliverable.
  2. has_image-first with backfill — prioritize image words but never ship a thin pack for a rare target.
  3. Replace removed, not hidden — delete the dialog + button; X + quick-add is the model.