Skip to content

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 touch packDb directly. (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() from src/lib/analytics.tstrack(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), sx responsive objects { xs, sm, md }, matching existing components (Builder.tsx, App_new.tsx). Tool accent for the editor: reuse TOOL_COLORS palette; editor uses primary.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 imports fake-indexeddb/auto and 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) — add reorderItem, replaceWord, renamePack, and saveError state + .catch on 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; render PackIndicatorChip.

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;
}
Renders an inline-editable title (TextField), target chips derived from 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:

  1. 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';
  1. Inside the App component, after the existing track('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();
  }, []);
  1. 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} />
            )}
  1. 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
Expected: all pass; build succeeds. (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 in ReplaceWordDialog). ✓
  • autosave + save-state: store persists on every commit; saveError surfaced in PackHeader (Tasks 1, 5). ✓
  • My Packs library (open/rename/duplicate/delete) + New / Start over: MyPacksDialog (Task 4) + PackHeader actions (Task 5) + PackEditor wiring (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: onOpenTool prop 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).