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:
- Remove the inline
PropertySliderPropsinterface (lines 53-58) andPropertySlidercomponent (lines 60-140). - 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">“{token.text.trim()}”</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:
-
Add import at the top (near other tool imports):
import GovernedGenerationTool from './components/tools/GovernedGenerationTool'; -
Update the icon import — replace
ChatBubbleOutlinewithAutoFixHigh:import { AutoFixHigh as GovernedGenIcon } from '@mui/icons-material'; -
In the
TOOL_DEFSarray, 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', }, -
In the
TOOL_COMPONENTSregistry, replace the placeholder:governedGeneration: () => <GovernedGenerationTool />, -
Remove the old placeholder import:
// DELETE: import GovernedGenerationPlaceholder from './components/tools/GovernedGenerationPlaceholder'; -
[ ] 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:
- Tool header shows with purple accent
- All 6 accordion sections render and expand
- Phoneme picker dialog opens from exclusion/inclusion sections
- Active constraints chip row appears when constraints are added
- Prompt field accepts text
- Generate button is present (disabled if server unreachable — expected in dev)
-
No console errors
-
[ ] 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.