Therapy Pack — Editor Shell (Phase 1b) 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: Build the Therapy Pack Editor — the new primary surface — as a sectioned workspace (Words · Contrasts · Sentences · Notes) backed by the existing usePackStore, plus a "My Packs" library dialog and a header-chip pack indicator wired into the app shell.
Architecture: Pure client-side React + MUI over the Zustand usePackStore completed in Phase 1 (src/store/packStore.ts). This phase adds the missing store mutations the editor needs (reorder, replace-word, rename-any-pack, save-error resilience), then the presentational components, then wires the Editor into App_new.tsx as a first-class destination alongside the six tools. No backend, no new API calls — seeding (Phase 3), export (Phase 2), and the ⟳ Qwensim suggestion picker (Phase 5) are out of scope; a plain type-to-replace dialog stands in for replace here.
Tech Stack: React 19, TypeScript, MUI 7 (@mui/material, @mui/icons-material), Zustand 5, idb-keyval, Vitest 4 + @testing-library/react + happy-dom + fake-indexeddb.
Global Constraints¶
- Store is the single mutation surface. All pack reads/writes go through
usePackStore(src/store/packStore.ts); components never touchpackDbdirectly. (spec §5.6) - Local-first / privacy. No accounts, no server persistence. Copy must remind users packs live in their browser and must not carry patient identifiers. (spec §5.6, §6)
- No new API calls this phase. Editor shell only. Seeding/export/Qwensim are later phases.
- Analytics via
track()fromsrc/lib/analytics.ts—track(name: string, props?). Editor events this phase:pack_edited(spec §6). No patient-identifying content in props. - Terminology: "feature vectors" not "embeddings"; the artifact is a "pack".
- MUI conventions: use theme tokens (
primary.main,text.secondary,divider),sxresponsive objects{ xs, sm, md }, matching existing components (Builder.tsx,App_new.tsx). Tool accent for the editor: reuseTOOL_COLORSpalette; editor usesprimary.main. - Test commands (run from
packages/web/frontend/):npm test -- --run <file>for a single file,npm run type-check,npm run lint,npm run build. The vitest setup (src/test/setup.ts) already importsfake-indexeddb/autoand RTL cleanup. - Existing pack types (
src/types/therapyPack.ts):TherapyPack,PackWord,PackContrast,PackSentence,PackItem,TargetSpec,PackSummary,createEmptyPack,summarize,PACK_SCHEMA_VERSION. Do not restructure them; extend only if a task says so.
File Structure¶
src/store/packStore.ts(modify) — addreorderItem,replaceWord,renamePack, andsaveErrorstate +.catchon the background persist.src/store/packStore.test.ts(modify) — tests for the new store methods.src/components/pack/PackEditor.tsx(create) — the editor container; assembles header + sections; subscribes to the store.src/components/pack/PackHeader.tsx(create) — editable title, target chips, counts, autosave state, action buttons (My Packs, New, Start over, Duplicate, Preview[disabled], Export[disabled]).src/components/pack/PackItemCard.tsx(create) — one row for a word/contrast/sentence with remove, move-up/down, and replace (word only).src/components/pack/PackSection.tsx(create) — a titled section wrapper with an "empty" hint and an optional "+ Add" affordance slot.src/components/pack/ManualAddField.tsx(create) — a small text field to add a word manually (sourceTool: 'manual'); the only content entry point until seeding (Phase 3) lands.src/components/pack/ReplaceWordDialog.tsx(create) — type-to-replace dialog (Qwensim suggestions deferred to Phase 5).src/components/pack/MyPacksDialog.tsx(create) — library list with open/rename/duplicate/delete/new.src/components/pack/PackIndicatorChip.tsx(create) — header chip shown once a pack exists; opens the editor.src/components/pack/*.test.tsx(create alongside each component) — RTL tests.src/App_new.tsx(modify) — treat'editor'as a primary destination; hydrate the library on mount; renderPackIndicatorChip.
Task 1: Store — reorder, replace-word, rename-any-pack, save-error resilience¶
Files:
- Modify: src/store/packStore.ts
- Test: src/store/packStore.test.ts
Interfaces:
- Consumes: existing usePackStore (activePack, library, commit helper, addItem, removeItem, setTitle, loadPack, refreshLibrary), TherapyPack, PackItem, PackWord, summarize, newId, migratePack, packDb.
- Produces (added to PackState):
- saveError: string | null
- reorderItem: (kind: 'word' | 'contrast' | 'sentence', id: string, dir: -1 | 1) => void — moves the item one slot toward the start (-1) or end (+1) within its own section; no-op at the ends or if not found.
- replaceWord: (id: string, newWord: string) => void — replaces word on the matching PackWord, clears ipa/hasImage (now unknown), stamps provenance.sourceTool = 'manual' and provenance.reason = 'replaced'; no-op if id not a word.
- renamePack: (id: string, title: string) => Promise<void> — renames any saved pack (active or not); persists and refreshes the library. If id is the active pack, updates activePack.title too.
- [ ] Step 1: Write the failing tests
Append to src/store/packStore.test.ts (inside the existing top-level describe('usePackStore', …) or a new sibling describe):
it('reorderItem moves a word toward the end and start', () => {
store.getState().newPack();
const a: PackWord = { kind: 'word', id: 'a', word: 'a', provenance: { sourceTool: 'manual', addedAt: 1 } };
const b: PackWord = { kind: 'word', id: 'b', word: 'b', provenance: { sourceTool: 'manual', addedAt: 2 } };
const c: PackWord = { kind: 'word', id: 'c', word: 'c', provenance: { sourceTool: 'manual', addedAt: 3 } };
store.getState().addItem(a);
store.getState().addItem(b);
store.getState().addItem(c);
store.getState().reorderItem('word', 'a', 1);
expect(store.getState().activePack!.words.map((w) => w.id)).toEqual(['b', 'a', 'c']);
store.getState().reorderItem('word', 'c', -1);
expect(store.getState().activePack!.words.map((w) => w.id)).toEqual(['b', 'c', 'a']);
});
it('reorderItem is a no-op at the boundary', () => {
store.getState().newPack();
const a: PackWord = { kind: 'word', id: 'a', word: 'a', provenance: { sourceTool: 'manual', addedAt: 1 } };
store.getState().addItem(a);
store.getState().reorderItem('word', 'a', -1);
expect(store.getState().activePack!.words.map((w) => w.id)).toEqual(['a']);
});
it('replaceWord swaps the word and resets derived fields', () => {
store.getState().newPack();
store.getState().addItem({
kind: 'word', id: 'w1', word: 'rocket', ipa: 'ɹ ɑ k ə t', hasImage: true,
provenance: { sourceTool: 'guided', addedAt: 1 },
});
store.getState().replaceWord('w1', 'rabbit');
const w = store.getState().activePack!.words[0];
expect(w.word).toBe('rabbit');
expect(w.ipa).toBeUndefined();
expect(w.hasImage).toBeUndefined();
expect(w.provenance.sourceTool).toBe('manual');
});
it('renamePack renames a non-active saved pack and persists', async () => {
store.getState().newPack();
const first = store.getState().activePack!.id;
store.getState().newPack(); // second pack becomes active
await store.getState().renamePack(first, 'Renamed while inactive');
const persisted = (await getAllPacks()).find((p) => p.id === first)!;
expect(persisted.title).toBe('Renamed while inactive');
expect(store.getState().library.find((s) => s.id === first)!.title).toBe('Renamed while inactive');
});
- [ ] Step 2: Run the tests to verify they fail
Run: npm test -- --run src/store/packStore.test.ts
Expected: FAIL — reorderItem/replaceWord/renamePack are not functions.
- [ ] Step 3: Implement the store additions
In src/store/packStore.ts, add saveError to the PackState interface and to the returned object's initial state, harden commit, and add the three methods.
Update the interface:
interface PackState {
activePack: TherapyPack | null;
library: PackSummary[];
saveError: string | null;
hydrate: () => Promise<void>;
refreshLibrary: () => Promise<void>;
newPack: (target?: TargetSpec | null) => void;
loadPack: (id: string) => Promise<void>;
duplicatePack: (id: string) => Promise<void>;
deletePack: (id: string) => Promise<void>;
startOver: () => void;
setTitle: (title: string) => void;
setNotes: (notes: string) => void;
addItem: (item: PackItem) => void;
removeItem: (id: string) => void;
reorderItem: (kind: 'word' | 'contrast' | 'sentence', id: string, dir: -1 | 1) => void;
replaceWord: (id: string, newWord: string) => void;
renamePack: (id: string, title: string) => Promise<void>;
}
Harden commit (resolves the Phase-1 deferred silent-rejection risk):
function commit(next: TherapyPack) {
const stamped = { ...next, updatedAt: Date.now() };
const others = get().library.filter((s) => s.id !== stamped.id);
const library = [summarize(stamped), ...others].sort(
(a, b) => b.updatedAt - a.updatedAt,
);
set({ activePack: stamped, library, saveError: null });
void packDb.putPack(stamped).catch((err) => {
set({ saveError: err instanceof Error ? err.message : 'Failed to save pack' });
});
}
Add saveError: null, to the initial state block (next to library: [],) and add the methods (place near removeItem):
reorderItem(kind, id, dir) {
const p = get().activePack;
if (!p) return;
const key = kind === 'word' ? 'words' : kind === 'contrast' ? 'contrasts' : 'sentences';
const list = [...(p[key] as PackItem[])];
const i = list.findIndex((it) => it.id === id);
const j = i + dir;
if (i === -1 || j < 0 || j >= list.length) return;
[list[i], list[j]] = [list[j], list[i]];
commit({ ...p, [key]: list });
},
replaceWord(id, newWord) {
const p = get().activePack;
if (!p) return;
const words = p.words.map((w) =>
w.id === id
? {
...w,
word: newWord,
ipa: undefined,
hasImage: undefined,
provenance: { ...w.provenance, sourceTool: 'manual' as const, reason: 'replaced' },
}
: w,
);
commit({ ...p, words });
},
async renamePack(id, title) {
const active = get().activePack;
if (active?.id === id) {
commit({ ...active, title });
return;
}
const raw = await packDb.getPack(id);
if (!raw) return;
const renamed = { ...migratePack(raw), title, updatedAt: Date.now() };
await packDb.putPack(renamed);
await get().refreshLibrary();
},
- [ ] Step 4: Run the tests to verify they pass
Run: npm test -- --run src/store/packStore.test.ts
Expected: PASS (all existing + 4 new).
- [ ] 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): store reorder/replaceWord/renamePack + save-error resilience (PHON-170)"
Task 2: PackSection + PackItemCard (item rows: remove, reorder, replace)¶
Files:
- Create: src/components/pack/PackSection.tsx
- Create: src/components/pack/PackItemCard.tsx
- Test: src/components/pack/PackItemCard.test.tsx
Interfaces:
- Consumes: PackItem, PackWord, PackContrast, PackSentence from ../../types/therapyPack.
- Produces:
- PackSection props: { title: string; count: number; children: React.ReactNode; action?: React.ReactNode; emptyHint?: string } — renders a titled block; when count === 0 shows emptyHint in muted text; renders action (e.g. a manual-add field) at the bottom.
- PackItemCard props: { item: PackItem; index: number; total: number; onRemove: (id: string) => void; onMoveUp: (id: string) => void; onMoveDown: (id: string) => void; onReplace?: (id: string) => void } — one MUI row. Move-up disabled at index === 0; move-down disabled at index === total - 1. Replace button (⟳) rendered only when onReplace is provided AND item.kind === 'word'.
- [ ] Step 1: Write the failing test
Create src/components/pack/PackItemCard.test.tsx:
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import PackItemCard from './PackItemCard';
import type { PackWord } from '../../types/therapyPack';
const word: PackWord = {
kind: 'word', id: 'w1', word: 'rocket', ipa: 'ɹ ɑ k ə t',
provenance: { sourceTool: 'manual', addedAt: 1 },
};
describe('PackItemCard', () => {
it('renders the word and fires remove', () => {
const onRemove = vi.fn();
render(
<PackItemCard item={word} index={0} total={2}
onRemove={onRemove} onMoveUp={vi.fn()} onMoveDown={vi.fn()} onReplace={vi.fn()} />,
);
expect(screen.getByText('rocket')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /remove rocket/i }));
expect(onRemove).toHaveBeenCalledWith('w1');
});
it('disables move-up at the top and move-down at the bottom', () => {
const { rerender } = render(
<PackItemCard item={word} index={0} total={2}
onRemove={vi.fn()} onMoveUp={vi.fn()} onMoveDown={vi.fn()} />,
);
expect(screen.getByRole('button', { name: /move rocket up/i })).toBeDisabled();
expect(screen.getByRole('button', { name: /move rocket down/i })).toBeEnabled();
rerender(
<PackItemCard item={word} index={1} total={2}
onRemove={vi.fn()} onMoveUp={vi.fn()} onMoveDown={vi.fn()} />,
);
expect(screen.getByRole('button', { name: /move rocket up/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /move rocket down/i })).toBeDisabled();
});
it('shows the replace button only for words with onReplace', () => {
const onReplace = vi.fn();
render(
<PackItemCard item={word} index={0} total={1}
onRemove={vi.fn()} onMoveUp={vi.fn()} onMoveDown={vi.fn()} onReplace={onReplace} />,
);
fireEvent.click(screen.getByRole('button', { name: /replace rocket/i }));
expect(onReplace).toHaveBeenCalledWith('w1');
});
});
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- --run src/components/pack/PackItemCard.test.tsx
Expected: FAIL — module not found.
- [ ] Step 3: Implement the components
Create src/components/pack/PackSection.tsx:
import React from 'react';
import { Box, Typography } from '@mui/material';
interface PackSectionProps {
title: string;
count: number;
children: React.ReactNode;
action?: React.ReactNode;
emptyHint?: string;
}
const PackSection: React.FC<PackSectionProps> = ({ title, count, children, action, emptyHint }) => (
<Box component="section" sx={{ mb: 3 }}>
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 1 }}>
{title}{' '}
<Typography component="span" variant="body2" color="text.secondary">
({count})
</Typography>
</Typography>
{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;
Create src/components/pack/PackItemCard.tsx:
import React from 'react';
import { Box, IconButton, Paper, Tooltip, Typography } from '@mui/material';
import {
ArrowUpward as UpIcon,
ArrowDownward as DownIcon,
Autorenew as ReplaceIcon,
Close as RemoveIcon,
} from '@mui/icons-material';
import type { PackItem } from '../../types/therapyPack';
interface PackItemCardProps {
item: PackItem;
index: number;
total: number;
onRemove: (id: string) => void;
onMoveUp: (id: string) => void;
onMoveDown: (id: string) => void;
onReplace?: (id: string) => void;
}
function primaryLabel(item: PackItem): string {
if (item.kind === 'word') return item.word;
if (item.kind === 'contrast') return item.label ?? `${item.a} vs ${item.b}`;
return item.text;
}
function secondaryLabel(item: PackItem): string | undefined {
if (item.kind === 'word') return item.ipa;
if (item.kind === 'contrast') return `${item.a} · ${item.b}`;
return undefined;
}
const PackItemCard: React.FC<PackItemCardProps> = ({
item, index, total, onRemove, onMoveUp, onMoveDown, onReplace,
}) => {
const label = primaryLabel(item);
const secondary = secondaryLabel(item);
return (
<Paper
variant="outlined"
sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.75, mb: 0.75 }}
>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Typography variant="body1" noWrap>{label}</Typography>
{secondary && (
<Typography variant="caption" color="text.secondary" noWrap component="div">
{secondary}
</Typography>
)}
</Box>
<Tooltip title="Move up">
<span>
<IconButton size="small" aria-label={`Move ${label} up`}
disabled={index === 0} onClick={() => onMoveUp(item.id)}>
<UpIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Move down">
<span>
<IconButton size="small" aria-label={`Move ${label} down`}
disabled={index === total - 1} onClick={() => onMoveDown(item.id)}>
<DownIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
{onReplace && item.kind === 'word' && (
<Tooltip title="Replace">
<IconButton size="small" aria-label={`Replace ${label}`} onClick={() => onReplace(item.id)}>
<ReplaceIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
<Tooltip title="Remove">
<IconButton size="small" aria-label={`Remove ${label}`} onClick={() => onRemove(item.id)}>
<RemoveIcon fontSize="small" />
</IconButton>
</Tooltip>
</Paper>
);
};
export default PackItemCard;
- [ ] Step 4: Run the test to verify it passes
Run: npm test -- --run src/components/pack/PackItemCard.test.tsx
Expected: PASS.
- [ ] Step 5: Commit
git add src/components/pack/PackSection.tsx src/components/pack/PackItemCard.tsx src/components/pack/PackItemCard.test.tsx
git commit -m "feat(packs): PackSection + PackItemCard row (remove/reorder/replace) (PHON-170)"
Task 3: ManualAddField + ReplaceWordDialog¶
Files:
- Create: src/components/pack/ManualAddField.tsx
- Create: src/components/pack/ReplaceWordDialog.tsx
- Test: src/components/pack/ManualAddField.test.tsx
Interfaces:
- Consumes: none beyond MUI.
- Produces:
- ManualAddField props: { onAdd: (word: string) => void; placeholder?: string } — a compact text field + "Add" button; submits on Enter or button click; trims, ignores empty, clears after add.
- ReplaceWordDialog props: { open: boolean; currentWord: string; onClose: () => void; onConfirm: (newWord: string) => void } — a dialog seeded with currentWord; "Replace" calls onConfirm(trimmed) then closes; disabled when empty or unchanged. (Qwensim suggestions deferred to Phase 5 — a comment records this.)
- [ ] Step 1: Write the failing test
Create src/components/pack/ManualAddField.test.tsx:
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import ManualAddField from './ManualAddField';
describe('ManualAddField', () => {
it('adds a trimmed word and clears the field', () => {
const onAdd = vi.fn();
render(<ManualAddField onAdd={onAdd} />);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: ' rocket ' } });
fireEvent.click(screen.getByRole('button', { name: /add/i }));
expect(onAdd).toHaveBeenCalledWith('rocket');
expect((input as HTMLInputElement).value).toBe('');
});
it('ignores an empty submit', () => {
const onAdd = vi.fn();
render(<ManualAddField onAdd={onAdd} />);
fireEvent.click(screen.getByRole('button', { name: /add/i }));
expect(onAdd).not.toHaveBeenCalled();
});
});
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- --run src/components/pack/ManualAddField.test.tsx
Expected: FAIL — module not found.
- [ ] Step 3: Implement the components
Create src/components/pack/ManualAddField.tsx:
import React, { useState } from 'react';
import { Box, Button, TextField } from '@mui/material';
import { Add as AddIcon } from '@mui/icons-material';
interface ManualAddFieldProps {
onAdd: (word: string) => void;
placeholder?: string;
}
const ManualAddField: React.FC<ManualAddFieldProps> = ({ onAdd, placeholder = 'Add a word…' }) => {
const [value, setValue] = useState('');
const submit = () => {
const trimmed = value.trim();
if (!trimmed) return;
onAdd(trimmed);
setValue('');
};
return (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
size="small"
value={value}
placeholder={placeholder}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); submit(); } }}
sx={{ maxWidth: 240 }}
/>
<Button size="small" variant="outlined" startIcon={<AddIcon />} onClick={submit}>
Add
</Button>
</Box>
);
};
export default ManualAddField;
Create src/components/pack/ReplaceWordDialog.tsx:
import React, { useEffect, useState } from 'react';
import {
Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField,
} from '@mui/material';
interface ReplaceWordDialogProps {
open: boolean;
currentWord: string;
onClose: () => void;
onConfirm: (newWord: string) => void;
}
// Qwensim word-suggestion picker (spec §5.5, /api/associations/*) is deferred
// to Phase 5 (tools integration). This shell offers a plain type-to-replace.
const ReplaceWordDialog: React.FC<ReplaceWordDialogProps> = ({
open, currentWord, onClose, onConfirm,
}) => {
const [value, setValue] = useState(currentWord);
useEffect(() => { if (open) setValue(currentWord); }, [open, currentWord]);
const trimmed = value.trim();
const disabled = !trimmed || trimmed === currentWord;
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>Replace “{currentWord}”</DialogTitle>
<DialogContent>
<TextField
autoFocus fullWidth margin="dense" label="New word"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && !disabled) { e.preventDefault(); onConfirm(trimmed); } }}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" disabled={disabled} onClick={() => onConfirm(trimmed)}>
Replace
</Button>
</DialogActions>
</Dialog>
);
};
export default ReplaceWordDialog;
- [ ] Step 4: Run the test to verify it passes
Run: npm test -- --run src/components/pack/ManualAddField.test.tsx
Expected: PASS.
- [ ] Step 5: Commit
git add src/components/pack/ManualAddField.tsx src/components/pack/ReplaceWordDialog.tsx src/components/pack/ManualAddField.test.tsx
git commit -m "feat(packs): ManualAddField + ReplaceWordDialog (PHON-170)"
Task 4: MyPacksDialog (library: open / rename / duplicate / delete / new)¶
Files:
- Create: src/components/pack/MyPacksDialog.tsx
- Test: src/components/pack/MyPacksDialog.test.tsx
Interfaces:
- Consumes: usePackStore (library, hydrate, loadPack, duplicatePack, deletePack, renamePack, newPack), PackSummary.
- Produces: MyPacksDialog props: { open: boolean; onClose: () => void; onOpenPack?: () => void }. On open it calls hydrate(). Renders each PackSummary with title, counts, updated date, and per-row actions Open / Rename (inline TextField) / Duplicate / Delete. "New pack" button calls newPack() then onOpenPack?.(). Opening a pack calls loadPack(id) then onOpenPack?.().
- [ ] Step 1: Write the failing test
Create src/components/pack/MyPacksDialog.test.tsx:
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import MyPacksDialog from './MyPacksDialog';
import { usePackStore } from '../../store/packStore';
import { getAllPacks, deletePack } from '../../lib/packDb';
async function clearDb() {
const all = await getAllPacks();
await Promise.all(all.map((p) => deletePack(p.id)));
}
describe('MyPacksDialog', () => {
beforeEach(async () => {
await clearDb();
usePackStore.setState({ activePack: null, library: [], saveError: null });
});
it('lists saved packs after hydrate', async () => {
usePackStore.getState().newPack();
usePackStore.getState().setTitle('Initial /r/');
usePackStore.setState({ activePack: null });
render(<MyPacksDialog open onClose={vi.fn()} />);
await waitFor(() => expect(screen.getByText('Initial /r/')).toBeInTheDocument());
});
it('opens a pack and calls onOpenPack', async () => {
usePackStore.getState().newPack();
usePackStore.getState().setTitle('Open me');
const id = usePackStore.getState().activePack!.id;
usePackStore.setState({ activePack: null });
const onOpenPack = vi.fn();
render(<MyPacksDialog open onClose={vi.fn()} onOpenPack={onOpenPack} />);
await waitFor(() => expect(screen.getByText('Open me')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /open Open me/i }));
await waitFor(() => expect(onOpenPack).toHaveBeenCalled());
expect(usePackStore.getState().activePack!.id).toBe(id);
});
it('deletes a pack', async () => {
usePackStore.getState().newPack();
usePackStore.getState().setTitle('Delete me');
usePackStore.setState({ activePack: null });
render(<MyPacksDialog open onClose={vi.fn()} />);
await waitFor(() => expect(screen.getByText('Delete me')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /delete Delete me/i }));
await waitFor(() => expect(screen.queryByText('Delete me')).not.toBeInTheDocument());
});
});
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- --run src/components/pack/MyPacksDialog.test.tsx
Expected: FAIL — module not found.
- [ ] Step 3: Implement the component
Create src/components/pack/MyPacksDialog.tsx:
import React, { useEffect, useState } from 'react';
import {
Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
IconButton, List, ListItem, Stack, TextField, Tooltip, Typography,
} from '@mui/material';
import {
Add as AddIcon, ContentCopy as DuplicateIcon,
Delete as DeleteIcon, Edit as RenameIcon, FolderOpen as OpenIcon,
} from '@mui/icons-material';
import { usePackStore } from '../../store/packStore';
interface MyPacksDialogProps {
open: boolean;
onClose: () => void;
onOpenPack?: () => void;
}
const MyPacksDialog: React.FC<MyPacksDialogProps> = ({ open, onClose, onOpenPack }) => {
const library = usePackStore((s) => s.library);
const { hydrate, loadPack, duplicatePack, deletePack, renamePack, newPack } = usePackStore.getState();
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
useEffect(() => { if (open) void hydrate(); }, [open, hydrate]);
const handleOpen = async (id: string) => {
await loadPack(id);
onOpenPack?.();
};
const handleNew = () => {
newPack();
onOpenPack?.();
};
const commitRename = async (id: string) => {
const t = renameValue.trim();
if (t) await renamePack(id, t);
setRenamingId(null);
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>My Packs</DialogTitle>
<DialogContent>
{library.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No packs yet. Create one to get started.
</Typography>
) : (
<List disablePadding>
{library.map((s) => (
<ListItem key={s.id} divider sx={{ px: 0 }}
secondaryAction={
<Stack direction="row" spacing={0.5}>
<Tooltip title="Open">
<IconButton edge="end" aria-label={`Open ${s.title}`} onClick={() => handleOpen(s.id)}>
<OpenIcon />
</IconButton>
</Tooltip>
<Tooltip title="Rename">
<IconButton aria-label={`Rename ${s.title}`}
onClick={() => { setRenamingId(s.id); setRenameValue(s.title); }}>
<RenameIcon />
</IconButton>
</Tooltip>
<Tooltip title="Duplicate">
<IconButton aria-label={`Duplicate ${s.title}`} onClick={() => duplicatePack(s.id)}>
<DuplicateIcon />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton aria-label={`Delete ${s.title}`} onClick={() => deletePack(s.id)}>
<DeleteIcon />
</IconButton>
</Tooltip>
</Stack>
}
>
<Box sx={{ minWidth: 0, pr: 2 }}>
{renamingId === s.id ? (
<TextField
size="small" autoFocus value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => commitRename(s.id)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitRename(s.id); } }}
/>
) : (
<Typography variant="body1" noWrap>{s.title}</Typography>
)}
<Typography variant="caption" color="text.secondary">
{s.counts.words} words · {s.counts.contrasts} contrasts · {s.counts.sentences} sentences
</Typography>
</Box>
</ListItem>
))}
</List>
)}
</DialogContent>
<DialogActions>
<Button startIcon={<AddIcon />} onClick={handleNew}>New pack</Button>
<Box sx={{ flexGrow: 1 }} />
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
);
};
export default MyPacksDialog;
- [ ] Step 4: Run the test to verify it passes
Run: npm test -- --run src/components/pack/MyPacksDialog.test.tsx
Expected: PASS.
- [ ] Step 5: Commit
git add src/components/pack/MyPacksDialog.tsx src/components/pack/MyPacksDialog.test.tsx
git commit -m "feat(packs): MyPacksDialog library (open/rename/duplicate/delete/new) (PHON-170)"
Task 5: PackHeader (title, target chips, counts, autosave state, actions)¶
Files:
- Create: src/components/pack/PackHeader.tsx
- Test: src/components/pack/PackHeader.test.tsx
Interfaces:
- Consumes: TherapyPack, TargetSpec from ../../types/therapyPack.
- Produces: PackHeader props:
{
pack: TherapyPack;
saveError: string | null;
onTitleChange: (title: string) => void;
onOpenMyPacks: () => void;
onNewPack: () => void;
onStartOver: () => void;
onDuplicate: () => void;
}
pack.target (mode + phones + position when present), counts line, and a save-state line ("Saved locally" or the saveError). Action buttons: My Packs, New, Start over, Duplicate. Preview and Export buttons render disabled with title="Coming soon" (wired in Phase 2).
- [ ] Step 1: Write the failing test
Create src/components/pack/PackHeader.test.tsx:
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import PackHeader from './PackHeader';
import { createEmptyPack } from '../../types/therapyPack';
function makePack() {
const p = createEmptyPack('p1', 1);
p.title = 'Initial /r/';
p.target = { mode: 'sound', phones: ['ɹ'], position: 'initial' };
return p;
}
const noop = () => {};
describe('PackHeader', () => {
it('shows the title, a target chip, and the saved state', () => {
render(
<PackHeader pack={makePack()} saveError={null}
onTitleChange={noop} onOpenMyPacks={noop} onNewPack={noop}
onStartOver={noop} onDuplicate={noop} />,
);
expect(screen.getByDisplayValue('Initial /r/')).toBeInTheDocument();
expect(screen.getByText(/ɹ/)).toBeInTheDocument();
expect(screen.getByText(/saved locally/i)).toBeInTheDocument();
});
it('fires onTitleChange on edit', () => {
const onTitleChange = vi.fn();
render(
<PackHeader pack={makePack()} saveError={null}
onTitleChange={onTitleChange} onOpenMyPacks={noop} onNewPack={noop}
onStartOver={noop} onDuplicate={noop} />,
);
fireEvent.change(screen.getByDisplayValue('Initial /r/'), { target: { value: 'R blends' } });
expect(onTitleChange).toHaveBeenCalledWith('R blends');
});
it('shows a save error when present', () => {
render(
<PackHeader pack={makePack()} saveError="disk full"
onTitleChange={noop} onOpenMyPacks={noop} onNewPack={noop}
onStartOver={noop} onDuplicate={noop} />,
);
expect(screen.getByText(/disk full/i)).toBeInTheDocument();
});
it('disables Preview and Export (Phase 2)', () => {
render(
<PackHeader pack={makePack()} saveError={null}
onTitleChange={noop} onOpenMyPacks={noop} onNewPack={noop}
onStartOver={noop} onDuplicate={noop} />,
);
expect(screen.getByRole('button', { name: /export/i })).toBeDisabled();
});
});
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- --run src/components/pack/PackHeader.test.tsx
Expected: FAIL — module not found.
- [ ] Step 3: Implement the component
Create src/components/pack/PackHeader.tsx:
import React from 'react';
import { Box, Button, Chip, Stack, TextField, Tooltip, Typography } from '@mui/material';
import type { TargetSpec, TherapyPack } from '../../types/therapyPack';
interface PackHeaderProps {
pack: TherapyPack;
saveError: string | null;
onTitleChange: (title: string) => void;
onOpenMyPacks: () => void;
onNewPack: () => void;
onStartOver: () => void;
onDuplicate: () => void;
}
function targetChips(target: TargetSpec | null): string[] {
if (!target) return [];
const chips = target.phones.map((p) => `/${p}/`);
if (target.position && target.position !== 'any') chips.push(target.position);
if (target.mode === 'contrast') chips.unshift('contrast');
if (target.mode === 'process' && target.processPreset) chips.unshift(target.processPreset);
return chips;
}
const PackHeader: React.FC<PackHeaderProps> = ({
pack, saveError, onTitleChange, onOpenMyPacks, onNewPack, onStartOver, onDuplicate,
}) => {
const chips = targetChips(pack.target);
const total = pack.words.length + pack.contrasts.length + pack.sentences.length;
return (
<Box sx={{ mb: 2 }}>
<TextField
variant="standard"
value={pack.title}
onChange={(e) => onTitleChange(e.target.value)}
inputProps={{ 'aria-label': 'Pack title', style: { fontSize: '1.5rem', fontWeight: 600 } }}
sx={{ mb: 1, maxWidth: 480 }}
/>
{chips.length > 0 && (
<Stack direction="row" spacing={0.5} sx={{ mb: 1, flexWrap: 'wrap', gap: 0.5 }}>
{chips.map((c) => <Chip key={c} label={c} size="small" color="primary" variant="outlined" />)}
</Stack>
)}
<Typography variant="body2" color="text.secondary">
{pack.words.length} words · {pack.contrasts.length} contrasts · {pack.sentences.length} sentences
{total === 0 && ' — add items below'}
</Typography>
<Typography variant="caption" color={saveError ? 'error' : 'text.secondary'}>
{saveError ? `Save failed: ${saveError}` : 'Saved locally in your browser'}
</Typography>
<Stack direction="row" spacing={1} sx={{ mt: 1.5, flexWrap: 'wrap', gap: 1 }}>
<Button size="small" variant="outlined" onClick={onOpenMyPacks}>My Packs</Button>
<Button size="small" variant="outlined" onClick={onNewPack}>New</Button>
<Button size="small" variant="outlined" onClick={onStartOver}>Start over</Button>
<Button size="small" variant="outlined" onClick={onDuplicate}>Duplicate</Button>
<Tooltip title="Coming soon">
<span><Button size="small" variant="outlined" disabled>Preview</Button></span>
</Tooltip>
<Tooltip title="Coming soon">
<span><Button size="small" variant="contained" disabled>Export</Button></span>
</Tooltip>
</Stack>
</Box>
);
};
export default PackHeader;
- [ ] Step 4: Run the test to verify it passes
Run: npm test -- --run src/components/pack/PackHeader.test.tsx
Expected: PASS.
- [ ] Step 5: Commit
git add src/components/pack/PackHeader.tsx src/components/pack/PackHeader.test.tsx
git commit -m "feat(packs): PackHeader (title/target/counts/autosave/actions) (PHON-170)"
Task 6: PackEditor container (assemble + wire the store)¶
Files:
- Create: src/components/pack/PackEditor.tsx
- Test: src/components/pack/PackEditor.test.tsx
Interfaces:
- Consumes: usePackStore (full API incl. Task 1 additions), PackHeader, PackSection, PackItemCard, ManualAddField, ReplaceWordDialog, MyPacksDialog, newId (../../lib/id), track (../../lib/analytics), createEmptyPack.
- Produces: PackEditor props: { onOpenTool?: (id: string) => void } (used later for "+ Add from [tool]"; unused affordances render but are inert this phase). Renders the empty-state prompt when activePack === null (a "New pack" button → newPack()), otherwise the header + four sections. Wires: title→setTitle, notes→setNotes, word add→addItem (manual provenance), remove→removeItem, reorder→reorderItem, replace→replaceWord (via ReplaceWordDialog). Emits track('pack_edited') on any content mutation.
- [ ] Step 1: Write the failing test
Create src/components/pack/PackEditor.test.tsx:
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import PackEditor from './PackEditor';
import { usePackStore } from '../../store/packStore';
import { getAllPacks, deletePack } from '../../lib/packDb';
vi.mock('../../lib/analytics', () => ({ track: vi.fn() }));
async function clearDb() {
const all = await getAllPacks();
await Promise.all(all.map((p) => deletePack(p.id)));
}
describe('PackEditor', () => {
beforeEach(async () => {
await clearDb();
usePackStore.setState({ activePack: null, library: [], saveError: null });
});
it('shows the empty state and creates a pack', async () => {
render(<PackEditor />);
fireEvent.click(screen.getByRole('button', { name: /new pack/i }));
await waitFor(() => expect(screen.getByLabelText('Pack title')).toBeInTheDocument());
});
it('adds a word manually and removes it', async () => {
usePackStore.getState().newPack();
render(<PackEditor />);
const input = screen.getByPlaceholderText(/add a word/i);
fireEvent.change(input, { target: { value: 'rocket' } });
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
await waitFor(() => expect(screen.getByText('rocket')).toBeInTheDocument());
expect(usePackStore.getState().activePack!.words).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: /remove rocket/i }));
await waitFor(() => expect(screen.queryByText('rocket')).not.toBeInTheDocument());
});
it('replaces a word through the dialog', async () => {
usePackStore.getState().newPack();
render(<PackEditor />);
const input = screen.getByPlaceholderText(/add a word/i);
fireEvent.change(input, { target: { value: 'rocket' } });
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
await waitFor(() => expect(screen.getByText('rocket')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /replace rocket/i }));
const dialogInput = await screen.findByLabelText('New word');
fireEvent.change(dialogInput, { target: { value: 'rabbit' } });
fireEvent.click(screen.getByRole('button', { name: /^replace$/i }));
await waitFor(() => expect(screen.getByText('rabbit')).toBeInTheDocument());
expect(usePackStore.getState().activePack!.words[0].word).toBe('rabbit');
});
});
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- --run src/components/pack/PackEditor.test.tsx
Expected: FAIL — module not found.
- [ ] Step 3: Implement the container
Create src/components/pack/PackEditor.tsx:
import React, { useState } from 'react';
import { Box, Button, TextField, Typography } from '@mui/material';
import { usePackStore } from '../../store/packStore';
import { newId } from '../../lib/id';
import { track } from '../../lib/analytics';
import type { PackWord } from '../../types/therapyPack';
import PackHeader from './PackHeader';
import PackSection from './PackSection';
import PackItemCard from './PackItemCard';
import ManualAddField from './ManualAddField';
import ReplaceWordDialog from './ReplaceWordDialog';
import MyPacksDialog from './MyPacksDialog';
interface PackEditorProps {
onOpenTool?: (id: string) => void;
}
const PackEditor: React.FC<PackEditorProps> = () => {
const activePack = usePackStore((s) => s.activePack);
const saveError = usePackStore((s) => s.saveError);
const {
newPack, setTitle, setNotes, addItem, removeItem, reorderItem,
replaceWord, startOver, duplicatePack,
} = usePackStore.getState();
const [myPacksOpen, setMyPacksOpen] = useState(false);
const [replaceId, setReplaceId] = useState<string | null>(null);
if (!activePack) {
return (
<Box sx={{ maxWidth: 640, mx: 'auto', textAlign: 'center', py: 6 }}>
<Typography variant="h6" gutterBottom>No pack open</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Start a new therapy pack or open a saved one from My Packs.
</Typography>
<Button variant="contained" onClick={() => newPack()}>New pack</Button>
<Button sx={{ ml: 1 }} onClick={() => setMyPacksOpen(true)}>My Packs</Button>
<MyPacksDialog open={myPacksOpen} onClose={() => setMyPacksOpen(false)}
onOpenPack={() => setMyPacksOpen(false)} />
</Box>
);
}
const edit = (fn: () => void) => { fn(); track('pack_edited'); };
const replacingWord = activePack.words.find((w) => w.id === replaceId);
return (
<Box sx={{ maxWidth: 900, mx: 'auto' }}>
<PackHeader
pack={activePack}
saveError={saveError}
onTitleChange={setTitle}
onOpenMyPacks={() => setMyPacksOpen(true)}
onNewPack={() => newPack()}
onStartOver={() => { if (window.confirm('Clear the current pack? Saved packs are kept.')) startOver(); }}
onDuplicate={() => duplicatePack(activePack.id)}
/>
<PackSection
title="Target words"
count={activePack.words.length}
emptyHint="No words yet. Add words below (seeding from tools arrives soon)."
action={<ManualAddField onAdd={(word) => edit(() => addItem({
kind: 'word', id: newId(), word,
provenance: { sourceTool: 'manual', addedAt: Date.now() },
} as PackWord))} />}
>
{activePack.words.map((w, i) => (
<PackItemCard key={w.id} item={w} index={i} total={activePack.words.length}
onRemove={(id) => edit(() => removeItem(id))}
onMoveUp={(id) => edit(() => reorderItem('word', id, -1))}
onMoveDown={(id) => edit(() => reorderItem('word', id, 1))}
onReplace={(id) => setReplaceId(id)} />
))}
</PackSection>
<PackSection title="Contrast pairs" count={activePack.contrasts.length}
emptyHint="No contrast pairs yet.">
{activePack.contrasts.map((c, i) => (
<PackItemCard key={c.id} item={c} index={i} total={activePack.contrasts.length}
onRemove={(id) => edit(() => removeItem(id))}
onMoveUp={(id) => edit(() => reorderItem('contrast', id, -1))}
onMoveDown={(id) => edit(() => reorderItem('contrast', id, 1))} />
))}
</PackSection>
<PackSection title="Sentences" count={activePack.sentences.length}
emptyHint="No sentences yet.">
{activePack.sentences.map((s, i) => (
<PackItemCard key={s.id} item={s} index={i} total={activePack.sentences.length}
onRemove={(id) => edit(() => removeItem(id))}
onMoveUp={(id) => edit(() => reorderItem('sentence', id, -1))}
onMoveDown={(id) => edit(() => reorderItem('sentence', id, 1))} />
))}
</PackSection>
<PackSection title="Clinician notes" count={activePack.notes ? 1 : 0}>
<TextField
fullWidth multiline minRows={3} placeholder="Notes print on the pack. Do not include patient identifiers."
value={activePack.notes}
onChange={(e) => edit(() => setNotes(e.target.value))}
/>
</PackSection>
<ReplaceWordDialog
open={replaceId !== null}
currentWord={replacingWord?.word ?? ''}
onClose={() => setReplaceId(null)}
onConfirm={(newWord) => { if (replaceId) edit(() => replaceWord(replaceId, newWord)); setReplaceId(null); }}
/>
<MyPacksDialog open={myPacksOpen} onClose={() => setMyPacksOpen(false)}
onOpenPack={() => setMyPacksOpen(false)} />
</Box>
);
};
export default PackEditor;
- [ ] Step 4: Run the test to verify it passes
Run: npm test -- --run src/components/pack/PackEditor.test.tsx
Expected: PASS.
- [ ] Step 5: Commit
git add src/components/pack/PackEditor.tsx src/components/pack/PackEditor.test.tsx
git commit -m "feat(packs): PackEditor container wiring the store to the sectioned workspace (PHON-170)"
Task 7: Wire the Editor into the app shell + pack indicator chip¶
Files:
- Create: src/components/pack/PackIndicatorChip.tsx
- Test: src/components/pack/PackIndicatorChip.test.tsx
- Modify: src/App_new.tsx
Interfaces:
- Consumes: usePackStore (activePack, hydrate), PackEditor.
- Produces:
- PackIndicatorChip props: { onOpen: () => void } — renders a MUI Chip only when activePack !== null, labeled with the pack title + item count; clicking calls onOpen. Renders nothing when no active pack.
- In App_new.tsx: a new primary destination activeTool === 'editor' rendering <PackEditor onOpenTool={handleToolSelect} /> in place of a tool component; usePackStore.getState().hydrate() called once on mount; PackIndicatorChip rendered in the shell (passed onOpen={() => setActiveTool('editor')}).
- [ ] Step 1: Write the failing test
Create src/components/pack/PackIndicatorChip.test.tsx:
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import PackIndicatorChip from './PackIndicatorChip';
import { usePackStore } from '../../store/packStore';
describe('PackIndicatorChip', () => {
beforeEach(() => usePackStore.setState({ activePack: null, library: [], saveError: null }));
it('renders nothing with no active pack', () => {
const { container } = render(<PackIndicatorChip onOpen={vi.fn()} />);
expect(container).toBeEmptyDOMElement();
});
it('shows the title and fires onOpen when a pack is active', () => {
usePackStore.getState().newPack();
usePackStore.getState().setTitle('Initial /r/');
const onOpen = vi.fn();
render(<PackIndicatorChip onOpen={onOpen} />);
fireEvent.click(screen.getByRole('button', { name: /initial \/r\// i }));
expect(onOpen).toHaveBeenCalled();
});
});
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- --run src/components/pack/PackIndicatorChip.test.tsx
Expected: FAIL — module not found.
- [ ] Step 3: Implement the chip
Create src/components/pack/PackIndicatorChip.tsx:
import React from 'react';
import { Chip } from '@mui/material';
import { Inventory2 as PackIcon } from '@mui/icons-material';
import { usePackStore } from '../../store/packStore';
interface PackIndicatorChipProps {
onOpen: () => void;
}
const PackIndicatorChip: React.FC<PackIndicatorChipProps> = ({ onOpen }) => {
const activePack = usePackStore((s) => s.activePack);
if (!activePack) return null;
const count = activePack.words.length + activePack.contrasts.length + activePack.sentences.length;
return (
<Chip
icon={<PackIcon />}
label={`${activePack.title} · ${count}`}
color="primary"
variant="outlined"
onClick={onOpen}
sx={{ maxWidth: 260 }}
/>
);
};
export default PackIndicatorChip;
- [ ] Step 4: Run the chip test to verify it passes
Run: npm test -- --run src/components/pack/PackIndicatorChip.test.tsx
Expected: PASS.
- [ ] Step 5: Wire into
App_new.tsx
Make these edits to src/App_new.tsx:
- Add imports near the other component imports (after
import Builder …):
import PackEditor from './components/pack/PackEditor';
import PackIndicatorChip from './components/pack/PackIndicatorChip';
import { usePackStore } from './store/packStore';
- Inside the
Appcomponent, after the existingtrack('app_loaded')effect, hydrate the library once:
// Hydrate the "My Packs" library from IndexedDB on mount (7.0.0 pack MVP)
React.useEffect(() => {
void usePackStore.getState().hydrate();
}, []);
- In the main content area, replace the tool/welcome conditional so
'editor'is a first-class destination. Change the block currently reading:
{activeToolDef ? (
<Box sx={{ maxWidth: 1200, mx: 'auto' }}>
...
{activeToolDef.component}
</Box>
) : (
<WelcomeView onSelectTool={handleToolSelect} />
)}
to:
{activeTool === 'editor' ? (
<PackEditor onOpenTool={handleToolSelect} />
) : activeToolDef ? (
<Box sx={{ maxWidth: 1200, mx: 'auto' }}>
<Box sx={{ mb: 1.5 }}>
<Typography
variant="h6"
fontWeight={600}
sx={{ fontSize: { xs: '1rem', sm: '1.25rem' } }}
>
{activeToolDef.title}
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 0.5, fontSize: { xs: '0.8125rem', sm: '0.875rem' } }}
>
{activeToolDef.description}
</Typography>
</Box>
{activeToolDef.component}
</Box>
) : (
<WelcomeView onSelectTool={handleToolSelect} />
)}
- Render the pack indicator so a clinician can jump back into the editor. Place it at the top of the main content
Box(just inside<Box sx={{ flexGrow: 1, p: … }}>, before the conditional):
{activeTool !== 'editor' && (
<Box sx={{ maxWidth: 1200, mx: 'auto', mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
<PackIndicatorChip onOpen={() => { setActiveTool('editor'); setSidebarOpen(false); }} />
</Box>
)}
- [ ] Step 6: Verify the full suite, types, lint, and build
Run (from packages/web/frontend/):
npm test -- --run
npm run type-check
npm run lint
npm run build
WelcomeView still renders the tool grid; the outcome-first front page replaces it in Phase 3.)
- [ ] Step 7: Commit
git add src/components/pack/PackIndicatorChip.tsx src/components/pack/PackIndicatorChip.test.tsx src/App_new.tsx
git commit -m "feat(packs): mount PackEditor as a primary surface + pack indicator chip (PHON-170)"
Self-Review notes (coverage vs spec §5.5 / §5.6 / §11.1)¶
- Sectioned workspace (Words/Contrasts/Sentences/Notes): Task 6 (
PackEditor+PackSection). ✓ - add/remove/replace/reorder: add =
ManualAddField(Task 3) +addItem; remove/reorder = Tasks 1–2, 6; replace =replaceWord+ReplaceWordDialog(Tasks 1, 3, 6). Qwensim suggestion picker explicitly deferred to Phase 5 (recorded inReplaceWordDialog). ✓ - autosave + save-state: store persists on every
commit;saveErrorsurfaced inPackHeader(Tasks 1, 5). ✓ - My Packs library (open/rename/duplicate/delete) + New / Start over:
MyPacksDialog(Task 4) +PackHeaderactions (Task 5) +PackEditorwiring (Task 6). ✓ - Header: editable title, target chips, counts, My Packs, New/Start over, Duplicate, Preview, Export: Task 5 (Preview/Export disabled → Phase 2). ✓
- "+ Add from [tool]" affordances:
onOpenToolprop threaded (Task 6) but section-level add-from-tool buttons are Phase 5; not required for the shell. Noted, not silently dropped. - Privacy copy (no patient identifiers): notes placeholder + save-state copy (Tasks 5, 6). ✓
- Editor as primary surface + pack indicator: Task 7. ✓
- Deferred Phase-1
.catch/saveError: resolved in Task 1. ✓ - Out of scope this phase (later plans): front page/resolver/seeding (Phase 3), export renderer + Preview/Export enablement (Phase 2), Qwensim replace suggestions + per-tool "Add to Editor" (Phase 5), IA "Advanced" grouping + 7.0.0 bump + full analytics taxonomy (Phase 6).