Skip to content

PHON-220: Word Lists Rebuilt on the Shared Grammar 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: Rebuild Word Lists' input surface on the shared grammar: ScopeBar + three composer sections + chips bar, state on a per-tool constraint store, categories from metadata (killing the hardcoded five-category accordion list), register categorical filters surfaced, ENDS_WITH-exclude expressible. Also root-fixes PHON-224 (contextual_diversity percentile buckets) inside the shared composer, before Word Lists adopts it.

Architecture: constraintStore.ts becomes a factory (createConstraintStore()); Sentences keeps its existing instance unchanged, Word Lists gets its own (useWordListConstraintStore) so the two tools never share chip state. Builder's input side is replaced: patterns/exclusions/CV-shape/bounds/categorical live as StoreEntrys; ScopeBar state (base forms / has image / specialized) and the Similar-To rule stay local component state and travel via WordOptions (they are universe scope and execution options, not chips — per spec §B). handleBuild = compileConstraints(entries) + opts → buildWordSearchRequest. Results side (WordListTable, export, selection) untouched.

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

Spec: docs/superpowers/specs/2026-08-14-constraint-ui-unification-design.md (§C Word Lists; §B grammar). PHON-224 root-fix decision is made here (see Task 2).

Global Constraints

  • Branch: feat/phon-220-word-lists-grammar (created off develop at 1dd5c5ea). PR targets develop.
  • Sentences must be byte-identically unaffected: GovernedGenerationTool/* files untouched; the useConstraintStore export keeps its name, its module-level singleton identity, and its behavior (the factory refactor is internal).
  • Results side untouched: WordListTable, selection, export, resultTotal copy. Ordering/sampling is PHON-221 — do NOT add sort controls here.
  • useSampleWords.ts and lib/seed/packSeeder.ts untouched (they call buildWordSearchRequest directly).
  • Payload compatibility: for equivalent user input, the request body must carry the same constraints the current Builder emits (the worker contract is unchanged). New capabilities (categorical, ENDS_WITH-exclude, cv_shape exclude) are additive.
  • PHON-224 ruling (this plan's authority): percentile-ness = use_log_scale || bucketsForProperty(id)?.mode === 'percentile' — bucket mode is authoritative where a bucket set exists (option 2 from the ticket; flipping use_log_scale would change slider display semantics beyond intent). Applied in PropertyFilterComposer only; Builder's old duplicate logic is deleted by this PR.
  • Test commands: frontend cd packages/web/frontend && npx tsc --noEmit && npm run lint && npm test && npm run build; worker suite must also stay green (cd packages/web/workers && npm test) — no worker files change, run once at the end.
  • Commits end with: Co-Authored-By: Claude Fable 5 noreply@anthropic.com

Task 1: Constraint-store factory + useWordListConstraintStore + upsertBound

Files: - Modify: packages/web/frontend/src/store/constraintStore.ts - Modify: packages/web/frontend/src/lib/entriesToChips.ts (add upsertBound beside upsertCategorical) - Test: packages/web/frontend/src/store/constraintStore.test.ts (create), packages/web/frontend/src/lib/entriesToChips.test.ts (extend)

Interfaces: - Consumes: existing ConstraintStoreState shape (entries/add/remove/removeAt/clear/load/snapshot/topK/setTopK). - Produces: - createConstraintStore(): UseBoundStore<StoreApi<ConstraintStoreState>> — factory returning an independent store hook. - export const useConstraintStore = createConstraintStore(); — UNCHANGED name + module-singleton semantics (Sentences' instance). - export const useWordListConstraintStore = createConstraintStore(); — Word Lists' instance (Task 3 consumes). - upsertBound(entries: StoreEntry[], entry: Extract<StoreEntry, { type: 'bound' }>): StoreEntry[] — same (norm, direction) replaces the value (one chip per norm+direction), else appends; immutable.

  • [ ] Step 1: Write the failing tests

Create src/store/constraintStore.test.ts:

import { describe, it, expect, beforeEach } from 'vitest';
import { createConstraintStore, useConstraintStore, useWordListConstraintStore } from './constraintStore';

describe('constraint store factory', () => {
  beforeEach(() => {
    useConstraintStore.getState().clear();
    useWordListConstraintStore.getState().clear();
  });

  it('sentences and word-list stores are independent instances', () => {
    useConstraintStore.getState().add({ type: 'cv_shape', shapes: ['CVC'] });
    expect(useConstraintStore.getState().entries).toHaveLength(1);
    expect(useWordListConstraintStore.getState().entries).toHaveLength(0);
  });

  it('factory instances do not share state either', () => {
    const a = createConstraintStore();
    const b = createConstraintStore();
    a.getState().add({ type: 'cv_shape', shapes: ['CV'] });
    expect(b.getState().entries).toHaveLength(0);
  });

  it('duplicate add is still a no-op per instance', () => {
    const s = createConstraintStore();
    s.getState().add({ type: 'cv_shape', shapes: ['CVC'] });
    s.getState().add({ type: 'cv_shape', shapes: ['CVC'] });
    expect(s.getState().entries).toHaveLength(1);
  });
});

Extend entriesToChips.test.ts:

import { upsertBound } from './entriesToChips';

describe('upsertBound', () => {
  const bound = (direction: 'min' | 'max', value: number): Extract<StoreEntry, { type: 'bound' }> =>
    ({ type: 'bound', norm: 'aoa', direction, value });

  it('same (norm, direction) replaces the value — one chip per side', () => {
    const out = upsertBound([bound('max', 5)], bound('max', 3));
    expect(out).toEqual([bound('max', 3)]);
  });

  it('opposite direction appends', () => {
    const out = upsertBound([bound('max', 5)], bound('min', 2));
    expect(out).toHaveLength(2);
  });

  it('different norm appends and does not mutate input', () => {
    const input = [bound('max', 5)];
    const out = upsertBound(input, { type: 'bound', norm: 'valence', direction: 'min', value: 4 });
    expect(out).toHaveLength(2);
    expect(input).toHaveLength(1);
  });
});
  • [ ] Step 2: Run to verify failure

Run: cd packages/web/frontend && npx vitest run src/store/constraintStore.test.ts src/lib/entriesToChips.test.ts Expected: FAIL (exports missing).

  • [ ] Step 3: Implement

constraintStore.ts — wrap the existing create<ConstraintStoreState>((set, get) => ({...})) body in a factory, byte-identical initializer:

export function createConstraintStore() {
  return create<ConstraintStoreState>((set, get) => ({
    // ... the existing initializer, unchanged ...
  }));
}

/** Sentences' instance — the original module singleton, name preserved. */
export const useConstraintStore = createConstraintStore();

/** Word Lists' instance (PHON-220). Separate store: chips added in one tool
 *  must never appear in another. */
export const useWordListConstraintStore = createConstraintStore();

entriesToChips.ts — add below upsertCategorical:

/** Replace-or-append a bound entry keyed by (norm, direction) — one chip per
 *  side per property; re-adding "AoA ≤ 3" after "AoA ≤ 5" updates the chip
 *  instead of stacking a dead one (the worker's filters map is last-wins for
 *  the same key, so a stacked chip would lie). Immutable. */
export function upsertBound(
  entries: StoreEntry[],
  entry: Extract<StoreEntry, { type: 'bound' }>,
): StoreEntry[] {
  const idx = entries.findIndex(
    (e) => e.type === 'bound' && e.norm === entry.norm && e.direction === entry.direction,
  );
  if (idx === -1) return [...entries, entry];
  return entries.map((e, i) => (i === idx ? entry : e));
}
  • [ ] Step 4: Run tests + full frontend suite + tsc

Run: cd packages/web/frontend && npx vitest run src/store/constraintStore.test.ts src/lib/entriesToChips.test.ts && npx tsc --noEmit && npm test Expected: PASS; Sentences suites (GovernedGenerationTool tests, if any exercise the store) unaffected.

  • [ ] Step 5: Commit
git add packages/web/frontend/src/store/constraintStore.ts packages/web/frontend/src/store/constraintStore.test.ts packages/web/frontend/src/lib/entriesToChips.ts packages/web/frontend/src/lib/entriesToChips.test.ts
git commit -m "feat(phon-220): constraint-store factory + word-list instance + upsertBound"

Task 2: PropertyFilterComposer — PHON-224 percentile authority + Infinity clamp

Files: - Modify: packages/web/frontend/src/components/shared/PropertyFilterComposer.tsx - Test: packages/web/frontend/src/components/shared/PropertyFilterComposer.test.tsx (extend)

Interfaces: - Consumes: bucketsForProperty (already imported by the component), PropertyBucketSet.mode. - Produces: the corrected percentile decision every tool inherits: isPercentile(prop) = prop.use_log_scale || bucketsForProperty(prop.id)?.mode === 'percentile'. When percentile via bucket-mode, the norm gets the _percentile suffix and baseline [0,100] exactly as use_log_scale percentile props do. handleAdd emits only finite moved sides (Number.isFinite guard) — closes the propertyBuckets Infinity-sentinel trap.

  • [ ] Step 1: Write the failing tests

Extend PropertyFilterComposer.test.tsx with a fixture property mirroring the real contextual_diversity shape — use_log_scale: false in metadata but a percentile-mode bucket set. Follow the file's existing fixture/mock idiom (it already mocks bucketsForProperty or uses real buckets — check which; if it uses the real lib/propertyBuckets, add the fixture prop under a REAL bucketed id with mode: 'percentile', e.g. use contextual_diversity itself in the metadata fixture). Binding assertions:

  it('a percentile-mode bucket set forces percentile semantics even when use_log_scale is false (PHON-224)', async () => {
    // select the contextual_diversity-shaped prop, click a bucket chip
    // (e.g. the top quintile), click Add filter, then:
    expect(onAdd).toHaveBeenCalledWith([
      { type: 'bound', norm: 'contextual_diversity_percentile', direction: 'min', value: 80 },
    ]);
  });

  it('non-finite range sides are never emitted', async () => {
    // fixture bucket with max: Infinity selected → only the min bound emits
    // (assert no entry whose value is Infinity / null lands in onAdd's payload)
  });

Write full test bodies per the file's established interaction idioms (Select open via mouseDown, bucket chips via role/button text). If no current bucket exposes max: Infinity, drive the Infinity case through the slider path by stubbing the metadata range — the contract is "no non-finite value in any emitted entry", assert it however the fixture best reaches it, and say which route you took.

  • [ ] Step 2: Run to verify failure — the PHON-224 case must fail with the current use_log_scale-only logic (emitting min_contextual_diversity = 80 style bounds).

  • [ ] Step 3: Implement

In PropertyFilterComposer.tsx: centralize the decision (it currently derives percentile-ness from use_log_scale alone around the normId/baseline block):

  // PHON-224: a percentile-mode bucket set is authoritative — its labels
  // describe distribution position ("Top 20%"), so the emitted bound must be
  // the _percentile norm regardless of use_log_scale. use_log_scale remains
  // the signal for slider-rendered percentile props.
  const isPercentile = (p: PropertyDef) =>
    p.use_log_scale || bucketsForProperty(p.id)?.mode === 'percentile';

…and use it wherever use_log_scale gated the suffix/baseline/slider-mode. In handleAdd, guard each side: emit the min entry only when Number.isFinite(value[0]) && moved; max likewise.

  • [ ] Step 4: Run the component suite + full frontend matrix

Run: cd packages/web/frontend && npx vitest run src/components/shared/PropertyFilterComposer.test.tsx && npx tsc --noEmit && npm run lint && npm test Expected: PASS.

  • [ ] Step 5: Commit
git add packages/web/frontend/src/components/shared/PropertyFilterComposer.tsx packages/web/frontend/src/components/shared/PropertyFilterComposer.test.tsx
git commit -m "fix(phon-224): bucket mode is authoritative for percentile bounds; clamp non-finite sides"

Task 3: Builder rebuilt on the grammar (input side + test migration)

Files: - Modify: packages/web/frontend/src/components/Builder.tsx (input side replaced; results side, ToolFrame/StickyActionBar usage, WordListTable block preserved) - Modify: packages/web/frontend/src/components/Builder.test.tsx (rewritten to drive the new flow)

Interfaces: - Consumes: useWordListConstraintStore, upsertBound, upsertCategorical, entriesToChips, compileConstraints, ScopeBar, PatternComposer, PropertyFilterComposer, CategoricalComposer, CategoricalRule (CV shapes), SimilarToRule, usePropertyMetadata().defaultScope. - Produces: the rebuilt Word Lists. Request contract: buildWordSearchRequest(compileConstraints(entries), { hasImage, lemmasOnly, includeSpecialized, similarTo? }, 200).

Target structure of Builder.tsx (the whole input side — write exactly this):

State:

  const { propertyMap, loaded, error: metadataError, defaultScope } = usePropertyMetadata();
  const { entries, add, removeAt, load, clear } = useWordListConstraintStore();

  // Universe scope + execution options — NOT chips (spec §B): the ScopeBar
  // shows its own state inline; similar-to is an execution option with its
  // own section. Everything else lives in the constraint store.
  const [hasPictureCard, setHasPictureCard] = useState(false);
  const [baseFormsOnly, setBaseFormsOnly] = useState(false);
  const [includeSpecialized, setIncludeSpecialized] = useState(false);
  const [similarTo, setSimilarTo] = useState<SimilarToValue>({ /* unchanged initial */ });

  // CV-shape composer draft (committed to the store via Add)
  const [cvDraft, setCvDraft] = useState<string[]>([]);
  const [cvMode, setCvMode] = useState<ConstraintMode>('include');

  // Results state — unchanged (results/resultTotal/loading/error).

Deleted entirely: patterns, filters/filtersInitialized + init effect, excludePhonemeInput, patternWarnings/exclusionWarning, phoneme-picker state + handlers, propsByCategory, activeFilterIds, isPercentileProp/propertyDefById, patternsBlock, exclusionsBlock, the five property accordions, the old inline scopeBar, the old chips memo, addPattern/removePattern/updatePattern, the PhonemePickerDialog at the bottom (PatternComposer owns its own). Imports pruned accordingly (Select/MenuItem/TextField/Checkbox/Switch/IconButton/Add/Delete/Keyboard/PhonemePickerDialog/validatePhonemeInput/PropertySlider/BucketChips/bucketsForProperty go unless still referenced).

Wiring:

  const handleAddBounds = (bounds: Array<Extract<StoreEntry, { type: 'bound' }>>) => {
    let next = useWordListConstraintStore.getState().entries;
    for (const b of bounds) next = upsertBound(next, b);
    load(next);
  };
  const handleUpsertCategorical = (entry: Extract<StoreEntry, { type: 'categorical' }>) =>
    load(upsertCategorical(useWordListConstraintStore.getState().entries, entry));

  const handleAddCvShapes = () => {
    if (cvDraft.length === 0) return;
    add({ type: 'cv_shape', shapes: cvDraft, mode: cvMode });
    setCvDraft([]);
  };

  const chips = useMemo<ChipDescriptor[]>(() => {
    const base = entriesToChips(entries, propertyMap, removeAt);
    if (similarTo.word.trim()) {
      base.push({
        id: 'similar-to',
        label: `Similar to: ${similarTo.word.trim()}`,
        color: 'info',
        onDelete: () => setSimilarTo((prev) => ({ ...prev, word: '' })),
      });
    }
    return base;
  }, [entries, propertyMap, removeAt, similarTo]);

  const badges = useMemo(() => ({
    patterns: entries.filter((e) => e.type === 'pattern' || e.type === 'cv_shape').length,
    properties: entries.filter((e) => e.type === 'bound' || e.type === 'categorical').length,
    soundSimilarity: similarTo.word.trim() ? 1 : 0,
  }), [entries, similarTo.word]);

handleBuild (same try/catch/track skeleton; replace the constraint assembly):

      const constraints = compileConstraints(useWordListConstraintStore.getState().entries);
      const opts: WordOptions = {
        hasImage: hasPictureCard,
        lemmasOnly: baseFormsOnly,
        includeSpecialized,
        similarTo: similarTo.word.trim() ? similarTo : undefined,
      };
      const request = buildWordSearchRequest(constraints, opts, PAGE_LIMIT);
      // analytics constraint_count: entries.length + (opts.hasImage?1:0) +
      // (opts.lemmasOnly?1:0) + (opts.similarTo?1:0)

handleClear: clear() + reset the four local states + results/error, unchanged skeleton.

Sections JSX:

  const sections = (
    <>
      <MetadataErrorAlert error={metadataError} />
      <ScopeBar
        baseForms={{ value: baseFormsOnly, onChange: setBaseFormsOnly }}
        hasImage={{ value: hasPictureCard, onChange: setHasPictureCard }}
        specialized={{ value: includeSpecialized, onChange: setIncludeSpecialized }}
        defaultScope={defaultScope}
      />
      <ToolSection title="Phoneme Patterns" defaultExpanded badgeCount={badges.patterns} idPrefix="builder-phoneme-rules">
        <Stack spacing={3}>
          <PatternComposer subject="word" onAdd={add} />
          <Stack spacing={1.5}>
            <CategoricalRule
              label="CV shape"
              presetsLabel="Common shapes:"
              presets={['V', 'CV', 'VC', 'CVC', 'CCV', 'CCVC', 'CVCC', 'CCVCC', 'CV-CV', 'CV-CVC', 'CCV-CV']}
              value={cvDraft}
              onChange={setCvDraft}
              allowCustom
              customValidator={(s) => /^[CV]+(-[CV]+)*$/.test(s)}
            />
            <Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
              <ToggleButtonGroup value={cvMode} exclusive size="small" aria-label="Include or exclude mode"
                onChange={(_, v: ConstraintMode | null) => { if (v) setCvMode(v); }}>
                <ToggleButton value="include" color="info">Include</ToggleButton>
                <ToggleButton value="exclude" color="error">Exclude</ToggleButton>
              </ToggleButtonGroup>
              <Button size="small" variant="outlined" startIcon={<AddIcon />} onClick={handleAddCvShapes} disabled={cvDraft.length === 0}>
                Add shapes
              </Button>
            </Stack>
          </Stack>
        </Stack>
      </ToolSection>
      <ToolSection title="Property Filters" badgeCount={badges.properties} idPrefix="builder-property-filters"
        helperText="Pick a property, set a range, add it as a filter">
        {!loaded ? (
          <Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}><CircularProgress size={24} /></Box>
        ) : (
          <Stack spacing={3}>
            <PropertyFilterComposer onAdd={handleAddBounds} />
            <CategoricalComposer onUpsert={handleUpsertCategorical} />
          </Stack>
        )}
      </ToolSection>
      <ToolSection title="Sound Similarity" badgeCount={badges.soundSimilarity} idPrefix="builder-sound-similarity">
        <SimilarToRule value={similarTo} onChange={setSimilarTo} />
      </ToolSection>
    </>
  );

Return block: same ToolFrame usage (activeBar = <ActiveConstraintsBar chips={chips} onClearAll={handleClear} />, actionBar and results unchanged) WITHOUT the trailing PhonemePickerDialog. Update the file-header comment to describe the new surface (grammar sections, store-backed, PHON-220) and drop the stale five-accordion description.

Test migration (Builder.test.tsx): keep the render harness (PropertyMetadataProvider + mocked api), reset useWordListConstraintStore in beforeEach/afterEach (useWordListConstraintStore.getState().clear() — a module-level store leaks state across tests otherwise). The metadata mock must now include default_scope (it already does post-PHON-218) and, for categorical coverage, a categorical prop with values. Required cases (payload assertions on api.searchWords's request.constraints, same style as today):

  1. ScopeBar: toggling Has image → { type:'flag', property:'has_image', value:true } in the payload; off → absent. (Replaces the current has-image switch tests.)
  2. ScopeBar: Base forms → { type:'scope', kind:'lemmas_only' }; Specialized → { type:'scope', kind:'include_specialized' }.
  3. PatternComposer path: type s, Add rule, Build → pattern constraint STARTS_WITH include.
  4. Exclusion path: mode Exclude + operator Ends with + t, Add rule, Build → { type:'pattern', pattern_type:'ENDS_WITH', phonemes:['t'], mode:'exclude' } — the newly-expressible case, pin it.
  5. Property filter: select a slider prop, move a thumb, Add filter, Build → bound constraint with correct norm (percentile-suffixed for a use_log_scale fixture prop).
  6. Categorical: select the categorical fixture prop, pick a value, Add, Build → categorical constraint.
  7. CV shapes: pick CVC, Add shapes, Build → { type:'cv_shape', shapes:['CVC'], mode:'include' }; exclude-mode variant.
  8. Chips: adding a pattern renders its chip; deleting the chip removes the entry (Build sends no pattern).
  9. Clear all resets store + scope toggles (Build sends constraints: undefined).

Adapt DOM mechanics from the shared components' own test files (they already solved the MUI interactions); assertions above are binding.

  • [ ] Step 1: Rewrite Builder.test.tsx first (RED: the new tests fail against the old Builder — run npx vitest run src/components/Builder.test.tsx and confirm the failures are the expected missing-UI ones).
  • [ ] Step 2: Rebuild Builder.tsx per the target structure above.
  • [ ] Step 3: Run npx vitest run src/components/Builder.test.tsx → GREEN, then the full frontend matrix (npx tsc --noEmit && npm run lint && npm test && npm run build).
  • [ ] Step 4: Manual smoke on local dev (worker npm run dev in packages/web/workers, frontend dev server, or curl the worker directly): Build with a pattern + a property filter + Base forms ON and verify results return and the POST body carries the expected constraints. Kill dev servers after.
  • [ ] Step 5: Commit
git add packages/web/frontend/src/components/Builder.tsx packages/web/frontend/src/components/Builder.test.tsx
git commit -m "feat(phon-220): Word Lists rebuilt on the shared grammar"

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

  • [ ] Step 1: 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. Any failure = stop.
  • [ ] Step 2: git branch --show-current = feat/phon-220-word-lists-grammar; git log --oneline origin/develop..HEAD; git push -u origin feat/phon-220-word-lists-grammar. Do not commit pre-existing untracked repo-root files or uv.lock.
  • [ ] Step 3 (controller, after clean final review): PR to develop, noting: five-accordion surface replaced (register category now visible; ENDS_WITH-exclude + CV-shape exclude newly expressible); PHON-224 root-fixed in the shared composer (transition the ticket after merge); slider wall removed per spec §B trade-off; ordering/sampling untouched (PHON-221 next).

Self-Review Notes

  • Spec §C coverage: ScopeBar promotion (base forms out of Word Shape; relabel via component) → T3; five hardcoded categories → metadata-driven composer → T3; register categorical surfaced → T3 (CategoricalComposer); exclusion as mode on any operator → T3 (case 4 pinned); CV shape stays under Patterns → T3; state onto constraint store → T1+T3; sampling untouched → global constraint.
  • Carried items closed: PHON-224 (T2, option 2 ruling), upsertBound (T1), Infinity clamp (T2).
  • Type consistency: handleAddBounds consumes PropertyFilterComposer's onAdd(entries: Array<bound StoreEntry>) (PHON-219 contract); handleUpsertCategorical consumes onUpsert(entry); PatternComposer.onAdd takes a single pattern StoreEntry = store add directly.
  • Known UX deltas, deliberate (spec-approved): slider wall gone; property filters are add-flow; exclusions field gone (composer exclude mode); scope toggles no longer duplicate into chips.