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 targetsdevelop. - 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/contrastivecall keeps its current body/params for equivalent user input — this is a reskin. The three positionSelects 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, andanyremains the default. - Verified during planning (binding):
/api/contrastivesupportshas_imageon its endpoints but has nolemmas_only/ register-scope support, and hardcodesis_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. KeepdescribeChildon 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: Commit —
feat(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):
- Position pickers → shared
PositionPicker. Three hand-writtenFormControl/InputLabel/Selectblocks (around lines 561, 673, 787) are replaced by<PositionPicker value={position} onChange={setPosition} variant="select" />. This fixes the inconsistent option ordering (one block listsanyfirst, another lists it last) — the canonical order becomes any/initial/medial/final everywhere.positionstate, its type, and every request body stay identical. - Picture-card
Switch→ScopeBar. Replace the bespokeFormControlLabel/Switch(~line 805) with<ScopeBar hasImage={{ value: pictureOnly, onChange: setPictureOnly, tooltip: 'Both words in a pair must have one' }} … />. Only the Has image slot — omitbaseFormsandspecialized(unsupported by/api/contrastive; see Global Constraints). NOTE:ScopeBarPropscurrently requiresbaseFormsandspecialized; 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. - Field groups →
ToolSection. Wrap the mode-specific field group in aToolSection(title per mode, e.g. "Minimal pair", "Maximal opposition", "Multiple opposition"), replacing the rawPaper. Keep the modeToggleButtonGroupand the maximal-modeStepperexactly where they are, outside the section. ActiveConstraintsBarshowing the configured contrast, derived from local state for display: the mode, the phonemes entered (mode-appropriate), the position when notany, and Has image when on. Chips are display-only here (noonDelete) — 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.- Six duplicated IPA-warning blocks →
getIpaTokenWarning.ipaWarning1/2/Sonorants/Obstruents/Substitute/Targetseach 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 renderedAlerts — 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 (mockapi, wrap inPropertyMetadataProviderif 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/Finalin that order in every mode (the bug being fixed), and selecting Medial sendsposition: 'medial'in the request body for at least one mode; - Has image ON sends
has_image: true; OFF omits it — assert on the actualapicall body; - the chips bar reflects mode + entered phonemes + a non-
anyposition + Has image; - an ASCII phoneme entry surfaces the shared IPA suggestion warning (use a token that really triggers one — check
utils/ipaValidation.ts; baregis 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: Commit —
feat(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 oruv.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/contrastivehas 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/specializedoptional onScopeBarPropsmust not alter Word Lists/Sentences rendering; (3) the position-order unification must not silently change the default (any) or the emitted values.