Phase 9: ESL superpowers — Chinese gloss + tone-rewrite

Inline Chinese gloss (offline) and a "say it more naturally" / tone-rewrite,
the two ESL features for the Mandarin-speaking writer.

Gloss: embedded English→Chinese dictionary (gloss.json.gz, 57k common words
built from ECDICT via scripts/build_gloss.py). lexicon gains Gloss()/Result.Gloss
and a lightweight GET /api/gloss/{word}; the right-click WordCard leads with the
中文; GlossTip shows it on a 350ms hover (reuses wordAt, so CJK is never glossed).
Offline + instant, works with the LLM down.

Rewrite: selecting text pops a SelectionBubble (更自然 + the tone vocabulary);
picking a style calls POST /api/docs/:id/rewrite (llm.RunRewrite, stateless,
owner-scoped) and shows a RewritePreview (original→rewrite, accept/cancel/retry).
Accept applies it in-editor.

Tests added in lexicon and suggestions. go build/vet/test, tsc, vite all clean;
live smoke vs a fake vLLM verified gloss + rewrite + 400/404/502 paths.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 00:07:26 -07:00
parent 8e1111d768
commit 60eba25fee
20 changed files with 1036 additions and 14 deletions

View File

@@ -0,0 +1,136 @@
import { REWRITE_STYLES } from './SelectionBubble'
// RewritePreview shows the result of a tone-rewrite before it touches the
// document: the writer's original passage, the model's rewrite beneath it, and
// accept/cancel. It mirrors the SuggestionCard's warm, bilingual chrome. While
// the model runs it shows a breathing dot; on failure it offers a gentle retry.
export type RewriteStatus = 'loading' | 'ready' | 'error'
interface Props {
style: string // the chosen rewrite style key (for the header label)
status: RewriteStatus
original: string
rewrite: string
cardStyle: React.CSSProperties
onAccept: () => void
onCancel: () => void
onRetry: () => void
}
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
export function RewritePreview({
style,
status,
original,
rewrite,
cardStyle,
onAccept,
onCancel,
onRetry,
}: Props) {
const meta = REWRITE_STYLES.find((s) => s.value === style) ?? REWRITE_STYLES[0]
return (
<div
className="petal-rewrite-card absolute z-30 p-3.5 text-sm"
role="dialog"
aria-label="Rewrite preview"
onMouseDown={(e) => e.stopPropagation()}
style={{
width: 340,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-card)',
boxShadow: 'var(--shadow-soft)',
fontFamily: CJK,
...cardStyle,
}}
>
<div className="flex items-center gap-1.5">
<span
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-bold"
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
>
<span aria-hidden>{meta.emoji}</span>
{meta.zh} · {meta.en}
</span>
<span className="text-xs font-semibold" style={{ color: 'var(--color-muted)' }}>
· Rewrite
</span>
</div>
{/* Original passage, dimmed — what's being replaced. */}
<p
className="mt-3 leading-snug"
style={{ color: 'var(--color-muted)', textDecoration: 'line-through', textDecorationColor: 'var(--color-border)' }}
>
{original}
</p>
{status === 'loading' && (
<div className="mt-3 inline-flex items-center gap-1.5" style={{ color: 'var(--color-muted)' }}>
<span
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
style={{ background: 'var(--color-accent)' }}
aria-hidden
/>
· Rewriting
</div>
)}
{status === 'error' && (
<div className="mt-3">
<p className="leading-snug" style={{ color: 'var(--color-muted)' }}>
· Couldnt rewrite try again
</p>
<div className="mt-2.5 flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="rounded-full px-3 py-1 text-xs font-semibold"
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
>
· Cancel
</button>
<button
type="button"
onClick={onRetry}
className="rounded-full px-3 py-1 text-xs font-bold"
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
>
· Retry
</button>
</div>
</div>
)}
{status === 'ready' && (
<>
<p className="mt-2 leading-snug" style={{ color: 'var(--color-plum)' }}>
{rewrite}
</p>
<div className="mt-3 flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="rounded-full px-3 py-1 text-xs font-semibold"
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
>
· Cancel
</button>
<button
type="button"
onClick={onAccept}
className="rounded-full px-3 py-1 text-xs font-bold"
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
>
· Use this
</button>
</div>
</>
)}
</div>
)
}