The garden learns which language a card is in, and read-aloud stops guessing

Phase 28 (c), the last of the phase. A word met inside a Portuguese
document is a Portuguese card: migration 0018 mirrors documents.doc_lang
onto vocab_words, set server-side from the ownership lookup capture was
already making. Every card still reviews — filtering the queue to the
half she is learning would drop the words she actually met.

Read-aloud was the larger surprise. detectLang routed Han/kana to
Chinese and everything else to en-US, so the zh pair was accidentally
right and every Latin pair wrong. doc_lang now reaches the client
read-only on the document JSON, and docLang(text, verdict) answers for a
passage taken out of it — with the script test still winning, because
quoted Chinese must never be spelled out one "Chinese letter" at a time.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
This commit is contained in:
prosolis
2026-07-28 23:45:26 -07:00
parent 29eb2fe1fc
commit 466055020f
15 changed files with 352 additions and 57 deletions
+14 -4
View File
@@ -33,8 +33,8 @@ import { Composition } from './Composition'
import { RewritePreview, type RewriteStatus } from './RewritePreview'
import { planBatch } from './acceptBatch'
import { entryId, idAfterRemoval, stepId, type Direction, type Span } from './triage'
import { api, type Suggestion, type SuggestionType, type WordInfo } from '../../api/client'
import { speak, speechSupported } from '../../audio/speech'
import { api, type DocLang, type Suggestion, type SuggestionType, type WordInfo } from '../../api/client'
import { docLang as docLocale, speak, speechSupported } from '../../audio/speech'
import type { SpellChecker } from '../../hooks/useSpellChecker'
import { fromIME } from '../../lib/ime'
import type { Segmenter } from '../../lib/segment'
@@ -65,6 +65,13 @@ export interface EditorChange {
interface Props {
// Changing docId reloads the editor with that document's content.
docId: string
// Which language this document is written in, as the server decided it
// ('' | 'en' | 'pair'). Read-aloud is the only thing here that reads it: a
// Portuguese selection has to be read by the Portuguese voice, and nothing in
// the letters says so. Updated by the server on save, so it trails a language
// flip by one auto-save — a passage read in the old voice once is the whole
// cost of not blocking the editor on a check.
docLang: DocLang
initialContent: string
onChange: (change: EditorChange) => void
// LLM suggestions to highlight; accept/dismiss notify the parent for the API
@@ -255,6 +262,7 @@ interface RewriteState {
// decoration layer. Hovering a highlight opens its SuggestionCard.
export function EditorCore({
docId,
docLang,
initialContent,
onChange,
suggestions,
@@ -1532,8 +1540,10 @@ export function EditorCore({
<SelectionBubble
style={{ top: selection.top, left: selection.left, transform: 'translateY(calc(-100% - 8px))' }}
onRewrite={handleRewrite}
onSpeak={speechSupported() ? () => speak(selection.text) : null}
onSpeakSlow={speechSupported() ? () => speak(selection.text, undefined, true) : null}
onSpeak={speechSupported() ? () => speak(selection.text, docLocale(selection.text, docLang)) : null}
onSpeakSlow={
speechSupported() ? () => speak(selection.text, docLocale(selection.text, docLang), true) : null
}
/>
)}
{rewrite && (
+19 -5
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { api, type VocabGrade, type VocabWord } from '../../api/client'
import { speak, speechSupported, stopSpeech } from '../../audio/speech'
import { docLang as docLocale, speak, speechSupported, stopSpeech } from '../../audio/speech'
import { useFocusTrap } from '../../hooks/useFocusTrap'
import { usePack, type Line } from '../../i18n'
import { JournalView } from './JournalView'
@@ -314,7 +314,21 @@ function GardenView({
{blossom(w.reps)}
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-bold text-plum">{w.word}</span>
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-sm font-bold text-plum">{w.word}</span>
{/* Only a card in her own language is marked. An English
garden with a badge on every blossom would be a
garden with no badges at all; the marker exists so
the rare Portuguese word is legible among them. */}
{w.lang === 'pair' && (
<span
className="shrink-0 rounded-full px-1.5 py-0.5 text-[9px] font-bold lowercase"
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-muted)' }}
>
{t.nativeName}
</span>
)}
</span>
{(w.gloss || w.definition) && (
<span
className="truncate text-xs"
@@ -354,7 +368,7 @@ function GardenView({
{speechSupported() && (
<button
type="button"
onClick={() => speak(w.word)}
onClick={() => speak(w.word, docLocale(w.word, w.lang))}
className="rounded-full px-2.5 py-1 text-xs font-semibold"
style={{ background: 'var(--color-surface-alt)' }}
>
@@ -477,7 +491,7 @@ function ReviewSession({
<>
<button
type="button"
onClick={() => speak(card.word)}
onClick={() => speak(card.word, docLocale(card.word, card.lang))}
aria-label={`Pronounce ${card.word}`}
className="flex h-6 w-6 items-center justify-center rounded-full text-xs"
style={{ background: 'var(--color-surface)' }}
@@ -488,7 +502,7 @@ function ReviewSession({
worth hearing stretched out. */}
<button
type="button"
onClick={() => speak(card.word, undefined, true)}
onClick={() => speak(card.word, docLocale(card.word, card.lang), true)}
aria-label={`Pronounce ${card.word} slowly`}
title={t.garden.readSlowly}
className="flex h-6 w-6 items-center justify-center rounded-full text-xs"