Skip to content

PHON-223: Contrast Sets Reskinned onto the Shared Components 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: Bring Contrast Sets — the last tool sharing almost nothing — onto the shared surface: ToolSection for its field groups, the shared PositionPicker replacing three hand-written position Selects (whose option orders currently disagree), ScopeBar replacing the bespoke picture-card Switch, ActiveConstraintsBar showing the configured contrast, and one IPA-warning helper replacing six duplicated validation blocks.

Architecture: A reskin, not a backend migration. The mode picker, the maximal-mode Stepper, every /api/contrastive call, and all result rendering stay exactly as they are. ContrastiveInterventionTool keeps its local state (it has no Constraint[] model and does not gain one in this PR); the chips bar is derived from that state for display only.

Tech Stack: React + TS + MUI, vitest + @testing-library/react.

Spec: docs/superpowers/specs/2026-08-14-constraint-ui-unification-design.md §C Contrast Sets + §B.

Global Constraints

  • Branch: feat/phon-223-contrast-sets-reskin (created off develop at 3bcaa9fe). PR targets develop.
  • No backend change. Zero files under packages/web/workers/. If a worker change looks necessary, stop and report.
  • Request payloads must not change. Every /api/contrastive call keeps its current body/params for equivalent user input — this is a reskin. The three position Selects currently disagree on option ORDER but all emit the same four values; unifying the order must not change which value is sent for a given user choice, and any remains the default.
  • Verified during planning (binding): /api/contrastive supports has_image on its endpoints but has no lemmas_only / register-scope support, and hardcodes is_canonical = 1. Therefore ScopeBar shows the Has image slot ONLY — Base forms and Specialized are omitted, because a toggle the backend drops is worse than no toggle (the Sentences lesson, PHON-222). Extending scope to the pair queries is ticketed separately.
  • Word Lists and Sentences must be untouched and unaffected; shared-component changes are additive and optional; their suites pass unedited.
  • Test commands: cd packages/web/frontend && npx tsc --noEmit && npm run lint && npm test && npm run build; worker suite run once at the end to prove it is untouched.
  • Commits end with: Co-Authored-By: Claude Fable 5 noreply@anthropic.com

Task 1: ScopeToggle gains a per-slot tooltip override

Files: - Modify: packages/web/frontend/src/components/shared/ScopeBar.tsx - Test: packages/web/frontend/src/components/shared/ScopeBar.test.tsx

Why: Contrast Sets' picture-card rule has pair semantics — its current caption reads "Both words in a pair must have one", which is materially different from Word Lists' "Only words with a picture card". Shipping the generic tooltip there would state something false about how the filter behaves.

Interfaces: - ScopeToggle gains tooltip?: string — when present it replaces that slot's default tooltip copy; absent keeps today's text exactly. This is a per-slot detail, not a fourth slot: the three-slot cap (spec §B) is unchanged, and no existing call site changes.

  • [ ] Step 1: Failing test — a slot rendered with tooltip: 'Both words in a pair must have one' shows that text (follow the file's existing tooltip-assertion idiom: fireEvent.mouseOver + findByRole('tooltip')); slots without the field keep their current copy (assert one existing default verbatim so a regression is caught).
  • [ ] Step 2: Verify failure.
  • [ ] Step 3: Implement — thread the optional field through ScopeChip. Keep describeChild on the Tooltip (the established pattern).
  • [ ] Step 4: Run npx vitest run src/components/shared/ScopeBar.test.tsx, then the full frontend matrix. Word Lists' and Sentences' suites must pass unedited.
  • [ ] Step 5: Commitfeat(phon-223): per-slot tooltip override on ScopeBar

Task 2: Contrast Sets reskin

Files: - Modify: packages/web/frontend/src/components/tools/ContrastiveInterventionTool.tsx - Test: packages/web/frontend/src/components/tools/ContrastiveInterventionTool.test.tsx (create if absent; grep first)

What changes (and nothing else):

  1. Position pickers → shared PositionPicker. Three hand-written FormControl/InputLabel/Select blocks (around lines 561, 673, 787) are replaced by <PositionPicker value={position} onChange={setPosition} variant="select" />. This fixes the inconsistent option ordering (one block lists any first, another lists it last) — the canonical order becomes any/initial/medial/final everywhere. position state, its type, and every request body stay identical.
  2. Picture-card SwitchScopeBar. Replace the bespoke FormControlLabel/Switch (~line 805) with <ScopeBar hasImage={{ value: pictureOnly, onChange: setPictureOnly, tooltip: 'Both words in a pair must have one' }} … />. Only the Has image slot — omit baseForms and specialized (unsupported by /api/contrastive; see Global Constraints). NOTE: ScopeBarProps currently requires baseForms and specialized; make those optional in the same commit (their slots already render conditionally in spirit — verify and extend), keeping every existing call site valid and unedited. If making them optional would change how Word Lists/Sentences render, stop and report.
  3. Field groups → ToolSection. Wrap the mode-specific field group in a ToolSection (title per mode, e.g. "Minimal pair", "Maximal opposition", "Multiple opposition"), replacing the raw Paper. Keep the mode ToggleButtonGroup and the maximal-mode Stepper exactly where they are, outside the section.
  4. ActiveConstraintsBar showing the configured contrast, derived from local state for display: the mode, the phonemes entered (mode-appropriate), the position when not any, and Has image when on. Chips are display-only here (no onDelete) — Contrast Sets has no constraint store, and inventing per-chip deletion for local fields would be a new interaction, not a reskin. Say this in a code comment.
  5. Six duplicated IPA-warning blocks → getIpaTokenWarning. ipaWarning1/2/Sonorants/Obstruents/Substitute/Targets each re-implement the same validate-and-suggest logic inline. Replace the bodies with the shared helper (utils/ipaValidation.ts), keeping the six state variables and the same rendered Alerts — same warnings, same placement, one implementation.

Explicitly unchanged: mode picker, Stepper flow and step state, all five /api/contrastive calls and their bodies, ContrastiveGroupsTable and every results block, PhonemePickerDialog wiring and activeField plumbing, export/selection behavior.

  • [ ] Step 1: Write the tests first (RED). Grep for an existing test file; create one if absent, following Builder.test.tsx's harness idiom (mock api, wrap in PropertyMetadataProvider if the component needs it). Cases:
  • each mode renders its field group inside a section, and the mode picker still switches modes;
  • the position control offers exactly Any/Initial/Medial/Final in that order in every mode (the bug being fixed), and selecting Medial sends position: 'medial' in the request body for at least one mode;
  • Has image ON sends has_image: true; OFF omits it — assert on the actual api call body;
  • the chips bar reflects mode + entered phonemes + a non-any position + Has image;
  • an ASCII phoneme entry surfaces the shared IPA suggestion warning (use a token that really triggers one — check utils/ipaValidation.ts; bare g is valid IPA in this repo, ngŋ works);
  • a request-parity case: for one representative configuration per mode, the body sent matches what the pre-reskin component sent (hand-write the expected body from the current code).
  • [ ] Step 2: Verify failure.
  • [ ] Step 3: Implement the five changes; delete the code they replace (no dead helpers, no unused imports).
  • [ ] Step 4: full frontend matrix, plus a local smoke: run a minimal-pair query and a multiple-opposition query against the local worker, confirming results render and the request bodies are unchanged from today. Kill dev servers after.
  • [ ] Step 5: Commitfeat(phon-223): Contrast Sets reskinned onto the shared components

Task 3: Full matrix + push (PR after final review)

  • [ ] Step 1: worker tsc+tests (must be untouched and green — proves no backend change); frontend tsc+lint+tests+build; python suite.
  • [ ] Step 2: confirm branch, git log --oneline origin/develop..HEAD, push. Never stage the pre-existing untracked repo-root files or uv.lock.
  • [ ] Step 3 (controller, after clean final review): open the PR, noting the position-order fix, that ScopeBar shows only the supported slot, and that request payloads are unchanged.

Self-Review Notes

  • Spec §C Contrast Sets coverage: ToolSection for the field group → T2.3; shared PositionPicker (one option order) → T2.1; ScopeBar replacing the bespoke switch → T1 + T2.2; chips bar → T2.4; backend untouched → global constraint. The §C line "Base forms + Specialized become newly available here" is struck: planning verified /api/contrastive has no such support, so shipping those toggles would silently drop them. Ticketed instead.
  • Why chips are display-only: every other tool's chips delete a store entry; Contrast Sets' fields are local scalars whose "deletion" is ambiguous (clear the field? reset the mode?). Read-only chips deliver the "what did I configure" affordance the spec asks for without inventing semantics.
  • Watch in review: (1) request-body parity per mode — the reskin's whole promise; (2) making baseForms/specialized optional on ScopeBarProps must not alter Word Lists/Sentences rendering; (3) the position-order unification must not silently change the default (any) or the emitted values.