Therapy Pack — Tools → "Add to Editor" 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: Let a clinician push a tool's current results into a therapy pack — a uniform "Add to Editor" control on the shared results toolbar (Word Lists, Contrast Sets, Sentences) plus a single-word add in Lookup — with a pack-picker (choose a saved pack or "New pack…") and a confirmation toast with Undo (spec §5.7 + §15).
Architecture: The shared SelectionToolbar already carries every grid tool's results as data: ExportData + dataType + selection indices — the one uniform surface. A pure toPackItems(data, dataType, indices, sourceTool) adapter maps those shapes to PackItem[] (words carry image refs → picture cards). An AddToEditorButton (button + pack-picker menu) activates the chosen pack and bulk-adds via new store actions addItems/removeItems (Undo), showing a toast. The button drops into SelectionToolbar (covers 3 tools) and into LookupTool (single word).
Tech Stack: React 19, TypeScript, MUI 7, Zustand 5, Vitest 4 + @testing-library/react. Reuses the pack store, PackSourceTool, and the ExportData contract.
Global Constraints¶
- Store is the single mutation surface. The control calls store actions (
addItems,removeItems,loadPack,newPack); it never touchespackDb. - Uniform contract via
ExportData.SelectionToolbar'sdata: ExportData(Word[] | MinimalPair[] | ContrastiveGroup[] | CorpusMatch[]) +dataType: 'words'|'pairs'|'groups'|'sentences'is the single mapping surface for the three grid tools. Do NOT add per-tool result plumbing. - Add the SELECTED items (SelectionToolbar only shows on selection). Map
datafiltered byselectedIndices. - Words link to the lexicon at add-time. Tool
Wordobjects already carryipa/image_file/image_provider/has_image(from the search API) — map them straight ontoPackWord.imageFile/imageProvider/ipa/hasImage(no re-fetch). (spec §15) - Pack-picker, not always-active. "Add to Editor" opens a menu of saved packs (from
usePackStore.library) + "New pack…"; the pick becomes the active pack, items are added, the pack indicator reflects it. (spec §15) - Confirmation toast with Undo. After add: a MUI Snackbar "Added N to
" with an Undo action that removes the just-added items. (spec §5.7) PackSourceToolalready includeswordLists,contrastive,sentences,lookup,textAnalysis(src/types/therapyPack.ts). Use the right one per surface.- No new analytics events (taxonomy is the next phase). Speech Analysis and Text Analysis are out of scope for this phase (audio stays outside the pack MVP; Text-Analysis add is deferred — noted).
- Test commands (from
packages/web/frontend/):npm test -- --run <file>,npm run type-check,npm run lint,npm run build.
File Structure¶
src/store/packStore.ts(modify) —addItems(items)+removeItems(ids)actions.src/lib/pack/toPackItems.ts(create) —ExportData→PackItem[]adapter.src/lib/pack/toPackItems.test.ts(create).src/components/pack/AddToEditorButton.tsx(create) — button + pack-picker menu + toast/Undo.src/components/pack/AddToEditorButton.test.tsx(create).src/components/shared/SelectionToolbar.tsx(modify) — renderAddToEditorButtonfromdata/dataType/selectedIndices.src/components/shared/SelectionToolbar.test.tsx(create or modify).src/components/tools/LookupTool.tsx(modify) — single-wordAddToEditorButton.
Task 1: Store addItems + removeItems¶
Files:
- Modify: src/store/packStore.ts
- Test: src/store/packStore.test.ts
Interfaces:
- Produces (added to PackState):
- addItems: (items: PackItem[]) => void — appends each item to its section (word/contrast/sentence) and commits once; no-op on empty; no-op if no active pack.
- removeItems: (ids: string[]) => void — removes every listed id across all sections in one commit; no-op on empty / no active pack.
- [ ] Step 1: Write the failing tests
Add to src/store/packStore.test.ts:
it('addItems appends a mixed batch in one commit', () => {
store.getState().newPack();
store.getState().addItems([
{ kind: 'word', id: 'w1', word: 'rocket', provenance: { sourceTool: 'wordLists', addedAt: 1 } },
{ kind: 'contrast', id: 'c1', a: 'key', b: 'tea', provenance: { sourceTool: 'contrastive', addedAt: 1 } },
{ kind: 'sentence', id: 's1', text: 'A rocket.', targets: [], provenance: { sourceTool: 'sentences', addedAt: 1 } },
]);
const p = store.getState().activePack!;
expect(p.words).toHaveLength(1);
expect(p.contrasts).toHaveLength(1);
expect(p.sentences).toHaveLength(1);
});
it('removeItems drops all listed ids across sections', () => {
store.getState().newPack();
store.getState().addItems([
{ kind: 'word', id: 'w1', word: 'a', provenance: { sourceTool: 'wordLists', addedAt: 1 } },
{ kind: 'word', id: 'w2', word: 'b', provenance: { sourceTool: 'wordLists', addedAt: 1 } },
{ kind: 'sentence', id: 's1', text: 'x', targets: [], provenance: { sourceTool: 'sentences', addedAt: 1 } },
]);
store.getState().removeItems(['w1', 's1']);
const p = store.getState().activePack!;
expect(p.words.map((w) => w.id)).toEqual(['w2']);
expect(p.sentences).toHaveLength(0);
});
it('addItems is a no-op with no active pack', () => {
store.setState({ activePack: null });
store.getState().addItems([{ kind: 'word', id: 'w', word: 'x', provenance: { sourceTool: 'manual', addedAt: 1 } }]);
expect(store.getState().activePack).toBeNull();
});
- [ ] Step 2: Run the tests to verify they fail
Run: npm test -- --run src/store/packStore.test.ts
Expected: FAIL — addItems/removeItems undefined.
- [ ] Step 3: Implement
In src/store/packStore.ts, add to the PackState interface:
addItems: (items: PackItem[]) => void;
removeItems: (ids: string[]) => void;
addItem):
addItems(items) {
const p = get().activePack;
if (!p || items.length === 0) return;
const words = [...p.words];
const contrasts = [...p.contrasts];
const sentences = [...p.sentences];
for (const it of items) {
if (it.kind === 'word') words.push(it);
else if (it.kind === 'contrast') contrasts.push(it);
else sentences.push(it);
}
commit({ ...p, words, contrasts, sentences });
},
removeItems(ids) {
const p = get().activePack;
if (!p || ids.length === 0) return;
const drop = new Set(ids);
commit({
...p,
words: p.words.filter((w) => !drop.has(w.id)),
contrasts: p.contrasts.filter((c) => !drop.has(c.id)),
sentences: p.sentences.filter((s) => !drop.has(s.id)),
});
},
- [ ] Step 4: Run the tests to verify they pass
Run: npm test -- --run src/store/packStore.test.ts
Expected: PASS.
- [ ] Step 5: Type-check and commit
npm run type-check
git add src/store/packStore.ts src/store/packStore.test.ts
git commit -m "feat(packs): addItems/removeItems bulk store actions (PHON-170)"
Task 2: toPackItems — ExportData → PackItem[]¶
Files:
- Create: src/lib/pack/toPackItems.ts
- Test: src/lib/pack/toPackItems.test.ts
Interfaces:
- Consumes: ExportData + ContrastiveGroup from ../../components/shared/ExportMenu; Word/MinimalPair from ../../services/phonolexApi; CorpusMatch from ../../types/governance; PackItem/PackWord/PackContrast/PackSentence/PackSourceTool from ../../types/therapyPack; newId from ../id.
- Produces:
- export function toPackItems(data: ExportData, dataType: 'words' | 'pairs' | 'groups' | 'sentences', indices: number[] | null, sourceTool: PackSourceTool, at: number): PackItem[] — maps the indices-selected rows (or all rows when indices is null/empty) to pack items:
- words → PackWord each (word, ipa, image_file→imageFile, image_provider→imageProvider, has_image→hasImage).
- pairs → PackContrast each (word1.word, word2.word, label "${phoneme1} vs ${phoneme2}").
- groups → the group's words flattened to PackWord[].
- sentences → PackSentence each (text; targets = union of highlights.include_surfaces + highlights.pair_surfaces).
- Every item carries provenance: { sourceTool, addedAt: at }.
- [ ] Step 1: Write the failing test
Create src/lib/pack/toPackItems.test.ts:
import { describe, it, expect } from 'vitest';
import { toPackItems } from './toPackItems';
describe('toPackItems', () => {
it('maps selected words with image refs', () => {
const data = [
{ word: 'rocket', ipa: 'ɹ', has_image: 1, image_file: 'rocket.svg', image_provider: 'mulberry' },
{ word: 'skip', ipa: 's' },
] as never;
const items = toPackItems(data, 'words', [0], 'wordLists', 5);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ kind: 'word', word: 'rocket', imageFile: 'rocket.svg', imageProvider: 'mulberry', hasImage: true });
expect(items[0].provenance).toMatchObject({ sourceTool: 'wordLists', addedAt: 5 });
});
it('maps all rows when indices is null', () => {
const data = [{ word: 'a' }, { word: 'b' }] as never;
expect(toPackItems(data, 'words', null, 'wordLists', 1)).toHaveLength(2);
});
it('maps pairs to contrasts', () => {
const data = [{ word1: { word: 'key' }, word2: { word: 'tea' }, phoneme1: 'k', phoneme2: 't' }] as never;
const items = toPackItems(data, 'pairs', null, 'contrastive', 1);
expect(items[0]).toMatchObject({ kind: 'contrast', a: 'key', b: 'tea', label: 'k vs t' });
});
it('flattens groups to words', () => {
const data = [{ words: [{ word: { word: 'cat' } }, { word: { word: 'bat' } }] }] as never;
const items = toPackItems(data, 'groups', null, 'contrastive', 1);
expect(items.map((i) => (i as { word: string }).word)).toEqual(['cat', 'bat']);
});
it('maps sentences with highlight targets', () => {
const data = [{ text: 'The key.', highlights: { include_surfaces: ['key'], pair_surfaces: [] } }] as never;
const items = toPackItems(data, 'sentences', null, 'sentences', 1);
expect(items[0]).toMatchObject({ kind: 'sentence', text: 'The key.', targets: ['key'] });
});
});
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- --run src/lib/pack/toPackItems.test.ts
Expected: FAIL — module not found.
- [ ] Step 3: Implement
Create src/lib/pack/toPackItems.ts:
/**
* Adapter: a tool's ExportData (the uniform results contract shared by
* SelectionToolbar/ExportMenu) → pack items (spec §5.7 / §15). Words carry
* their lexicon image refs so they render as picture cards with no re-fetch.
*/
import type { ExportData, ContrastiveGroup } from '../../components/shared/ExportMenu';
import type { Word, MinimalPair } from '../../services/phonolexApi';
import type { CorpusMatch } from '../../types/governance';
import type { PackItem, PackWord, PackSourceTool } from '../../types/therapyPack';
import { newId } from '../id';
function wordToPack(w: Word, sourceTool: PackSourceTool, at: number): PackWord {
return {
kind: 'word',
id: newId(),
word: w.word,
ipa: w.ipa ?? undefined,
hasImage: w.has_image || w.image_file ? true : undefined,
imageFile: w.image_file ?? undefined,
imageProvider: w.image_provider ?? undefined,
provenance: { sourceTool, addedAt: at },
};
}
function pick<T>(rows: T[], indices: number[] | null): T[] {
if (!indices || indices.length === 0) return rows;
const set = new Set(indices);
return rows.filter((_, i) => set.has(i));
}
export function toPackItems(
data: ExportData,
dataType: 'words' | 'pairs' | 'groups' | 'sentences',
indices: number[] | null,
sourceTool: PackSourceTool,
at: number,
): PackItem[] {
if (dataType === 'words') {
return pick(data as Word[], indices).map((w) => wordToPack(w, sourceTool, at));
}
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}`,
provenance: { sourceTool, addedAt: at },
}));
}
if (dataType === 'groups') {
return pick(data as ContrastiveGroup[], indices).flatMap((g) =>
g.words.map((gw) => wordToPack(gw.word, sourceTool, at)),
);
}
return pick(data as CorpusMatch[], indices).map((m) => ({
kind: 'sentence' as const,
id: newId(),
text: m.text,
targets: Array.from(new Set([
...(m.highlights?.include_surfaces ?? []),
...(m.highlights?.pair_surfaces ?? []),
])),
provenance: { sourceTool, addedAt: at },
}));
}
(If MinimalPair / ContrastiveGroup field names differ from the above when you read ExportMenu.tsx/phonolexApi, adjust the field access to match the real types — the shapes are: MinimalPair has word1/word2 (Word) + phoneme1/phoneme2; ContrastiveGroup has words: { word: Word; phoneme; position }[].)
- [ ] Step 4: Run the test to verify it passes
Run: npm test -- --run src/lib/pack/toPackItems.test.ts
Expected: PASS (5/5).
- [ ] Step 5: Type-check and commit
npm run type-check
git add src/lib/pack/toPackItems.ts src/lib/pack/toPackItems.test.ts
git commit -m "feat(packs): toPackItems — ExportData → pack items adapter (PHON-170)"
Task 3: AddToEditorButton — pack-picker + toast/Undo¶
Files:
- Create: src/components/pack/AddToEditorButton.tsx
- Test: src/components/pack/AddToEditorButton.test.tsx
Interfaces:
- Consumes: usePackStore (library, hydrate, loadPack, newPack, addItems, removeItems, activePack); PackItem type.
- Produces: AddToEditorButton props { getItems: () => PackItem[]; disabled?: boolean; size?: 'small' | 'medium'; label?: string }.
- A button ("Add to Editor" by default). On click: hydrate() then open a Menu listing each saved pack (title + counts) and a "New pack…" item.
- Picking a saved pack → loadPack(id); "New pack…" → newPack(). Then compute const items = getItems(), addItems(items), remember items.map(i => i.id), and show a Snackbar Added ${items.length} to "${activePack.title}" with an Undo action that calls removeItems(addedIds).
- If getItems() returns [], no-op (no menu, or a disabled state).
- [ ] Step 1: Write the failing test
Create src/components/pack/AddToEditorButton.test.tsx:
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import AddToEditorButton from './AddToEditorButton';
import { usePackStore } from '../../store/packStore';
import { getAllPacks, deletePack } from '../../lib/packDb';
import type { PackItem } from '../../types/therapyPack';
async function clearDb() {
const all = await getAllPacks();
await Promise.all(all.map((p) => deletePack(p.id)));
}
const items = (): PackItem[] => [
{ kind: 'word', id: 'w1', word: 'rocket', provenance: { sourceTool: 'wordLists', addedAt: 1 } },
{ kind: 'word', id: 'w2', word: 'rabbit', provenance: { sourceTool: 'wordLists', addedAt: 1 } },
];
describe('AddToEditorButton', () => {
beforeEach(async () => {
await clearDb();
usePackStore.setState({ activePack: null, library: [], saveError: null });
});
it('creates a new pack and adds items via the menu', async () => {
render(<AddToEditorButton getItems={items} />);
fireEvent.click(screen.getByRole('button', { name: /add to editor/i }));
fireEvent.click(await screen.findByRole('menuitem', { name: /new pack/i }));
await waitFor(() => expect(usePackStore.getState().activePack).not.toBeNull());
expect(usePackStore.getState().activePack!.words).toHaveLength(2);
expect(await screen.findByText(/added 2 to/i)).toBeInTheDocument();
});
it('adds to an existing saved pack', async () => {
usePackStore.getState().newPack();
usePackStore.getState().setTitle('My /r/ pack');
const id = usePackStore.getState().activePack!.id;
usePackStore.setState({ activePack: null });
render(<AddToEditorButton getItems={items} />);
fireEvent.click(screen.getByRole('button', { name: /add to editor/i }));
fireEvent.click(await screen.findByRole('menuitem', { name: /my \/r\/ pack/i }));
await waitFor(() => expect(usePackStore.getState().activePack!.id).toBe(id));
expect(usePackStore.getState().activePack!.words).toHaveLength(2);
});
it('Undo removes the just-added items', async () => {
render(<AddToEditorButton getItems={items} />);
fireEvent.click(screen.getByRole('button', { name: /add to editor/i }));
fireEvent.click(await screen.findByRole('menuitem', { name: /new pack/i }));
await waitFor(() => expect(usePackStore.getState().activePack!.words).toHaveLength(2));
fireEvent.click(await screen.findByRole('button', { name: /undo/i }));
await waitFor(() => expect(usePackStore.getState().activePack!.words).toHaveLength(0));
});
});
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- --run src/components/pack/AddToEditorButton.test.tsx
Expected: FAIL — module not found.
- [ ] Step 3: Implement
Create src/components/pack/AddToEditorButton.tsx:
import React, { useState } from 'react';
import { Button, Menu, MenuItem, Divider, ListItemText, Snackbar } from '@mui/material';
import { PlaylistAdd as AddIcon } from '@mui/icons-material';
import { usePackStore } from '../../store/packStore';
import type { PackItem } from '../../types/therapyPack';
interface AddToEditorButtonProps {
getItems: () => PackItem[];
disabled?: boolean;
size?: 'small' | 'medium';
label?: string;
}
const AddToEditorButton: React.FC<AddToEditorButtonProps> = ({
getItems, disabled = false, size = 'small', label = 'Add to Editor',
}) => {
const library = usePackStore((s) => s.library);
const [anchor, setAnchor] = useState<null | HTMLElement>(null);
const [snack, setSnack] = useState<{ msg: string; ids: string[] } | null>(null);
const open = (e: React.MouseEvent<HTMLElement>) => {
void usePackStore.getState().hydrate();
setAnchor(e.currentTarget);
};
const close = () => setAnchor(null);
const addTo = (activate: () => void | Promise<void>) => {
return async () => {
close();
const items = getItems();
if (!items.length) return;
await activate();
usePackStore.getState().addItems(items);
const title = usePackStore.getState().activePack?.title ?? 'pack';
setSnack({ msg: `Added ${items.length} to "${title}"`, ids: items.map((i) => i.id) });
};
};
return (
<>
<Button size={size} variant="outlined" startIcon={<AddIcon />} disabled={disabled} onClick={open}>
{label}
</Button>
<Menu anchorEl={anchor} open={Boolean(anchor)} onClose={close}>
<MenuItem onClick={addTo(() => usePackStore.getState().newPack())}>
<ListItemText primary="New pack…" />
</MenuItem>
{library.length > 0 && <Divider />}
{library.map((s) => (
<MenuItem key={s.id} onClick={addTo(() => usePackStore.getState().loadPack(s.id))}>
<ListItemText
primary={s.title}
secondary={`${s.counts.words} words · ${s.counts.contrasts} contrasts · ${s.counts.sentences} sentences`}
/>
</MenuItem>
))}
</Menu>
<Snackbar
open={Boolean(snack)}
autoHideDuration={6000}
onClose={() => setSnack(null)}
message={snack?.msg}
action={
<Button color="secondary" size="small" onClick={() => {
if (snack) usePackStore.getState().removeItems(snack.ids);
setSnack(null);
}}>
Undo
</Button>
}
/>
</>
);
};
export default AddToEditorButton;
- [ ] Step 4: Run the test to verify it passes
Run: npm test -- --run src/components/pack/AddToEditorButton.test.tsx
Expected: PASS (3/3).
- [ ] Step 5: Commit
git add src/components/pack/AddToEditorButton.tsx src/components/pack/AddToEditorButton.test.tsx
git commit -m "feat(packs): AddToEditorButton — pack-picker menu + toast/undo (PHON-170)"
Task 4: Wire "Add to Editor" into SelectionToolbar (Word Lists · Contrast Sets · Sentences)¶
Files:
- Modify: src/components/shared/SelectionToolbar.tsx
- Test: src/components/shared/SelectionToolbar.test.tsx (create if absent)
Interfaces:
- SelectionToolbar renders <AddToEditorButton getItems={() => toPackItems(data, dataType, selectedIndices, sourceToolFor(dataType), Date.now())} /> alongside its existing ExportMenu.
- Add a helper sourceToolFor(dataType): 'words'→'wordLists', 'pairs'|'groups'→'contrastive', 'sentences'→'sentences'.
- [ ] Step 1: Write the failing test
Create/extend src/components/shared/SelectionToolbar.test.tsx. Read the current SelectionToolbar render to supply required props (totalCount, selectedCount, selectedIndices, onSelectAll, onClearAll, data, dataType). Because the toolbar renders only when selectedCount > 0, pass a non-zero selection.
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import SelectionToolbar from './SelectionToolbar';
import { usePackStore } from '../../store/packStore';
describe('SelectionToolbar — Add to Editor', () => {
beforeEach(() => usePackStore.setState({ activePack: null, library: [], saveError: null }));
it('adds the selected words to a new pack', async () => {
render(
<SelectionToolbar
totalCount={2} selectedCount={1} selectedIndices={[0]}
onSelectAll={vi.fn()} onClearAll={vi.fn()}
data={[{ word: 'rocket', ipa: 'ɹ' }, { word: 'skip' }] as never}
dataType="words"
/>,
);
fireEvent.click(screen.getByRole('button', { name: /add to editor/i }));
fireEvent.click(await screen.findByRole('menuitem', { name: /new pack/i }));
await waitFor(() => expect(usePackStore.getState().activePack!.words.map((w) => w.word)).toEqual(['rocket']));
});
});
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- --run src/components/shared/SelectionToolbar.test.tsx
Expected: FAIL — no "Add to Editor" button.
- [ ] Step 3: Implement
In src/components/shared/SelectionToolbar.tsx: import the button + adapter, add the helper, render the button next to ExportMenu (read the file to place it in the same action row). Map only the selected rows via selectedIndices.
import AddToEditorButton from '../pack/AddToEditorButton';
import { toPackItems } from '../../lib/pack/toPackItems';
import type { PackSourceTool } from '../../types/therapyPack';
function sourceToolFor(dataType: 'words' | 'pairs' | 'groups' | 'sentences'): PackSourceTool {
if (dataType === 'words') return 'wordLists';
if (dataType === 'sentences') return 'sentences';
return 'contrastive';
}
<ExportMenu .../>):
<AddToEditorButton
getItems={() => toPackItems(data, dataType, selectedIndices, sourceToolFor(dataType), Date.now())}
/>
- [ ] Step 4: Run the test to verify it passes
Run: npm test -- --run src/components/shared/SelectionToolbar.test.tsx
Expected: PASS.
- [ ] Step 5: Commit
git add src/components/shared/SelectionToolbar.tsx src/components/shared/SelectionToolbar.test.tsx
git commit -m "feat(packs): Add to Editor on the shared results toolbar (word lists/contrasts/sentences) (PHON-170)"
Task 5: Lookup single-word "Add to Editor" + full gate¶
Files:
- Modify: src/components/tools/LookupTool.tsx
- Test: src/components/tools/LookupTool.test.tsx (create if absent; otherwise a focused new test)
Interfaces:
- In LookupTool, when wordResult (a Word) is present, render <AddToEditorButton getItems={() => toPackItems([wordResult], 'words', null, 'lookup', Date.now())} /> near the word header/detail.
- [ ] Step 1: Write the failing test
Read LookupTool.tsx to find how to render it with a populated wordResult (it fetches on a query; the test can mock api.getWord). Create src/components/tools/LookupTool.test.tsx:
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import LookupTool from './LookupTool';
import api from '../../services/phonolexApi';
import { usePackStore } from '../../store/packStore';
describe('LookupTool — Add to Editor', () => {
beforeEach(() => usePackStore.setState({ activePack: null, library: [], saveError: null }));
it('adds the looked-up word to a new pack', async () => {
vi.spyOn(api, 'getWord').mockResolvedValue({ word: 'rocket', ipa: 'ɹ', has_image: 1, image_file: 'rocket.svg', image_provider: 'mulberry' } as never);
render(<LookupTool initialWord="rocket" />);
// wait for the word to load, then Add to Editor
await waitFor(() => expect(screen.getByRole('button', { name: /add to editor/i })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /add to editor/i }));
fireEvent.click(await screen.findByRole('menuitem', { name: /new pack/i }));
await waitFor(() => expect(usePackStore.getState().activePack!.words[0]).toMatchObject({ word: 'rocket', imageFile: 'rocket.svg' }));
});
});
(If LookupTool doesn't accept initialWord or fetches differently, adapt the test to drive a lookup through its input — read the component first. The assertion that matters: a loaded word + Add to Editor → new pack contains that word with its image ref.)
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- --run src/components/tools/LookupTool.test.tsx
Expected: FAIL — no "Add to Editor" button.
- [ ] Step 3: Implement
In src/components/tools/LookupTool.tsx, import AddToEditorButton + toPackItems, and render the button in the word-result header block (guarded by wordResult):
{wordResult && (
<AddToEditorButton getItems={() => toPackItems([wordResult], 'words', null, 'lookup', Date.now())} />
)}
- [ ] Step 4: Run the test to verify it passes
Run: npm test -- --run src/components/tools/LookupTool.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/tools/LookupTool.tsx src/components/tools/LookupTool.test.tsx
git commit -m "feat(packs): Add to Editor on Lookup (single word) (PHON-170)"
Self-Review notes (coverage vs spec §5.7 / §15)¶
- Uniform "Add to Editor" across grid tools: Task 4 (one
SelectionToolbarintegration → Word Lists, Contrast Sets, Sentences). ✓ - Single Lookup word: Task 5. ✓
- Pack-picker (saved packs + New pack…): Task 3
AddToEditorButtonmenu. ✓ (your amendment ask.) - Words link to the lexicon (picture cards): Task 2 maps
image_file/image_providerontoPackWord; the editor-cards phase renders them. ✓ - Confirmation toast with Undo: Task 3 Snackbar +
removeItems. ✓ - Pack indicator updates: the chosen pack becomes active; the existing
PackIndicatorChipreflects it (no change needed). ✓ - Deferred / out of scope: Text-Analysis "add extraction" (marginal fit + would need async word resolution — a follow-up); Speech Analysis (audio stays outside the pack MVP); navigating to the editor on add (the pack indicator is the path — intentional, tool keeps its UX); adding to a NON-active pack without activating it (picking activates — simpler, one active-pack model).
Decisions resolved at planning¶
SelectionToolbaris the single anchor for the three grid tools — it already carriesdata/dataType/selectedIndices. One integration, not three.- Add the selected rows (the toolbar is selection-scoped); "Select all" covers "add everything."
- Picking a pack activates it (
loadPack/newPack) then adds — one active-pack model; the indicator reflects it; no separate add-to-inactive path. - Undo via
removeItems(addedIds)on the active pack (immediate, within the snackbar window). - Text Analysis deferred (weak fit + async resolution); Lookup included (clear value).