Skip to content

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 targets develop.
  • No tool rewiring: Builder.tsx, ContrastiveInterventionTool.tsx, GovernedGenerationTool/*, LookupTool.tsx, TextAnalysisTool.tsx are NOT modified in this PR (exception: none). New components live in packages/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 Constraint unions mirrored by hand; any union change lands in BOTH packages/web/workers/src/types.ts and packages/web/frontend/src/types/governance.ts within 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; frontend cd 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 failurenpx 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 reading propertyMap/ranges, WITHOUT its CURATED_BOUNDS curation and WITHOUT its one-sided restriction), local draft state, Add filter button, 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.values added 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 (upsertCategorical for the UI); defaults-on param pin → Task 2.
  • Type consistency: WordPosition/POSITION_OPTIONS (T5) used by later PRs; ScopeToggle (T4); entriesToChips consumes ChipDescriptor from 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.