feat(web): implement VoiceView (Wave B) on cocotte ui + getVoiceAlignment

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-29 21:53:10 -04:00
parent f6e72bf41f
commit a46fb8dfa0

356
web/src/views/VoiceView.tsx Normal file
View file

@ -0,0 +1,356 @@
import { useState } from 'react';
import { getVoiceAlignment } from '../api';
import { usePoll, formatTime } from '../usePoll';
import { Card, VStack, HStack, Button, Pill, Muted, Title } from '../ui';
/** Voice alignment lens (Wave B unit).
* Persona north-star + colored metrics row + togglable voice-corpus exemplars (sourced from Marketor)
* + before/after alignment correction pairs + this-week voice/tone fixes list.
* Backed exclusively by real GET /voice/alignment (corrections category 'voice'|'tone').
* Renders using *only* cocotte ui primitives (Card/VStack/HStack/Button/Pill/Muted/Title).
* Client-local toggle state for exemplars (matches prototype behavior).
*/
interface PairView {
readonly cat: string;
readonly before: string;
readonly after: string;
readonly note: string;
}
interface CorrView {
readonly cat: string;
readonly summary: string;
readonly at: string;
}
interface Exemplar {
readonly key: string;
readonly name: string;
readonly meta: string;
readonly source: string;
readonly synced: string;
readonly kind: string;
readonly body: string;
open: boolean;
readonly chev: string;
readonly toggle: () => void;
}
const EXEMPLARS_BASE: ReadonlyArray<Omit<Exemplar, 'open' | 'chev' | 'toggle'>> = [
{
key: 'ts4rent',
name: 'TS4Rent · Profile',
kind: 'source of truth',
source: 'Marketor',
synced: '2d ago',
meta: 'profile body + 22 interview answers',
body: 'Pink-haired trans gamer girl, Williamsburg. Sweet sub good-girl energy, brainy-bimbo. Loves concrete nerd detail (mech keyboards, JRPGs). Incall only, $1000/hr, 2hr min for new friends. The 22 interview answers cover boundaries, kinks, vibe, and scheduling — the richest single voice source.',
},
{
key: 'tryst',
name: 'Tryst · Bio',
kind: 'public bio',
source: 'Marketor',
synced: '2d ago',
meta: 'short public bio',
body: 'your favorite pink-haired distraction 💗 sweet, nerdy, a little bratty. incall in Williamsburg. lets play 🌹',
},
{
key: 'eros',
name: 'Eros · AD block',
kind: 'ad copy',
source: 'Marketor',
synced: '5d ago',
meta: 'ad-platform block',
body: 'TS Quinn · Williamsburg incall · pink hair · GFE + nerd talk · $1000/hr · verified, screening required.',
},
];
function catTone(cat: string): 'accent' | 'neutral' | 'success' | 'warning' | 'error' {
if (cat === 'voice' || cat === 'tone') return 'accent';
if (cat === 'safety') return 'error';
if (cat === 'logistics') return 'warning';
if (cat === 'classification') return 'success';
return 'neutral';
}
export function VoiceView(): JSX.Element {
const { data, error, loading } = usePoll(() => getVoiceAlignment(50), 60000);
const [openDocs, setOpenDocs] = useState<Record<string, boolean>>({});
const [realignMsg, setRealignMsg] = useState<string | null>(null);
const toggleDoc = (key: string): void => {
setOpenDocs((prev) => ({ ...prev, [key]: !prev[key] }));
};
const handleRealign = (): void => {
setRealignMsg('Voice re-alignment queued on GPU — folds this weeks voice/tone corrections into the drafter.');
// Poll continues; full action would POST to a distill endpoint (not part of landed serial read).
window.setTimeout(() => setRealignMsg(null), 4200);
};
if (error) {
return (
<Card>
<VStack $gap={8}>
<Title>Voice alignment</Title>
<Muted>Error loading alignment: {error}</Muted>
</VStack>
</Card>
);
}
if (loading || !data) {
return (
<Card>
<VStack $gap={8}>
<Title>Voice alignment</Title>
<Muted>Loading voice alignment</Muted>
</VStack>
</Card>
);
}
// Real data from /corrections/voice/alignment (loose shape in api surface; narrow safely here)
const persona: string = (data as any).persona ?? 'Direct · warm · specific rates · 🌹 signoff · bilingual ok';
const metrics: Array<{ v: string; k: string; sub: string; color: string }> = (data as any).metrics ?? [];
const rawPairs: any[] = (data as any).pairs ?? [];
const rawCorrections: any[] = (data as any).corrections ?? [];
const corrCount: number = (data as any).corrCount ?? 0;
const rules = persona
.split(' · ')
.filter(Boolean)
.map((r) => ({ r: r.trim() }));
const pairs: PairView[] = rawPairs.slice(0, 4).map((r: unknown) => {
const p = r as { cat?: string; orig?: string | null; corr?: string | null };
return {
cat: p.cat ?? 'voice',
before: p.orig ?? '',
after: p.corr ?? '',
note: '',
};
});
const corrs: CorrView[] = rawCorrections.map((r: unknown) => {
const c = r as { cat?: string; summary?: string | null; at?: string };
return {
cat: c.cat ?? 'voice',
summary: c.summary ?? '',
at: c.at ?? '',
};
});
const exemplars: Exemplar[] = EXEMPLARS_BASE.map((d) => ({
...d,
open: !!openDocs[d.key],
chev: openDocs[d.key] ? '✕' : 'read',
toggle: () => toggleDoc(d.key),
}));
return (
<VStack $gap={14}>
{/* header */}
<HStack $justify="space-between" $align="center" $wrap $gap={10}>
<HStack $gap={8} $align="baseline">
<Title>Voice alignment</Title>
<Muted>how close drafts sound to Quinn</Muted>
</HStack>
<Button $variant="primary" onClick={handleRealign}>
Re-align voice
</Button>
</HStack>
{realignMsg ? <Muted>{realignMsg}</Muted> : null}
{/* metrics row */}
<HStack $gap={8} $wrap>
{metrics.length === 0 ? (
<Card style={{ flex: 1 }}>
<Muted>No metrics</Muted>
</Card>
) : (
metrics.map((m, idx) => (
<Card key={idx} style={{ flex: 1, minWidth: 140 }}>
<VStack $gap={3}>
<span
style={{
fontSize: 20,
fontWeight: 700,
fontFamily: 'ui-monospace, Menlo, monospace',
color: m.color,
}}
>
{m.v}
</span>
<span style={{ fontSize: 11, fontWeight: 600 }}>{m.k}</span>
<Muted style={{ fontSize: 10 }}>{m.sub}</Muted>
</VStack>
</Card>
))
)}
</HStack>
{/* persona card */}
<Card>
<Muted
style={{
fontSize: '9.5px',
fontWeight: 700,
letterSpacing: '0.12em',
textTransform: 'uppercase',
display: 'block',
marginBottom: 8,
}}
>
PERSONA · NORTH STAR
</Muted>
<span style={{ fontSize: 13, lineHeight: 1.55, marginBottom: 11, display: 'block' }}>{persona}</span>
<HStack $gap={6} $wrap>
{rules.map((r, i) => (
<Pill key={i} $tone="accent">
{r.r}
</Pill>
))}
</HStack>
</Card>
{/* exemplars list (toggle) */}
<VStack $gap={4}>
<HStack $align="baseline" $gap={9}>
<span style={{ fontSize: 13, fontWeight: 600 }}>Voice corpus · exemplars</span>
<Muted>the source of truth drafts are conditioned on · owned by Marketor</Muted>
</HStack>
<VStack $gap={9}>
{exemplars.map((d) => (
<Card key={d.key}>
<VStack $gap={0}>
<Button
$variant="ghost"
onClick={d.toggle}
style={{ width: '100%', justifyContent: 'flex-start', padding: 0, textAlign: 'left' }}
>
<HStack $gap={11} $align="center" style={{ flex: 1, minWidth: 0 }}>
<span style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{d.name}</div>
<Muted style={{ fontSize: 11, marginTop: 2, display: 'block' }}>
{d.meta} · {d.source} · synced {d.synced}
</Muted>
</span>
<Pill $tone="neutral">{d.kind}</Pill>
<Muted style={{ flexShrink: 0 }}>{d.chev}</Muted>
</HStack>
</Button>
{d.open ? (
<Muted
style={{
marginTop: 11,
paddingTop: 11,
borderTop: '1px solid #232328',
fontSize: 12.5,
lineHeight: 1.6,
display: 'block',
}}
>
{d.body}
</Muted>
) : null}
</VStack>
</Card>
))}
</VStack>
</VStack>
{/* before/after pairs */}
<Card>
<HStack $justify="space-between" $align="baseline" $gap={8}>
<span style={{ fontSize: 13, fontWeight: 600 }}>Alignment corrections · before after</span>
<Muted>how the teach-loop pulls drafts back to voice</Muted>
</HStack>
<VStack $gap={12} style={{ marginTop: 8 }}>
{pairs.length === 0 ? (
<Muted>No alignment pairs yet.</Muted>
) : (
pairs.map((p, i) => (
<VStack $gap={6} key={i}>
<HStack $gap={8} $align="center">
<Pill $tone={catTone(p.cat)}>{p.cat}</Pill>
{p.note ? <Muted>{p.note}</Muted> : null}
</HStack>
<HStack $gap={9} $align="stretch">
<span
style={{
flex: 1,
fontSize: 12,
color: '#94959c',
lineHeight: 1.45,
background: '#101012',
border: '1px solid #242428',
borderRadius: 8,
padding: '9px 11px',
textDecoration: 'line-through',
textDecorationColor: '#52525b',
}}
>
{p.before || '(no original)'}
</span>
<span style={{ alignSelf: 'center', color: '#34d399', fontSize: 14, flexShrink: 0 }}></span>
<span
style={{
flex: 1,
fontSize: 12,
color: '#d6f5e8',
lineHeight: 1.45,
background: 'rgba(16,185,129,.1)',
border: '1px solid rgba(16,185,129,.3)',
borderRadius: 8,
padding: '9px 11px',
}}
>
{p.after || '(no corrected)'}
</span>
</HStack>
</VStack>
))
)}
</VStack>
</Card>
{/* corrections list */}
<Card>
<HStack $justify="space-between" $align="baseline">
<span style={{ fontSize: 13, fontWeight: 600 }}>This weeks voice &amp; tone fixes</span>
<Muted>{corrCount} feeding the next distill</Muted>
</HStack>
<VStack $gap={8} style={{ marginTop: 4 }}>
{corrs.length === 0 ? (
<Muted>No voice/tone corrections this period.</Muted>
) : (
corrs.map((c, i) => (
<HStack key={i} $gap={9} $align="center" style={{ fontSize: 12 }}>
<Pill $tone={catTone(c.cat)}>{c.cat}</Pill>
<Muted style={{ fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 11, flexShrink: 0 }}>
{formatTime(c.at)}
</Muted>
<span
style={{
color: '#d4d4d8',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
flex: 1,
}}
>
{c.summary || '(no summary)'}
</span>
</HStack>
))
)}
</VStack>
</Card>
</VStack>
);
}