Skip to content

Governed Generation Tool UI — 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: Replace the GovernedGenerationPlaceholder with a full tool that composes constraints visually, takes a freeform prompt, and displays output as cards in a vertical feed with toggleable compliance analysis.

Architecture: Port constraint store + compiler from packages/dashboard/frontend/ into packages/web/frontend/. Build new MUI components following existing patterns (Accordion filters from Builder, PhonemePickerDialog, PropertySlider, SelectionToolbar/ExportMenu). The generation backend stays unchanged — we call its /api/generate-single endpoint directly.

Tech Stack: React 19, TypeScript, MUI 7, Zustand (new dep), Vitest + happy-dom

Spec: docs/superpowers/specs/2026-04-05-governed-generation-tool-ui-design.md


File Structure

New Files

packages/web/frontend/src/
  types/
    governance.ts                         — StoreEntry, Constraint, RichToken, API response types (port from dashboard)
  store/
    constraintStore.ts                    — Zustand constraint store (port from dashboard)
    constraintStore.test.ts               — Store unit tests
  lib/
    constraintCompiler.ts                 — Store → API compiler (port from dashboard)
    constraintCompiler.test.ts            — Compiler unit tests
    generationApi.ts                      — Generation backend API client + useServerStatus hook
  components/
    shared/
      PropertySlider.tsx                  — Extracted from Builder.tsx
    tools/
      GovernedGenerationTool/
        index.tsx                         — Main tool component (assembly)
        PhonemeConstraints.tsx            — Exclusion + inclusion accordion sections
        BoundsSection.tsx                 — Psycholinguistic bounds accordion (PropertySlider per norm)
        OtherConstraints.tsx              — Structural + boosts + thematic accordion sections
        ActiveConstraints.tsx             — Dismissible chip summary row
        OutputCard.tsx                    — Single output card (clean + analysis states)
        OutputFeed.tsx                    — Card feed container with selection + export
        TokenDisplay.tsx                  — RichTokenText + TokenDetailPopover (MUI-adapted)

Modified Files

packages/web/frontend/package.json                           — Add zustand dependency
packages/web/frontend/src/components/Builder.tsx              — Import PropertySlider from shared instead of inline
packages/web/frontend/src/App_new.tsx                         — Replace placeholder with GovernedGenerationTool, update icon

Task 1: Install Zustand + Create Governance Types

Files: - Modify: packages/web/frontend/package.json - Create: packages/web/frontend/src/types/governance.ts

  • [ ] Step 1: Install Zustand
cd packages/web/frontend && npm install zustand
  • [ ] Step 2: Create governance types

Create packages/web/frontend/src/types/governance.ts — ported from packages/dashboard/frontend/src/types.ts, keeping only what we need (no chat types, no session types):

/**
 * Governance types — constraint store, API constraints, and generation response types.
 * Ported from packages/dashboard/frontend/src/types.ts (chat/session types removed).
 */

// ---------------------------------------------------------------------------
// Token types (matches backend schemas.py)
// ---------------------------------------------------------------------------

export interface Phono {
  phonemes: string[];
  ipa: string;
  syllable_count: number;
  syllable_shapes: string[];
  wcm: number;
  cluster_phonemes: string[];
  syllables: string[];
}

export interface RichToken {
  id: number;
  text: string;
  word_group?: number;
  phono: Phono | null;
  norms: Record<string, number>;
  vocab_memberships: string[];
  compliance: {
    passed: boolean;
    violations: string[];
  };
}

// ---------------------------------------------------------------------------
// API constraint types (matches backend schemas.py)
// ---------------------------------------------------------------------------

export interface ExcludeConstraint {
  type: "exclude";
  phonemes: string[];
}

export interface ExcludeClustersConstraint {
  type: "exclude_clusters";
  phonemes: string[];
}

export interface IncludeConstraint {
  type: "include";
  phonemes: string[];
  strength: number;
  target_rate?: number;
  max_boost?: number;
}

export interface VocabBoostConstraint {
  type: "vocab_boost";
  lists?: string[];
  words?: string[];
  strength?: number;
  target_rate?: number;
  max_boost?: number;
}

export interface BoundConstraint {
  type: "bound";
  norm: string;
  min?: number;
  max?: number;
  mechanism?: "gate" | "cdd";
}

export interface ComplexityConstraint {
  type: "complexity";
  max_wcm?: number;
  max_syllables?: number;
  allowed_shapes?: string[];
}

export interface VocabOnlyConstraint {
  type: "vocab_only";
  lists?: string[];
  words?: string[];
  include_punctuation?: boolean;
}

export interface MSHConstraint {
  type: "msh";
  max_stage: number;
}

export interface MinPairBoostConstraint {
  type: "boost_minpair";
  target: string;
  contrast: string;
  strength: number;
}

export interface MaxOppositionBoostConstraint {
  type: "boost_maxopp";
  target: string;
  contrast: string;
  strength: number;
}

export interface ThematicConstraint {
  type: "thematic";
  seed_words: string[];
  strength: number;
  threshold?: number;
}

export type Constraint =
  | ExcludeConstraint
  | ExcludeClustersConstraint
  | IncludeConstraint
  | VocabBoostConstraint
  | BoundConstraint
  | ComplexityConstraint
  | VocabOnlyConstraint
  | MSHConstraint
  | MinPairBoostConstraint
  | MaxOppositionBoostConstraint
  | ThematicConstraint;

// ---------------------------------------------------------------------------
// Per-entry constraint store types (granular, one per UI action)
// ---------------------------------------------------------------------------

export type StoreEntry =
  | { type: "exclude"; phoneme: string }
  | { type: "exclude_clusters"; phoneme: string }
  | { type: "include"; phoneme: string; strength: number; targetRate?: number }
  | { type: "vocab_boost"; lists?: string[]; words?: string[]; targetRate?: number }
  | { type: "bound"; norm: string; direction: "min" | "max"; value: number; mechanism?: "gate" | "cdd" }
  | { type: "complexity_wcm"; max: number }
  | { type: "complexity_syllables"; max: number }
  | { type: "complexity_shapes"; shapes: string[] }
  | { type: "msh"; maxStage: number }
  | { type: "boost_minpair"; target: string; contrast: string; strength: number }
  | { type: "boost_maxopp"; target: string; contrast: string; strength: number }
  | { type: "theme"; seedWords: string[]; strength: number };

// ---------------------------------------------------------------------------
// Generation API types
// ---------------------------------------------------------------------------

export interface SingleGenerationResponse {
  tokens: RichToken[];
  text: string;
  gen_time_ms: number;
  compliant: boolean;
  violation_count: number;
}

export interface ServerStatus {
  model: string;
  vocab_size: number;
  memory_gb: number;
  status: "loading" | "ready" | "error";
  error?: string;
  lookup_entries: number;
}

// ---------------------------------------------------------------------------
// Client-side generation result (stored in card feed)
// ---------------------------------------------------------------------------

export interface GenerationResult {
  id: string;
  prompt: string;
  constraintSnapshot: StoreEntry[];
  tokens: RichToken[];
  text: string;
  genTimeMs: number;
  compliant: boolean;
  violationCount: number;
  timestamp: number;
}
  • [ ] Step 3: Verify TypeScript compiles
cd packages/web/frontend && npx tsc --noEmit

Expected: No errors related to governance.ts.

  • [ ] Step 4: Commit
git add packages/web/frontend/package.json packages/web/frontend/package-lock.json packages/web/frontend/src/types/governance.ts
git commit -m "feat(frontend): add governance types and zustand dependency"

Task 2: Constraint Store

Files: - Create: packages/web/frontend/src/store/constraintStore.ts - Create: packages/web/frontend/src/store/constraintStore.test.ts

  • [ ] Step 1: Write failing tests

Create packages/web/frontend/src/store/constraintStore.test.ts:

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

// Direct access to the store for testing (Zustand stores can be called outside React)
const store = useConstraintStore;

describe('constraintStore', () => {
  beforeEach(() => {
    store.getState().clear();
  });

  it('starts empty', () => {
    expect(store.getState().entries).toEqual([]);
  });

  it('adds an entry', () => {
    store.getState().add({ type: 'exclude', phoneme: 'ɹ' });
    expect(store.getState().entries).toEqual([{ type: 'exclude', phoneme: 'ɹ' }]);
  });

  it('rejects duplicate entries', () => {
    store.getState().add({ type: 'exclude', phoneme: 'ɹ' });
    store.getState().add({ type: 'exclude', phoneme: 'ɹ' });
    expect(store.getState().entries).toHaveLength(1);
  });

  it('removes by type', () => {
    store.getState().add({ type: 'exclude', phoneme: 'ɹ' });
    store.getState().add({ type: 'exclude', phoneme: 's' });
    store.getState().remove('exclude');
    expect(store.getState().entries).toEqual([]);
  });

  it('removes by type + match', () => {
    store.getState().add({ type: 'exclude', phoneme: 'ɹ' });
    store.getState().add({ type: 'exclude', phoneme: 's' });
    store.getState().remove('exclude', { phoneme: 'ɹ' });
    expect(store.getState().entries).toEqual([{ type: 'exclude', phoneme: 's' }]);
  });

  it('clears all entries', () => {
    store.getState().add({ type: 'exclude', phoneme: 'ɹ' });
    store.getState().add({ type: 'bound', norm: 'aoa_kuperman', direction: 'max', value: 5 });
    store.getState().clear();
    expect(store.getState().entries).toEqual([]);
  });

  it('loads entries in bulk', () => {
    const entries = [
      { type: 'exclude' as const, phoneme: 'ɹ' },
      { type: 'bound' as const, norm: 'aoa_kuperman', direction: 'max' as const, value: 5 },
    ];
    store.getState().load(entries);
    expect(store.getState().entries).toEqual(entries);
  });

  it('snapshot returns current entries', () => {
    store.getState().add({ type: 'exclude', phoneme: 'ɹ' });
    const snap = store.getState().snapshot();
    expect(snap).toEqual([{ type: 'exclude', phoneme: 'ɹ' }]);
    // Snapshot is a copy, not a reference
    expect(snap).not.toBe(store.getState().entries);
  });
});
  • [ ] Step 2: Run tests to verify they fail
cd packages/web/frontend && npx vitest run src/store/constraintStore.test.ts

Expected: FAIL — module not found.

  • [ ] Step 3: Implement constraint store

Create packages/web/frontend/src/store/constraintStore.ts:

/**
 * Constraint store — Zustand store for governed generation constraints.
 *
 * Holds granular per-entry constraints (one phoneme per exclude, etc.).
 * The compiler merges these into API format on generation.
 *
 * Ported from packages/dashboard/frontend/src/store/constraintStore.ts.
 */

import { create } from 'zustand';
import type { StoreEntry } from '../types/governance';

interface ConstraintStoreState {
  entries: StoreEntry[];
  add: (entry: StoreEntry) => void;
  remove: (type: string, match?: Record<string, unknown>) => void;
  clear: () => void;
  load: (entries: StoreEntry[]) => void;
  snapshot: () => StoreEntry[];
}

export const useConstraintStore = create<ConstraintStoreState>((set, get) => ({
  entries: [],

  add: (entry) =>
    set((state) => {
      const isDuplicate = state.entries.some(
        (e) => JSON.stringify(e) === JSON.stringify(entry),
      );
      if (isDuplicate) return state;
      return { entries: [...state.entries, entry] };
    }),

  remove: (type, match) =>
    set((state) => ({
      entries: state.entries.filter((e) => {
        if (e.type !== type) return true;
        if (!match) return false;
        return !Object.entries(match).every(
          ([k, v]) => (e as Record<string, unknown>)[k] === v,
        );
      }),
    })),

  clear: () => set({ entries: [] }),
  load: (entries) => set({ entries }),
  snapshot: () => [...get().entries],
}));
  • [ ] Step 4: Run tests to verify they pass
cd packages/web/frontend && npx vitest run src/store/constraintStore.test.ts

Expected: All 8 tests PASS.

  • [ ] Step 5: Commit
git add packages/web/frontend/src/store/constraintStore.ts packages/web/frontend/src/store/constraintStore.test.ts
git commit -m "feat(frontend): add constraint store for governed generation"

Task 3: Constraint Compiler

Files: - Create: packages/web/frontend/src/lib/constraintCompiler.ts - Create: packages/web/frontend/src/lib/constraintCompiler.test.ts

  • [ ] Step 1: Write failing tests

Create packages/web/frontend/src/lib/constraintCompiler.test.ts:

import { describe, it, expect } from 'vitest';
import { compileConstraints } from './constraintCompiler';
import type { StoreEntry } from '../types/governance';

describe('compileConstraints', () => {
  it('returns empty array for no entries', () => {
    expect(compileConstraints([])).toEqual([]);
  });

  it('merges exclude phonemes into one constraint', () => {
    const entries: StoreEntry[] = [
      { type: 'exclude', phoneme: 'ɹ' },
      { type: 'exclude', phoneme: 's' },
    ];
    const result = compileConstraints(entries);
    expect(result).toEqual([{ type: 'exclude', phonemes: ['ɹ', 's'] }]);
  });

  it('deduplicates exclude phonemes', () => {
    const entries: StoreEntry[] = [
      { type: 'exclude', phoneme: 'ɹ' },
      { type: 'exclude', phoneme: 'ɹ' },
    ];
    const result = compileConstraints(entries);
    expect(result).toEqual([{ type: 'exclude', phonemes: ['ɹ'] }]);
  });

  it('compiles include with coverage target', () => {
    const entries: StoreEntry[] = [
      { type: 'include', phoneme: 'k', strength: 2.0, targetRate: 20 },
    ];
    const result = compileConstraints(entries);
    expect(result).toEqual([
      { type: 'include', phonemes: ['k'], strength: 2.0, target_rate: 0.2, max_boost: 3.0 },
    ]);
  });

  it('compiles bound entry', () => {
    const entries: StoreEntry[] = [
      { type: 'bound', norm: 'aoa_kuperman', direction: 'max', value: 5 },
    ];
    const result = compileConstraints(entries);
    expect(result).toEqual([{ type: 'bound', norm: 'aoa_kuperman', max: 5 }]);
  });

  it('merges complexity entries into one constraint', () => {
    const entries: StoreEntry[] = [
      { type: 'complexity_wcm', max: 3 },
      { type: 'complexity_syllables', max: 2 },
      { type: 'complexity_shapes', shapes: ['CV', 'CVC'] },
    ];
    const result = compileConstraints(entries);
    expect(result).toEqual([
      { type: 'complexity', max_wcm: 3, max_syllables: 2, allowed_shapes: ['CV', 'CVC'] },
    ]);
  });

  it('compiles msh entry', () => {
    const entries: StoreEntry[] = [{ type: 'msh', maxStage: 3 }];
    const result = compileConstraints(entries);
    expect(result).toEqual([{ type: 'msh', max_stage: 3 }]);
  });

  it('compiles theme entry', () => {
    const entries: StoreEntry[] = [
      { type: 'theme', seedWords: ['dog', 'cat'], strength: 1.5 },
    ];
    const result = compileConstraints(entries);
    expect(result).toEqual([
      { type: 'thematic', seed_words: ['dog', 'cat'], strength: 1.5 },
    ]);
  });

  it('compiles mixed entries', () => {
    const entries: StoreEntry[] = [
      { type: 'exclude', phoneme: 'ɹ' },
      { type: 'bound', norm: 'aoa_kuperman', direction: 'max', value: 5 },
      { type: 'include', phoneme: 'k', strength: 2.0 },
    ];
    const result = compileConstraints(entries);
    expect(result).toHaveLength(3);
    expect(result[0]).toEqual({ type: 'exclude', phonemes: ['ɹ'] });
    expect(result[1]).toEqual({ type: 'include', phonemes: ['k'], strength: 2.0 });
    expect(result[2]).toEqual({ type: 'bound', norm: 'aoa_kuperman', max: 5 });
  });
});
  • [ ] Step 2: Run tests to verify they fail
cd packages/web/frontend && npx vitest run src/lib/constraintCompiler.test.ts

Expected: FAIL — module not found.

  • [ ] Step 3: Implement compiler

Create packages/web/frontend/src/lib/constraintCompiler.ts:

/**
 * Constraint compiler: merge per-entry StoreEntry[] into API Constraint[] format.
 *
 * The store holds granular, per-UI-action entries (one phoneme per exclude, etc.).
 * The API expects merged constraints (one exclude with all phonemes, etc.).
 *
 * Ported from packages/dashboard/frontend/src/commands/compiler.ts.
 */

import type { StoreEntry, Constraint } from '../types/governance';

export function compileConstraints(entries: StoreEntry[]): Constraint[] {
  const result: Constraint[] = [];

  // --- Exclude: merge all phonemes into one constraint ---
  const excludePhonemes = entries
    .filter((e): e is Extract<StoreEntry, { type: 'exclude' }> => e.type === 'exclude')
    .map((e) => e.phoneme);

  if (excludePhonemes.length > 0) {
    result.push({ type: 'exclude', phonemes: [...new Set(excludePhonemes)] });
  }

  // --- Exclude clusters: merge all phonemes into one constraint ---
  const clusterPhonemes = entries
    .filter((e): e is Extract<StoreEntry, { type: 'exclude_clusters' }> => e.type === 'exclude_clusters')
    .map((e) => e.phoneme);

  if (clusterPhonemes.length > 0) {
    result.push({ type: 'exclude_clusters', phonemes: [...new Set(clusterPhonemes)] });
  }

  // --- Include: one constraint per entry (may have coverage target) ---
  const includes = entries.filter(
    (e): e is Extract<StoreEntry, { type: 'include' }> => e.type === 'include',
  );
  for (const inc of includes) {
    const c: Constraint = { type: 'include', phonemes: [inc.phoneme], strength: inc.strength };
    if (inc.targetRate !== undefined) {
      (c as any).target_rate = inc.targetRate / 100;
      (c as any).max_boost = 3.0;
    }
    result.push(c);
  }

  // --- VocabBoost ---
  const vocabBoosts = entries.filter(
    (e): e is Extract<StoreEntry, { type: 'vocab_boost' }> => e.type === 'vocab_boost',
  );
  for (const vb of vocabBoosts) {
    const c: any = { type: 'vocab_boost' };
    if (vb.lists) c.lists = vb.lists;
    if (vb.words) c.words = vb.words;
    if (vb.targetRate !== undefined) {
      c.target_rate = vb.targetRate / 100;
      c.max_boost = 3.0;
    } else {
      c.strength = 2.0;
    }
    result.push(c);
  }

  // --- Bound: one constraint per entry ---
  const bounds = entries.filter(
    (e): e is Extract<StoreEntry, { type: 'bound' }> => e.type === 'bound',
  );
  for (const b of bounds) {
    const constraint: Constraint = {
      type: 'bound',
      norm: b.norm,
      ...(b.direction === 'min' ? { min: b.value } : { max: b.value }),
      ...(b.mechanism ? { mechanism: b.mechanism } : {}),
    };
    result.push(constraint);
  }

  // --- Complexity: merge wcm + syllables + shapes into one ---
  const wcmEntry = entries.find(
    (e): e is Extract<StoreEntry, { type: 'complexity_wcm' }> => e.type === 'complexity_wcm',
  );
  const syllablesEntry = entries.find(
    (e): e is Extract<StoreEntry, { type: 'complexity_syllables' }> => e.type === 'complexity_syllables',
  );
  const shapesEntry = entries.find(
    (e): e is Extract<StoreEntry, { type: 'complexity_shapes' }> => e.type === 'complexity_shapes',
  );

  if (wcmEntry || syllablesEntry || shapesEntry) {
    result.push({
      type: 'complexity',
      ...(wcmEntry ? { max_wcm: wcmEntry.max } : {}),
      ...(syllablesEntry ? { max_syllables: syllablesEntry.max } : {}),
      ...(shapesEntry ? { allowed_shapes: shapesEntry.shapes } : {}),
    });
  }

  // --- MSH ---
  const mshEntry = entries.find(
    (e): e is Extract<StoreEntry, { type: 'msh' }> => e.type === 'msh',
  );
  if (mshEntry) {
    result.push({ type: 'msh', max_stage: mshEntry.maxStage });
  }

  // --- Boost minpair: one per entry ---
  const minpairs = entries.filter(
    (e): e is Extract<StoreEntry, { type: 'boost_minpair' }> => e.type === 'boost_minpair',
  );
  for (const mp of minpairs) {
    result.push({ type: 'boost_minpair', target: mp.target, contrast: mp.contrast, strength: mp.strength });
  }

  // --- Boost maxopp: one per entry ---
  const maxopps = entries.filter(
    (e): e is Extract<StoreEntry, { type: 'boost_maxopp' }> => e.type === 'boost_maxopp',
  );
  for (const mo of maxopps) {
    result.push({ type: 'boost_maxopp', target: mo.target, contrast: mo.contrast, strength: mo.strength });
  }

  // --- Theme ---
  const themes = entries.filter(
    (e): e is Extract<StoreEntry, { type: 'theme' }> => e.type === 'theme',
  );
  for (const t of themes) {
    result.push({ type: 'thematic', seed_words: t.seedWords, strength: t.strength });
  }

  return result;
}
  • [ ] Step 4: Run tests to verify they pass
cd packages/web/frontend && npx vitest run src/lib/constraintCompiler.test.ts

Expected: All 9 tests PASS.

  • [ ] Step 5: Commit
git add packages/web/frontend/src/lib/constraintCompiler.ts packages/web/frontend/src/lib/constraintCompiler.test.ts
git commit -m "feat(frontend): add constraint compiler for governed generation"

Task 4: Generation API Client

Files: - Create: packages/web/frontend/src/lib/generationApi.ts

  • [ ] Step 1: Create API client

Create packages/web/frontend/src/lib/generationApi.ts:

/**
 * Generation API client — talks to the governed generation backend (FastAPI).
 *
 * Separate from the main PhonoLex API client (apiClient.ts) because it points
 * to a different service. Base URL is configurable via VITE_GENERATION_API_URL.
 */

import { useState, useEffect, useRef } from 'react';
import type { Constraint, SingleGenerationResponse, ServerStatus } from '../types/governance';

const GENERATION_API_URL = import.meta.env.VITE_GENERATION_API_URL || 'http://localhost:8000';

export async function generateContent(
  prompt: string,
  constraints: Constraint[],
): Promise<SingleGenerationResponse> {
  const res = await fetch(`${GENERATION_API_URL}/api/generate-single`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ prompt, constraints }),
  });

  if (!res.ok) {
    const detail = await res.text().catch(() => res.statusText);
    throw new Error(`Generation failed (${res.status}): ${detail}`);
  }

  return res.json();
}

export async function fetchServerStatus(): Promise<ServerStatus> {
  const res = await fetch(`${GENERATION_API_URL}/api/server/status`);

  if (!res.ok) {
    throw new Error(`Server status check failed (${res.status})`);
  }

  return res.json();
}

/**
 * Hook that polls the generation server status every 5 seconds.
 * Returns current status or null if unreachable.
 */
export function useServerStatus(pollIntervalMs = 5000): ServerStatus | null {
  const [status, setStatus] = useState<ServerStatus | null>(null);
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

  useEffect(() => {
    let cancelled = false;

    async function poll() {
      try {
        const s = await fetchServerStatus();
        if (!cancelled) setStatus(s);
      } catch {
        if (!cancelled) setStatus(null);
      }
    }

    poll();
    intervalRef.current = setInterval(poll, pollIntervalMs);

    return () => {
      cancelled = true;
      if (intervalRef.current) clearInterval(intervalRef.current);
    };
  }, [pollIntervalMs]);

  return status;
}
  • [ ] Step 2: Verify TypeScript compiles
cd packages/web/frontend && npx tsc --noEmit

Expected: No errors.

  • [ ] Step 3: Commit
git add packages/web/frontend/src/lib/generationApi.ts
git commit -m "feat(frontend): add generation API client and server status hook"

Task 5: Extract PropertySlider to Shared

Files: - Create: packages/web/frontend/src/components/shared/PropertySlider.tsx - Modify: packages/web/frontend/src/components/Builder.tsx

  • [ ] Step 1: Create shared PropertySlider

Create packages/web/frontend/src/components/shared/PropertySlider.tsx — extracted from Builder.tsx lines 53-140:

/**
 * PropertySlider — two-thumb range slider for psycholinguistic property filtering.
 *
 * Handles both linear and logarithmic scales. Extracted from Builder.tsx
 * for reuse in GovernedGenerationTool.
 */

import React from 'react';
import { Box, Typography, Slider } from '@mui/material';
import type { PropertyDef } from '../../hooks/usePropertyMetadata';

export interface PropertySliderProps {
  prop: PropertyDef;
  value: [number, number];
  range: [number, number];
  onChange: (id: string, value: [number, number]) => void;
}

const PropertySlider: React.FC<PropertySliderProps> = React.memo(({ prop, value, range, onChange }) => {
  const formatValue = (v: number): string => {
    if (prop.is_integer) return String(Math.round(v));
    const match = prop.display_format.match(/\.(\d+)f/);
    if (match) return v.toFixed(parseInt(match[1]));
    return String(v);
  };

  // Log scale handling (e.g., frequency)
  if (prop.use_log_scale && range[1] > 0) {
    const logMin = 0;
    const logMax = Math.log10(Math.max(range[1], 1));
    const logValue: [number, number] = [
      value[0] > 0 ? Math.log10(value[0]) : 0,
      Math.log10(Math.max(value[1], 1)),
    ];

    const marks: Array<{ value: number; label: string }> = [];
    for (let exp = 0; exp <= Math.ceil(logMax); exp++) {
      if (exp <= logMax) {
        const label = exp === 0 ? '0' : exp <= 3 ? String(Math.pow(10, exp)) : `${Math.pow(10, exp - 3)}K`;
        marks.push({ value: exp, label });
      }
    }

    return (
      <Box>
        <Typography variant="body2" gutterBottom sx={{ fontSize: { xs: '0.8125rem', sm: '0.875rem' } }}>
          {prop.label}: {Math.round(value[0])} - {Math.round(value[1])}
          <Typography variant="caption" color="text.secondary" display="block" sx={{ fontSize: { xs: '0.6875rem', sm: '0.75rem' } }}>
            {prop.source} ({prop.interpretation})
          </Typography>
        </Typography>
        <Slider
          aria-label={`${prop.label} range`}
          value={logValue}
          onChange={(_, v) => {
            const [minLog, maxLog] = v as [number, number];
            onChange(prop.id, [
              minLog > 0 ? Math.pow(10, minLog) : 0,
              Math.pow(10, maxLog),
            ]);
          }}
          min={logMin}
          max={logMax}
          step={0.01}
          valueLabelDisplay="auto"
          valueLabelFormat={(v) => Math.round(Math.pow(10, v)).toString()}
          marks={marks}
          sx={{ '& .MuiSlider-markLabel': { fontSize: { xs: '0.625rem', sm: '0.75rem' } } }}
        />
      </Box>
    );
  }

  return (
    <Box>
      <Typography variant="body2" gutterBottom sx={{ fontSize: { xs: '0.8125rem', sm: '0.875rem' } }}>
        {prop.label}: {formatValue(value[0])} - {formatValue(value[1])}
        <Typography variant="caption" color="text.secondary" display="block" sx={{ fontSize: { xs: '0.6875rem', sm: '0.75rem' } }}>
          {prop.source} ({prop.interpretation})
        </Typography>
      </Typography>
      <Slider
        aria-label={`${prop.label} range`}
        value={value}
        onChange={(_, v) => onChange(prop.id, v as [number, number])}
        min={range[0]}
        max={range[1]}
        step={prop.slider_step}
        marks={prop.is_integer}
        valueLabelDisplay="auto"
        valueLabelFormat={formatValue}
        sx={{ '& .MuiSlider-markLabel': { fontSize: { xs: '0.625rem', sm: '0.75rem' } } }}
      />
    </Box>
  );
});

PropertySlider.displayName = 'PropertySlider';

export default PropertySlider;
  • [ ] Step 2: Update Builder.tsx to use shared PropertySlider

In packages/web/frontend/src/components/Builder.tsx:

  1. Remove the inline PropertySliderProps interface (lines 53-58) and PropertySlider component (lines 60-140).
  2. Add import at the top:
import PropertySlider from './shared/PropertySlider';

Remove the Slider import from MUI if it was only used by PropertySlider (check — Builder may use Slider elsewhere). Keep all other imports unchanged.

  • [ ] Step 3: Verify build succeeds
cd packages/web/frontend && npx tsc --noEmit

Expected: No errors. Builder still compiles and uses PropertySlider from the shared import.

  • [ ] Step 4: Commit
git add packages/web/frontend/src/components/shared/PropertySlider.tsx packages/web/frontend/src/components/Builder.tsx
git commit -m "refactor(frontend): extract PropertySlider to shared component"

Task 6: Token Display Components

Files: - Create: packages/web/frontend/src/components/tools/GovernedGenerationTool/TokenDisplay.tsx

  • [ ] Step 1: Create TokenDisplay component

Create the directory and file. This adapts the dashboard's RichTokenSpan.tsx and TokenPopover.tsx from Tailwind to MUI:

/**
 * Token display components for governed generation output.
 *
 * RichTokenText: renders generated text with per-token compliance highlighting.
 * TokenDetailPopover: shows phonology + norms on token click.
 *
 * Adapted from packages/dashboard/frontend/src/components/ChatWindow/
 * RichTokenSpan.tsx and TokenPopover.tsx (Tailwind → MUI).
 */

import React, { useState, useRef } from 'react';
import {
  Box,
  Popover,
  Typography,
  IconButton,
  Divider,
  Stack,
} from '@mui/material';
import { Close as CloseIcon } from '@mui/icons-material';
import type { RichToken } from '../../../types/governance';

// ---------------------------------------------------------------------------
// Norm labels for display
// ---------------------------------------------------------------------------

const NORM_LABELS: Record<string, string> = {
  aoa_kuperman: 'AoA',
  concreteness: 'Concreteness',
  valence: 'Valence',
  arousal: 'Arousal',
  imageability: 'Imageability',
  familiarity: 'Familiarity',
  log_frequency: 'Frequency',
};

const VISIBLE_NORMS = new Set(Object.keys(NORM_LABELS));

// ---------------------------------------------------------------------------
// TokenDetailPopover
// ---------------------------------------------------------------------------

function TokenDetailPopover({
  token,
  anchorEl,
  onClose,
}: {
  token: RichToken;
  anchorEl: HTMLElement | null;
  onClose: () => void;
}) {
  const visibleNorms = Object.entries(token.norms).filter(([k]) => VISIBLE_NORMS.has(k));

  return (
    <Popover
      open={Boolean(anchorEl)}
      anchorEl={anchorEl}
      onClose={onClose}
      anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
      transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
      slotProps={{ paper: { sx: { width: 280, maxHeight: '70vh', overflow: 'auto' } } }}
    >
      {/* Header */}
      <Box sx={{ px: 2, py: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: 1, borderColor: 'divider' }}>
        <Typography variant="subtitle2">&ldquo;{token.text.trim()}&rdquo;</Typography>
        <IconButton size="small" onClick={onClose}><CloseIcon fontSize="small" /></IconButton>
      </Box>

      <Box sx={{ p: 2 }}>
        <Stack spacing={2}>
          {/* Phonology */}
          {token.phono && (
            <Box>
              <Typography variant="caption" fontWeight="bold" color="text.secondary">Phonology</Typography>
              <Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0.5, mt: 0.5, fontSize: '0.75rem' }}>
                <Typography variant="caption">IPA: <code>{token.phono.ipa}</code></Typography>
                <Typography variant="caption">Syllables: {token.phono.syllable_count || '—'}</Typography>
                <Typography variant="caption">WCM: {token.phono.wcm}</Typography>
              </Box>
              <Typography variant="caption" display="block" sx={{ mt: 0.5 }}>
                Phonemes: <code>{token.phono.phonemes.join(' ')}</code>
              </Typography>
              {token.phono.cluster_phonemes.length > 0 && (
                <Typography variant="caption" display="block" color="text.secondary">
                  Clusters: <code>{token.phono.cluster_phonemes.join(' ')}</code>
                </Typography>
              )}
            </Box>
          )}

          {/* Norms */}
          {visibleNorms.length > 0 && (
            <Box>
              <Divider sx={{ mb: 1 }} />
              <Typography variant="caption" fontWeight="bold" color="text.secondary">Norms</Typography>
              <Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0.5, mt: 0.5 }}>
                {visibleNorms.map(([k, v]) => (
                  <Typography key={k} variant="caption">
                    {NORM_LABELS[k] ?? k}: <code>{typeof v === 'number' ? v.toFixed(2) : v}</code>
                  </Typography>
                ))}
              </Box>
            </Box>
          )}

          {/* Compliance */}
          {!token.compliance.passed && (
            <Box>
              <Divider sx={{ mb: 1 }} />
              <Typography variant="caption" fontWeight="bold" color="error.main">Violations</Typography>
              {token.compliance.violations.map((v, i) => (
                <Typography key={i} variant="caption" display="block" color="error.main">{v}</Typography>
              ))}
            </Box>
          )}
        </Stack>
      </Box>
    </Popover>
  );
}

// ---------------------------------------------------------------------------
// RichTokenSpan (single token)
// ---------------------------------------------------------------------------

function RichTokenSpan({ token }: { token: RichToken }) {
  const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
  const spanRef = useRef<HTMLSpanElement>(null);

  const violationStyle = !token.compliance.passed
    ? { backgroundColor: 'rgba(239, 68, 68, 0.3)' }
    : {};

  return (
    <>
      <Box
        component="span"
        ref={spanRef}
        onClick={() => setAnchorEl(spanRef.current)}
        title={token.phono?.ipa}
        sx={{
          cursor: 'pointer',
          borderRadius: '2px',
          '&:hover': { outline: '1px solid', outlineColor: 'primary.light' },
          ...violationStyle,
        }}
      >
        {token.text}
      </Box>
      <TokenDetailPopover
        token={token}
        anchorEl={anchorEl}
        onClose={() => setAnchorEl(null)}
      />
    </>
  );
}

// ---------------------------------------------------------------------------
// RichTokenText (full text as clickable tokens)
// ---------------------------------------------------------------------------

export default function RichTokenText({ tokens }: { tokens: RichToken[] }) {
  return (
    <Typography component="div" variant="body1" sx={{ lineHeight: 1.8 }}>
      {tokens.map((token) => (
        <RichTokenSpan key={token.id} token={token} />
      ))}
    </Typography>
  );
}
  • [ ] Step 2: Verify TypeScript compiles
cd packages/web/frontend && npx tsc --noEmit

Expected: No errors.

  • [ ] Step 3: Commit
git add packages/web/frontend/src/components/tools/GovernedGenerationTool/TokenDisplay.tsx
git commit -m "feat(frontend): add token display components for governed generation"

Task 7: Output Card Component

Files: - Create: packages/web/frontend/src/components/tools/GovernedGenerationTool/OutputCard.tsx

  • [ ] Step 1: Create OutputCard component
/**
 * Output card — displays a single generation result.
 *
 * Two states:
 * - Default (clean): plain text + slim metadata footer
 * - Analysis (toggle): token-level compliance overlay with clickable tokens
 */

import React, { useState } from 'react';
import {
  Paper,
  Box,
  Typography,
  Stack,
  IconButton,
  Switch,
  FormControlLabel,
  Chip,
  Checkbox,
  Tooltip,
} from '@mui/material';
import {
  ContentCopy as CopyIcon,
  Replay as ReplayIcon,
  Delete as DeleteIcon,
  CheckCircleOutline as CheckIcon,
  ErrorOutline as ErrorIcon,
} from '@mui/icons-material';
import type { GenerationResult, StoreEntry } from '../../../types/governance';
import RichTokenText from './TokenDisplay';

// ---------------------------------------------------------------------------
// Constraint chip label helpers
// ---------------------------------------------------------------------------

function chipLabel(entry: StoreEntry): string {
  switch (entry.type) {
    case 'exclude': return `/${entry.phoneme}/`;
    case 'exclude_clusters': return `/${entry.phoneme}/ (clusters)`;
    case 'include': return entry.targetRate ? `/${entry.phoneme}/ ~${entry.targetRate}%` : `/${entry.phoneme}/`;
    case 'bound': return `${entry.norm.replace('_', ' ')} ${entry.direction === 'min' ? '≥' : '≤'} ${entry.value}`;
    case 'complexity_wcm': return `WCM ≤ ${entry.max}`;
    case 'complexity_syllables': return `Syl ≤ ${entry.max}`;
    case 'complexity_shapes': return entry.shapes.join(', ');
    case 'msh': return `MSH ≤ ${entry.maxStage}`;
    case 'boost_minpair': return `MinPair ${entry.target}${entry.contrast}`;
    case 'boost_maxopp': return `MaxOpp ${entry.target}${entry.contrast}`;
    case 'vocab_boost': return 'Vocab boost';
    case 'theme': return `Theme: ${entry.seedWords.slice(0, 3).join(', ')}`;
    default: return 'Constraint';
  }
}

function chipColor(entry: StoreEntry): 'error' | 'info' | 'success' | 'default' | 'secondary' {
  switch (entry.type) {
    case 'exclude':
    case 'exclude_clusters':
      return 'error';
    case 'bound':
      return 'info';
    case 'include':
    case 'vocab_boost':
    case 'boost_minpair':
    case 'boost_maxopp':
      return 'success';
    case 'complexity_wcm':
    case 'complexity_syllables':
    case 'complexity_shapes':
    case 'msh':
      return 'default';
    case 'theme':
      return 'secondary';
    default:
      return 'default';
  }
}

// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------

interface OutputCardProps {
  result: GenerationResult;
  selected: boolean;
  onSelect: (id: string) => void;
  onRegenerate: (result: GenerationResult) => void;
  onDelete: (id: string) => void;
}

const OutputCard: React.FC<OutputCardProps> = ({ result, selected, onSelect, onRegenerate, onDelete }) => {
  const [analysisMode, setAnalysisMode] = useState(false);
  const [copied, setCopied] = useState(false);

  const handleCopy = async () => {
    await navigator.clipboard.writeText(result.text);
    setCopied(true);
    setTimeout(() => setCopied(false), 1500);
  };

  return (
    <Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
      {/* Header row: checkbox + analysis toggle */}
      <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
        <Checkbox
          checked={selected}
          onChange={() => onSelect(result.id)}
          size="small"
        />
        <FormControlLabel
          control={
            <Switch
              size="small"
              checked={analysisMode}
              onChange={(_, checked) => setAnalysisMode(checked)}
            />
          }
          label={<Typography variant="caption">Analysis</Typography>}
          sx={{ mr: 0 }}
        />
      </Stack>

      {/* Content: plain text or token-level display */}
      <Box sx={{ mb: 2 }}>
        {analysisMode ? (
          <RichTokenText tokens={result.tokens} />
        ) : (
          <Typography variant="body1" sx={{ lineHeight: 1.8, whiteSpace: 'pre-wrap' }}>
            {result.text}
          </Typography>
        )}
      </Box>

      {/* Constraint snapshot (visible in analysis mode) */}
      {analysisMode && result.constraintSnapshot.length > 0 && (
        <Box sx={{ mb: 1.5, display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
          {result.constraintSnapshot.map((entry, i) => (
            <Chip key={i} label={chipLabel(entry)} color={chipColor(entry)} size="small" variant="outlined" />
          ))}
        </Box>
      )}

      {/* Footer: metadata + actions */}
      <Stack direction="row" justifyContent="space-between" alignItems="center">
        <Stack direction="row" spacing={1.5} alignItems="center">
          {result.compliant ? (
            <Chip icon={<CheckIcon />} label="Compliant" size="small" color="success" variant="outlined" />
          ) : (
            <Chip icon={<ErrorIcon />} label={`${result.violationCount} violation${result.violationCount !== 1 ? 's' : ''}`} size="small" color="error" variant="outlined" />
          )}
          <Typography variant="caption" color="text.secondary">
            {result.tokens.length} tokens
          </Typography>
          <Typography variant="caption" color="text.secondary">
            {(result.genTimeMs / 1000).toFixed(1)}s
          </Typography>
        </Stack>

        <Stack direction="row" spacing={0.5}>
          <Tooltip title={copied ? 'Copied!' : 'Copy text'}>
            <IconButton size="small" onClick={handleCopy}>
              <CopyIcon fontSize="small" />
            </IconButton>
          </Tooltip>
          <Tooltip title="Regenerate">
            <IconButton size="small" onClick={() => onRegenerate(result)}>
              <ReplayIcon fontSize="small" />
            </IconButton>
          </Tooltip>
          <Tooltip title="Delete">
            <IconButton size="small" onClick={() => onDelete(result.id)}>
              <DeleteIcon fontSize="small" />
            </IconButton>
          </Tooltip>
        </Stack>
      </Stack>
    </Paper>
  );
};

export default OutputCard;
export { chipLabel, chipColor };
  • [ ] Step 2: Verify TypeScript compiles
cd packages/web/frontend && npx tsc --noEmit
  • [ ] Step 3: Commit
git add packages/web/frontend/src/components/tools/GovernedGenerationTool/OutputCard.tsx
git commit -m "feat(frontend): add output card component for governed generation"

Task 8: Output Feed with Selection + Export

Files: - Create: packages/web/frontend/src/components/tools/GovernedGenerationTool/OutputFeed.tsx

  • [ ] Step 1: Create OutputFeed component
/**
 * Output feed — vertical card list with selection + export.
 *
 * Displays generation results newest-first. Supports checkbox selection
 * and export to .txt and .csv via custom export logic (not ExportMenu,
 * since ExportMenu expects Word[] and we have GenerationResult[]).
 */

import React, { useState, useCallback } from 'react';
import {
  Box,
  Stack,
  Typography,
  Button,
  Chip,
  Fade,
  Menu,
  MenuItem,
  ListItemIcon,
  ListItemText,
} from '@mui/material';
import {
  Download as DownloadIcon,
  SelectAll as SelectAllIcon,
  Clear as ClearIcon,
  Description as TextIcon,
  TableChart as CsvIcon,
} from '@mui/icons-material';
import type { GenerationResult, StoreEntry } from '../../../types/governance';
import OutputCard, { chipLabel } from './OutputCard';

// ---------------------------------------------------------------------------
// Export helpers
// ---------------------------------------------------------------------------

function downloadFile(content: string, filename: string, mime: string) {
  const blob = new Blob([content], { type: mime });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);
}

function exportTxt(results: GenerationResult[]) {
  const text = results.map((r) => r.text.trim()).join('\n\n');
  downloadFile(text, 'governed_generation.txt', 'text/plain');
}

function exportCsv(results: GenerationResult[]) {
  const headers = ['Prompt', 'Generated Text', 'Constraints', 'Compliant', 'Violations', 'Tokens', 'Time (s)', 'Timestamp'];
  const rows = results.map((r) => [
    `"${r.prompt.replace(/"/g, '""')}"`,
    `"${r.text.replace(/"/g, '""')}"`,
    `"${r.constraintSnapshot.map(chipLabel).join('; ')}"`,
    r.compliant ? 'Yes' : 'No',
    String(r.violationCount),
    String(r.tokens.length),
    (r.genTimeMs / 1000).toFixed(1),
    new Date(r.timestamp).toISOString(),
  ]);
  const csv = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
  downloadFile(csv, 'governed_generation.csv', 'text/csv');
}

// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------

interface OutputFeedProps {
  results: GenerationResult[];
  onRegenerate: (result: GenerationResult) => void;
  onDelete: (id: string) => void;
}

const OutputFeed: React.FC<OutputFeedProps> = ({ results, onRegenerate, onDelete }) => {
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
  const [exportAnchor, setExportAnchor] = useState<null | HTMLElement>(null);

  const handleSelect = useCallback((id: string) => {
    setSelectedIds((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }, []);

  const selectAll = () => setSelectedIds(new Set(results.map((r) => r.id)));
  const clearAll = () => setSelectedIds(new Set());

  const selectedResults = results.filter((r) => selectedIds.has(r.id));
  const hasSelection = selectedIds.size > 0;

  if (results.length === 0) return null;

  return (
    <Box>
      {/* Selection toolbar */}
      <Fade in={hasSelection} unmountOnExit>
        <Box sx={{ bgcolor: 'primary.light', color: 'primary.contrastText', px: 2, py: 1.5, borderRadius: 1, mb: 2 }}>
          <Stack direction={{ xs: 'column', sm: 'row' }} alignItems={{ xs: 'stretch', sm: 'center' }} justifyContent="space-between" spacing={2}>
            <Stack direction="row" alignItems="center" spacing={1}>
              <Chip label={selectedIds.size} size="small" color="primary" />
              <Typography variant="body2">of {results.length} selected</Typography>
            </Stack>
            <Stack direction="row" spacing={1}>
              {selectedIds.size < results.length && (
                <Button size="small" startIcon={<SelectAllIcon />} onClick={selectAll} sx={{ color: 'inherit' }}>
                  Select All
                </Button>
              )}
              <Button size="small" startIcon={<ClearIcon />} onClick={clearAll} sx={{ color: 'inherit' }}>
                Clear
              </Button>
              <Button size="small" startIcon={<DownloadIcon />} onClick={(e) => setExportAnchor(e.currentTarget)} sx={{ color: 'inherit' }}>
                Export
              </Button>
              <Menu anchorEl={exportAnchor} open={Boolean(exportAnchor)} onClose={() => setExportAnchor(null)}>
                <MenuItem onClick={() => { exportTxt(selectedResults); setExportAnchor(null); }}>
                  <ListItemIcon><TextIcon fontSize="small" /></ListItemIcon>
                  <ListItemText>Text file (.txt)</ListItemText>
                </MenuItem>
                <MenuItem onClick={() => { exportCsv(selectedResults); setExportAnchor(null); }}>
                  <ListItemIcon><CsvIcon fontSize="small" /></ListItemIcon>
                  <ListItemText>CSV (.csv)</ListItemText>
                </MenuItem>
              </Menu>
            </Stack>
          </Stack>
        </Box>
      </Fade>

      {/* Cards */}
      {results.map((result) => (
        <OutputCard
          key={result.id}
          result={result}
          selected={selectedIds.has(result.id)}
          onSelect={handleSelect}
          onRegenerate={onRegenerate}
          onDelete={onDelete}
        />
      ))}
    </Box>
  );
};

export default OutputFeed;
  • [ ] Step 2: Verify TypeScript compiles
cd packages/web/frontend && npx tsc --noEmit
  • [ ] Step 3: Commit
git add packages/web/frontend/src/components/tools/GovernedGenerationTool/OutputFeed.tsx
git commit -m "feat(frontend): add output feed with selection and export"

Task 9: Phoneme Constraint Sections

Files: - Create: packages/web/frontend/src/components/tools/GovernedGenerationTool/PhonemeConstraints.tsx

  • [ ] Step 1: Create PhonemeConstraints component
/**
 * Phoneme constraint accordion sections — Exclusion + Inclusion.
 *
 * Both use the same PhonemePickerDialog pattern from Builder.tsx.
 * Reads/writes the constraint store directly via Zustand.
 */

import React, { useState } from 'react';
import {
  Accordion,
  AccordionSummary,
  AccordionDetails,
  Typography,
  TextField,
  IconButton,
  Box,
  Chip,
  Stack,
  Slider,
  InputAdornment,
} from '@mui/material';
import {
  ExpandMore as ExpandMoreIcon,
  Keyboard as KeyboardIcon,
} from '@mui/icons-material';
import PhonemePickerDialog from '../../PhonemePickerDialog';
import { useConstraintStore } from '../../../store/constraintStore';
import type { StoreEntry } from '../../../types/governance';

// ---------------------------------------------------------------------------
// Exclusion Section
// ---------------------------------------------------------------------------

function ExclusionSection() {
  const { entries, add, remove } = useConstraintStore();
  const [pickerOpen, setPickerOpen] = useState(false);
  const [input, setInput] = useState('');

  const excludeEntries = entries.filter((e): e is Extract<StoreEntry, { type: 'exclude' }> => e.type === 'exclude');

  const addPhoneme = (phoneme: string) => {
    const trimmed = phoneme.trim();
    if (trimmed) add({ type: 'exclude', phoneme: trimmed });
  };

  const handleInputKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && input.trim()) {
      input.trim().split(/\s+/).forEach(addPhoneme);
      setInput('');
    }
  };

  return (
    <Accordion>
      <AccordionSummary expandIcon={<ExpandMoreIcon />}>
        <Typography variant="subtitle1">
          Phoneme Exclusion
          {excludeEntries.length > 0 && (
            <Chip label={excludeEntries.length} size="small" color="error" sx={{ ml: 1 }} />
          )}
        </Typography>
      </AccordionSummary>
      <AccordionDetails sx={{ px: { xs: 1.5, sm: 2 }, py: { xs: 1, sm: 2 } }}>
        <TextField
          fullWidth
          size="small"
          placeholder="Type phonemes or use picker..."
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={handleInputKeyDown}
          slotProps={{
            input: {
              endAdornment: (
                <InputAdornment position="end">
                  <IconButton onClick={() => setPickerOpen(true)} edge="end" color="primary" size="small">
                    <KeyboardIcon />
                  </IconButton>
                </InputAdornment>
              ),
            },
          }}
        />
        {excludeEntries.length > 0 && (
          <Box sx={{ mt: 1.5, display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
            {excludeEntries.map((entry) => (
              <Chip
                key={entry.phoneme}
                label={`/${entry.phoneme}/`}
                color="error"
                size="small"
                variant="outlined"
                onDelete={() => remove('exclude', { phoneme: entry.phoneme })}
              />
            ))}
          </Box>
        )}
        <PhonemePickerDialog
          open={pickerOpen}
          onClose={() => setPickerOpen(false)}
          onSelect={addPhoneme}
        />
      </AccordionDetails>
    </Accordion>
  );
}

// ---------------------------------------------------------------------------
// Inclusion Section
// ---------------------------------------------------------------------------

function InclusionSection() {
  const { entries, add, remove } = useConstraintStore();
  const [pickerOpen, setPickerOpen] = useState(false);
  const [input, setInput] = useState('');

  const includeEntries = entries.filter((e): e is Extract<StoreEntry, { type: 'include' }> => e.type === 'include');

  const addPhoneme = (phoneme: string) => {
    const trimmed = phoneme.trim();
    if (trimmed) add({ type: 'include', phoneme: trimmed, strength: 2.0 });
  };

  const handleInputKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && input.trim()) {
      input.trim().split(/\s+/).forEach(addPhoneme);
      setInput('');
    }
  };

  const updateStrength = (phoneme: string, strength: number) => {
    remove('include', { phoneme });
    const existing = includeEntries.find((e) => e.phoneme === phoneme);
    add({ type: 'include', phoneme, strength, targetRate: existing?.targetRate });
  };

  const updateTargetRate = (phoneme: string, targetRate: number | undefined) => {
    remove('include', { phoneme });
    const existing = includeEntries.find((e) => e.phoneme === phoneme);
    add({ type: 'include', phoneme, strength: existing?.strength ?? 2.0, targetRate });
  };

  return (
    <Accordion>
      <AccordionSummary expandIcon={<ExpandMoreIcon />}>
        <Typography variant="subtitle1">
          Phoneme Inclusion
          {includeEntries.length > 0 && (
            <Chip label={includeEntries.length} size="small" color="success" sx={{ ml: 1 }} />
          )}
        </Typography>
      </AccordionSummary>
      <AccordionDetails sx={{ px: { xs: 1.5, sm: 2 }, py: { xs: 1, sm: 2 } }}>
        <TextField
          fullWidth
          size="small"
          placeholder="Type phonemes or use picker..."
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={handleInputKeyDown}
          slotProps={{
            input: {
              endAdornment: (
                <InputAdornment position="end">
                  <IconButton onClick={() => setPickerOpen(true)} edge="end" color="primary" size="small">
                    <KeyboardIcon />
                  </IconButton>
                </InputAdornment>
              ),
            },
          }}
        />
        {includeEntries.length > 0 && (
          <Stack spacing={1.5} sx={{ mt: 1.5 }}>
            {includeEntries.map((entry) => (
              <Box key={entry.phoneme} sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
                <Chip
                  label={`/${entry.phoneme}/`}
                  color="success"
                  size="small"
                  variant="outlined"
                  onDelete={() => remove('include', { phoneme: entry.phoneme })}
                />
                <Box sx={{ flex: 1, minWidth: 100 }}>
                  <Typography variant="caption" color="text.secondary">Strength</Typography>
                  <Slider
                    size="small"
                    value={entry.strength}
                    onChange={(_, v) => updateStrength(entry.phoneme, v as number)}
                    min={0.5}
                    max={5}
                    step={0.5}
                    valueLabelDisplay="auto"
                  />
                </Box>
                <TextField
                  size="small"
                  type="number"
                  label="Target %"
                  value={entry.targetRate ?? ''}
                  onChange={(e) => updateTargetRate(entry.phoneme, e.target.value ? Number(e.target.value) : undefined)}
                  sx={{ width: 90 }}
                  slotProps={{ htmlInput: { min: 0, max: 100 } }}
                />
              </Box>
            ))}
          </Stack>
        )}
        <PhonemePickerDialog
          open={pickerOpen}
          onClose={() => setPickerOpen(false)}
          onSelect={addPhoneme}
        />
      </AccordionDetails>
    </Accordion>
  );
}

// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------

export default function PhonemeConstraints() {
  return (
    <>
      <ExclusionSection />
      <InclusionSection />
    </>
  );
}
  • [ ] Step 2: Verify TypeScript compiles
cd packages/web/frontend && npx tsc --noEmit
  • [ ] Step 3: Commit
git add packages/web/frontend/src/components/tools/GovernedGenerationTool/PhonemeConstraints.tsx
git commit -m "feat(frontend): add phoneme exclusion/inclusion constraint sections"

Task 10: Psycholinguistic Bounds Section

Files: - Create: packages/web/frontend/src/components/tools/GovernedGenerationTool/BoundsSection.tsx

  • [ ] Step 1: Create BoundsSection component
/**
 * Psycholinguistic bounds accordion — range sliders per norm.
 *
 * Uses PropertySlider (shared) and usePropertyMetadata hook.
 * When a slider is moved from its full range, the constraint is added
 * to the store. When reset to full range, it's removed.
 */

import React, { useState, useEffect } from 'react';
import {
  Accordion,
  AccordionSummary,
  AccordionDetails,
  Typography,
  Stack,
  Chip,
} from '@mui/material';
import { ExpandMore as ExpandMoreIcon } from '@mui/icons-material';
import PropertySlider from '../../shared/PropertySlider';
import { usePropertyMetadata } from '../../../hooks/usePropertyMetadata';
import { useConstraintStore } from '../../../store/constraintStore';

/**
 * Maps a property slider range into bound store entries.
 *
 * If the user moves the min above the property minimum, we add a "min" bound.
 * If the user moves the max below the property maximum, we add a "max" bound.
 * If both are at the property range extremes, we remove the bound.
 */
export default function BoundsSection() {
  const { categories, ranges, loaded } = usePropertyMetadata();
  const { entries, add, remove } = useConstraintStore();

  // Local slider state: { propertyId: [min, max] }
  const [sliderValues, setSliderValues] = useState<Record<string, [number, number]>>({});

  // Initialize slider values from property ranges when metadata loads
  useEffect(() => {
    if (!loaded || Object.keys(sliderValues).length > 0) return;
    const initial: Record<string, [number, number]> = {};
    for (const [propId, range] of Object.entries(ranges)) {
      initial[propId] = [...range] as [number, number];
    }
    setSliderValues(initial);
  }, [loaded, ranges, sliderValues]);

  const boundEntries = entries.filter((e) => e.type === 'bound');

  // Only show filterable categories that have numeric norms
  const filteredCategories = categories
    .map((cat) => ({
      ...cat,
      properties: cat.properties.filter((p) => p.filterable && ranges[p.id]),
    }))
    .filter((cat) => cat.properties.length > 0);

  const handleSliderChange = (propId: string, value: [number, number]) => {
    setSliderValues((prev) => ({ ...prev, [propId]: value }));

    const range = ranges[propId];
    if (!range) return;

    // Remove existing bounds for this norm
    remove('bound', { norm: propId, direction: 'min' });
    remove('bound', { norm: propId, direction: 'max' });

    // Add new bounds if slider moved from extremes
    const epsilon = (range[1] - range[0]) * 0.001;
    if (value[0] > range[0] + epsilon) {
      add({ type: 'bound', norm: propId, direction: 'min', value: value[0] });
    }
    if (value[1] < range[1] - epsilon) {
      add({ type: 'bound', norm: propId, direction: 'max', value: value[1] });
    }
  };

  if (!loaded) return null;

  return (
    <Accordion>
      <AccordionSummary expandIcon={<ExpandMoreIcon />}>
        <Typography variant="subtitle1">
          Psycholinguistic Bounds
          {boundEntries.length > 0 && (
            <Chip label={boundEntries.length} size="small" color="info" sx={{ ml: 1 }} />
          )}
        </Typography>
      </AccordionSummary>
      <AccordionDetails sx={{ px: { xs: 1.5, sm: 2 }, py: { xs: 1, sm: 2 } }}>
        {filteredCategories.map((cat) => (
          <Accordion key={cat.id} defaultExpanded={false}>
            <AccordionSummary expandIcon={<ExpandMoreIcon />}>
              <Typography variant="subtitle2">{cat.label}</Typography>
            </AccordionSummary>
            <AccordionDetails>
              <Stack spacing={{ xs: 1.5, sm: 2 }}>
                {cat.properties.map((prop) => (
                  <PropertySlider
                    key={prop.id}
                    prop={prop}
                    value={sliderValues[prop.id] ?? ranges[prop.id] ?? [0, 1]}
                    range={ranges[prop.id] ?? [0, 1]}
                    onChange={handleSliderChange}
                  />
                ))}
              </Stack>
            </AccordionDetails>
          </Accordion>
        ))}
      </AccordionDetails>
    </Accordion>
  );
}
  • [ ] Step 2: Verify TypeScript compiles
cd packages/web/frontend && npx tsc --noEmit
  • [ ] Step 3: Commit
git add packages/web/frontend/src/components/tools/GovernedGenerationTool/BoundsSection.tsx
git commit -m "feat(frontend): add psycholinguistic bounds constraint section"

Task 11: Structural + Boosts + Thematic Sections

Files: - Create: packages/web/frontend/src/components/tools/GovernedGenerationTool/OtherConstraints.tsx

  • [ ] Step 1: Create OtherConstraints component
/**
 * Remaining constraint accordion sections:
 * - Structural (WCM, syllables, shapes, MSH)
 * - Boosts (minimal pair, maximal opposition, vocab boost)
 * - Thematic (seed words + strength)
 */

import React, { useState } from 'react';
import {
  Accordion,
  AccordionSummary,
  AccordionDetails,
  Typography,
  TextField,
  Slider,
  Select,
  MenuItem,
  FormControl,
  InputLabel,
  ToggleButton,
  ToggleButtonGroup,
  Stack,
  Box,
  Chip,
  IconButton,
  InputAdornment,
} from '@mui/material';
import {
  ExpandMore as ExpandMoreIcon,
  Keyboard as KeyboardIcon,
} from '@mui/icons-material';
import PhonemePickerDialog from '../../PhonemePickerDialog';
import { useConstraintStore } from '../../../store/constraintStore';
import type { StoreEntry } from '../../../types/governance';

const SYLLABLE_SHAPES = ['V', 'CV', 'VC', 'CVC', 'CCV', 'CCVC', 'CVCC', 'CCVCC'];

// ---------------------------------------------------------------------------
// Structural Section
// ---------------------------------------------------------------------------

function StructuralSection() {
  const { entries, add, remove } = useConstraintStore();

  const wcmEntry = entries.find((e): e is Extract<StoreEntry, { type: 'complexity_wcm' }> => e.type === 'complexity_wcm');
  const sylEntry = entries.find((e): e is Extract<StoreEntry, { type: 'complexity_syllables' }> => e.type === 'complexity_syllables');
  const shapesEntry = entries.find((e): e is Extract<StoreEntry, { type: 'complexity_shapes' }> => e.type === 'complexity_shapes');
  const mshEntry = entries.find((e): e is Extract<StoreEntry, { type: 'msh' }> => e.type === 'msh');

  const activeCount = [wcmEntry, sylEntry, shapesEntry, mshEntry].filter(Boolean).length;

  return (
    <Accordion>
      <AccordionSummary expandIcon={<ExpandMoreIcon />}>
        <Typography variant="subtitle1">
          Structural
          {activeCount > 0 && <Chip label={activeCount} size="small" sx={{ ml: 1 }} />}
        </Typography>
      </AccordionSummary>
      <AccordionDetails sx={{ px: { xs: 1.5, sm: 2 }, py: { xs: 1, sm: 2 } }}>
        <Stack spacing={2}>
          {/* WCM max */}
          <Box>
            <Typography variant="body2" gutterBottom>Max WCM: {wcmEntry?.max ?? '—'}</Typography>
            <Slider
              value={wcmEntry?.max ?? 20}
              onChange={(_, v) => {
                remove('complexity_wcm');
                if ((v as number) < 20) add({ type: 'complexity_wcm', max: v as number });
              }}
              min={0}
              max={20}
              step={1}
              marks
              valueLabelDisplay="auto"
            />
          </Box>

          {/* Max syllables */}
          <Box>
            <Typography variant="body2" gutterBottom>Max Syllables: {sylEntry?.max ?? '—'}</Typography>
            <Slider
              value={sylEntry?.max ?? 6}
              onChange={(_, v) => {
                remove('complexity_syllables');
                if ((v as number) < 6) add({ type: 'complexity_syllables', max: v as number });
              }}
              min={1}
              max={6}
              step={1}
              marks
              valueLabelDisplay="auto"
            />
          </Box>

          {/* Allowed shapes */}
          <Box>
            <Typography variant="body2" gutterBottom>Allowed Shapes</Typography>
            <ToggleButtonGroup
              value={shapesEntry?.shapes ?? []}
              onChange={(_, shapes: string[]) => {
                remove('complexity_shapes');
                if (shapes.length > 0) add({ type: 'complexity_shapes', shapes });
              }}
              size="small"
              sx={{ flexWrap: 'wrap', gap: 0.5 }}
            >
              {SYLLABLE_SHAPES.map((s) => (
                <ToggleButton key={s} value={s}>{s}</ToggleButton>
              ))}
            </ToggleButtonGroup>
          </Box>

          {/* MSH stage */}
          <FormControl size="small" sx={{ minWidth: 150 }}>
            <InputLabel>MSH Stage</InputLabel>
            <Select
              value={mshEntry?.maxStage ?? ''}
              label="MSH Stage"
              onChange={(e) => {
                remove('msh');
                if (e.target.value) add({ type: 'msh', maxStage: Number(e.target.value) });
              }}
            >
              <MenuItem value="">None</MenuItem>
              {[1, 2, 3, 4, 5].map((s) => (
                <MenuItem key={s} value={s}>Stage {s}</MenuItem>
              ))}
            </Select>
          </FormControl>
        </Stack>
      </AccordionDetails>
    </Accordion>
  );
}

// ---------------------------------------------------------------------------
// Boosts Section
// ---------------------------------------------------------------------------

function BoostsSection() {
  const { entries, add, remove } = useConstraintStore();
  const [pickerOpen, setPickerOpen] = useState(false);
  const [pickerTarget, setPickerTarget] = useState<'minpair_target' | 'minpair_contrast' | 'maxopp_target' | 'maxopp_contrast' | null>(null);

  // Minimal pair state
  const [mpTarget, setMpTarget] = useState('');
  const [mpContrast, setMpContrast] = useState('');
  const [mpStrength, setMpStrength] = useState(2.0);

  // Maximal opposition state
  const [moTarget, setMoTarget] = useState('');
  const [moContrast, setMoContrast] = useState('');
  const [moStrength, setMoStrength] = useState(2.0);

  // Vocab boost state
  const [vbWords, setVbWords] = useState('');

  const minpairEntries = entries.filter((e) => e.type === 'boost_minpair');
  const maxoppEntries = entries.filter((e) => e.type === 'boost_maxopp');
  const vocabEntries = entries.filter((e) => e.type === 'vocab_boost');

  const activeCount = minpairEntries.length + maxoppEntries.length + vocabEntries.length;

  const handlePhonemeSelect = (phoneme: string) => {
    switch (pickerTarget) {
      case 'minpair_target': setMpTarget(phoneme); break;
      case 'minpair_contrast': setMpContrast(phoneme); break;
      case 'maxopp_target': setMoTarget(phoneme); break;
      case 'maxopp_contrast': setMoContrast(phoneme); break;
    }
    setPickerOpen(false);
  };

  const addMinPair = () => {
    if (mpTarget && mpContrast) {
      add({ type: 'boost_minpair', target: mpTarget, contrast: mpContrast, strength: mpStrength });
      setMpTarget('');
      setMpContrast('');
    }
  };

  const addMaxOpp = () => {
    if (moTarget && moContrast) {
      add({ type: 'boost_maxopp', target: moTarget, contrast: moContrast, strength: moStrength });
      setMoTarget('');
      setMoContrast('');
    }
  };

  const addVocabBoost = () => {
    const words = vbWords.trim().split(/[\s,]+/).filter(Boolean);
    if (words.length > 0) {
      add({ type: 'vocab_boost', words });
      setVbWords('');
    }
  };

  return (
    <Accordion>
      <AccordionSummary expandIcon={<ExpandMoreIcon />}>
        <Typography variant="subtitle1">
          Boosts
          {activeCount > 0 && <Chip label={activeCount} size="small" color="success" sx={{ ml: 1 }} />}
        </Typography>
      </AccordionSummary>
      <AccordionDetails sx={{ px: { xs: 1.5, sm: 2 }, py: { xs: 1, sm: 2 } }}>
        <Stack spacing={3}>
          {/* Minimal Pair Boost */}
          <Box>
            <Typography variant="body2" fontWeight="bold" gutterBottom>Minimal Pair Boost</Typography>
            <Stack direction="row" spacing={1} alignItems="center">
              <TextField
                size="small"
                label="Target"
                value={mpTarget}
                onChange={(e) => setMpTarget(e.target.value)}
                sx={{ width: 100 }}
                slotProps={{ input: { endAdornment: (
                  <InputAdornment position="end">
                    <IconButton size="small" onClick={() => { setPickerTarget('minpair_target'); setPickerOpen(true); }}>
                      <KeyboardIcon fontSize="small" />
                    </IconButton>
                  </InputAdornment>
                ) } }}
              />
              <TextField
                size="small"
                label="Contrast"
                value={mpContrast}
                onChange={(e) => setMpContrast(e.target.value)}
                sx={{ width: 100 }}
                slotProps={{ input: { endAdornment: (
                  <InputAdornment position="end">
                    <IconButton size="small" onClick={() => { setPickerTarget('minpair_contrast'); setPickerOpen(true); }}>
                      <KeyboardIcon fontSize="small" />
                    </IconButton>
                  </InputAdornment>
                ) } }}
              />
              <Chip label="Add" clickable onClick={addMinPair} size="small" color="success" disabled={!mpTarget || !mpContrast} />
            </Stack>
            {minpairEntries.length > 0 && (
              <Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
                {minpairEntries.map((e, i) => (
                  <Chip key={i} label={`${(e as any).target}${(e as any).contrast}`} size="small" color="success" variant="outlined"
                    onDelete={() => remove('boost_minpair', { target: (e as any).target, contrast: (e as any).contrast })} />
                ))}
              </Box>
            )}
          </Box>

          {/* Maximal Opposition Boost */}
          <Box>
            <Typography variant="body2" fontWeight="bold" gutterBottom>Maximal Opposition Boost</Typography>
            <Stack direction="row" spacing={1} alignItems="center">
              <TextField
                size="small"
                label="Target"
                value={moTarget}
                onChange={(e) => setMoTarget(e.target.value)}
                sx={{ width: 100 }}
                slotProps={{ input: { endAdornment: (
                  <InputAdornment position="end">
                    <IconButton size="small" onClick={() => { setPickerTarget('maxopp_target'); setPickerOpen(true); }}>
                      <KeyboardIcon fontSize="small" />
                    </IconButton>
                  </InputAdornment>
                ) } }}
              />
              <TextField
                size="small"
                label="Contrast"
                value={moContrast}
                onChange={(e) => setMoContrast(e.target.value)}
                sx={{ width: 100 }}
                slotProps={{ input: { endAdornment: (
                  <InputAdornment position="end">
                    <IconButton size="small" onClick={() => { setPickerTarget('maxopp_contrast'); setPickerOpen(true); }}>
                      <KeyboardIcon fontSize="small" />
                    </IconButton>
                  </InputAdornment>
                ) } }}
              />
              <Chip label="Add" clickable onClick={addMaxOpp} size="small" color="success" disabled={!moTarget || !moContrast} />
            </Stack>
            {maxoppEntries.length > 0 && (
              <Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
                {maxoppEntries.map((e, i) => (
                  <Chip key={i} label={`${(e as any).target}${(e as any).contrast}`} size="small" color="success" variant="outlined"
                    onDelete={() => remove('boost_maxopp', { target: (e as any).target, contrast: (e as any).contrast })} />
                ))}
              </Box>
            )}
          </Box>

          {/* Vocab Boost */}
          <Box>
            <Typography variant="body2" fontWeight="bold" gutterBottom>Vocabulary Boost</Typography>
            <TextField
              fullWidth
              size="small"
              placeholder="Words to boost (space or comma separated)"
              value={vbWords}
              onChange={(e) => setVbWords(e.target.value)}
              onKeyDown={(e) => { if (e.key === 'Enter') addVocabBoost(); }}
            />
            {vocabEntries.length > 0 && (
              <Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
                {vocabEntries.map((e, i) => (
                  <Chip key={i} label={`Vocab: ${(e as any).words?.join(', ') || (e as any).lists?.join(', ')}`}
                    size="small" color="success" variant="outlined"
                    onDelete={() => remove('vocab_boost')} />
                ))}
              </Box>
            )}
          </Box>
        </Stack>
      </AccordionDetails>
      <PhonemePickerDialog
        open={pickerOpen}
        onClose={() => setPickerOpen(false)}
        onSelect={handlePhonemeSelect}
      />
    </Accordion>
  );
}

// ---------------------------------------------------------------------------
// Thematic Section
// ---------------------------------------------------------------------------

function ThematicSection() {
  const { entries, add, remove } = useConstraintStore();
  const [input, setInput] = useState('');
  const [strength, setStrength] = useState(1.5);

  const themeEntries = entries.filter((e) => e.type === 'theme');

  const addTheme = () => {
    const words = input.trim().split(/[\s,]+/).filter(Boolean);
    if (words.length > 0) {
      add({ type: 'theme', seedWords: words, strength });
      setInput('');
    }
  };

  return (
    <Accordion>
      <AccordionSummary expandIcon={<ExpandMoreIcon />}>
        <Typography variant="subtitle1">
          Thematic
          {themeEntries.length > 0 && <Chip label={themeEntries.length} size="small" color="secondary" sx={{ ml: 1 }} />}
        </Typography>
      </AccordionSummary>
      <AccordionDetails sx={{ px: { xs: 1.5, sm: 2 }, py: { xs: 1, sm: 2 } }}>
        <Stack spacing={2}>
          <TextField
            fullWidth
            size="small"
            placeholder="Seed words (space or comma separated)"
            value={input}
            onChange={(e) => setInput(e.target.value)}
            onKeyDown={(e) => { if (e.key === 'Enter') addTheme(); }}
          />
          <Box>
            <Typography variant="body2" gutterBottom>Strength: {strength}</Typography>
            <Slider
              value={strength}
              onChange={(_, v) => setStrength(v as number)}
              min={0.5}
              max={5}
              step={0.5}
              valueLabelDisplay="auto"
            />
          </Box>
          {themeEntries.length > 0 && (
            <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
              {themeEntries.map((e, i) => (
                <Chip key={i} label={`Theme: ${(e as any).seedWords.join(', ')}`}
                  size="small" color="secondary" variant="outlined"
                  onDelete={() => remove('theme')} />
              ))}
            </Box>
          )}
        </Stack>
      </AccordionDetails>
    </Accordion>
  );
}

// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------

export default function OtherConstraints() {
  return (
    <>
      <StructuralSection />
      <BoostsSection />
      <ThematicSection />
    </>
  );
}
  • [ ] Step 2: Verify TypeScript compiles
cd packages/web/frontend && npx tsc --noEmit
  • [ ] Step 3: Commit
git add packages/web/frontend/src/components/tools/GovernedGenerationTool/OtherConstraints.tsx
git commit -m "feat(frontend): add structural, boost, and thematic constraint sections"

Task 12: Active Constraints Chip Summary

Files: - Create: packages/web/frontend/src/components/tools/GovernedGenerationTool/ActiveConstraints.tsx

  • [ ] Step 1: Create ActiveConstraints component
/**
 * Active constraints — dismissible chip summary row.
 *
 * Sits between the constraint accordions and the prompt input.
 * Shows all active constraints as colored chips.
 */

import React from 'react';
import { Box, Chip, Typography, Button } from '@mui/material';
import { Clear as ClearIcon } from '@mui/icons-material';
import { useConstraintStore } from '../../../store/constraintStore';
import { chipLabel, chipColor } from './OutputCard';

export default function ActiveConstraints() {
  const { entries, remove, clear } = useConstraintStore();

  if (entries.length === 0) return null;

  const handleDelete = (entry: typeof entries[0], index: number) => {
    // Build a match object from the entry's distinguishing fields
    const match: Record<string, unknown> = {};
    switch (entry.type) {
      case 'exclude':
      case 'exclude_clusters':
        match.phoneme = entry.phoneme;
        break;
      case 'include':
        match.phoneme = entry.phoneme;
        break;
      case 'bound':
        match.norm = entry.norm;
        match.direction = entry.direction;
        break;
      case 'boost_minpair':
      case 'boost_maxopp':
        match.target = entry.target;
        match.contrast = entry.contrast;
        break;
      default:
        // For types without unique keys (msh, complexity, theme, vocab_boost),
        // remove all of that type
        break;
    }
    remove(entry.type, Object.keys(match).length > 0 ? match : undefined);
  };

  return (
    <Box sx={{ py: 1.5 }}>
      <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
        <Typography variant="caption" color="text.secondary" fontWeight="bold">
          Active Constraints ({entries.length})
        </Typography>
        <Button size="small" startIcon={<ClearIcon />} onClick={clear} color="inherit" sx={{ fontSize: '0.75rem' }}>
          Clear All
        </Button>
      </Box>
      <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
        {entries.map((entry, i) => (
          <Chip
            key={`${entry.type}-${i}`}
            label={chipLabel(entry)}
            color={chipColor(entry)}
            size="small"
            variant="outlined"
            onDelete={() => handleDelete(entry, i)}
          />
        ))}
      </Box>
    </Box>
  );
}
  • [ ] Step 2: Verify TypeScript compiles
cd packages/web/frontend && npx tsc --noEmit
  • [ ] Step 3: Commit
git add packages/web/frontend/src/components/tools/GovernedGenerationTool/ActiveConstraints.tsx
git commit -m "feat(frontend): add active constraints chip summary"

Task 13: Main Tool Component + App Registration

Files: - Create: packages/web/frontend/src/components/tools/GovernedGenerationTool/index.tsx - Modify: packages/web/frontend/src/App_new.tsx

  • [ ] Step 1: Create GovernedGenerationTool index.tsx
/**
 * GovernedGenerationTool — constrained content generation, integrated as a
 * standard PhonoLex tool. Clinicians compose constraints visually, write a
 * prompt, and get output cards with toggleable compliance analysis.
 */

import React, { useState, useCallback } from 'react';
import {
  Box,
  TextField,
  Button,
  Stack,
  CircularProgress,
  Chip,
  Typography,
} from '@mui/material';
import {
  AutoFixHigh as GenerateIcon,
} from '@mui/icons-material';
import { useConstraintStore } from '../../../store/constraintStore';
import { compileConstraints } from '../../../lib/constraintCompiler';
import { generateContent, useServerStatus } from '../../../lib/generationApi';
import type { GenerationResult } from '../../../types/governance';
import PhonemeConstraints from './PhonemeConstraints';
import BoundsSection from './BoundsSection';
import OtherConstraints from './OtherConstraints';
import ActiveConstraints from './ActiveConstraints';
import OutputFeed from './OutputFeed';

let nextId = 0;

const GovernedGenerationTool: React.FC = () => {
  const [prompt, setPrompt] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [results, setResults] = useState<GenerationResult[]>([]);

  const snapshot = useConstraintStore((s) => s.snapshot);
  const serverStatus = useServerStatus();

  const serverReady = serverStatus?.status === 'ready';

  const handleGenerate = useCallback(async (overridePrompt?: string, overrideSnapshot?: any[]) => {
    const currentPrompt = overridePrompt ?? prompt;
    if (!currentPrompt.trim()) return;

    const constraintEntries = overrideSnapshot ?? snapshot();
    const compiled = compileConstraints(constraintEntries);

    setLoading(true);
    setError(null);

    try {
      const response = await generateContent(currentPrompt, compiled);
      const result: GenerationResult = {
        id: String(++nextId),
        prompt: currentPrompt,
        constraintSnapshot: constraintEntries,
        tokens: response.tokens,
        text: response.text,
        genTimeMs: response.gen_time_ms,
        compliant: response.compliant,
        violationCount: response.violation_count,
        timestamp: Date.now(),
      };
      setResults((prev) => [result, ...prev]);
      if (!overridePrompt) setPrompt('');
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Generation failed');
    } finally {
      setLoading(false);
    }
  }, [prompt, snapshot]);

  const handleRegenerate = useCallback((result: GenerationResult) => {
    handleGenerate(result.prompt, result.constraintSnapshot);
  }, [handleGenerate]);

  const handleDelete = useCallback((id: string) => {
    setResults((prev) => prev.filter((r) => r.id !== id));
  }, []);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
      e.preventDefault();
      handleGenerate();
    }
  };

  return (
    <Box>
      {/* Server status */}
      {serverStatus && (
        <Box sx={{ mb: 2 }}>
          <Chip
            size="small"
            label={serverStatus.status === 'ready' ? 'Server ready' : serverStatus.status === 'loading' ? 'Model loading...' : 'Server error'}
            color={serverReady ? 'success' : serverStatus.status === 'loading' ? 'warning' : 'error'}
            variant="outlined"
          />
        </Box>
      )}
      {!serverStatus && (
        <Box sx={{ mb: 2 }}>
          <Chip size="small" label="Server unreachable" color="error" variant="outlined" />
        </Box>
      )}

      {/* Constraint composition */}
      <PhonemeConstraints />
      <BoundsSection />
      <OtherConstraints />

      {/* Active constraint summary */}
      <ActiveConstraints />

      {/* Prompt input */}
      <Box sx={{ my: 2 }}>
        <TextField
          fullWidth
          multiline
          minRows={2}
          maxRows={6}
          placeholder="Describe what you want to generate..."
          value={prompt}
          onChange={(e) => setPrompt(e.target.value)}
          onKeyDown={handleKeyDown}
          disabled={loading}
        />
        <Stack direction="row" justifyContent={{ xs: 'stretch', sm: 'flex-end' }} spacing={1} sx={{ mt: 1.5 }}>
          <Button
            variant="contained"
            startIcon={loading ? <CircularProgress size={18} color="inherit" /> : <GenerateIcon />}
            onClick={() => handleGenerate()}
            disabled={loading || !prompt.trim() || !serverReady}
            fullWidth
            sx={{ maxWidth: { sm: 200 } }}
          >
            {loading ? 'Generating...' : 'Generate'}
          </Button>
        </Stack>
        {error && (
          <Typography variant="body2" color="error" sx={{ mt: 1 }}>{error}</Typography>
        )}
      </Box>

      {/* Output feed */}
      <OutputFeed
        results={results}
        onRegenerate={handleRegenerate}
        onDelete={handleDelete}
      />
    </Box>
  );
};

export default GovernedGenerationTool;
  • [ ] Step 2: Update App_new.tsx — import and register

In packages/web/frontend/src/App_new.tsx:

  1. Add import at the top (near other tool imports):

    import GovernedGenerationTool from './components/tools/GovernedGenerationTool';
    

  2. Update the icon import — replace ChatBubbleOutline with AutoFixHigh:

    import { AutoFixHigh as GovernedGenIcon } from '@mui/icons-material';
    

  3. In the TOOL_DEFS array, update the governed generation entry's icon:

    {
      id: 'governedGeneration',
      icon: <GovernedGenIcon />,
      title: 'Governed Generation',
      description: 'Constrained content generation with phonological targets, psycholinguistic bounds, and compliance analysis.',
      color: '#7E57C2',
      section: 'explore',
    },
    

  4. In the TOOL_COMPONENTS registry, replace the placeholder:

    governedGeneration: () => <GovernedGenerationTool />,
    

  5. Remove the old placeholder import:

    // DELETE: import GovernedGenerationPlaceholder from './components/tools/GovernedGenerationPlaceholder';
    

  6. [ ] Step 3: Verify build succeeds

cd packages/web/frontend && npx tsc --noEmit

Expected: No errors.

  • [ ] Step 4: Run all frontend tests
cd packages/web/frontend && npx vitest run

Expected: All tests pass (store + compiler tests from Tasks 2-3, plus any existing tests).

  • [ ] Step 5: Commit
git add packages/web/frontend/src/components/tools/GovernedGenerationTool/index.tsx packages/web/frontend/src/App_new.tsx
git commit -m "feat(frontend): integrate governed generation tool into main app"

Task 14: Visual Verification

Files: None (verification only)

  • [ ] Step 1: Start the dev server
cd packages/web/frontend && npm run dev
  • [ ] Step 2: Open browser and verify

Navigate to http://localhost:3000. Click "Governed Generation" in the sidebar. Verify:

  1. Tool header shows with purple accent
  2. All 6 accordion sections render and expand
  3. Phoneme picker dialog opens from exclusion/inclusion sections
  4. Active constraints chip row appears when constraints are added
  5. Prompt field accepts text
  6. Generate button is present (disabled if server unreachable — expected in dev)
  7. No console errors

  8. [ ] Step 3: Run lint

cd packages/web/frontend && npm run lint

Fix any lint errors.

  • [ ] Step 4: Final commit (lint fixes if any)
git add -A && git commit -m "fix(frontend): lint fixes for governed generation tool"

Only commit if there were lint fixes to make.