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 routing —
PackEditoralready receivesonOpenTool?: (id: string) => void(wired tohandleToolSelectinApp_new). Tool ids: Word Lists =wordLists, Contrast Sets =contrastive, Sentences =governedGeneration. - No new deps — the copy button uses
navigator.clipboard.writeText+ MUI'sContentCopyicon. - 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) — passonOpenTool+ 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>
)}
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 box — WordImageThumb (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
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/handleToolSelectroute). ✓ - 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¶
- 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. - "Add from [tool]" as an explicit header button (not just a clickable title) — discoverable, matches spec §5.5 wording; reuses the existing
onOpenToolrouting. - 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.