PHON-219: Shared Constraint-Input 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: Build the shared constraint-input component kit (ScopeBar, PatternComposer, PropertyFilterComposer, CategoricalComposer, PositionPicker, chip-labeling helper, IPA plumbing) that PHON-220–223 rewire the tools onto — plus the three findings carried from PHON-218's final review (CV-shape exclude mode, categorical duplicate-merge, defaults-on param-order pin). No tool is rewired in this PR — components land built and tested but unused, so staging stays fully usable.
Architecture: All new components are presentational and store-agnostic: they take metadata via usePropertyMetadata() where needed and emit StoreEntry objects through onAdd/onUpsert callbacks — the tools wire them to the Zustand constraintStore (or local state) in later PRs. Chip labeling is centralized in one entriesToChips function so every tool's ActiveConstraintsBar renders identical copy. Worker-side, the Constraint model gains CV-shape exclude expressiveness (cv_shape_exclude body key, normalized from the legacy cv_shape_mode) and categorical constraints merge instead of last-wins.
Tech Stack: React + TS + MUI, vitest + @testing-library/react (frontend); Hono worker + vitest (worker).
Spec: docs/superpowers/specs/2026-08-14-constraint-ui-unification-design.md (Section B is this PR's authority; the three carried findings are logged in PR #249's description and the PHON-217 ledger rulings).
Global Constraints¶
- Branch:
feat/phon-219-shared-constraint-components(already created off develop at 62f944df). PR targetsdevelop. - No tool rewiring:
Builder.tsx,ContrastiveInterventionTool.tsx,GovernedGenerationTool/*,LookupTool.tsx,TextAnalysisTool.tsxare NOT modified in this PR (exception: none). New components live inpackages/web/frontend/src/components/shared/. - ScopeBar slot cap is three, by rule (spec Section B): Base forms · Has image · Specialized vocab. Do not add a fourth prop slot.
- One canonical position order everywhere: any, initial, medial, final (exported as
POSITION_OPTIONS). - Keep the worker/frontend
Constraintunions mirrored by hand; any union change lands in BOTHpackages/web/workers/src/types.tsandpackages/web/frontend/src/types/governance.tswithin the same task. - SQL values always bound; property/column names only from config allowlists.
- Test commands: worker
cd packages/web/workers && npx tsc --noEmit && npm test; frontendcd packages/web/frontend && npx tsc --noEmit && npm run lint && npm test && npm run build. - Commits end with: Co-Authored-By: Claude Fable 5 noreply@anthropic.com
- Terminology: "feature vectors", never "embeddings".
Task 1: Worker — CV-shape exclude expressiveness (mode on CvShapeConstraint, cv_shape_exclude body key)¶
Files:
- Modify: packages/web/workers/src/types.ts (CvShapeConstraint ~line 268; WordSearchBody cv_shape_mode area ~line 210)
- Modify: packages/web/workers/src/lib/constraintsToBody.ts (cv_shape case)
- Modify: packages/web/workers/src/lib/wordFilter.ts (cv_shape block ~line 229)
- Test: packages/web/workers/src/__tests__/constraintsToBody.test.ts, packages/web/workers/src/__tests__/constraintCompilation.parity.test.ts, packages/web/workers/src/__tests__/registerFilter.test.ts (cv-shape describe additions)
Interfaces:
- Consumes: existing CvShapeConstraint, cv_shape/cv_shape_mode body keys, the NULL-passing NOT LIKE exclude branch in wordFilter.ts.
- Produces: CvShapeConstraint { type: 'cv_shape'; shapes: string[]; mode?: ConstraintMode } (worker side; Task 3 mirrors frontend). WordSearchBody.cv_shape_exclude?: string[]. Normalization rule Task 3's frontend and PHON-220's Builder rely on: include-mode constraints append to cv_shape; exclude-mode constraints append to cv_shape_exclude; legacy { cv_shape, cv_shape_mode: 'exclude' } is normalized inside compileWordFilter to the exclude list, so both spellings compile identically.
- [ ] Step 1: Write the failing tests
Append to constraintsToBody.test.ts:
describe('constraintsToBody: cv_shape modes', () => {
it('include-mode shapes append to cv_shape (default mode)', () => {
const out = constraintsToBody(
[{ type: 'cv_shape', shapes: ['CVC', 'CV'] }] as Constraint[],
base,
);
expect(out.cv_shape).toEqual(['CVC', 'CV']);
expect(out.cv_shape_exclude).toBeUndefined();
});
it('exclude-mode shapes append to cv_shape_exclude', () => {
const out = constraintsToBody(
[{ type: 'cv_shape', shapes: ['CCVCC'], mode: 'exclude' }] as Constraint[],
base,
);
expect(out.cv_shape_exclude).toEqual(['CCVCC']);
expect(out.cv_shape).toBeUndefined();
});
it('both modes coexist', () => {
const out = constraintsToBody(
[
{ type: 'cv_shape', shapes: ['CVC'] },
{ type: 'cv_shape', shapes: ['CCVCC'], mode: 'exclude' },
] as Constraint[],
base,
);
expect(out.cv_shape).toEqual(['CVC']);
expect(out.cv_shape_exclude).toEqual(['CCVCC']);
});
});
Append to registerFilter.test.ts's cv-shape coverage (same file, new describe):
describe('cv_shape exclude compilation', () => {
it('cv_shape_exclude compiles the NULL-passing NOT LIKE clause', () => {
const compiled = compileWordFilter({ cv_shape_exclude: ['CCVCC'] });
const sql = compiled.wordsWhere.join(' AND ');
expect(sql).toContain('(w.cv_shapes IS NULL OR (w.cv_shapes NOT LIKE ?))');
expect(compiled.wordsParams).toContain('%|CCVCC|%');
});
it('legacy cv_shape_mode=exclude normalizes to the same clause', () => {
const legacy = compileWordFilter({ cv_shape: ['CCVCC'], cv_shape_mode: 'exclude' });
const modern = compileWordFilter({ cv_shape_exclude: ['CCVCC'] });
expect(legacy.wordsWhere).toEqual(modern.wordsWhere);
expect(legacy.params).toEqual(modern.params);
});
it('include and exclude lists compile together', () => {
const compiled = compileWordFilter({ cv_shape: ['CVC'], cv_shape_exclude: ['CCVCC'] });
const sql = compiled.wordsWhere.join(' AND ');
expect(sql).toContain('w.cv_shapes LIKE ?');
expect(sql).toContain('w.cv_shapes NOT LIKE ?');
});
});
Append to constraintCompilation.parity.test.ts:
it('cv_shape exclude: legacy cv_shape_mode vs mode-carrying constraint', () => {
expectSqlParity(
{ cv_shape: ['CCVCC'], cv_shape_mode: 'exclude' },
[{ type: 'cv_shape', shapes: ['CCVCC'], mode: 'exclude' }],
);
});
- [ ] Step 2: Run to verify failure
Run: cd packages/web/workers && npx vitest run src/__tests__/constraintsToBody.test.ts src/__tests__/registerFilter.test.ts src/__tests__/constraintCompilation.parity.test.ts
Expected: the new cases FAIL (cv_shape_exclude unknown, mode ignored).
- [ ] Step 3: Implement
types.ts — add mode?: ConstraintMode; to CvShapeConstraint with the JSDoc line /** exclude = no attested pronunciation may carry any listed shape. Default include. */; add to WordSearchBody next to cv_shape_mode:
/** Exclude list compiled as NULL-passing NOT LIKE — coexists with cv_shape.
* The legacy pair (cv_shape + cv_shape_mode:'exclude') normalizes to this
* inside compileWordFilter. */
cv_shape_exclude?: string[];
constraintsToBody.ts — replace the cv_shape case:
case 'cv_shape': {
if (Array.isArray(c.shapes) && c.shapes.length) {
if (c.mode === 'exclude') {
merged.cv_shape_exclude = [...(merged.cv_shape_exclude ?? []), ...c.shapes];
} else {
merged.cv_shape = [...(merged.cv_shape ?? []), ...c.shapes];
}
}
break;
}
wordFilter.ts — replace the cv_shape block (keep its explanatory comment, amended to mention the exclude list) with a normalize-then-compile version:
// Normalize the legacy spelling: cv_shape_mode='exclude' means the whole
// cv_shape list is an exclude list. After this, cvInclude/cvExclude are the
// single source of truth for both wire spellings.
const legacyExclude = body.cv_shape_mode === 'exclude';
const cvInclude = legacyExclude ? [] : body.cv_shape ?? [];
const cvExclude = [
...(legacyExclude ? body.cv_shape ?? [] : []),
...(body.cv_shape_exclude ?? []),
];
if (cvInclude.length) {
const likes = cvInclude.map(() => 'w.cv_shapes LIKE ?').join(' OR ');
wordsWhere.push(`(${likes})`);
wordsParams.push(...cvInclude.map((s) => '%|' + s + '|%'));
}
if (cvExclude.length) {
const notLikes = cvExclude.map(() => 'w.cv_shapes NOT LIKE ?').join(' AND ');
wordsWhere.push(`(w.cv_shapes IS NULL OR (${notLikes}))`);
wordsParams.push(...cvExclude.map((s) => '%|' + s + '|%'));
}
- [ ] Step 4: Run the three suites, then the full worker suite + tsc
Run: cd packages/web/workers && npx vitest run src/__tests__/constraintsToBody.test.ts src/__tests__/registerFilter.test.ts src/__tests__/constraintCompilation.parity.test.ts && npx tsc --noEmit && npm test
Expected: PASS. If wordFilter.test.ts pins the old include/exclude branch shape, update the pins (behavior for existing inputs is unchanged — the clauses are identical; only failures from clause-ORDER changes are legitimate to re-pin).
- [ ] Step 5: Commit
git add packages/web/workers/src/types.ts packages/web/workers/src/lib/constraintsToBody.ts packages/web/workers/src/lib/wordFilter.ts packages/web/workers/src/__tests__/
git commit -m "feat(phon-219): cv_shape exclude mode — carried from PHON-218 final review"
Task 2: Worker — categorical duplicate-merge, PropertyDef.values, defaults-on param-order pin¶
Files:
- Modify: packages/web/workers/src/lib/constraintsToBody.ts (categorical case)
- Modify: packages/web/workers/src/config/properties.ts (PropertyDef type + the four categorical prop defs)
- Test: packages/web/workers/src/__tests__/constraintsToBody.test.ts, packages/web/workers/src/__tests__/registerFilter.test.ts, packages/web/workers/src/__tests__/api.test.ts
Interfaces:
- Consumes: Task 1's file state; CATEGORICAL_WORD_PROPERTIES; the PropertyDef interface in config/properties.ts.
- Produces: duplicate categorical constraints on the same (property, mode) UNION their values instead of last-wins. PropertyDef gains values?: readonly string[], populated for specialization (['everyday','term_of_art','nomenclature','slang','regional','not_applicable']), currency (['current','historicism','archaic','obsolete']), nativization (['english','naturalized','unassimilated']), basic_level (['basic','subordinate','superordinate','not_applicable']) — served through /api/property-metadata automatically (PropertyDefs serialize as-is), consumed by Task 7's CategoricalComposer. One absolute defaults-on param-order pin (the PHON-218 T2 ledger gap).
- [ ] Step 1: Write the failing tests
constraintsToBody.test.ts:
describe('constraintsToBody: duplicate categorical constraints merge', () => {
it('same property+mode unions values (order-preserving, deduped)', () => {
const out = constraintsToBody(
[
{ type: 'categorical', property: 'specialization', values: ['term_of_art'] },
{ type: 'categorical', property: 'specialization', values: ['slang', 'term_of_art'] },
] as Constraint[],
base,
);
expect(out.specialization).toEqual(['term_of_art', 'slang']);
});
it('different modes stay on separate keys', () => {
const out = constraintsToBody(
[
{ type: 'categorical', property: 'currency', values: ['current'] },
{ type: 'categorical', property: 'currency', values: ['archaic'], mode: 'exclude' },
] as Constraint[],
base,
);
expect(out.currency).toEqual(['current']);
expect((out as Record<string, unknown>).currency_exclude).toEqual(['archaic']);
});
});
registerFilter.test.ts — the defaults-on absolute pin:
describe('defaults-on flat param order (PHON-218 T2 ledger gap)', () => {
it('bundle params precede pattern/cv_shape wordsParams, props params trail', () => {
const compiled = compileWordFilter({ cv_shape: ['CVC'], filters: { min_aoa: 2 } });
expect(compiled.params).toEqual([
'term_of_art', 'nomenclature',
'historicism', 'archaic', 'obsolete',
'unassimilated',
'%|CVC|%',
2,
]);
});
});
(If the actual clause order places the aoa param differently — min_aoa partitions to propsParams and the flat array is wordsParams ++ propsParams ++ pctParams, so the expectation above is the predicted order — adjust ONLY if the current compile provably orders otherwise, and say so in the report; the point of the pin is to freeze the real order, verified once by hand.)
api.test.ts — extend the first property-metadata describe:
it('categorical properties carry their value enumerations', async () => {
const response = await SELF.fetch('http://localhost/api/property-metadata');
const body = await response.json() as { categories: Array<{ properties: Array<{ id: string; values?: string[] }> }> };
const all = body.categories.flatMap((c) => c.properties);
const spec = all.find((p) => p.id === 'specialization');
expect(spec?.values).toContain('term_of_art');
const cur = all.find((p) => p.id === 'currency');
expect(cur?.values).toContain('historicism');
});
- [ ] Step 2: Run to verify failure
Run: cd packages/web/workers && npx vitest run src/__tests__/constraintsToBody.test.ts src/__tests__/registerFilter.test.ts src/__tests__/api.test.ts
Expected: merge test fails (last-wins), values test fails (no values field), pin test may pass or fail — verify its expectation against the actual compile before implementing (it is a pin).
- [ ] Step 3: Implement
constraintsToBody.ts categorical case — replace the assignment line:
const key = c.mode === 'exclude' ? `${c.property}_exclude` : c.property;
const existing = (merged as Record<string, unknown>)[key];
(merged as Record<string, unknown>)[key] = Array.isArray(existing)
? [...new Set([...(existing as string[]), ...values])]
: values;
config/properties.ts — add to the PropertyDef interface:
/** Enumerable value set for kind:'categorical' props — served through
* /api/property-metadata so composers render value chips without
* hardcoding axis vocabularies in the frontend. */
values?: readonly string[];
and add the values: arrays (listed in Interfaces above) to the four categorical PropertyDefs (basic_level ~line 249, specialization ~line 266, currency ~line 279, nativization ~line 289).
Also mirror the field in the frontend PropertyDef type — find it with grep -n "interface PropertyDef" packages/web/frontend/src/services/apiClient.ts and add values?: string[]; (type-only; no component consumes it until Task 7).
- [ ] Step 4: Run suites + tsc (both packages)
Run: cd packages/web/workers && npx tsc --noEmit && npm test and cd packages/web/frontend && npx tsc --noEmit
Expected: PASS.
- [ ] Step 5: Commit
git add packages/web/workers/src/lib/constraintsToBody.ts packages/web/workers/src/config/properties.ts packages/web/workers/src/__tests__/ packages/web/frontend/src/services/apiClient.ts
git commit -m "feat(phon-219): categorical merge semantics + PropertyDef.values + defaults-on param pin"
Task 3: Frontend lib — union mirror, entriesToChips, upsertCategorical, getIpaTokenWarning¶
Files:
- Modify: packages/web/frontend/src/types/governance.ts (CvShapeConstraint + StoreEntry cv_shape gain mode)
- Modify: packages/web/frontend/src/lib/constraintCompiler.ts (cv_shape passes mode through)
- Modify: packages/web/frontend/src/utils/ipaValidation.ts (add getIpaTokenWarning)
- Create: packages/web/frontend/src/lib/entriesToChips.ts
- Test: packages/web/frontend/src/lib/entriesToChips.test.ts, extend packages/web/frontend/src/lib/ compiler coverage in entriesToChips.test.ts (one describe for upsert + one for compiler mode pass-through)
Interfaces:
- Consumes: StoreEntry/Constraint from governance.ts, PropertyDef map shape from usePropertyMetadata, ChipDescriptor from ActiveConstraintsBar.
- Produces (used by Tasks 4–7 and PHON-220–223):
- governance.ts: CvShapeConstraint.mode?: ConstraintMode; StoreEntry cv_shape variant becomes { type: "cv_shape"; shapes: string[]; mode?: ConstraintMode }.
- entriesToChips(entries: StoreEntry[], propertyMap: Record<string, PropertyDef>, removeAt: (i: number) => void): ChipDescriptor[] — THE chip-label source of truth for all tools.
- upsertCategorical(entries: StoreEntry[], entry: Extract<StoreEntry, { type: 'categorical' }>): StoreEntry[] — returns a new array where an existing (property, mode) entry has its values unioned, else appends.
- getIpaTokenWarning(input: string): string | null — first suggestion across space-separated tokens (extracted from PatternConstraints' useMemo body).
- [ ] Step 1: Write the failing tests
Create packages/web/frontend/src/lib/entriesToChips.test.ts:
import { describe, it, expect, vi } from 'vitest';
import { entriesToChips, upsertCategorical } from './entriesToChips';
import { compileConstraints } from './constraintCompiler';
import { getIpaTokenWarning } from '../utils/ipaValidation';
import type { StoreEntry } from '../types/governance';
import type { PropertyDef } from '../services/apiClient';
const propertyMap: Record<string, PropertyDef> = {
aoa: { id: 'aoa', label: 'Age of Acquisition', display_format: '.1f' } as PropertyDef,
specialization: { id: 'specialization', label: 'Register', display_format: 'string' } as PropertyDef,
};
describe('entriesToChips', () => {
it('labels every StoreEntry kind and wires removeAt by index', () => {
const entries: StoreEntry[] = [
{ type: 'pattern', patternType: 'STARTS_WITH', phonemes: ['s'], mode: 'include' },
{ type: 'pattern', patternType: 'CONTAINS', phonemes: ['θ'], mode: 'exclude' },
{ type: 'bound', norm: 'aoa', direction: 'max', value: 5 },
{ type: 'cv_shape', shapes: ['CVC', 'CV'] },
{ type: 'cv_shape', shapes: ['CCVCC'], mode: 'exclude' },
{ type: 'contrastive_minpair', phoneme1: 'p', phoneme2: 'b', position: 'initial' },
{ type: 'categorical', property: 'specialization', values: ['term_of_art'], mode: 'include' },
{ type: 'flag', property: 'has_image', value: true },
{ type: 'scope', kind: 'lemmas_only' },
];
const removeAt = vi.fn();
const chips = entriesToChips(entries, propertyMap, removeAt);
expect(chips.map((c) => c.label)).toEqual([
'starts with /s/',
'exclude /θ/',
'Age of Acquisition ≤ 5',
'shape: CVC, CV',
'not shape: CCVCC',
'minimal pair p–b (initial)',
'Register: term of art',
'Has image',
'Base forms only',
]);
chips[3].onDelete?.();
expect(removeAt).toHaveBeenCalledWith(3);
// exclude-flavored chips are error-colored; include chips are info/default
expect(chips[1].color).toBe('error');
expect(chips[4].color).toBe('error');
});
it('falls back to the raw norm id when propertyMap lacks it', () => {
const chips = entriesToChips(
[{ type: 'bound', norm: 'zipf_missing', direction: 'min', value: 3 }],
{}, () => {},
);
expect(chips[0].label).toBe('zipf_missing ≥ 3');
});
});
describe('upsertCategorical', () => {
const cat = (values: string[], mode: 'include' | 'exclude' = 'include'): Extract<StoreEntry, { type: 'categorical' }> =>
({ type: 'categorical', property: 'specialization', values, mode });
it('unions values into an existing (property, mode) entry', () => {
const out = upsertCategorical([cat(['slang'])], cat(['term_of_art', 'slang']));
expect(out).toEqual([cat(['slang', 'term_of_art'])]);
});
it('different mode appends a separate entry', () => {
const out = upsertCategorical([cat(['slang'])], cat(['archaic'], 'exclude'));
expect(out).toHaveLength(2);
});
it('does not mutate the input array', () => {
const input = [cat(['slang'])];
upsertCategorical(input, cat(['term_of_art']));
expect(input[0].values).toEqual(['slang']);
});
});
describe('compileConstraints: cv_shape mode pass-through', () => {
it('carries mode to the Constraint', () => {
const out = compileConstraints([{ type: 'cv_shape', shapes: ['CCVCC'], mode: 'exclude' }]);
expect(out).toEqual([{ type: 'cv_shape', shapes: ['CCVCC'], mode: 'exclude' }]);
});
});
describe('getIpaTokenWarning', () => {
it('returns null for valid IPA and empty input', () => {
expect(getIpaTokenWarning('')).toBeNull();
expect(getIpaTokenWarning('s t')).toBeNull();
});
it('returns the first suggestion for an ASCII stand-in', () => {
// 'g' (ASCII) should suggest IPA ɡ per normalize conventions
expect(getIpaTokenWarning('g')).toMatch(/ɡ/);
});
});
(If the 'g' expectation mismatches validatePhonemeInput's actual suggestion copy, adjust the matcher to the real copy — the test pins extraction fidelity, not the copy itself. Check utils/ipaValidation.ts:106 first.)
- [ ] Step 2: Run to verify failure
Run: cd packages/web/frontend && npx vitest run src/lib/entriesToChips.test.ts
Expected: FAIL (module missing).
- [ ] Step 3: Implement
governance.ts: add mode?: ConstraintMode; to CvShapeConstraint (same JSDoc as worker) and change the StoreEntry variant to { type: "cv_shape"; shapes: string[]; mode?: ConstraintMode }.
constraintCompiler.ts cv_shape loop:
for (const e of entries) {
if (e.type !== 'cv_shape') continue;
result.push({ type: 'cv_shape', shapes: e.shapes, mode: e.mode });
}
utils/ipaValidation.ts — append:
/** First ASCII→IPA suggestion across space-separated tokens, or null.
* Extracted from the per-tool useMemo blocks (PHON-219) — the four tools
* previously re-implemented this loop locally. */
export function getIpaTokenWarning(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
for (const token of trimmed.split(/\s+/)) {
const v = validatePhonemeInput(token);
if (!v.isValid && v.suggestion) return v.suggestion;
}
return null;
}
Create lib/entriesToChips.ts:
/**
* entriesToChips — THE chip-label source of truth (PHON-219).
*
* Every tool's ActiveConstraintsBar renders StoreEntry lists through this
* one function so constraint copy is identical across Word Lists, Sentences,
* and Contrast Sets. Colors: exclude-flavored chips are 'error', include
* patterns 'info', everything else default.
*/
import type { StoreEntry } from '../types/governance';
import type { PropertyDef } from '../services/apiClient';
import type { ChipDescriptor } from '../components/shared/ActiveConstraintsBar';
const humanize = (v: string) => v.replace(/_/g, ' ');
const POSITION_LABEL: Record<string, string> = {
any: 'any position', initial: 'initial', medial: 'medial', final: 'final',
};
function label(e: StoreEntry, propertyMap: Record<string, PropertyDef>): { label: string; color?: ChipDescriptor['color'] } {
switch (e.type) {
case 'pattern': {
if (e.mode === 'exclude') return { label: `exclude /${e.phonemes.join(' ')}/`, color: 'error' };
const where = e.patternType === 'STARTS_WITH' ? 'starts with'
: e.patternType === 'ENDS_WITH' ? 'ends with'
: e.patternType === 'CONTAINS' ? 'contains'
: 'medial';
return { label: `${where} /${e.phonemes.join(' ')}/`, color: 'info' };
}
case 'bound': {
const name = propertyMap[e.norm.replace(/_percentile$/, '')]?.label ?? propertyMap[e.norm]?.label ?? e.norm;
const pct = e.norm.endsWith('_percentile') ? 'th %ile' : '';
return { label: `${name} ${e.direction === 'max' ? '≤' : '≥'} ${e.value}${pct}` };
}
case 'cv_shape':
return e.mode === 'exclude'
? { label: `not shape: ${e.shapes.join(', ')}`, color: 'error' }
: { label: `shape: ${e.shapes.join(', ')}` };
case 'contrastive_minpair':
return { label: `minimal pair ${e.phoneme1}–${e.phoneme2} (${POSITION_LABEL[e.position] ?? e.position})` };
case 'contrastive_maxopp':
return { label: `max opposition ${e.phoneme1}–${e.phoneme2} (${POSITION_LABEL[e.position] ?? e.position})` };
case 'contrastive_multopp':
return { label: `multiple opposition ${e.substitute} → ${e.targets.join(', ')}` };
case 'categorical': {
const name = propertyMap[e.property]?.label ?? e.property;
const vals = e.values.map(humanize).join(', ');
return e.mode === 'exclude'
? { label: `${name}: not ${vals}`, color: 'error' }
: { label: `${name}: ${vals}` };
}
case 'flag':
return { label: e.property === 'has_image' ? 'Has image' : humanize(e.property) };
case 'scope':
return { label: e.kind === 'lemmas_only' ? 'Base forms only' : 'Incl. specialized' };
}
}
export function entriesToChips(
entries: StoreEntry[],
propertyMap: Record<string, PropertyDef>,
removeAt: (index: number) => void,
): ChipDescriptor[] {
return entries.map((e, i) => {
const { label: text, color } = label(e, propertyMap);
return { id: `${e.type}-${i}-${text}`, label: text, color, onDelete: () => removeAt(i) };
});
}
/** Merge a categorical entry into an existing (property, mode) entry (union,
* order-preserving, deduped) or append. Duplicate categorical entries are
* last-wins at the worker adapter no longer (PHON-219 unions there too) —
* this keeps the UI from ever showing two chips for one axis. */
export function upsertCategorical(
entries: StoreEntry[],
entry: Extract<StoreEntry, { type: 'categorical' }>,
): StoreEntry[] {
const idx = entries.findIndex(
(e) => e.type === 'categorical' && e.property === entry.property && (e.mode ?? 'include') === (entry.mode ?? 'include'),
);
if (idx === -1) return [...entries, entry];
const existing = entries[idx] as Extract<StoreEntry, { type: 'categorical' }>;
const mergedEntry = { ...existing, values: [...new Set([...existing.values, ...entry.values])] };
return entries.map((e, i) => (i === idx ? mergedEntry : e));
}
(Note: StoreEntry's categorical variant declares mode: ConstraintMode non-optional — from PHON-218 Task 6. The ?? 'include' guards are defensive; keep them.)
- [ ] Step 4: Run frontend suite + tsc
Run: cd packages/web/frontend && npx vitest run src/lib/entriesToChips.test.ts && npx tsc --noEmit && npm test
Expected: PASS.
- [ ] Step 5: Commit
git add packages/web/frontend/src/types/governance.ts packages/web/frontend/src/lib/constraintCompiler.ts packages/web/frontend/src/utils/ipaValidation.ts packages/web/frontend/src/lib/entriesToChips.ts packages/web/frontend/src/lib/entriesToChips.test.ts
git commit -m "feat(phon-219): entriesToChips label source + upsertCategorical + IPA warning extraction"
Task 4: ScopeBar component¶
Files:
- Create: packages/web/frontend/src/components/shared/ScopeBar.tsx
- Test: packages/web/frontend/src/components/shared/ScopeBar.test.tsx
Interfaces:
- Consumes: ScopeExclusion from services/apiClient.
- Produces: the component PHON-220/222/223 mount above their sections.
export interface ScopeToggle {
value: boolean;
onChange: (value: boolean) => void;
}
export interface ScopeBarProps {
/** Base forms only — ON hides inflected variants. */
baseForms: ScopeToggle;
/** Has image — omit the prop entirely to hide the slot (Sentences). */
hasImage?: ScopeToggle;
/** ON drops the default vocabulary scope (shows specialized & dated vocab). */
specialized: ScopeToggle;
/** DEFAULT_SCOPE_EXCLUSIONS from usePropertyMetadata().defaultScope — the
* specialized toggle's tooltip enumerates it. */
defaultScope: ScopeExclusion[];
}
- [ ] Step 1: Write the failing test
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import ScopeBar from './ScopeBar';
const scope = [
{ property: 'specialization', values: ['term_of_art', 'nomenclature'] },
{ property: 'currency', values: ['historicism', 'archaic', 'obsolete'] },
{ property: 'nativization', values: ['unassimilated'] },
];
function setup(overrides: Partial<Parameters<typeof ScopeBar>[0]> = {}) {
const baseForms = { value: false, onChange: vi.fn() };
const hasImage = { value: false, onChange: vi.fn() };
const specialized = { value: false, onChange: vi.fn() };
render(
<ScopeBar baseForms={baseForms} hasImage={hasImage} specialized={specialized} defaultScope={scope} {...overrides} />,
);
return { baseForms, hasImage, specialized };
}
describe('ScopeBar', () => {
it('renders the three capped slots as toggle chips', () => {
setup();
expect(screen.getByRole('button', { name: /base forms/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /has image/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /specialized/i })).toBeTruthy();
});
it('clicking a chip toggles its value', () => {
const { baseForms, specialized } = setup();
fireEvent.click(screen.getByRole('button', { name: /base forms/i }));
expect(baseForms.onChange).toHaveBeenCalledWith(true);
fireEvent.click(screen.getByRole('button', { name: /specialized/i }));
expect(specialized.onChange).toHaveBeenCalledWith(true);
});
it('omitting hasImage hides that slot', () => {
const baseForms = { value: false, onChange: vi.fn() };
const specialized = { value: false, onChange: vi.fn() };
render(<ScopeBar baseForms={baseForms} specialized={specialized} defaultScope={scope} />);
expect(screen.queryByRole('button', { name: /has image/i })).toBeNull();
});
it('the specialized tooltip enumerates the served default scope, not hardcoded copy', async () => {
setup();
fireEvent.mouseOver(screen.getByRole('button', { name: /specialized/i }));
const tip = await screen.findByRole('tooltip');
expect(tip.textContent).toMatch(/term of art/);
expect(tip.textContent).toMatch(/historicism/);
expect(tip.textContent).toMatch(/unassimilated/);
});
});
-
[ ] Step 2: Run to verify failure —
npx vitest run src/components/shared/ScopeBar.test.tsx→ FAIL (module missing). -
[ ] Step 3: Implement
/**
* ScopeBar — the capped vocabulary-universe control row (PHON-219, spec §B).
*
* THREE slots, by rule: Base forms · Has image · Specialized vocab. A scope
* control must restrict the vocabulary universe, not filter a property —
* anything else goes through a composer. Adding a fourth slot requires
* demoting one (spec §B); do not widen this props interface.
*
* Toggles show their own state inline (filled when off-default) and do NOT
* emit chips — the constraints bar is for composed constraints only. The
* specialized tooltip enumerates the SERVED DEFAULT_SCOPE_EXCLUSIONS so UI
* copy can never drift from the SQL definition.
*/
import { Chip, Paper, Stack, Tooltip, Typography } from '@mui/material';
import { Check as CheckIcon } from '@mui/icons-material';
import type { ScopeExclusion } from '../../services/apiClient';
export interface ScopeToggle {
value: boolean;
onChange: (value: boolean) => void;
}
export interface ScopeBarProps {
baseForms: ScopeToggle;
hasImage?: ScopeToggle;
specialized: ScopeToggle;
defaultScope: ScopeExclusion[];
}
const humanize = (v: string) => v.replace(/_/g, ' ');
function ScopeChip({ label, tooltip, toggle }: { label: string; tooltip: string; toggle: ScopeToggle }) {
return (
<Tooltip title={tooltip} arrow>
<Chip
label={label}
clickable
size="small"
color={toggle.value ? 'primary' : 'default'}
variant={toggle.value ? 'filled' : 'outlined'}
icon={toggle.value ? <CheckIcon /> : undefined}
onClick={() => toggle.onChange(!toggle.value)}
role="button"
aria-pressed={toggle.value}
/>
</Tooltip>
);
}
export default function ScopeBar({ baseForms, hasImage, specialized, defaultScope }: ScopeBarProps) {
const scopeSummary = defaultScope
.map((s) => s.values.map(humanize).join(', '))
.join('; ');
return (
<Paper variant="outlined" sx={{ p: 1, mb: 2 }}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flexWrap: 'wrap', rowGap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', mr: 0.5 }}>
Scope:
</Typography>
<ScopeChip
label="Base forms only"
tooltip="Hide inflected variants (cached, caches, caching → cache)"
toggle={baseForms}
/>
{hasImage && (
<ScopeChip
label="Has image"
tooltip="Only words with a picture card"
toggle={hasImage}
/>
)}
<ScopeChip
label="Specialized & dated vocab"
tooltip={
scopeSummary
? `Off (default) hides: ${scopeSummary}. Turn on to include everything.`
: 'Turn on to include specialized vocabulary.'
}
toggle={specialized}
/>
</Stack>
</Paper>
);
}
(If the tooltip test cannot find role="tooltip" with mouseOver, use fireEvent.focus or userEvent.hover per the repo's existing tooltip-test idiom — check how other tests interact with MUI Tooltips before fighting it; adjust the test's event, not the component.)
- [ ] Step 4: Run + commit
npx vitest run src/components/shared/ScopeBar.test.tsx && npx tsc --noEmit, then:
git add packages/web/frontend/src/components/shared/ScopeBar.tsx packages/web/frontend/src/components/shared/ScopeBar.test.tsx
git commit -m "feat(phon-219): ScopeBar — capped vocabulary-universe control row"
Task 5: PositionPicker component¶
Files:
- Create: packages/web/frontend/src/components/shared/PositionPicker.tsx
- Test: packages/web/frontend/src/components/shared/PositionPicker.test.tsx
Interfaces: - Produces (replaces four hand-written pickers in PHON-222/223):
export type WordPosition = 'any' | 'initial' | 'medial' | 'final';
export const POSITION_OPTIONS: readonly WordPosition[] = ['any', 'initial', 'medial', 'final'];
export interface PositionPickerProps {
value: WordPosition;
onChange: (position: WordPosition) => void;
/** Visual density; 'toggle' = ToggleButtonGroup (default), 'select' = form Select. */
variant?: 'toggle' | 'select';
label?: string; // used by the select variant; default "Position in Word"
}
- [ ] Step 1: Failing test
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import PositionPicker, { POSITION_OPTIONS } from './PositionPicker';
describe('PositionPicker', () => {
it('exports the ONE canonical option order', () => {
expect(POSITION_OPTIONS).toEqual(['any', 'initial', 'medial', 'final']);
});
it('toggle variant renders all four in order and fires onChange', () => {
const onChange = vi.fn();
render(<PositionPicker value="any" onChange={onChange} />);
const buttons = screen.getAllByRole('button');
expect(buttons.map((b) => b.textContent)).toEqual(['Any', 'Initial', 'Medial', 'Final']);
fireEvent.click(screen.getByRole('button', { name: 'Medial' }));
expect(onChange).toHaveBeenCalledWith('medial');
});
it('select variant renders a labelled Select with the same order', () => {
const onChange = vi.fn();
render(<PositionPicker value="initial" onChange={onChange} variant="select" />);
fireEvent.mouseDown(screen.getByRole('combobox', { name: /position in word/i }));
const options = screen.getAllByRole('option');
expect(options.map((o) => o.textContent)).toEqual(['Any', 'Initial', 'Medial', 'Final']);
fireEvent.click(screen.getByRole('option', { name: 'Final' }));
expect(onChange).toHaveBeenCalledWith('final');
});
});
-
[ ] Step 2: verify failure — module missing.
-
[ ] Step 3: Implement
/**
* PositionPicker — the ONE word-position control (PHON-219).
*
* Replaces four hand-written pickers (three Selects in Contrast Sets with
* inconsistent option order, one ToggleButtonGroup in Sentences'
* ContrastiveSection). Canonical order: any, initial, medial, final.
*/
import {
FormControl, InputLabel, MenuItem, Select, ToggleButton, ToggleButtonGroup,
} from '@mui/material';
export type WordPosition = 'any' | 'initial' | 'medial' | 'final';
export const POSITION_OPTIONS: readonly WordPosition[] = ['any', 'initial', 'medial', 'final'];
const LABELS: Record<WordPosition, string> = {
any: 'Any', initial: 'Initial', medial: 'Medial', final: 'Final',
};
export interface PositionPickerProps {
value: WordPosition;
onChange: (position: WordPosition) => void;
variant?: 'toggle' | 'select';
label?: string;
}
export default function PositionPicker({
value, onChange, variant = 'toggle', label = 'Position in Word',
}: PositionPickerProps) {
if (variant === 'select') {
return (
<FormControl size="small" fullWidth>
<InputLabel>{label}</InputLabel>
<Select
value={value}
label={label}
onChange={(e) => onChange(e.target.value as WordPosition)}
>
{POSITION_OPTIONS.map((p) => (
<MenuItem key={p} value={p}>{LABELS[p]}</MenuItem>
))}
</Select>
</FormControl>
);
}
return (
<ToggleButtonGroup
value={value}
exclusive
size="small"
aria-label={label}
onChange={(_, v: WordPosition | null) => { if (v) onChange(v); }}
>
{POSITION_OPTIONS.map((p) => (
<ToggleButton key={p} value={p}>{LABELS[p]}</ToggleButton>
))}
</ToggleButtonGroup>
);
}
- [ ] Step 4: Run + commit
git add packages/web/frontend/src/components/shared/PositionPicker.tsx packages/web/frontend/src/components/shared/PositionPicker.test.tsx
git commit -m "feat(phon-219): PositionPicker — one canonical position control"
Task 6: PatternComposer component¶
Files:
- Create: packages/web/frontend/src/components/shared/PatternComposer.tsx
- Test: packages/web/frontend/src/components/shared/PatternComposer.test.tsx
Interfaces:
- Consumes: PhonemePickerDialog (components/PhonemePickerDialog.tsx), getIpaTokenWarning (Task 3), StoreEntry.
- Produces: the composer PHON-220 (Word Lists) and PHON-222 (Sentences) mount. Presentational — no store access, no chip strip (chips are the tools' ActiveConstraintsBar job):
export interface PatternComposerProps {
onAdd: (entry: Extract<StoreEntry, { type: 'pattern' }>) => void;
/** Wording context: 'word' = "Words must match…", 'sentence' = "The sentence must contain…". */
subject?: 'word' | 'sentence';
}
- [ ] Step 1: Failing test
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import PatternComposer from './PatternComposer';
function setup() {
const onAdd = vi.fn();
render(<PatternComposer onAdd={onAdd} />);
return { onAdd };
}
const type = (value: string) =>
fireEvent.change(screen.getByLabelText(/phoneme/i), { target: { value } });
describe('PatternComposer', () => {
it('Add is disabled until input is non-empty', () => {
setup();
expect(screen.getByRole('button', { name: /add rule/i })).toHaveProperty('disabled', true);
});
it('emits a STARTS_WITH include entry and clears the input', () => {
const { onAdd } = setup();
type('s');
fireEvent.click(screen.getByRole('button', { name: /add rule/i }));
expect(onAdd).toHaveBeenCalledWith({
type: 'pattern', patternType: 'STARTS_WITH', phonemes: ['s'], mode: 'include',
});
expect((screen.getByLabelText(/phoneme/i) as HTMLInputElement).value).toBe('');
});
it('space-separated input becomes a multi-phoneme sequence', () => {
const { onAdd } = setup();
fireEvent.click(screen.getByRole('button', { name: /contains/i }));
type(' s t ');
fireEvent.click(screen.getByRole('button', { name: /add rule/i }));
expect(onAdd).toHaveBeenCalledWith({
type: 'pattern', patternType: 'CONTAINS', phonemes: ['s', 't'], mode: 'include',
});
});
it('exclude mode + medial-only produce CONTAINS_MEDIAL exclude', () => {
const { onAdd } = setup();
fireEvent.click(screen.getByRole('button', { name: /contains/i }));
fireEvent.click(screen.getByRole('button', { name: /exclude/i }));
fireEvent.click(screen.getByRole('checkbox', { name: /medial only/i }));
type('ɹ');
fireEvent.click(screen.getByRole('button', { name: /add rule/i }));
expect(onAdd).toHaveBeenCalledWith({
type: 'pattern', patternType: 'CONTAINS_MEDIAL', phonemes: ['ɹ'], mode: 'exclude',
});
});
it('Enter submits', () => {
const { onAdd } = setup();
type('k');
fireEvent.keyDown(screen.getByLabelText(/phoneme/i), { key: 'Enter' });
expect(onAdd).toHaveBeenCalled();
});
it('ASCII input surfaces an IPA suggestion warning', () => {
setup();
type('g');
expect(screen.getByRole('alert').textContent).toMatch(/ɡ/);
});
});
(As in Task 3: if the 'g' suggestion copy differs, match the real copy from validatePhonemeInput. If the medial checkbox's accessible name resolves differently under testing-library, query by the label text used in the JSX below.)
-
[ ] Step 2: verify failure — module missing.
-
[ ] Step 3: Implement
Extract from GovernedGenerationTool/PatternConstraints.tsx:40-207 (operator/mode toggles, medial checkbox, IPA field + picker button, warning alert, Add button) into the presentational component — same JSX structure and copy, with these deltas ONLY:
/**
* PatternComposer — the ONE add-a-phoneme-pattern flow (PHON-219, spec §B).
*
* Extracted from Sentences' PatternConstraints; presentational. Emits a
* StoreEntry via onAdd and renders NO chips — committed constraints surface
* in the tool's ActiveConstraintsBar. Replaces Word Lists' editable pattern
* rows and its separate free-text exclusion field in PHON-220.
*/
import { useMemo, useState } from 'react';
import type { KeyboardEvent } from 'react';
import {
Alert, Box, Button, Checkbox, FormControlLabel, IconButton, InputAdornment,
Stack, TextField, ToggleButton, ToggleButtonGroup, Typography,
} from '@mui/material';
import { Keyboard as KeyboardIcon, Add as AddIcon } from '@mui/icons-material';
import PhonemePickerDialog from '../PhonemePickerDialog';
import { getIpaTokenWarning } from '../../utils/ipaValidation';
import type { ConstraintMode, StoreEntry } from '../../types/governance';
type Operator = 'STARTS_WITH' | 'ENDS_WITH' | 'CONTAINS';
export interface PatternComposerProps {
onAdd: (entry: Extract<StoreEntry, { type: 'pattern' }>) => void;
subject?: 'word' | 'sentence';
}
export default function PatternComposer({ onAdd, subject = 'word' }: PatternComposerProps) {
const [operator, setOperator] = useState<Operator>('STARTS_WITH');
const [mode, setMode] = useState<ConstraintMode>('include');
const [pickerOpen, setPickerOpen] = useState(false);
const [input, setInput] = useState('');
const [medialOnly, setMedialOnly] = useState(false);
const ipaWarning = useMemo(() => getIpaTokenWarning(input), [input]);
const helpText =
subject === 'sentence'
? mode === 'exclude'
? 'No word in the sentence may match. Space-separate phonemes for multi-phoneme sequences (e.g., "s t" matches /st/).'
: 'The sentence must contain ≥1 word matching this pattern. Space-separate phonemes for multi-phoneme sequences.'
: mode === 'exclude'
? 'Exclude words matching this pattern. Space-separate phonemes for multi-phoneme sequences (e.g., "s t" matches /st/).'
: 'Words must match this pattern. Space-separate phonemes for multi-phoneme sequences.';
const submit = () => {
const phonemes = input.trim().split(/\s+/).filter(Boolean);
if (phonemes.length === 0) return;
const patternType = operator === 'CONTAINS' && medialOnly ? 'CONTAINS_MEDIAL' : operator;
onAdd({ type: 'pattern', patternType, phonemes, mode });
setInput('');
};
const handleInputKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key !== 'Enter') return;
e.preventDefault();
submit();
};
// JSX: identical structure to PatternConstraints.tsx lines 109-235 minus
// the ToolSection wrapper, the store reads, and the chip strip (lines
// 209-229) — operator ToggleButtonGroup, mode ToggleButtonGroup, helpText
// caption, medial checkbox (CONTAINS only), phoneme TextField with picker
// adornment, warning Alert (role="alert" is Alert's default), Add rule
// button (disabled={!input.trim()}), PhonemePickerDialog.
// Copy that block verbatim and adjust the identifiers named above.
Complete the JSX by transplanting PatternConstraints.tsx:109-235 exactly as described in the comment (this is a copy-adjust, not new design — the composer must look identical to the Sentences original so PHON-222's swap is invisible).
- [ ] Step 4: Run + full frontend suite + commit
git add packages/web/frontend/src/components/shared/PatternComposer.tsx packages/web/frontend/src/components/shared/PatternComposer.test.tsx
git commit -m "feat(phon-219): PatternComposer — one add-a-pattern flow, extracted presentational"
Task 7: PropertyFilterComposer + CategoricalComposer¶
Files:
- Create: packages/web/frontend/src/components/shared/PropertyFilterComposer.tsx
- Create: packages/web/frontend/src/components/shared/CategoricalComposer.tsx
- Test: packages/web/frontend/src/components/shared/PropertyFilterComposer.test.tsx, packages/web/frontend/src/components/shared/CategoricalComposer.test.tsx
Interfaces:
- Consumes: usePropertyMetadata() (categories, propertyMap, ranges), PropertySlider ({ prop, value, range, onChange(id, value), mode }), BucketChips ({ prop, bucketSet, value, baseline, onChange }) + bucketsForProperty from lib/propertyBuckets, PropertyDef.values (Task 2), upsertCategorical semantics (Task 3 — the composer emits, the tool upserts).
- Produces:
export interface PropertyFilterComposerProps {
/** One or two bound entries per Add: min-side and/or max-side. */
onAdd: (entries: Array<Extract<StoreEntry, { type: 'bound' }>>) => void;
}
export interface CategoricalComposerProps {
/** The tool applies this with upsertCategorical (one chip per axis+mode). */
onUpsert: (entry: Extract<StoreEntry, { type: 'categorical' }>) => void;
}
PropertyFilterComposer behavior (drives the test): a category-grouped Select (MUI ListSubheader per metadata category) listing properties where filterable && kind !== 'categorical' && kind !== 'boolean' and (ranges[id] exists or use_log_scale). Percentile properties (use_log_scale: true): norm id ${id}_percentile, fixed range [0,100], step 5, PropertySlider mode="percentile". Absolute: range from ranges[id], slider_step from metadata. When bucketsForProperty(id) returns a set, render BucketChips instead of the slider. Local two-thumb value initialized to the full range (baseline); Add filter button disabled while value === baseline; on Add emit [{ type:'bound', norm, direction:'min', value: v[0] }] and/or max-side entry — only the sides that moved off baseline — then reset the picker.
CategoricalComposer behavior: a Select of properties where kind === 'categorical' && filterable && id !== 'cv_shape' (from metadata categories), an include/exclude ToggleButtonGroup (reuse the exact Include/Exclude toggle idiom from PatternComposer), then the axis's values (from PropertyDef.values) as multi-select chips (clickable Chip toggling membership, not_applicable listed last), Add filter disabled until ≥1 value selected; on Add emit { type:'categorical', property, values: selected, mode } and reset.
- [ ] Step 1: Failing tests
PropertyFilterComposer.test.tsx — wrap renders in a PropertyMetadataProvider with a mocked api.getPropertyMetadata/getPropertyRanges (copy the mock-provider idiom from components/Builder.test.tsx:34-90 — it already builds a PropertyMetadataResponse fixture; reuse its fixture shape with two categories, one percentile prop (use_log_scale: true), one absolute prop):
describe('PropertyFilterComposer', () => {
it('lists filterable numeric properties grouped by category', async () => { /* open Select, assert ListSubheader labels + option order match the fixture */ });
it('absolute property: moving the max thumb then Add emits one max bound', async () => { /* select prop, move slider via fireEvent on the second thumb (Builder.test.tsx shows the MUI slider-thumb idiom), click Add filter, expect onAdd([{type:'bound', norm:'<id>', direction:'max', value:<moved>}]) */ });
it('percentile property emits `${id}_percentile` bounds on the 0-100 scale', async () => { /* both thumbs moved → two entries, norms suffixed */ });
it('Add is disabled at baseline', async () => { /* select prop, assert disabled before any thumb moves */ });
});
CategoricalComposer.test.tsx — same provider idiom with a fixture carrying specialization (values: ['everyday','term_of_art','slang','not_applicable'], kind:'categorical'):
describe('CategoricalComposer', () => {
it('lists categorical properties and renders the axis values as chips (not_applicable last)', async () => { /* ... */ });
it('selecting values and Add emits an include categorical entry', async () => { /* expect onUpsert({type:'categorical', property:'specialization', values:['term_of_art'], mode:'include'}) */ });
it('exclude mode emits mode: exclude', async () => { /* ... */ });
it('Add disabled with zero values selected', async () => { /* ... */ });
});
Write the full test bodies following Builder.test.tsx's established MUI-interaction idioms (Select open = fireEvent.mouseDown on the combobox; slider thumbs = the keyboard/fireEvent.change approach that file uses). The behavioral assertions above are the contract; the DOM mechanics follow the repo idiom.
-
[ ] Step 2: verify failure — modules missing.
-
[ ] Step 3: Implement both components per the behavior specs above. Structure each as: metadata-driven option list built in a
useMemo(PsycholinguisticsSection.tsx:61-92 is the reference for readingpropertyMap/ranges, WITHOUT itsCURATED_BOUNDScuration and WITHOUT its one-sided restriction), local draft state,Add filterbutton, reset on add. Keep each file under ~220 lines; shared Include/Exclude toggle stays inline (two call sites is not yet an abstraction). -
[ ] Step 4: Run both test files + full frontend matrix
Run: cd packages/web/frontend && npx vitest run src/components/shared/PropertyFilterComposer.test.tsx src/components/shared/CategoricalComposer.test.tsx && npx tsc --noEmit && npm run lint && npm test && npm run build
Expected: PASS.
- [ ] Step 5: Commit
git add packages/web/frontend/src/components/shared/PropertyFilterComposer.tsx packages/web/frontend/src/components/shared/PropertyFilterComposer.test.tsx packages/web/frontend/src/components/shared/CategoricalComposer.tsx packages/web/frontend/src/components/shared/CategoricalComposer.test.tsx
git commit -m "feat(phon-219): PropertyFilterComposer + CategoricalComposer — metadata-driven add flows"
Task 8: Full matrix, push (PR after final review)¶
- [ ] Step 1: Full CI matrix
cd packages/web/workers && npx tsc --noEmit && npm test
cd ../frontend && npx tsc --noEmit && npm run lint && npm test && npm run build
cd ../../.. && uv run python -m pytest packages/data/tests/ --ignore=packages/data/tests/test_datasets.py --ignore=packages/data/tests/test_new_loaders.py
-
[ ] Step 2: Verify branch (
git branch --show-current= feat/phon-219-shared-constraint-components),git log --oneline origin/develop..HEAD,git push -u origin feat/phon-219-shared-constraint-components. -
[ ] Step 3 (controller, after final whole-branch review is clean): open the PR to develop titled
feat(phon-219): shared constraint-input components (ScopeBar, composers, PositionPicker), body noting: components land unused by design (tools rewire in PHON-220–223); carried findings closed (cv_shape exclude, categorical merge, defaults-on pin);PropertyDef.valuesadded to metadata; rebase/merge order note if PR #250 (smoke-test fix) has not merged first.
Self-Review Notes¶
- Spec coverage (Section B): ScopeBar capped-3 with metadata tooltip → Task 4; composer grammar (pattern/property/categorical) → Tasks 6-7; PositionPicker one-order → Task 5; chips remove-only via one labeling function → Task 3; IPA plumbing extraction → Task 3. Chips-bar mounting on every tool is PHON-220–223 wiring, deliberately not here.
- Carried findings: cv_shape mode → Task 1; duplicate categorical → Task 2 (worker) + Task 3 (
upsertCategoricalfor the UI); defaults-on param pin → Task 2. - Type consistency:
WordPosition/POSITION_OPTIONS(T5) used by later PRs;ScopeToggle(T4);entriesToChipsconsumesChipDescriptorfrom the existing ActiveConstraintsBar; StoreEntry cv_shape mode added in T3 before T3's compiler test uses it (same task). - Known looseness, deliberate: Task 7's test bodies specify contracts + repo idioms rather than verbatim DOM scripts — MUI interaction mechanics must follow
Builder.test.tsx's proven patterns, which the implementer reads directly; pinning exact DOM queries here would rot faster than the contract.