Phase 21: Petal learns to be an English+Portuguese pair

The plan said "Hunspell pt-PT vendored like en-US". Measuring that first is
what saved it: nspell expands affixes eagerly on construction, and European
Portuguese's 1,340 rules over 44,257 stems want over a gigabyte of browser
heap — ~340 MB for the first 12,000 entries, and no return at all after three
minutes on the whole file. So the expansion runs once at build time instead:
1,039,058 forms, 2.66 MB gzipped, read by the same nspell in 842 ms.

The obvious npm package would also have shipped the wrong language. Both
dictionary-pt and dictionary-pt-br carry VERO, the Brazilian word list, so
vendoring by name puts pt-BR spellings behind a pt-PT label — the drift
SUGGESTIONS §3 warns about, arriving through the packaging where no reviewer
can see it. The source is Projecto Natura's, and the build script now asserts
the fault lines (receção in, recepção out) before writing anything.

Spellcheck consults both dictionaries and flags only what both reject, which
is the no-detector answer to a pair with no script boundary. The word card
does the same in the other direction: "data" is a word in both languages, so
Petal shows both readings rather than guessing which she meant.

Writing the tests caught the one real bug — extendedAlphabet was a snapshot
while correct/suggest read live, and her dictionary arrives after English, so
every lookup would have resolved "cora" while the underlines were already
right.

Not done, and not claimed: the pack has not been read by a pt-PT speaker, and
the Piper voice is deferred with the deploy.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
prosolis
2026-07-27 12:43:02 -07:00
parent 4de83d0da5
commit ccb43e5a4d
22 changed files with 1458 additions and 107 deletions
+31 -9
View File
@@ -32,6 +32,7 @@ import { RewritePreview, type RewriteStatus } from './RewritePreview'
import { api, type Suggestion, type WordInfo } from '../../api/client'
import { speak, speechSupported } from '../../audio/speech'
import type { SpellChecker } from '../../hooks/useSpellChecker'
import { usePack } from '../../i18n'
export interface EditorChange {
content: string // Tiptap JSON, stringified
@@ -176,6 +177,8 @@ interface HoverState {
interface GlossState {
word: string
gloss: string
// The other reading, when the token is a word in her language too.
reverse?: string
from: number
to: number
top: number
@@ -222,6 +225,9 @@ export function EditorCore({
spellChecker,
onAddWord,
}: Props) {
// Her pair's copy — the hover tip labels the second reading with the language's
// own name, so it says "português" rather than "pt-PT".
const pack = usePack()
const wrapperRef = useRef<HTMLDivElement>(null)
const [hover, setHover] = useState<HoverState | null>(null)
// The open spelling popover (click a red-underlined word), or null.
@@ -368,6 +374,13 @@ export function EditorCore({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [docId, editor])
// Which letters count as part of a word. The spell checker owns the answer,
// because it depends on which dictionaries this writer's pair loaded — see
// SpellCheck's wordRe. Every surface that resolves "the word under here"
// (lookup, gloss, right-click) has to agree with the underline, or the
// popover would offer a definition of "cora".
const wordAlphabet = spellChecker?.extendedAlphabet ?? false
// Push the spell checker into its decoration plugin once the dictionary loads
// (and again whenever the personal dictionary changes its identity).
useEffect(() => {
@@ -590,7 +603,7 @@ export function EditorCore({
const openMisspellAt = useCallback(
(pos: number): boolean => {
if (!editor || !spellChecker) return false
const range = wordAt(editor.state.doc, pos)
const range = wordAt(editor.state.doc, pos, spellChecker.extendedAlphabet)
if (!range || spellChecker.correct(range.word)) return false
const wrapper = wrapperRef.current
if (!wrapper) return false
@@ -679,7 +692,7 @@ export function EditorCore({
const openWordLookup = useCallback(
(pos: number) => {
if (!editor) return
const range = wordAt(editor.state.doc, pos)
const range = wordAt(editor.state.doc, pos, wordAlphabet)
if (!range) return
const wrapper = wrapperRef.current
if (!wrapper) return
@@ -784,13 +797,13 @@ export function EditorCore({
if (!editor) return
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
if (!coords) return
if (!wordAt(editor.state.doc, coords.pos)) return
if (!wordAt(editor.state.doc, coords.pos, wordAlphabet)) return
e.preventDefault()
// A misspelled word offers corrections first; otherwise look it up.
if (openMisspellAt(coords.pos)) return
openWordLookup(coords.pos)
},
[editor, openMisspellAt, openWordLookup],
[editor, wordAlphabet, openMisspellAt, openWordLookup],
)
// Touch has no hover or right-click, so a long-press (~500ms without moving)
@@ -844,7 +857,7 @@ export function EditorCore({
clear()
return
}
const range = wordAt(editor.state.doc, coords.pos)
const range = wordAt(editor.state.doc, coords.pos, wordAlphabet)
if (!range) {
clear()
return
@@ -859,7 +872,9 @@ export function EditorCore({
.then((g) => {
if (token !== glossReqRef.current) return
const wrapper = wrapperRef.current
if (!g.gloss || !wrapper) {
// A token can have only the reverse reading — a Portuguese word she
// hovers in her own sentence — and that is still worth a tooltip.
if ((!g.gloss && !g.reverse) || !wrapper) {
setGloss(null)
return
}
@@ -868,14 +883,14 @@ export function EditorCore({
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 })
setGloss({ word: range.word, gloss: g.gloss, reverse: g.reverse, from: range.from, to: range.to, top, left })
})
.catch(() => {
if (token === glossReqRef.current) setGloss(null)
})
}, 350)
},
[editor, selection, rewrite, misspell, wordInfo, pinned, gloss],
[editor, wordAlphabet, selection, rewrite, misspell, wordInfo, pinned, gloss],
)
// Leaving the editor surface drops any pending/shown gloss.
@@ -1081,7 +1096,14 @@ export function EditorCore({
<EditorContent editor={editor} className="h-full" />
{findOpen && editor && <FindReplace editor={editor} onClose={() => setFindOpen(false)} />}
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
{gloss && <GlossTip gloss={gloss.gloss} style={{ top: gloss.top, left: gloss.left }} />}
{gloss && (
<GlossTip
gloss={gloss.gloss}
reverse={gloss.reverse}
reverseLang={pack.nativeName}
style={{ top: gloss.top, left: gloss.left }}
/>
)}
{selection && !rewrite && !dragging && (
<SelectionBubble
style={{ top: selection.top, left: selection.left, transform: 'translateY(calc(-100% - 8px))' }}
+14 -1
View File
@@ -7,10 +7,17 @@
interface Props {
gloss: string
// The English meaning of the same token read as a word of the writer's own
// language, when it is one. On a Latin-script pair "sale" is both, and the
// bubble shows the two readings stacked rather than picking one — the same
// both-directions rule the word card follows, in one line less space.
reverse?: string
// How the writer's language names itself, to label the second line.
reverseLang?: string
style: React.CSSProperties
}
export function GlossTip({ gloss, style }: Props) {
export function GlossTip({ gloss, reverse, reverseLang, style }: Props) {
return (
<div
className="petal-gloss-tip pointer-events-none absolute z-20 px-2.5 py-1.5 text-sm"
@@ -27,6 +34,12 @@ export function GlossTip({ gloss, style }: Props) {
}}
>
{gloss}
{reverse && (
<span className="mt-0.5 block" style={{ opacity: 0.72, fontSize: '0.85em' }}>
{reverseLang ? `${reverseLang}: ` : ''}
{reverse}
</span>
)}
</div>
)
}
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest'
import { Schema, type Node as PMNode } from '@tiptap/pm/model'
import { wordAt } from './SpellCheck'
// Same minimal schema the suggestion-anchoring tests use: enough of a document
// to walk textblocks, none of the editor.
const schema = new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: { group: 'block', content: 'inline*', toDOM: () => ['p', 0] },
text: { group: 'inline' },
},
})
const para = (text: string): PMNode =>
schema.node('doc', null, [schema.node('paragraph', null, text ? [schema.text(text)] : [])])
// posOf turns a plain-text offset into a ProseMirror position inside the single
// paragraph (+1 for the paragraph's opening token).
const posOf = (offset: number) => offset + 1
describe('wordAt and the pair alphabet', () => {
it('keeps English-only tokenizing when no accented dictionary is loaded', () => {
const doc = para('the river runs')
expect(wordAt(doc, posOf(5))?.word).toBe('river')
})
it('cuts an accented word into fragments on the narrow alphabet', () => {
// Not a hypothetical: this is what every surface did before the pt-PT pair,
// and it is why the alphabet had to become a property of the checker rather
// than a constant. "coração" tokenized as A-Z yields "cora" — a definition
// of which would be worse than no popover.
const doc = para('o coração dela')
expect(wordAt(doc, posOf(3))?.word).toBe('cora')
})
it('resolves the whole word once the pair widens the alphabet', () => {
const doc = para('o coração dela')
expect(wordAt(doc, posOf(3), true)?.word).toBe('coração')
// …from either side of the accented letters, not just before them.
expect(wordAt(doc, posOf(9), true)?.word).toBe('coração')
})
it('never tokenizes CJK, whichever alphabet is in force', () => {
// The zh pair's guarantee, and it must survive a change made for another
// pair entirely: Chinese is the source language, not something to spellcheck.
const doc = para('我在写作 today')
expect(wordAt(doc, posOf(1))).toBeNull()
expect(wordAt(doc, posOf(1), true)).toBeNull()
expect(wordAt(doc, posOf(6), true)?.word).toBe('today')
})
it('stops the wide alphabet at the maths symbols hiding in Latin-1', () => {
// × (U+00D7) and ÷ (U+00F7) sit inside the accented-letter block. A range
// written À-ÿ would swallow them and glue "3×4" into one token.
const doc = para('3×4 é isso')
expect(wordAt(doc, posOf(4), true)?.word).toBe('é')
})
})
+33 -5
View File
@@ -22,7 +22,25 @@ interface PluginState {
// A word is a run of Latin letters with optional internal/edge apostrophes
// (don't, O'Brien). Anything else — digits, punctuation, CJK — terminates a run.
//
// Two alphabets, because the writer's pair decides which is right. English needs
// only A-Z. European Portuguese needs ç and the accented vowels, and tokenizing
// "ação" without them yields "a" and "o" — two fragments short enough that
// isCheckable throws them away, so the word is silently never checked at all.
//
// The narrow alphabet stays the default rather than always widening: for a
// writer with no Latin second language, adding accented letters can only find
// new words to underline (the "café" and "naïve" she borrows), and finds no
// mistakes she has actually made.
const WORD_RE = /[A-Za-z][A-Za-z']*/g
const WORD_RE_LATIN = /[A-Za-zÀ-ÖØ-öø-ÿ][A-Za-zÀ-ÖØ-öø-ÿ']*/g
// wordRe returns a fresh matcher for the alphabet in force. Fresh because these
// are /g regexes carrying lastIndex, and two scans sharing one would interleave.
function wordRe(extended: boolean): RegExp {
const src = extended ? WORD_RE_LATIN : WORD_RE
return new RegExp(src.source, 'g')
}
// isCheckable filters tokens we shouldn't flag: single letters and all-caps
// acronyms (NASA, USA), which dictionaries reliably miss and which read as noise
@@ -47,12 +65,13 @@ function eachMisspelling(
checker: SpellChecker,
visit: (from: number, to: number, word: string) => void,
) {
const re = wordRe(checker.extendedAlphabet)
doc.descendants((node, pos) => {
if (!node.isTextblock) return true
const text = node.textContent
WORD_RE.lastIndex = 0
re.lastIndex = 0
let m: RegExpExecArray | null
while ((m = WORD_RE.exec(text)) !== null) {
while ((m = re.exec(text)) !== null) {
const { core, lead } = coreOf(m[0])
if (!isCheckable(core) || checker.correct(core)) continue
const from = mapOffset(node, pos, m.index + lead)
@@ -78,16 +97,25 @@ function buildDecorations(doc: PMNode, checker: SpellChecker, cursor: number): D
// click), returning its range + text so the card can offer corrections and the
// replacement can target the exact span — robust to duplicate words anywhere
// else in the document. Returns null if the position isn't inside a Latin word.
export function wordAt(doc: PMNode, pos: number): { from: number; to: number; word: string } | null {
//
// `extended` widens the alphabet the same way the decoration pass does, so that
// right-clicking "coração" looks up the whole word rather than "cora". Callers
// pass the live checker's flag; the default keeps English-only behaviour.
export function wordAt(
doc: PMNode,
pos: number,
extended = false,
): { from: number; to: number; word: string } | null {
let found: { from: number; to: number; word: string } | null = null
const re = wordRe(extended)
doc.descendants((node, nodePos) => {
if (found) return false
if (!node.isTextblock) return true
if (pos <= nodePos || pos >= nodePos + node.nodeSize) return false
const text = node.textContent
WORD_RE.lastIndex = 0
re.lastIndex = 0
let m: RegExpExecArray | null
while ((m = WORD_RE.exec(text)) !== null) {
while ((m = re.exec(text)) !== null) {
const { core, lead } = coreOf(m[0])
if (!core) continue
const from = mapOffset(node, nodePos, m.index + lead)
+34 -1
View File
@@ -28,9 +28,11 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
const gloss = info?.gloss ?? ''
const phonetic = info?.phonetic ?? ''
const etymology = info?.etymology ?? ''
// Present only when the token is also a word in her own language.
const reverse = info?.reverse ?? null
// Null whenever the dictionary has no opinion — the chip then doesn't render.
const band = info ? wordBand(info.frequency ?? 0, info.difficulty ?? -1) : null
const empty = !loading && !gloss && definitions.length === 0 && synonyms.length === 0
const empty = !loading && !gloss && !reverse && definitions.length === 0 && synonyms.length === 0
return (
<div
@@ -184,6 +186,37 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
</div>
)}
{/* The same word read as one of hers. Only a Latin-script pair ever sees
this — "sale" is English and Portuguese, "chat" is English and French —
and Petal shows both readings rather than deciding which she meant. A
detector would be right most of the time and wrong about her writing
the rest; two lines are right always, and for a learner the collision
is the interesting part. */}
{reverse && (
<div
className="mt-3 rounded-xl px-2.5 py-2"
style={{ background: 'var(--color-surface-alt)' }}
>
<p className="mb-1 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
{t.editor.alsoIn}
</p>
<p className="leading-snug" style={{ color: 'var(--color-plum)' }}>
{reverse.gloss || word}
{reverse.phonetic && (
<span className="ml-1.5 text-xs" style={{ color: 'var(--color-muted)' }}>
/{reverse.phonetic}/
</span>
)}
</p>
{(reverse.definitions ?? []).map((m, i) => (
<p key={i} className="mt-1 text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
{m.part_of_speech && <span className="mr-1 italic">{m.part_of_speech}</span>}
{m.definition}
</p>
))}
</div>
)}
{/* Where the word came from. Last, and in small muted type, because it is
the one thing here that is interesting rather than useful — and for a
writer whose own language shares Latin roots with English, "efémero"