Skip to content

Therapy Pack — Editor Polish (drop presets, CSV copy, contrast mirror, section→tool) 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: From live review: (1) drop the non-functional Kids/Adult export preset toggle; (2) add a Copy-to-clipboard button on the CSV export; (3) make the editor's contrast rows mirror the Contrast Sets tool's word-box layout; (4) let editor section headers open the matching tool ("Add from [tool]", spec §5.5).

Tech Stack: React 19, TypeScript, MUI 7, Vitest 4. Reuses WordImageThumb, ClickableWord, existing tool component patterns, and App_new's handleToolSelect.

Global Constraints

  • Reuse existing components/patterns — the contrast row must mirror ContrastiveGroupsTable's per-word box (WordImageThumb + ClickableWord + IPA in a bordered box, 2-up Grid). No bespoke image/click logic.
  • Section→tool uses the existing routingPackEditor already receives onOpenTool?: (id: string) => void (wired to handleToolSelect in App_new). Tool ids: Word Lists = wordLists, Contrast Sets = contrastive, Sentences = governedGeneration.
  • No new deps — the copy button uses navigator.clipboard.writeText + MUI's ContentCopy icon.
  • Test commands (from packages/web/frontend/): npm test -- --run <file>, npm run type-check, npm run lint, npm run build.

File Structure

  • src/components/pack/export/ExportDialog.tsx (modify) — remove preset toggle; add CSV Copy button.
  • src/components/pack/export/ExportDialog.test.tsx (modify).
  • src/components/pack/ContrastRow.tsx (modify) — mirror the tool's word-box Grid.
  • src/components/pack/ContrastRow.test.tsx (modify).
  • src/components/pack/PackSection.tsx (modify) — optional "Add from [tool]" header affordance.
  • src/components/pack/PackSection.test.tsx (create, if none).
  • src/components/pack/PackEditor.tsx (modify) — pass onOpenTool + tool label to each section.
  • src/components/pack/PackEditor.test.tsx (modify).

Task 1: Drop the Kids/Adult preset toggle + add a CSV Copy button

Files: - Modify: src/components/pack/export/ExportDialog.tsx - Modify: src/components/pack/export/ExportDialog.test.tsx

Interfaces: No prop changes. The dialog uses a single fixed export-options default (the former kids options); the preset/ToggleButtonGroup are removed. On the CSV tab, a "Copy" button copies the CSV to the clipboard with transient "Copied" feedback.

  • [ ] Step 1: Update the test

In src/components/pack/export/ExportDialog.test.tsx: - The tests that switch presets (if any) — remove preset interactions; the export still works with the default options. - Add a CSV copy test:

  it('copies the CSV to the clipboard', async () => {
    const writeText = vi.fn().mockResolvedValue(undefined);
    Object.assign(navigator, { clipboard: { writeText } });
    render(<ExportDialog open onClose={vi.fn()} />);
    fireEvent.click(screen.getByRole('tab', { name: /csv/i }));
    await waitFor(() => expect(screen.getByRole('button', { name: /^copy$/i })).toBeEnabled());
    fireEvent.click(screen.getByRole('button', { name: /^copy$/i }));
    await waitFor(() => expect(writeText).toHaveBeenCalled());
    expect(writeText.mock.calls[0][0]).toContain('word,ipa'); // CSV header
  });

  • [ ] Step 2: Run the test to verify it fails

Run: npm test -- --run src/components/pack/export/ExportDialog.test.tsx Expected: FAIL — no Copy button.

  • [ ] Step 3: Implement

In src/components/pack/export/ExportDialog.tsx: - Remove import { ... ToggleButton, ToggleButtonGroup ... } usage; remove the preset state and the ExportPreset import if now unused. Replace const opts = useMemo(() => PRESET_OPTIONS[preset], [preset]) with a fixed default: const opts = PRESET_OPTIONS.kids; (keep PRESET_OPTIONS import). Remove the {format !== 'csv' && (<Box>…Preset…</Box>)} block entirely. - Add a copied state (useState(false)), a handleCopyCsv:

const handleCopyCsv = async () => {
  if (!pack) return;
  await navigator.clipboard.writeText(buildPackCsv(pack, images));
  setCopied(true);
  setTimeout(() => setCopied(false), 1500);
};
(setTimeout is fine in a component; the transient state resets the label.) - In DialogActions, when format === 'csv', render a Copy button before Download:
{format === 'csv' && (
  <Button
    startIcon={<ContentCopyIcon />}
    onClick={handleCopyCsv}
    disabled={imagesLoading || !pack || totalItems === 0}
  >
    {copied ? 'Copied' : 'Copy'}
  </Button>
)}
Import ContentCopy as ContentCopyIcon from @mui/icons-material.

  • [ ] Step 4: Run the test to verify it passes

Run: npm test -- --run src/components/pack/export/ExportDialog.test.tsx Expected: PASS.

  • [ ] Step 5: Type-check and commit
npm run type-check
git add src/components/pack/export/ExportDialog.tsx src/components/pack/export/ExportDialog.test.tsx
git commit -m "feat(packs): drop unused Kids/Adult preset toggle; add CSV copy-to-clipboard (PHON-170)"

Task 2: ContrastRow mirrors the Contrast Sets tool's word-box layout

Files: - Modify: src/components/pack/ContrastRow.tsx - Modify: src/components/pack/ContrastRow.test.tsx

Interfaces: Same props. The row now renders like ContrastiveGroupsTable's pair card: a small header (the contrast label + reorder/remove controls) then a 2-up Grid where each member is a bordered word boxWordImageThumb (size 28, when present) + ClickableWord (body1, bold, primary.main) inline, with the IPA (body2, monospace) beneath — matching the tool exactly.

  • [ ] Step 1: Update the test

src/components/pack/ContrastRow.test.tsx should still assert both members' img (rocket/key/tea alt), both ClickableWord buttons (view profile for …), both IPAs, the no-image member case, and remove/move-boundary. Keep those assertions (they hold in the new layout). If the test asserted a "vs" text node, remove that assertion (the boxed layout drops the "vs" separator; the label chip conveys the contrast).

  • [ ] Step 2: Run the test to verify it fails (if layout assertions changed)

Run: npm test -- --run src/components/pack/ContrastRow.test.tsx

  • [ ] Step 3: Implement — mirror the tool

Rewrite src/components/pack/ContrastRow.tsx:

import React from 'react';
import { Box, Grid, IconButton, Paper, Stack, 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;
}

// One member rendered as a bordered word box, matching ContrastiveGroupsTable's cell.
function WordBox({ word, ipa, imageFile, imageProvider }: {
  word: string; ipa?: string; imageFile?: string; imageProvider?: string;
}) {
  return (
    <Box sx={{ p: 1.5, bgcolor: 'background.default', borderRadius: 1, border: 1, borderColor: 'divider', height: '100%' }}>
      <Stack direction="row" spacing={1} alignItems="center">
        {imageFile && imageProvider && (
          <WordImageThumb word={word} imageFile={imageFile} provider={imageProvider} size={28} />
        )}
        <ClickableWord word={word}>
          <Typography variant="body1" fontWeight={600} color="primary.main">{word}</Typography>
        </ClickableWord>
      </Stack>
      {ipa && (
        <Typography variant="body2" fontFamily="monospace" color="text.secondary">{ipa}</Typography>
      )}
    </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={{ p: 1.5, mb: 0.75 }}>
      <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
        <Typography variant="subtitle2" color="text.secondary">{name}</Typography>
        <Box sx={{ flexShrink: 0 }}>
          <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>
        </Box>
      </Stack>
      <Grid container spacing={1.5}>
        <Grid size={{ xs: 12, sm: 6 }}>
          <WordBox word={contrast.a} ipa={contrast.aIpa} imageFile={contrast.aImageFile} imageProvider={contrast.aImageProvider} />
        </Grid>
        <Grid size={{ xs: 12, sm: 6 }}>
          <WordBox word={contrast.b} ipa={contrast.bIpa} imageFile={contrast.bImageFile} imageProvider={contrast.bImageProvider} />
        </Grid>
      </Grid>
    </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.

  • [ ] 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): contrast rows mirror the Contrast Sets tool word-box layout (PHON-170)"

Task 3: Section headers open the matching tool ("Add from [tool]")

Files: - Modify: src/components/pack/PackSection.tsx - Test: src/components/pack/PackSection.test.tsx (create) - Modify: src/components/pack/PackEditor.tsx - Modify: src/components/pack/PackEditor.test.tsx

Interfaces: - PackSection gains optional { toolLabel?: string; onOpenTool?: () => void }. When both are present, the header renders an "+ Add from [toolLabel]" button (right-aligned) that calls onOpenTool. The title stays as text. - PackEditor passes each section its tool: Target words → toolLabel="Word Lists", onOpenTool={() => onOpenTool?.('wordLists')}; Contrast pairs → "Contrast Sets" / 'contrastive'; Sentences → "Sentences" / 'governedGeneration'. (PackEditor already declares onOpenTool?: (id: string) => void; destructure and use it. App_new already passes onOpenTool={handleToolSelect}.)

  • [ ] Step 1: Write the failing tests

Create src/components/pack/PackSection.test.tsx:

import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import PackSection from './PackSection';

describe('PackSection — Add from tool', () => {
  it('renders an Add-from-tool button and fires onOpenTool', () => {
    const onOpenTool = vi.fn();
    render(<PackSection title="Contrast pairs" count={0} toolLabel="Contrast Sets" onOpenTool={onOpenTool}>x</PackSection>);
    fireEvent.click(screen.getByRole('button', { name: /add from contrast sets/i }));
    expect(onOpenTool).toHaveBeenCalled();
  });

  it('renders no Add-from-tool button without the props', () => {
    render(<PackSection title="Notes" count={0}>x</PackSection>);
    expect(screen.queryByRole('button', { name: /add from/i })).not.toBeInTheDocument();
  });
});

Add to src/components/pack/PackEditor.test.tsx (the editor already gets onOpenTool via a prop; pass a spy and assert a section opens the tool):

  it('opens the matching tool from a section header', () => {
    usePackStore.getState().newPack();
    const onOpenTool = vi.fn();
    render(<PackEditor onOpenTool={onOpenTool} />);
    fireEvent.click(screen.getByRole('button', { name: /add from word lists/i }));
    expect(onOpenTool).toHaveBeenCalledWith('wordLists');
  });
  • [ ] Step 2: Run the tests to verify they fail

Run: npm test -- --run src/components/pack/PackSection.test.tsx src/components/pack/PackEditor.test.tsx Expected: FAIL — no Add-from-tool affordance.

  • [ ] Step 3: Implement

src/components/pack/PackSection.tsx — render the header as a row with the title and an optional Add-from-tool button:

import React from 'react';
import { Box, Button, Stack, Typography } from '@mui/material';
import { Add as AddIcon } from '@mui/icons-material';

interface PackSectionProps {
  title: string;
  count: number;
  children: React.ReactNode;
  action?: React.ReactNode;
  emptyHint?: string;
  toolLabel?: string;
  onOpenTool?: () => void;
}

const PackSection: React.FC<PackSectionProps> = ({ title, count, children, action, emptyHint, toolLabel, onOpenTool }) => (
  <Box component="section" sx={{ mb: 3 }}>
    <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
      <Typography variant="subtitle1" fontWeight={600}>
        {title}{' '}
        <Typography component="span" variant="body2" color="text.secondary">({count})</Typography>
      </Typography>
      {toolLabel && onOpenTool && (
        <Button size="small" startIcon={<AddIcon />} onClick={onOpenTool}>
          Add from {toolLabel}
        </Button>
      )}
    </Stack>
    {count === 0 && emptyHint && (
      <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>{emptyHint}</Typography>
    )}
    {children}
    {action && <Box sx={{ mt: 1 }}>{action}</Box>}
  </Box>
);

export default PackSection;

src/components/pack/PackEditor.tsx — destructure onOpenTool from props and pass the tool label + handler to each of the three content sections: - Target words: toolLabel="Word Lists" onOpenTool={() => onOpenTool?.('wordLists')} - Contrast pairs: toolLabel="Contrast Sets" onOpenTool={() => onOpenTool?.('contrastive')} - Sentences: toolLabel="Sentences" onOpenTool={() => onOpenTool?.('governedGeneration')} (The Notes section gets no tool.)

  • [ ] Step 4: Run the tests to verify they pass

Run: npm test -- --run src/components/pack/PackSection.test.tsx 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/PackSection.tsx src/components/pack/PackSection.test.tsx src/components/pack/PackEditor.tsx src/components/pack/PackEditor.test.tsx
git commit -m "feat(packs): 'Add from [tool]' on editor section headers — opens the matching tool (PHON-170)"

Self-Review notes

  • (1) Drop Kids/Adult toggle → Task 1 (single default options; preset UI removed). ✓
  • (2) CSV copy button → Task 1 (ContentCopy + clipboard + transient "Copied"). ✓
  • (3) Contrast rows mirror the tool → Task 2 (bordered word-box, thumbnail + bold ClickableWord + IPA, 2-up Grid — matches ContrastiveGroupsTable). ✓
  • (4) Section→tool → Task 3 (spec §5.5 "Add from [tool]" via the existing onOpenTool/handleToolSelect route). ✓
  • Deferred: exposing individual export options (label position / grid size) as direct controls now that the preset toggle is gone — a later nicety; the fixed default covers the common case. The tool's per-word phoneme/WCM/AoA chips aren't shown on the editor contrast box (that norm data isn't stored on PackContrast).

Decisions resolved at planning

  1. Single default export options (former kids: labeled cards, 12-grid) — the presets weren't differentiating meaningfully; drop the toggle rather than ship a distinction that isn't real.
  2. "Add from [tool]" as an explicit header button (not just a clickable title) — discoverable, matches spec §5.5 wording; reuses the existing onOpenTool routing.
  3. Contrast box mirrors the tool minus the norm/phoneme chips (data not on the pack item) — the visual treatment (bordered box, thumbnail + bold word + IPA) is what matches.