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

@@ -11,6 +11,9 @@ import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHigh
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
import { MisspellCard } from './MisspellCard'
import { WordCard } from './WordCard'
import { GlossTip } from './GlossTip'
import { SelectionBubble } from './SelectionBubble'
import { RewritePreview, type RewriteStatus } from './RewritePreview'
import { api, type Suggestion, type WordInfo } from '../../api/client'
import type { SpellChecker } from '../../hooks/useSpellChecker'
@@ -106,6 +109,39 @@ interface HoverState {
left: number
}
// The inline hover gloss: the word under the resting pointer, its Chinese
// translation, the PM range it covers (to dedupe re-fetches), and where to anchor.
interface GlossState {
word: string
gloss: string
from: number
to: number
top: number
left: number
}
// A non-empty text selection that the rewrite bubble hovers over.
interface SelectionState {
from: number
to: number
text: string
top: number
left: number
}
// An in-flight or completed tone-rewrite preview, pinned over the selection it
// came from. `from`/`to` are the PM range the accepted rewrite replaces.
interface RewriteState {
from: number
to: number
original: string
style: string
top: number
left: number
status: RewriteStatus
rewrite: string
}
// EditorCore is the Tiptap instance: StarterKit formatting plus underline, text
// alignment, a placeholder, character counting, and the suggestion-highlight
// decoration layer. Hovering a highlight opens its SuggestionCard.
@@ -138,6 +174,15 @@ export function EditorCore({
// While the Ask Petal panel is expanded the card is pinned: the hover-close
// timer is suppressed so chatting doesn't dismiss it. A click outside closes.
const [pinned, setPinned] = useState(false)
// Inline hover gloss (rest the pointer on a word → its Chinese meaning).
const [gloss, setGloss] = useState<GlossState | null>(null)
const glossTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
const glossReqRef = useRef(0)
// The rewrite affordances: a bubble over the current selection, and the
// preview that replaces it once a style is chosen.
const [selection, setSelection] = useState<SelectionState | null>(null)
const [rewrite, setRewrite] = useState<RewriteState | null>(null)
const rewriteReqRef = useRef(0)
const editor = useEditor({
extensions: [
@@ -158,12 +203,37 @@ export function EditorCore({
// Any edit shifts positions, stranding the popover anchors.
setMisspell(null)
setWordInfo(null)
setGloss(null)
setSelection(null)
// A stale rewrite preview points at a range that just moved — drop it.
rewriteReqRef.current++
setRewrite(null)
onChange({
content: JSON.stringify(editor.getJSON()),
content_text: editor.getText(),
word_count: editor.storage.characterCount.words(),
})
},
onSelectionUpdate: ({ editor }) => {
// A selection supersedes the hover gloss; an empty one clears the bubble.
setGloss(null)
const { from, to, empty } = editor.state.selection
const wrapper = wrapperRef.current
if (empty || !wrapper) {
setSelection(null)
return
}
const text = editor.state.doc.textBetween(from, to, ' ').trim()
if (!text) {
setSelection(null)
return
}
const start = editor.view.coordsAtPos(from)
const wrapRect = wrapper.getBoundingClientRect()
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 360))
const top = start.top - wrapRect.top
setSelection({ from, to, text, top, left })
},
})
// When the selected document changes, swap in its content without emitting an
@@ -174,6 +244,10 @@ export function EditorCore({
setHover(null)
setMisspell(null)
setWordInfo(null)
setGloss(null)
setSelection(null)
rewriteReqRef.current++
setRewrite(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [docId, editor])
@@ -381,6 +455,143 @@ export function EditorCore({
[editor, wordInfo],
)
// Hover gloss: rest the pointer on an English word and, after a short delay,
// show its Chinese meaning from the offline gloss dataset. Suppressed while a
// selection, preview, or another popover is active, or over a word that has its
// own affordance (a suggestion highlight / a misspelling).
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!editor) return
if (selection || rewrite || misspell || wordInfo || pinned) return
if (!editor.state.selection.empty) return
const t = e.target as HTMLElement
const clear = () => {
clearTimeout(glossTimer.current)
glossReqRef.current++
setGloss(null)
}
if (t.closest('.petal-suggestion') || t.closest('.petal-misspelling')) {
clear()
return
}
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
if (!coords) {
clear()
return
}
const range = wordAt(editor.state.doc, coords.pos)
if (!range) {
clear()
return
}
// Already showing this exact word — leave it be (no flicker on micro-moves).
if (gloss && gloss.from === range.from && gloss.to === range.to) return
clearTimeout(glossTimer.current)
const token = ++glossReqRef.current
glossTimer.current = setTimeout(() => {
api
.glossWord(range.word)
.then((g) => {
if (token !== glossReqRef.current) return
const wrapper = wrapperRef.current
if (!g.gloss || !wrapper) {
setGloss(null)
return
}
const start = editor.view.coordsAtPos(range.from)
const end = editor.view.coordsAtPos(range.to)
const wrapRect = wrapper.getBoundingClientRect()
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 280))
const top = end.bottom - wrapRect.top + 6
setGloss({ word: range.word, gloss: g.gloss, from: range.from, to: range.to, top, left })
})
.catch(() => {
if (token === glossReqRef.current) setGloss(null)
})
}, 350)
},
[editor, selection, rewrite, misspell, wordInfo, pinned, gloss],
)
// Leaving the editor surface drops any pending/shown gloss.
const handleMouseLeave = useCallback(() => {
clearTimeout(glossTimer.current)
glossReqRef.current++
setGloss(null)
}, [])
// runRewrite fires the LLM rewrite for a captured range + style and tracks it
// through loading → ready/error. A request token discards a response whose
// preview has since been cancelled or superseded.
const runRewrite = useCallback(
(from: number, to: number, original: string, style: string, top: number, left: number) => {
const token = ++rewriteReqRef.current
setRewrite({ from, to, original, style, top, left, status: 'loading', rewrite: '' })
api
.rewriteSelection(docId, original, style)
.then((res) => {
if (token === rewriteReqRef.current) {
setRewrite((r) => (r ? { ...r, status: 'ready', rewrite: res.rewrite } : null))
}
})
.catch((err) => {
console.error('rewrite failed', err)
if (token === rewriteReqRef.current) {
setRewrite((r) => (r ? { ...r, status: 'error' } : null))
}
})
},
[docId],
)
// Picking a style in the selection bubble: capture the selection's range +
// text, anchor a preview below it, and kick off the rewrite.
const handleRewrite = useCallback(
(style: string) => {
if (!editor || !selection) return
const wrapper = wrapperRef.current
if (!wrapper) return
const start = editor.view.coordsAtPos(selection.from)
const end = editor.view.coordsAtPos(selection.to)
const wrapRect = wrapper.getBoundingClientRect()
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 340))
const top = end.bottom - wrapRect.top + 6
setSelection(null)
setGloss(null)
runRewrite(selection.from, selection.to, selection.text, style, top, left)
},
[editor, selection, runRewrite],
)
const acceptRewrite = useCallback(() => {
if (editor && rewrite && rewrite.rewrite) {
editor.chain().focus().insertContentAt({ from: rewrite.from, to: rewrite.to }, rewrite.rewrite).run()
}
rewriteReqRef.current++
setRewrite(null)
}, [editor, rewrite])
const cancelRewrite = useCallback(() => {
rewriteReqRef.current++
setRewrite(null)
}, [])
const retryRewrite = useCallback(() => {
if (rewrite) runRewrite(rewrite.from, rewrite.to, rewrite.original, rewrite.style, rewrite.top, rewrite.left)
}, [rewrite, runRewrite])
// A pointer-down outside the rewrite preview dismisses it.
useEffect(() => {
if (!rewrite) return
const onDown = (e: MouseEvent) => {
if ((e.target as HTMLElement).closest('.petal-rewrite-card')) return
rewriteReqRef.current++
setRewrite(null)
}
document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown)
}, [rewrite])
// A pointer-down outside the word popover (and not on another word, which would
// reopen it via the context menu) closes it.
useEffect(() => {
@@ -409,6 +620,7 @@ export function EditorCore({
useEffect(() => () => {
clearTimeout(closeTimer.current)
clearTimeout(confettiTimer.current)
clearTimeout(glossTimer.current)
}, [])
// While pinned (Ask Petal open), a pointer-down outside the card closes it —
@@ -430,11 +642,32 @@ export function EditorCore({
className="relative flex-1"
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={handleSpellClick}
onContextMenu={handleContextMenu}
>
<EditorContent editor={editor} className="h-full" />
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
{gloss && <GlossTip gloss={gloss.gloss} style={{ top: gloss.top, left: gloss.left }} />}
{selection && !rewrite && (
<SelectionBubble
style={{ top: selection.top, left: selection.left, transform: 'translateY(calc(-100% - 8px))' }}
onRewrite={handleRewrite}
/>
)}
{rewrite && (
<RewritePreview
style={rewrite.style}
status={rewrite.status}
original={rewrite.original}
rewrite={rewrite.rewrite}
cardStyle={{ top: rewrite.top, left: rewrite.left }}
onAccept={acceptRewrite}
onCancel={cancelRewrite}
onRetry={retryRewrite}
/>
)}
{wordInfo && (
<WordCard
word={wordInfo.word}

View File

@@ -0,0 +1,32 @@
// GlossTip is the inline hover gloss: rest the pointer on an English word and a
// small bubble shows its Chinese meaning, pulled instantly from the offline
// gloss dataset. It's a pure reading aid — pointer-events are off so it never
// steals hover or blocks a click, and it sits just under the word. Bilingual
// chrome elsewhere puts zh first; here the gloss IS the content (the word is
// already on screen), so the bubble shows only the 中文.
interface Props {
gloss: string
style: React.CSSProperties
}
export function GlossTip({ gloss, style }: Props) {
return (
<div
className="petal-gloss-tip pointer-events-none absolute z-20 px-2.5 py-1.5 text-sm"
role="tooltip"
style={{
maxWidth: 280,
background: 'var(--color-plum)',
color: 'var(--color-surface)',
borderRadius: 'var(--radius-input)',
boxShadow: 'var(--shadow-soft)',
lineHeight: 1.35,
fontFamily: "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif",
...style,
}}
>
{gloss}
</div>
)
}

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>
)
}

View File

@@ -0,0 +1,85 @@
// SelectionBubble floats above a text selection and offers to rewrite it: a
// prominent "✨ 更自然 Say it naturally" action plus the tone vocabulary (学术,
// 轻松, …) mirrored from the document-tone picker. Picking one hands the style up
// to EditorCore, which calls the LLM and shows a preview. Buttons use
// onMouseDown→preventDefault so clicking them doesn't collapse the selection
// before the handler captures its range.
export interface RewriteStyle {
value: string
emoji: string
zh: string
en: string
}
// 'natural' is the default "say it more naturally" rewrite; the rest mirror the
// llm styleGuidance keys (and the ToneSelect labels) so the two stay in step.
export const REWRITE_STYLES: RewriteStyle[] = [
{ value: 'natural', emoji: '✨', zh: '更自然', en: 'Natural' },
{ value: 'academic', emoji: '🎓', zh: '学术', en: 'Academic' },
{ value: 'professional', emoji: '💼', zh: '专业', en: 'Professional' },
{ value: 'casual', emoji: '☕', zh: '轻松', en: 'Casual' },
{ value: 'humorous', emoji: '😄', zh: '幽默', en: 'Humorous' },
{ value: 'creative', emoji: '🎨', zh: '创意', en: 'Creative' },
{ value: 'persuasive', emoji: '📣', zh: '说服', en: 'Persuasive' },
]
interface Props {
style: React.CSSProperties
onRewrite: (style: string) => void
}
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
export function SelectionBubble({ style, onRewrite }: Props) {
const [natural, ...tones] = REWRITE_STYLES
return (
<div
className="petal-selection-bubble absolute z-30 flex max-w-[360px] flex-wrap items-center gap-1.5 p-2"
role="toolbar"
aria-label="Rewrite the selection"
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-pill)',
boxShadow: 'var(--shadow-soft)',
fontFamily: CJK,
...style,
}}
>
<button
type="button"
onClick={() => onRewrite(natural.value)}
className="inline-flex h-8 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)', color: 'var(--color-plum)' }}
title="Rewrite the selection to sound more natural"
>
<span aria-hidden>{natural.emoji}</span>
<span>{natural.zh}</span>
<span className="font-semibold" style={{ color: 'var(--color-plum)', opacity: 0.7 }}>
{natural.en}
</span>
</button>
<span className="mx-0.5 h-5 w-px shrink-0" style={{ background: 'var(--color-border)' }} />
{tones.map((t) => (
<button
key={t.value}
type="button"
onClick={() => onRewrite(t.value)}
className="inline-flex h-8 items-center gap-1 whitespace-nowrap px-2.5 text-sm font-semibold"
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-lavender)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
title={`Rewrite in a ${t.en.toLowerCase()} tone`}
>
<span aria-hidden>{t.emoji}</span>
<span>{t.zh}</span>
</button>
))}
</div>
)
}

View File

@@ -17,7 +17,8 @@ interface Props {
export function WordCard({ word, info, loading, style, onReplace }: Props) {
const definitions = info?.definitions ?? []
const synonyms = info?.synonyms ?? []
const empty = !loading && definitions.length === 0 && synonyms.length === 0
const gloss = info?.gloss ?? ''
const empty = !loading && !gloss && definitions.length === 0 && synonyms.length === 0
return (
<div
@@ -47,6 +48,19 @@ export function WordCard({ word, info, loading, style, onReplace }: Props) {
</span>
</div>
{/* Chinese gloss first — it's what the Mandarin-speaking writer reaches for. */}
{gloss && (
<p
className="mt-2.5 leading-snug"
style={{
color: 'var(--color-plum)',
fontFamily: "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif",
}}
>
{gloss}
</p>
)}
{loading && (
<div className="mt-3 inline-flex items-center gap-1.5" style={{ color: 'var(--color-muted)' }}>
<span