Files
petal/web/src/components/Editor/WordCard.tsx
T
prosolis 97e9c269ec Phase 20: the dictionary stops being English and Chinese only
Word lookups now come from DreamDict's dict.db for every pair but Chinese —
opened read-only beside petal.db, no service, nothing over the VPN, because a
hover gloss has to answer in milliseconds.

`Provider` is the two questions the popover and the tooltip already asked, so
the embedded *Lexicon satisfies it with no changes at all; Set.For(lang) is the
single place the choice between them is made. The prerequisite in the dreamdict
repo turned out to be two things, not one: the module path was unfetchable
*and* the query layer sat in internal/, which no other module may import
whatever the module is called. Both fixed upstream.

The plan's central assumption did not survive the data. It mapped
Gloss ← Translate(word, "en", L1) one-to-one; against the real 452 MB database
that table answers for 17% of the 2,000 commonest English words into pt-PT.
Wiktionary's translation sections are thin in that direction — "ephemeral",
"think" and "quickly" have no en→pt-PT row at all. Shared WordNet synsets
answer for 61%, so DreamDict gained Equivalents() and Petal glosses through it.
Ordering those was wrong in an instructive way too: sorting by frequency
glosses "think" as lembrar, "remember", because lembrar is the commoner
Portuguese word even though pensar shares six of think's synsets to lembrar's
one. Counting sense agreement first asks the right question.

The same measurement is why zh stays on ECDICT: DreamDict reaches a Chinese
gloss for 53% of those words, ECDICT for nearly all of them. The plan said
converge only if quality holds. It didn't, so nothing converged.

Two decisions about failure worth keeping. A missing dict.db is not an error —
a laptop checkout has never had one — but a present-and-never-imported one is,
because that is a half-finished deploy. And a pt-PT writer with no dictionary
falls back to the embedded datasets with the gloss suppressed, keeping
definitions, synonyms and phonetics rather than blanking the popover: an empty
field reads as "not found", the wrong language reads as broken.

The new fields surface as an etymology line and a three-band chip. Three, not
five: the difficulty score separates "everyday" from "you'll have to explain
this" but cannot rank obfuscate against serendipity, and a finer scale would be
a confident-looking lie. An unscored word gets no chip.

Writing the tests found two bugs first — trimEtymology sliced by byte, which
would have emitted invalid UTF-8 for exactly the Greek and Latin etymologies
the feature exists for, and its ellipsis path overran its own cap.

go build/vet/test, tsc, vite, vitest 96/96 clean; live smoke against the real
dict.db with one instance flipped from zh to pt-PT mid-run.

Not deployed: go.mod still replaces github.com/prosolis/dreamdict with
../dreamdict, so the Docker build needs the two upstream commits pushed and the
replace dropped. The deployed dict.db also predates DreamDict's Spanish data.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 09:38:50 -07:00

210 lines
7.8 KiB
TypeScript

import type { WordInfo } from '../../api/client'
import { speak, speechSupported } from '../../audio/speech'
import { usePack } from '../../i18n'
import { wordBand } from './wordband'
// WordCard is the right-click popover for any word: its dictionary definition(s)
// on top and tappable synonym pills below. Clicking a synonym replaces the word
// in place. Both datasets are offline, so this opens instantly and fills in as
// the (local) lookup returns. Labels are bilingual (zh-first, en subtitle) to
// match the rest of Petal's chrome — the writer uses Mandarin and English.
interface Props {
word: string
info: WordInfo | null
loading: boolean
// Whether the word is in the vocabulary garden (auto-saved on lookup). The
// heart toggles it; `onToggleSave` removes/re-adds it.
saved: boolean
onToggleSave: () => void
style: React.CSSProperties
onReplace: (synonym: string) => void
}
export function WordCard({ word, info, loading, saved, onToggleSave, style, onReplace }: Props) {
const t = usePack()
const definitions = info?.definitions ?? []
const synonyms = info?.synonyms ?? []
const gloss = info?.gloss ?? ''
const phonetic = info?.phonetic ?? ''
const etymology = info?.etymology ?? ''
// 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
return (
<div
role="dialog"
aria-label={`Definition and synonyms for ${word}`}
className="petal-word-card absolute z-20 p-3.5 text-sm"
style={{
width: 300,
maxHeight: 340,
overflowY: 'auto',
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-card)',
boxShadow: 'var(--shadow-soft)',
...style,
}}
>
<div className="flex items-center gap-1.5">
<span
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold"
style={{ background: 'var(--color-lavender)', color: 'var(--color-plum)' }}
>
{t.editor.word}
</span>
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
{word}
</span>
<div className="ml-auto flex items-center gap-1.5">
{!empty && !loading && (
<button
type="button"
onClick={onToggleSave}
aria-label={saved ? 'Remove from vocabulary garden' : 'Save to vocabulary garden'}
aria-pressed={saved}
title={saved ? t.editor.inGarden : t.editor.saveToGarden}
className="flex h-7 w-7 items-center justify-center rounded-full text-sm transition-transform"
style={{
background: saved ? 'var(--color-accent)' : 'var(--color-surface-alt)',
}}
>
{saved ? '💚' : '🤍'}
</button>
)}
{speechSupported() && (
<button
type="button"
onClick={() => speak(word)}
aria-label={`Pronounce ${word}`}
title={t.editor.readAloud}
className="flex h-7 w-7 items-center justify-center rounded-full text-sm"
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
>
🔊
</button>
)}
</div>
</div>
{/* How to say it, and how hard it is. The pronunciation aid pairs with the
🔊 button above; the band answers the question a learner actually has
when she has found a word she likes — "can I use this?". Both are
quiet, muted lines: information she can take or leave, never a verdict
on her writing. */}
{(phonetic || band) && (
<div className="mt-1.5 flex items-center gap-2 text-sm">
{phonetic && <span style={{ color: 'var(--color-muted)' }}>/{phonetic}/</span>}
{band && (
<span
className="rounded-full px-2 py-0.5 text-xs font-semibold"
title={t.editor.wordBands[band].en}
style={{
background: 'var(--color-surface-alt)',
color: band === 'advanced' ? 'var(--color-accent-hover)' : 'var(--color-muted)',
}}
>
{t.editor.wordBands[band].native}
</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
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
style={{ background: 'var(--color-accent)' }}
aria-hidden
/>
{t.editor.lookingUp}
</div>
)}
{definitions.length > 0 && (
<div className="mt-3 space-y-2">
<p className="text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
{t.editor.definition}
</p>
<ol className="space-y-1.5">
{definitions.map((m, i) => (
<li key={i} className="leading-snug" style={{ color: 'var(--color-plum)' }}>
{m.part_of_speech && (
<span className="mr-1 italic" style={{ color: 'var(--color-accent-hover)' }}>
{m.part_of_speech}
</span>
)}
{m.definition}
{m.example && (
<span className="mt-0.5 block italic" style={{ color: 'var(--color-muted)' }}>
{m.example}
</span>
)}
</li>
))}
</ol>
</div>
)}
{synonyms.length > 0 && (
<div className="mt-3">
<p className="mb-1.5 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
{t.editor.synonyms} <span className="font-normal">({t.editor.tapToSwap})</span>
</p>
<div className="flex flex-wrap gap-1.5">
{synonyms.map((s) => (
<button
key={s}
type="button"
onClick={() => onReplace(s)}
className="rounded-full px-3 py-1 text-xs font-semibold"
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
>
{s}
</button>
))}
</div>
</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"
sitting under "ephemeral" is how a word stops needing to be memorised. */}
{etymology && (
<div className="mt-3">
<p className="mb-1 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
{t.editor.origin}
</p>
<p className="text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
{etymology}
</p>
</div>
)}
{empty && (
<p className="mt-3 leading-snug" style={{ color: 'var(--color-muted)' }}>
{t.editor.nothingFound}
</p>
)}
</div>
)
}