Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bed33c27e | ||
|
|
7383bdb403 | ||
|
|
3cc23b8ea4 | ||
|
|
6026d98598 | ||
|
|
15398eab4d | ||
|
|
2c5b05b398 | ||
|
|
acb35108c0 | ||
|
|
e67f77eb05 | ||
|
|
c719effe1d | ||
|
|
1fdc206576 | ||
|
|
466055020f | ||
|
|
29eb2fe1fc | ||
|
|
76dede8856 | ||
|
|
db9cfb7abf | ||
|
|
f82f2b589d | ||
|
|
5cd3aeabde | ||
|
|
047f4ae67f | ||
|
|
f2dd30628a | ||
|
|
c6bf36bddf | ||
|
|
178cb7ae67 |
File diff suppressed because one or more lines are too long
+180
-8
@@ -958,7 +958,146 @@ categories are the unit she can reason about — five article fixes are one
|
|||||||
decision, but her whole queue is not — and a single button that rewrites the
|
decision, but her whole queue is not — and a single button that rewrites the
|
||||||
document in one press is the opposite of a tool that teaches.
|
document in one press is the opposite of a tool that teaches.
|
||||||
|
|
||||||
**Still open in item 8:** the keyboard triage flow.
|
### 8 — Keyboard triage DONE (eleventh session). The item's own keys were the one part that couldn't be built.
|
||||||
|
|
||||||
|
This is the last of item 8, and the last small item in the review. It arrives with
|
||||||
|
half of it already built by item 7: because the anchored popover is the primary
|
||||||
|
surface in *both* layouts, one keyboard flow covers rail and no-rail, and there
|
||||||
|
was never a question of driving two.
|
||||||
|
|
||||||
|
**The item asks for Tab/Shift+Tab "or n/p", and n/p cannot exist.** This is a
|
||||||
|
text editor. An unmodified letter is a letter, and `n` would type an n in the
|
||||||
|
middle of her sentence. Tab is nearly as bad while the caret is in the prose.
|
||||||
|
Both are fine *once a card is open and holding focus* — which is exactly where
|
||||||
|
the item asks for them — so the only real design question was how to get there,
|
||||||
|
and that needs a key that is safe to press mid-sentence. Mid-*composition*, even:
|
||||||
|
she writes Chinese, and a Chinese IME uses `,` and `.` to page its candidate
|
||||||
|
window, so the entry chord is guarded by `fromIME` like every other key Petal
|
||||||
|
binds.
|
||||||
|
|
||||||
|
**The shape, then:**
|
||||||
|
|
||||||
|
- `Ctrl/Cmd+.` and `Ctrl/Cmd+,` step to the next/previous underline from
|
||||||
|
anywhere in the text, which is both how triage is entered and how it is
|
||||||
|
continued. They join the existing `Ctrl+F` / `Ctrl+D` / `Ctrl+J` family in the
|
||||||
|
same handler.
|
||||||
|
- The card that opens **takes focus**, and from there the item's keys work as
|
||||||
|
written: `Tab`/`Shift+Tab` step, `Enter` accepts, `Del`/`Backspace` dismisses,
|
||||||
|
`Esc` leaves. `?` opens Ask Petal — the panel focuses its own input, and
|
||||||
|
`Esc` there steps back out to the card rather than out of triage, so the one
|
||||||
|
detour that matters to an ESL writer isn't a mouse-only feature.
|
||||||
|
- **Answering a card advances by itself.** Accept or dismiss and the next stop
|
||||||
|
opens focused; the last one closes the card and puts the caret back in the
|
||||||
|
text, just past the span she was reading about. That is the whole acceptance
|
||||||
|
criterion — a document triaged without the mouse is five presses of Enter.
|
||||||
|
|
||||||
|
**Implemented:**
|
||||||
|
|
||||||
|
- `triage.ts` — `stepId`, `entryId`, `idAfterRemoval`. Pure, and taking the queue
|
||||||
|
as an argument, so wrap-around, caret-relative entry and "where does an
|
||||||
|
answered card hand over to" are testable without a ProseMirror document or a
|
||||||
|
layout — the same split `acceptBatch.ts` used, for the same reason.
|
||||||
|
- **The queue is the underlines, not the suggestion list.** Read off the
|
||||||
|
decoration DOM in document order. A suggestion the editor couldn't anchor has
|
||||||
|
no underline, and a triage stop she cannot see is worse than one she never
|
||||||
|
visits; reading the DOM also guarantees every stop can be anchored, which is
|
||||||
|
what the card needs to position itself.
|
||||||
|
- `SuggestionCard.tsx` — `keyboard` mode: `tabIndex={-1}`, focus on mount *and on
|
||||||
|
every step* (stepping keeps the same component mounted and swaps the suggestion
|
||||||
|
inside it), `focus({ preventScroll: true })` for AskPetal's reason, an accent
|
||||||
|
border where a pointer would otherwise be saying "this one", and the legend.
|
||||||
|
- `EditorCore.tsx` — `orderedSpans` / `openTriageAt` / `stepTriage` / `exitTriage`,
|
||||||
|
and `queueTriageAfter`, which notes the next stop *before* the action, because
|
||||||
|
the queue has to be read while the answered card is still in it.
|
||||||
|
- An Accept-all pressed from a triage card resumes after **her** card, not after
|
||||||
|
whichever member of the batch happened to be last — the queue is in document
|
||||||
|
order and a category is scattered through it.
|
||||||
|
|
||||||
|
**Two things the code had to be told, and both are about other people's keys.**
|
||||||
|
|
||||||
|
- **Escape is overloaded.** App has a window listener where Escape leaves
|
||||||
|
distraction-free mode; unhandled, one press would have closed the card *and*
|
||||||
|
restored the sidebar *and* — via item 7's rail-follows-the-mode — pulled the
|
||||||
|
rail out from under her. In triage that key means "this card", never "the
|
||||||
|
writing mode", so the card stops the event.
|
||||||
|
- **The Spanish pack's own test caught the legend.** `?` in a Spanish `Line`
|
||||||
|
must open with `¿`, and the i18n suite says so for every native half in the
|
||||||
|
pack. It is right, and it is wrong here: this `?` is a key cap, no more Spanish
|
||||||
|
punctuation than `Esc`. The exemption is one named entry with the reason
|
||||||
|
written next to it, rather than a loosened rule.
|
||||||
|
|
||||||
|
**The legend is bilingual, against the card's own convention.** Accept, Dismiss
|
||||||
|
and Ask Petal stay English because they name the thing she is learning to talk
|
||||||
|
about (item 5's reasoning, and item 8's for the Accept-all label). This isn't
|
||||||
|
that: it is an instruction for operating Petal, like the status bar, so it is
|
||||||
|
bilingual and leads with the pair language. The key *names* are what is printed
|
||||||
|
on her keyboard, so fr says `Entrée`/`Suppr`/`Échap` and es says `Intro`/`Supr` —
|
||||||
|
a legend she has to translate back to find the key is not a legend.
|
||||||
|
|
||||||
|
**Verified in a real browser at the review's own 1517×810**, on a fresh database
|
||||||
|
with no model at all (the rule pack from item 3b needs none), against the served
|
||||||
|
bundle hash checked against `web/dist` first. Nineteen assertions on a clean run,
|
||||||
|
then the acceptance criterion itself:
|
||||||
|
|
||||||
|
- *Entry.* Five underlines from one typed paragraph. `Ctrl+.` opened the first
|
||||||
|
card after the caret, focused, accent-bordered, legend showing both halves —
|
||||||
|
and **did not type a period into her sentence**.
|
||||||
|
- *Walking.* Tab through all five to the last, once more to wrap to the first,
|
||||||
|
Shift+Tab to wrap backwards. Every step landed on the card it should.
|
||||||
|
- *Answering.* Enter accepted and the next card opened focused by itself
|
||||||
|
(5 → 4 underlines, text corrected); Del dismissed and advanced (4 → 3, text
|
||||||
|
untouched); `?` opened Ask Petal with its input focused, and Escape there came
|
||||||
|
back to the card rather than out of triage.
|
||||||
|
- *The criterion.* From `Ctrl+.`, **five presses of Enter and nothing else**:
|
||||||
|
zero underlines left, `I want an apple and an orange. She has three cats. He
|
||||||
|
walk to an office.`, card closed, caret back in the prose, no horizontal
|
||||||
|
overflow. No mouse after the initial click into the document.
|
||||||
|
- *Both layouts.* Escape out of distraction-free (rail gone, sidebar back), then
|
||||||
|
`Ctrl+,` — a card opened, focused, on the last underline before the caret,
|
||||||
|
with no rail anywhere.
|
||||||
|
- *The mixed path.* Accept-all **clicked** while a keyboard card was open: whole
|
||||||
|
category applied, triage ended cleanly with focus in the text. No page errors
|
||||||
|
in any run.
|
||||||
|
|
||||||
|
**No Chrome extension this session** — it wasn't connected — so the browser was
|
||||||
|
driven over CDP against a real headless Chrome instead. That turned out to be
|
||||||
|
the better tool for this item and is worth recording: `Input.dispatchKeyEvent`
|
||||||
|
produces genuine trusted keystrokes, which is the only honest way to test a
|
||||||
|
feature that *is* keystrokes. It also sidesteps the tenth session's rAF trap —
|
||||||
|
`document.visibilityState` reads `visible`, so `recomputeRail` runs. The driver
|
||||||
|
is ~70 lines (`connect` → `key`/`click`/`typeText`/`shot`/`ev`).
|
||||||
|
|
||||||
|
**A measurement trap, and a cheap one.** The first run reported zero of
|
||||||
|
everything because the click that focused the editor was at y=300 and the empty
|
||||||
|
document's prose box ends at y=231. Nothing errored; the text simply went
|
||||||
|
nowhere. The second reported five underlines becoming three, because it reused
|
||||||
|
the *previous run's document* — where two of those spans had already been
|
||||||
|
dismissed, and item 8's own settled-spans work was correctly refusing to raise
|
||||||
|
them again. **Reset the database between browser runs**, or the feature you
|
||||||
|
shipped last session will look like the bug you're chasing this one.
|
||||||
|
|
||||||
|
**Deliberately not done:**
|
||||||
|
|
||||||
|
- No keyboard binding for Accept-all. Every other triage key answers the card in
|
||||||
|
front of her; a key that rewrites parts of the document she cannot see is a
|
||||||
|
different kind of decision, and it is one worth the deliberate reach for a
|
||||||
|
button. The path still works if she clicks it, and is tested.
|
||||||
|
- No visual cue in the text beyond the existing active-span glow (which the rail
|
||||||
|
already drives), and no "3 of 5" counter on the card. The status bar already
|
||||||
|
counts the queue, and a position indicator turns walking one's own mistakes
|
||||||
|
into a progress bar — the pressure this review's non-goals rule out.
|
||||||
|
|
||||||
|
Coverage: `triage.test.ts` (wrap-around at both ends, entry from either
|
||||||
|
direction with the caret before/on/after a span, a current card that has left the
|
||||||
|
queue, single-item and empty queues, handover after one answer and after an
|
||||||
|
Accept-all swept several, never handing back the card just answered, and a card
|
||||||
|
that was never in the queue — the provisional rule-pack case from item 3b), and
|
||||||
|
an `i18n.test.ts` case that every pack names all five keys in both halves.
|
||||||
|
**The wiring itself has no unit test**, for the reason items 6, 7 and 8 all
|
||||||
|
recorded: jsdom has no layout, every rect is zero, and a test there would pass
|
||||||
|
whatever the code did. It is browser-verified only, and is written down as such.
|
||||||
|
|
||||||
|
**Item 8 is now complete.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1071,6 +1210,21 @@ of it is stale until a screenshot forces a paint. Second, **the binary embeds
|
|||||||
bundle-hash check will (rightly) fail. Untouched: item 8's keyboard flow, item 3's
|
bundle-hash check will (rightly) fail. Untouched: item 8's keyboard flow, item 3's
|
||||||
incremental half.)*
|
incremental half.)*
|
||||||
|
|
||||||
|
*(Eleventh session: item 8's keyboard triage done — see the subsection under item
|
||||||
|
8. **Item 8 is finished, and item 3's incremental surfacing is the only thing
|
||||||
|
left in the whole review.** Note this session started from a `main` that had
|
||||||
|
moved on past the tenth session's note: the settled-spans and Accept-all work is
|
||||||
|
merged and pushed, alongside three later commits that were not review items (the
|
||||||
|
zh learner direction, the es pair, the IME composition guards). Two things to
|
||||||
|
carry forward. First, **a keystroke feature has to be tested with real
|
||||||
|
keystrokes**: with the Chrome extension unconnected, CDP's
|
||||||
|
`Input.dispatchKeyEvent` against a headless Chrome turned out to be the right
|
||||||
|
tool rather than a fallback — trusted events, real layout, and
|
||||||
|
`visibilityState: visible`, so the tenth session's rAF freeze doesn't apply.
|
||||||
|
Second, **reset the database between browser runs**: a second run against the
|
||||||
|
first run's document showed two underlines missing, which was not a bug but
|
||||||
|
item 8's own dismissal persistence working exactly as it should.)*
|
||||||
|
|
||||||
**Migration 0015 on the live database.** It rebuilds the suggestions table, so
|
**Migration 0015 on the live database.** It rebuilds the suggestions table, so
|
||||||
unlike 0014 it could have dropped her rows. Backed up first — and the backup
|
unlike 0014 it could have dropped her rows. Backed up first — and the backup
|
||||||
had to be the whole WAL set (`petal.db`, `-wal`, `-shm` in
|
had to be the whole WAL set (`petal.db`, `-wal`, `-shm` in
|
||||||
@@ -1085,13 +1239,31 @@ CHECK, and every existing row still carrying the label she has already read (the
|
|||||||
seven `clarity` rows include the mislabelled Chinese one — by design, only new
|
seven `clarity` rows include the mislabelled Chinese one — by design, only new
|
||||||
findings get the new type; if you want that card relabelled, edit the sentence).
|
findings get the new type; if you want that card relabelled, edit the sentence).
|
||||||
|
|
||||||
**Suggested next (tenth session onward):** two things remain in the whole review.
|
**Suggested next (eleventh session onward): item 3's incremental surfacing is all
|
||||||
**Keyboard triage** is the one to take: it is the last of item 8, and Accept All
|
that is left of this review.** It is also the largest, and the only one that
|
||||||
just built half of what it needs — a category is now a thing the UI can act on in
|
changes how the app *feels* rather than what it can do. What it needs hasn't
|
||||||
one step, so "triage without the mouse" is mostly about driving the anchored
|
changed: a streaming `/check`, which the current response shape doesn't do. What
|
||||||
popover between spans. **Item 3's incremental surfacing** is the last item of any
|
*has* changed is that the expensive prerequisite is long since built — item 2's
|
||||||
size, and still needs a streaming `/check`. It remains the only one left that
|
chunking means the server already knows which sentences it is re-reading and
|
||||||
changes how the app *feels* rather than what it can do.
|
already returns cached rows for the rest, so "deliver per-chunk results as each
|
||||||
|
sentence finishes" is a transport change rather than an analysis one. The status
|
||||||
|
bar's running count ("Found 3 so far…") is the cheap half and can ship with it;
|
||||||
|
`petalsToPolish` in the packs is already the line to reuse.
|
||||||
|
|
||||||
|
A caution before starting it: **the 250 ms rule pass already covers the felt
|
||||||
|
latency for the errors it knows** (item 3b), so the honest scope of what remains
|
||||||
|
is the LLM's own findings arriving one sentence at a time. Measure what she
|
||||||
|
actually waits for now before designing streaming for a wait that may be
|
||||||
|
noticeably shorter than the review's original 8–15 s.
|
||||||
|
|
||||||
|
*(Superseded, kept for the reading list: the tenth session's advice.)* Two things
|
||||||
|
remained. **Keyboard triage** was the one to take: the last of item 8, with
|
||||||
|
Accept All having built half of what it needed — a category was now a thing the
|
||||||
|
UI could act on in one step, so "triage without the mouse" was mostly about
|
||||||
|
driving the anchored popover between spans. That reading was right about the
|
||||||
|
surface and wrong about the effort: the popover was ready, but the item's own key
|
||||||
|
choices (bare Tab, n/p) can't be bound in a text editor, and picking the entry
|
||||||
|
chord was the design work.
|
||||||
|
|
||||||
*(Superseded, kept for the reading list: the ninth session's advice.)* Three
|
*(Superseded, kept for the reading list: the ninth session's advice.)* Three
|
||||||
things remained. **Accept All per category** was the one with real value left —
|
things remained. **Accept All per category** was the one with real value left —
|
||||||
|
|||||||
@@ -180,14 +180,17 @@ func TestDirectionRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The refusal this axis exists to make: a pair with no word list cannot be
|
// The refusal this axis exists to make: a pair with no learner-side data cannot
|
||||||
// learned toward, however good its langpack is. fr, es and pt-PT all have copy,
|
// be learned toward, however good its langpack is. fr and es have copy, voices
|
||||||
// voices and spelling dictionaries — and nothing that could segment a sentence
|
// and spelling dictionaries, and no `learner` block in their packs to offer the
|
||||||
// or read from that language into English, which is what a learner needs.
|
// choice with — so the server keeps saying no until one is written.
|
||||||
|
//
|
||||||
|
// pt-PT is deliberately no longer in this list; see TestLearnerDirectionForPtPT
|
||||||
|
// below and the argument in `learnerPairs`.
|
||||||
func TestLearnerDirectionRefusedForPairsWithoutData(t *testing.T) {
|
func TestLearnerDirectionRefusedForPairsWithoutData(t *testing.T) {
|
||||||
_, users, _ := newStores(t)
|
_, users, _ := newStores(t)
|
||||||
|
|
||||||
for _, lang := range []string{"pt-PT", "fr", "es"} {
|
for _, lang := range []string{"fr", "es"} {
|
||||||
if err := users.SetPair("bob", lang, DirectionLearningEn); err != nil {
|
if err := users.SetPair("bob", lang, DirectionLearningEn); err != nil {
|
||||||
t.Fatalf("set %s: %v", lang, err)
|
t.Fatalf("set %s: %v", lang, err)
|
||||||
}
|
}
|
||||||
@@ -201,6 +204,37 @@ func TestLearnerDirectionRefusedForPairsWithoutData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The other direction of that same rule, and the one a native English speaker
|
||||||
|
// writing Portuguese depends on.
|
||||||
|
//
|
||||||
|
// This is not only a settings toggle: `direction` is what decides which language
|
||||||
|
// Petal *explains* in (see suggestions.targetFor), so an account that cannot
|
||||||
|
// reach learning_pair gets its Portuguese annotated in Portuguese with no way to
|
||||||
|
// ask for English. Pinned in both directions — the move must take, and it must
|
||||||
|
// still be there when the account is read back.
|
||||||
|
func TestLearnerDirectionForPtPT(t *testing.T) {
|
||||||
|
_, users, _ := newStores(t)
|
||||||
|
|
||||||
|
if err := users.SetPair("bob", "pt-PT", DirectionLearningEn); err != nil {
|
||||||
|
t.Fatalf("set pt-PT: %v", err)
|
||||||
|
}
|
||||||
|
if w := patchMe(t, users, "bob", `{"direction":"learning_pair"}`); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d (%s), want 200", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
u, _ := users.Get("bob")
|
||||||
|
if u.Direction != DirectionLearningPair || u.PairLang != "pt-PT" {
|
||||||
|
t.Fatalf("account = %+v, want pt-PT learning_pair", u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it can be turned back, the same as zh.
|
||||||
|
if w := patchMe(t, users, "bob", `{"direction":"learning_en"}`); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("turn back: status = %d (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if u, _ := users.Get("bob"); u.Direction != DirectionLearningEn {
|
||||||
|
t.Fatalf("direction = %q after turning back", u.Direction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The two-field combination the handler validates as one decision. An account
|
// The two-field combination the handler validates as one decision. An account
|
||||||
// already learning Chinese that asks only to change pair is asking for a state
|
// already learning Chinese that asks only to change pair is asking for a state
|
||||||
// neither field names on its own — French with segmentation — and it must not
|
// neither field names on its own — French with segmentation — and it must not
|
||||||
|
|||||||
+27
-12
@@ -104,19 +104,34 @@ const (
|
|||||||
// The pairs whose *learner* direction Petal can actually serve, which is a
|
// The pairs whose *learner* direction Petal can actually serve, which is a
|
||||||
// narrower thing than a shipped pair and narrower again than a langpack.
|
// narrower thing than a shipped pair and narrower again than a langpack.
|
||||||
//
|
//
|
||||||
// Turning a pair around needs data no langpack carries: a word list to segment
|
// Turning a pair around needs data no langpack carries: a way to find word
|
||||||
// with, and a dictionary that reads from the pair language into English. Chinese
|
// boundaries, and a dictionary that reads from the pair language into English. A
|
||||||
// has both as of Phase 26 (CC-CEDICT + jieba); French, Spanish and Portuguese
|
// pair missing either would leave a writer looking at an editor that silently
|
||||||
// have neither yet, and — unlike a missing pack, which leaves a writer looking
|
// does nothing when she hovers — worse than a missing pack, which at least reads
|
||||||
// at copy she cannot read — a missing word list would leave her looking at an
|
// as a bug rather than as an absence. So the server refuses, for the same reason
|
||||||
// editor that silently does nothing when she hovers. Both are bad; only one is
|
// and by the same mechanism as `shippedPairs`.
|
||||||
// legible as a bug. So the server refuses, for the same reason and by the same
|
|
||||||
// mechanism as `shippedPairs`.
|
|
||||||
//
|
//
|
||||||
// This list is expected to grow one pair at a time and never to be inferred:
|
// Chinese has both as of Phase 26 (CC-CEDICT + jieba). Portuguese turns out to
|
||||||
// segmentation is a property of a writing system, and there is no rule that
|
// have both as well, and the original note here — "French, Spanish and
|
||||||
// derives "has a word list" from a language code.
|
// Portuguese have neither" — was written one phase too early to see it:
|
||||||
var learnerPairs = []string{"zh"}
|
//
|
||||||
|
// - Word boundaries are spaces. The megabyte word list jieba needs is a
|
||||||
|
// property of a writing system that doesn't use them, not a debt every pair
|
||||||
|
// owes; a Latin-script pair needs nothing loaded to be segmented.
|
||||||
|
// - The dictionary arrived with dict.db, which reads pt→en as readily as
|
||||||
|
// en→pt (see lexicon.dreamProvider.reverse). The reverse lookup the hover
|
||||||
|
// and the word card need is already there and already answering.
|
||||||
|
//
|
||||||
|
// So the pair a native English speaker learning Portuguese needs is real, and
|
||||||
|
// what was actually blocking it was this list. French and Spanish clear the same
|
||||||
|
// two bars through the same dict.db; they are held back only by their packs
|
||||||
|
// carrying no `learner` copy yet (see Pack.learner), which is a translation
|
||||||
|
// question rather than a data one.
|
||||||
|
//
|
||||||
|
// This list is still expected to grow one pair at a time and never to be
|
||||||
|
// inferred: segmentation is a property of a writing system, and there is no rule
|
||||||
|
// that derives "has a word list" from a language code.
|
||||||
|
var learnerPairs = []string{"zh", "pt-PT"}
|
||||||
|
|
||||||
// SupportsLearnerDirection reports whether a pair can be turned around.
|
// SupportsLearnerDirection reports whether a pair can be turned around.
|
||||||
func SupportsLearnerDirection(lang string) bool {
|
func SupportsLearnerDirection(lang string) bool {
|
||||||
|
|||||||
@@ -589,6 +589,55 @@ CREATE INDEX idx_suggestions_resolved ON suggestions(status, resolved_at);
|
|||||||
stmt: `
|
stmt: `
|
||||||
ALTER TABLE users ADD COLUMN direction TEXT NOT NULL DEFAULT 'learning_en'
|
ALTER TABLE users ADD COLUMN direction TEXT NOT NULL DEFAULT 'learning_en'
|
||||||
CHECK(direction IN ('learning_en','learning_pair'));
|
CHECK(direction IN ('learning_en','learning_pair'));
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Which language this document is written in — 'en' or 'pair'.
|
||||||
|
//
|
||||||
|
// It is stored, rather than recomputed per pass and forgotten, for one
|
||||||
|
// reason: the verdict has hysteresis (see suggestions/doclang.go). A
|
||||||
|
// bilingual document sits between the two thresholds, and "whatever it
|
||||||
|
// was last time" is only an answer if last time was written down. Without
|
||||||
|
// the column a mixed paragraph would alternate its cards' language
|
||||||
|
// between passes.
|
||||||
|
//
|
||||||
|
// 'pair' rather than a language code, deliberately. Which language "pair"
|
||||||
|
// names is the owner's users.pair_lang, so changing her pair re-reads her
|
||||||
|
// documents instead of stranding a stale language name on every one of
|
||||||
|
// them.
|
||||||
|
//
|
||||||
|
// Empty is the backfill and means English: every document that exists
|
||||||
|
// today was written by a Mandarin native practising English, and English
|
||||||
|
// is what every surface assumed before this phase.
|
||||||
|
name: "0017_document_lang",
|
||||||
|
stmt: `
|
||||||
|
ALTER TABLE documents ADD COLUMN doc_lang TEXT NOT NULL DEFAULT ''
|
||||||
|
CHECK(doc_lang IN ('', 'en', 'pair'));
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Which language a garden card is in — the same '' | 'en' | 'pair'
|
||||||
|
// vocabulary as documents.doc_lang, and set from it: a word is captured
|
||||||
|
// (or a phrase planted) out of a document, so the document's verdict is
|
||||||
|
// the card's language. A card with no document keeps '', which reads as
|
||||||
|
// English like every other empty here.
|
||||||
|
//
|
||||||
|
// The garden needed this the moment a document could be written in her
|
||||||
|
// own language. Before Phase 28 every card was English by construction;
|
||||||
|
// now a Portuguese lookup lands beside an English one with nothing to
|
||||||
|
// tell them apart, and two surfaces get it wrong without the tag — the
|
||||||
|
// review card's read-aloud (which would say a Portuguese word in a US
|
||||||
|
// English voice) and the panel, where a mixed garden is illegible.
|
||||||
|
//
|
||||||
|
// Every card is reviewed regardless. Filtering the queue to the half she
|
||||||
|
// is learning was the alternative and is wrong for the writer this is
|
||||||
|
// for: the words she met while writing Portuguese are still words she
|
||||||
|
// met, and a garden that quietly drops them is a garden that stops being
|
||||||
|
// a record of her reading.
|
||||||
|
name: "0018_vocab_lang",
|
||||||
|
stmt: `
|
||||||
|
ALTER TABLE vocab_words ADD COLUMN lang TEXT NOT NULL DEFAULT ''
|
||||||
|
CHECK(lang IN ('', 'en', 'pair'));
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ type Document struct {
|
|||||||
ContentText string `json:"content_text"` // plain text for the LLM
|
ContentText string `json:"content_text"` // plain text for the LLM
|
||||||
Tone string `json:"tone"` // target writing tone; steers LLM advice
|
Tone string `json:"tone"` // target writing tone; steers LLM advice
|
||||||
WordCount int `json:"word_count"`
|
WordCount int `json:"word_count"`
|
||||||
|
// DocLang is which language this document is written in — '' | 'en' | 'pair'
|
||||||
|
// (migration 0017), written by the checkpoint pass and never by the client.
|
||||||
|
// It reaches the client read-only, for the one decision the client has to
|
||||||
|
// make on its own: which voice reads a selection aloud. '' means English.
|
||||||
|
DocLang string `json:"doc_lang"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
|||||||
@@ -225,13 +225,14 @@ func (h *Handler) fetch(userID, id string) (db.Document, error) {
|
|||||||
var doc db.Document
|
var doc db.Document
|
||||||
err := h.DB.QueryRow(
|
err := h.DB.QueryRow(
|
||||||
`SELECT id, user_id, title, content, content_text, tone, word_count,
|
`SELECT id, user_id, title, content, content_text, tone, word_count,
|
||||||
created_at, updated_at, preserve_history
|
created_at, updated_at, preserve_history, doc_lang
|
||||||
FROM documents
|
FROM documents
|
||||||
WHERE id = ? AND user_id = ?`,
|
WHERE id = ? AND user_id = ?`,
|
||||||
id, userID,
|
id, userID,
|
||||||
).Scan(
|
).Scan(
|
||||||
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
||||||
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt, &doc.PreserveHistory,
|
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt, &doc.PreserveHistory,
|
||||||
|
&doc.DocLang,
|
||||||
)
|
)
|
||||||
return doc, err
|
return doc, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,10 +49,19 @@ func (h *Handler) GlossRoutes() chi.Router {
|
|||||||
// It does not go through [Handler.providerFor], and that is not an oversight.
|
// It does not go through [Handler.providerFor], and that is not an oversight.
|
||||||
// providerFor picks a dictionary by the writer's *pair*, to answer "what does
|
// providerFor picks a dictionary by the writer's *pair*, to answer "what does
|
||||||
// this English word mean in her language" — a question whose answer differs per
|
// this English word mean in her language" — a question whose answer differs per
|
||||||
// pair. This endpoint asks the opposite question of exactly one language, and
|
// pair. This endpoint asks the opposite question of exactly one language: it
|
||||||
// [auth.SupportsLearnerDirection] already guarantees that language is Chinese.
|
// reads hanzi, and hanzi are Chinese whoever is looking them up. Routing it
|
||||||
// Routing it through the pair would add a database read per hover to choose
|
// through the pair would add a database read per hover to choose between one
|
||||||
// between one option and itself.
|
// option and itself.
|
||||||
|
//
|
||||||
|
// What no longer holds is the reason this used to give — that
|
||||||
|
// [auth.SupportsLearnerDirection] guarantees the caller is on the zh pair. Since
|
||||||
|
// Portuguese joined `learnerPairs` a learning_pair account may be Portuguese, so
|
||||||
|
// the guarantee now comes from the *caller*: the client only ever asks this
|
||||||
|
// route about a token its Chinese segmenter found, and that segmenter is loaded
|
||||||
|
// only for the zh pair (see useSegmenter in App.tsx). A stray lookup is still
|
||||||
|
// answered safely — a word the Chinese dictionary has never heard of is a 200
|
||||||
|
// with empty lists, exactly like any other miss.
|
||||||
func (h *Handler) HanziRoutes() chi.Router {
|
func (h *Handler) HanziRoutes() chi.Router {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Get("/{word}", h.hanzi)
|
r.Get("/{word}", h.hanzi)
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ type checkpointResponse struct {
|
|||||||
// RunCheckpoint sends the grammar checkpoint and parses the JSON result. It
|
// RunCheckpoint sends the grammar checkpoint and parses the JSON result. It
|
||||||
// applies the latency-guard truncation and the checkpoint sampling parameters
|
// applies the latency-guard truncation and the checkpoint sampling parameters
|
||||||
// from the spec.
|
// from the spec.
|
||||||
func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone string, _ Lang) ([]RawSuggestion, error) {
|
func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone string, t Target) ([]RawSuggestion, error) {
|
||||||
raw, err := client.Complete(ctx, CompletionRequest{
|
raw, err := client.Complete(ctx, CompletionRequest{
|
||||||
Messages: CheckpointMessages(TruncateDoc(contentText), tone),
|
Messages: CheckpointMessages(TruncateDoc(contentText), tone, t),
|
||||||
MaxTokens: checkpointMaxTokens,
|
MaxTokens: checkpointMaxTokens,
|
||||||
Temperature: 0.3,
|
Temperature: 0.3,
|
||||||
RepetitionPenalty: 1.15,
|
RepetitionPenalty: 1.15,
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ const CollocationInterval = 25 * time.Second
|
|||||||
// The tone argument is accepted for a uniform pass signature and passed through
|
// The tone argument is accepted for a uniform pass signature and passed through
|
||||||
// to the prompt so a hint can prefer a register-appropriate pairing. `lang` is
|
// to the prompt so a hint can prefer a register-appropriate pairing. `lang` is
|
||||||
// the writer's pair language — the one each hint's short gloss is written in.
|
// the writer's pair language — the one each hint's short gloss is written in.
|
||||||
func RunCollocation(ctx context.Context, client LLMClient, contentText, tone string, lang Lang) ([]RawSuggestion, error) {
|
func RunCollocation(ctx context.Context, client LLMClient, contentText, tone string, t Target) ([]RawSuggestion, error) {
|
||||||
raw, err := client.Complete(ctx, CompletionRequest{
|
raw, err := client.Complete(ctx, CompletionRequest{
|
||||||
Messages: CollocationMessages(contentText, tone, lang),
|
Messages: CollocationMessages(contentText, tone, t),
|
||||||
MaxTokens: 2048,
|
MaxTokens: 2048,
|
||||||
Temperature: 0.3,
|
Temperature: 0.3,
|
||||||
RepetitionPenalty: 1.15,
|
RepetitionPenalty: 1.15,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func TestLangForFallsBackToDefault(t *testing.T) {
|
|||||||
func TestPromptsNameTheWritersLanguage(t *testing.T) {
|
func TestPromptsNameTheWritersLanguage(t *testing.T) {
|
||||||
pt := LangFor("pt-PT")
|
pt := LangFor("pt-PT")
|
||||||
|
|
||||||
collocation := CollocationMessages("The rain was strong.", "casual", pt)[0].Content
|
collocation := CollocationMessages("The rain was strong.", "casual", EnglishTarget(pt))[0].Content
|
||||||
if !strings.Contains(collocation, "European Portuguese") {
|
if !strings.Contains(collocation, "European Portuguese") {
|
||||||
t.Fatalf("collocation prompt doesn't ask for a pt-PT gloss:\n%s", collocation)
|
t.Fatalf("collocation prompt doesn't ask for a pt-PT gloss:\n%s", collocation)
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@ func TestPromptsNameTheWritersLanguage(t *testing.T) {
|
|||||||
func TestDefaultPairStillReadsAsBefore(t *testing.T) {
|
func TestDefaultPairStillReadsAsBefore(t *testing.T) {
|
||||||
zh := LangFor("zh")
|
zh := LangFor("zh")
|
||||||
|
|
||||||
if got := CollocationMessages("x", "", zh)[0].Content; !strings.Contains(got, "Simplified Chinese (Mandarin) gloss in parentheses") {
|
if got := CollocationMessages("x", "", EnglishTarget(zh))[0].Content; !strings.Contains(got, "Simplified Chinese (Mandarin) gloss in parentheses") {
|
||||||
t.Fatalf("zh collocation gloss changed:\n%s", got)
|
t.Fatalf("zh collocation gloss changed:\n%s", got)
|
||||||
}
|
}
|
||||||
if got := TranslateMessages("x", zh)[0].Content; !strings.Contains(got, "natural, friendly Simplified Chinese (Mandarin)") {
|
if got := TranslateMessages("x", zh)[0].Content; !strings.Contains(got, "natural, friendly Simplified Chinese (Mandarin)") {
|
||||||
|
|||||||
+95
-8
@@ -45,11 +45,55 @@ func toneGuidance(tone string) string {
|
|||||||
"be improved, prefer suggestions that fit that tone, and gently flag wording that clashes with it."
|
"be improved, prefer suggestions that fit that tone, and gently flag wording that clashes with it."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pairCheckpointSystemPrompt is the grammar checkpoint for a document written in
|
||||||
|
// the writer's own language rather than in English.
|
||||||
|
//
|
||||||
|
// It is a separate constant rather than a language clause appended to
|
||||||
|
// checkpointSystemPrompt, because that prompt opens by naming the reader as an
|
||||||
|
// ESL learner and asks for "common ESL patterns" — appending "and explain in
|
||||||
|
// Portuguese" would hand the model two contradictory framings. Only the framing
|
||||||
|
// differs; the JSON contract and the tone clause below it are the same
|
||||||
|
// instructions in the same order, so the two prompts stay comparable.
|
||||||
|
//
|
||||||
|
// The "never translate" line is the one the model most wants to disobey: asked
|
||||||
|
// to improve Portuguese while being an English writing assistant by training, it
|
||||||
|
// will happily hand back an English rendering, which is a translation card
|
||||||
|
// (Phase 25's `isTranslation`) and not a correction.
|
||||||
|
const pairCheckpointSystemPrompt = `You are a warm, encouraging writing assistant. The person you are helping is ` +
|
||||||
|
`writing in %[1]s, and the text below is %[1]s. ` +
|
||||||
|
`Analyze it and identify up to 5 issues: grammar errors, unnatural phrasing, ` +
|
||||||
|
`incorrect idiom usage, or unclear sentences.
|
||||||
|
|
||||||
|
Both "original" and "replacement" must be written in %[1]s. You are improving their %[1]s writing — ` +
|
||||||
|
`never translate it into English, and never suggest they write in English instead.
|
||||||
|
Write every "explanation" in %[2]s.
|
||||||
|
|
||||||
|
Be specific, friendly, and explain WHY each suggestion improves the writing.%[3]s
|
||||||
|
|
||||||
|
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
|
||||||
|
{
|
||||||
|
"suggestions": [
|
||||||
|
{
|
||||||
|
"original": "exact text from the document that needs fixing",
|
||||||
|
"replacement": "corrected version",
|
||||||
|
"explanation": "friendly one-sentence explanation",
|
||||||
|
"type": "grammar|phrasing|idiom|clarity"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
If the writing looks good, return: {"suggestions": []}`
|
||||||
|
|
||||||
// CheckpointMessages builds the message array for a grammar checkpoint over the
|
// CheckpointMessages builds the message array for a grammar checkpoint over the
|
||||||
// given (already-truncated) document text, steered toward the document's tone.
|
// given (already-truncated) document text, steered toward the document's tone
|
||||||
func CheckpointMessages(contentText, tone string) []Message {
|
// and aimed at the language the document is actually written in.
|
||||||
|
func CheckpointMessages(contentText, tone string, t Target) []Message {
|
||||||
|
system := fmt.Sprintf(checkpointSystemPrompt, toneGuidance(tone))
|
||||||
|
if t.Flipped() {
|
||||||
|
system = fmt.Sprintf(pairCheckpointSystemPrompt, t.Correct.Name, t.Explain.Name, toneGuidance(tone))
|
||||||
|
}
|
||||||
return []Message{
|
return []Message{
|
||||||
{Role: "system", Content: fmt.Sprintf(checkpointSystemPrompt, toneGuidance(tone))},
|
{Role: "system", Content: system},
|
||||||
{Role: "user", Content: contentText},
|
{Role: "user", Content: contentText},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,12 +126,48 @@ Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
|
|||||||
|
|
||||||
If the voice is consistent throughout, return: {"suggestions": []}`
|
If the voice is consistent throughout, return: {"suggestions": []}`
|
||||||
|
|
||||||
|
// pairVoiceSystemPrompt is the voice pass for a document in the writer's own
|
||||||
|
// language. Voice consistency is the one pass that transfers across languages
|
||||||
|
// unchanged — a paragraph that reads as pasted from elsewhere reads that way in
|
||||||
|
// any language — so only the framing and the explanation language move.
|
||||||
|
const pairVoiceSystemPrompt = `You are a warm, encouraging writing assistant. The person you are helping is writing ` +
|
||||||
|
`in %[1]s. You are reviewing a COMPLETE %[1]s document for VOICE CONSISTENCY only — not grammar.
|
||||||
|
|
||||||
|
Read the whole document to learn the writer's natural voice, then identify any passages (2 or more sentences) ` +
|
||||||
|
`that feel tonally inconsistent with the surrounding writing — unusually formal, unusually polished, or phrased ` +
|
||||||
|
`in a way that differs from the writer's established voice elsewhere in the document. These often signal text ` +
|
||||||
|
`that was paraphrased too closely from another source. Do not flag the first paragraph (there is no baseline yet). ` +
|
||||||
|
`Do not flag grammar or spelling mistakes — only voice.
|
||||||
|
|
||||||
|
Quote each passage exactly as it appears, in %[1]s. Write every "explanation" in %[2]s.
|
||||||
|
|
||||||
|
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
|
||||||
|
{
|
||||||
|
"suggestions": [
|
||||||
|
{
|
||||||
|
"original": "exact passage from the document that feels inconsistent",
|
||||||
|
"replacement": null,
|
||||||
|
"explanation": "friendly one-sentence note about why this passage sounds unlike the rest",
|
||||||
|
"type": "voice"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
If the voice is consistent throughout, return: {"suggestions": []}`
|
||||||
|
|
||||||
// VoiceMessages builds the message array for a voice-consistency pass. Unlike
|
// VoiceMessages builds the message array for a voice-consistency pass. Unlike
|
||||||
// the checkpoint, the caller passes the WHOLE document (no truncation) — voice
|
// the checkpoint, the caller passes the WHOLE document (no truncation) — voice
|
||||||
// consistency is judged against the established voice everywhere else.
|
// consistency is judged against the established voice everywhere else.
|
||||||
func VoiceMessages(contentText string) []Message {
|
//
|
||||||
|
// The pass had no language argument at all before Phase 28, which was the same
|
||||||
|
// English assumption the checkpoint made, just unstated.
|
||||||
|
func VoiceMessages(contentText string, t Target) []Message {
|
||||||
|
system := voiceSystemPrompt
|
||||||
|
if t.Flipped() {
|
||||||
|
system = fmt.Sprintf(pairVoiceSystemPrompt, t.Correct.Name, t.Explain.Name)
|
||||||
|
}
|
||||||
return []Message{
|
return []Message{
|
||||||
{Role: "system", Content: voiceSystemPrompt},
|
{Role: "system", Content: system},
|
||||||
{Role: "user", Content: contentText},
|
{Role: "user", Content: contentText},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,10 +213,17 @@ If every pairing already sounds natural, return: {"suggestions": []}`
|
|||||||
// CollocationMessages builds the message array for a collocation pass over the
|
// CollocationMessages builds the message array for a collocation pass over the
|
||||||
// WHOLE document (no truncation), gently steered toward the document's tone so a
|
// WHOLE document (no truncation), gently steered toward the document's tone so a
|
||||||
// hint can prefer a register-appropriate pairing. The parenthetical gloss is
|
// hint can prefer a register-appropriate pairing. The parenthetical gloss is
|
||||||
// written in the writer's own language.
|
// written in the writer's own language — `Pair`, not `Explain`: the gloss is
|
||||||
func CollocationMessages(contentText, tone string, lang Lang) []Message {
|
// addressed to her rather than to the document.
|
||||||
|
//
|
||||||
|
// The coach itself remains English-only. Collocation lists are the one thing
|
||||||
|
// here that is genuinely per-language knowledge rather than framing, and
|
||||||
|
// "natives usually say" for Portuguese is a claim this prompt has no grounds to
|
||||||
|
// make yet; a flipped document simply gets the pass it always got. (Phase 28
|
||||||
|
// moved the checkpoint and the voice pass; this one waits for evidence.)
|
||||||
|
func CollocationMessages(contentText, tone string, t Target) []Message {
|
||||||
return []Message{
|
return []Message{
|
||||||
{Role: "system", Content: fmt.Sprintf(collocationSystemPrompt, toneGuidance(tone), lang.Name)},
|
{Role: "system", Content: fmt.Sprintf(collocationSystemPrompt, toneGuidance(tone), t.Pair.Name)},
|
||||||
{Role: "user", Content: contentText},
|
{Role: "user", Content: contentText},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
// Which language a pass corrects, and which language it explains in.
|
||||||
|
//
|
||||||
|
// Until Phase 28 there was no question to answer: every prompt was written
|
||||||
|
// around English prose explained in English, and the pair language reached her
|
||||||
|
// only when she asked for it (Ask Petal, the explanation translator). That is
|
||||||
|
// the right default for a writer practising English and the wrong one for a
|
||||||
|
// document she wrote in her own language, where Petal would read Portuguese,
|
||||||
|
// say nothing about it, and file a mechanics note about the one English
|
||||||
|
// sentence at the end.
|
||||||
|
//
|
||||||
|
// The two fields are two different decisions reading two different pieces of
|
||||||
|
// state, and collapsing them would be the bug:
|
||||||
|
//
|
||||||
|
// - Correct follows the DOCUMENT. Portuguese prose gets Portuguese
|
||||||
|
// corrections; that is the whole point.
|
||||||
|
// - Explain follows the WRITER — the half of her pair she is *not* learning
|
||||||
|
// (users.direction), because an explanation is teaching, and teaching lands
|
||||||
|
// in the language she reads most easily.
|
||||||
|
//
|
||||||
|
// Today those two coincide for every account that exists: `learnerPairs` is
|
||||||
|
// {"zh"}, so fr, es and pt-PT writers are all `learning_en` and their
|
||||||
|
// non-learned half *is* the pair language. That equality is a fact about
|
||||||
|
// today's roster, not about the design — the same shape of assumption that had
|
||||||
|
// to be unpicked from `pair_lang` in migration 0016. Keep them apart.
|
||||||
|
type Target struct {
|
||||||
|
// Correct is the language the writing is in, and so the language both
|
||||||
|
// `original` and `replacement` must be written in.
|
||||||
|
Correct Lang
|
||||||
|
// Explain is the language each explanation is written in.
|
||||||
|
Explain Lang
|
||||||
|
// Pair is the writer's pair language regardless of what this document is
|
||||||
|
// written in. The collocation coach's parenthetical gloss is addressed to
|
||||||
|
// her rather than to the document, so it reads this and not Correct.
|
||||||
|
Pair Lang
|
||||||
|
}
|
||||||
|
|
||||||
|
// English as the prompts name it. Not in `langs`: that map answers "which
|
||||||
|
// language is the writer's half of the pair", and English is the constant on
|
||||||
|
// the other side of every pair Petal supports.
|
||||||
|
var English = Lang{Code: "en", Name: "English", Why: "why"}
|
||||||
|
|
||||||
|
// EnglishTarget is the pre-Phase-28 behaviour, made explicit: an English
|
||||||
|
// document, corrected and explained in English, for a writer whose pair
|
||||||
|
// language is `pair`. Every existing user is on this path and the prompt it
|
||||||
|
// produces is byte-identical to the one that shipped before this phase.
|
||||||
|
func EnglishTarget(pair Lang) Target {
|
||||||
|
return Target{Correct: English, Explain: English, Pair: pair}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flipped reports whether this document is in the pair language rather than in
|
||||||
|
// English — i.e. whether the pass is reading her own language.
|
||||||
|
func (t Target) Flipped() bool { return t.Correct.Code != English.Code }
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The prompt every account is on today, written out in full.
|
||||||
|
//
|
||||||
|
// Phase 28 gave the checkpoint a second framing for documents in the writer's
|
||||||
|
// own language, and the risk of that change is not that the new prompt is wrong
|
||||||
|
// — it is that the old one moved by a word while nobody was looking. Every user
|
||||||
|
// who exists is a Mandarin native writing English, so this string is the one
|
||||||
|
// Petal actually sends, all day. It is duplicated here on purpose: a golden
|
||||||
|
// copied from the constant it guards guards nothing.
|
||||||
|
const goldenEnglishCheckpointPrompt = `You are a warm, encouraging writing assistant helping someone who speaks English as a second language. Analyze the text below and identify up to 5 issues: grammar errors, unnatural phrasing, incorrect idiom usage, or unclear sentences that are common ESL patterns.
|
||||||
|
|
||||||
|
Be specific, friendly, and explain WHY each suggestion improves the writing.
|
||||||
|
|
||||||
|
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
|
||||||
|
{
|
||||||
|
"suggestions": [
|
||||||
|
{
|
||||||
|
"original": "exact text from the document that needs fixing",
|
||||||
|
"replacement": "corrected version",
|
||||||
|
"explanation": "friendly one-sentence explanation",
|
||||||
|
"type": "grammar|phrasing|idiom|clarity"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
If the writing looks good, return: {"suggestions": []}`
|
||||||
|
|
||||||
|
func TestEnglishDocumentPromptIsUnchanged(t *testing.T) {
|
||||||
|
msgs := CheckpointMessages("I has two apple.", "", EnglishTarget(LangFor("zh")))
|
||||||
|
if got := msgs[0].Content; got != goldenEnglishCheckpointPrompt {
|
||||||
|
t.Fatalf("the English checkpoint prompt moved:\n--- got ---\n%s\n--- want ---\n%s", got, goldenEnglishCheckpointPrompt)
|
||||||
|
}
|
||||||
|
if msgs[1].Content != "I has two apple." {
|
||||||
|
t.Fatalf("document text mangled: %q", msgs[1].Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tone clause still lands, in the same place it always did.
|
||||||
|
toned := CheckpointMessages("x", "academic", EnglishTarget(LangFor("zh")))[0].Content
|
||||||
|
if !strings.Contains(toned, "formal, academic, and objective") {
|
||||||
|
t.Fatalf("English checkpoint lost its tone guidance:\n%s", toned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A document in her own language gets a prompt that names that language, keeps
|
||||||
|
// the corrections inside it, and drops the framing that only makes sense when
|
||||||
|
// the thing being written is English.
|
||||||
|
func TestFlippedCheckpointPrompt(t *testing.T) {
|
||||||
|
pt := LangFor("pt-PT")
|
||||||
|
system := CheckpointMessages("Hoje foi um dia bom.", "casual", Target{Correct: pt, Explain: pt, Pair: pt})[0].Content
|
||||||
|
|
||||||
|
if !strings.Contains(system, "European Portuguese") {
|
||||||
|
t.Fatalf("flipped checkpoint doesn't name the language:\n%s", system)
|
||||||
|
}
|
||||||
|
if strings.Contains(system, "second language") || strings.Contains(system, "ESL") {
|
||||||
|
t.Fatalf("flipped checkpoint kept the ESL framing:\n%s", system)
|
||||||
|
}
|
||||||
|
if !strings.Contains(system, "never translate it into English") {
|
||||||
|
t.Fatalf("flipped checkpoint doesn't forbid translating:\n%s", system)
|
||||||
|
}
|
||||||
|
// The shared contract below the framing has to survive the split.
|
||||||
|
for _, want := range []string{`"suggestions"`, `"replacement"`, "grammar|phrasing|idiom|clarity", "relaxed, friendly, and conversational"} {
|
||||||
|
if !strings.Contains(system, want) {
|
||||||
|
t.Fatalf("flipped checkpoint dropped %q:\n%s", want, system)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(system, "%!") {
|
||||||
|
t.Fatalf("flipped checkpoint has a formatting error:\n%s", system)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two decisions are separate arguments and must reach the prompt separately:
|
||||||
|
// corrections in the document's language, the explanation in the language she
|
||||||
|
// reads most easily. Only the zh pair can be travelled both ways today, so it is
|
||||||
|
// the only one that can prove they haven't been quietly collapsed into one.
|
||||||
|
func TestFlippedPromptsExplainInTheirOwnLanguage(t *testing.T) {
|
||||||
|
zh := LangFor("zh")
|
||||||
|
|
||||||
|
// Native Mandarin, practising English, writing Chinese: both halves Chinese.
|
||||||
|
both := CheckpointMessages("今天天气很好。", "", Target{Correct: zh, Explain: zh, Pair: zh})[0].Content
|
||||||
|
if strings.Count(both, "Simplified Chinese (Mandarin)") < 2 {
|
||||||
|
t.Fatalf("expected corrections and explanations both in Chinese:\n%s", both)
|
||||||
|
}
|
||||||
|
if strings.Contains(both, "explanation"+`" in English`) {
|
||||||
|
t.Fatalf("explanation language leaked to English:\n%s", both)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native English, learning Chinese, writing Chinese: Chinese corrections,
|
||||||
|
// English explanations.
|
||||||
|
split := CheckpointMessages("今天天气很好。", "", Target{Correct: zh, Explain: English, Pair: zh})[0].Content
|
||||||
|
if !strings.Contains(split, `Write every "explanation" in English.`) {
|
||||||
|
t.Fatalf("learner direction didn't get English explanations:\n%s", split)
|
||||||
|
}
|
||||||
|
if !strings.Contains(split, "writing in Simplified Chinese (Mandarin)") {
|
||||||
|
t.Fatalf("learner direction lost its Chinese corrections:\n%s", split)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same for the voice pass, which had no language at all before this phase.
|
||||||
|
voice := VoiceMessages("今天天气很好。", Target{Correct: zh, Explain: English, Pair: zh})[0].Content
|
||||||
|
if !strings.Contains(voice, `Write every "explanation" in English.`) || !strings.Contains(voice, "Simplified Chinese") {
|
||||||
|
t.Fatalf("flipped voice prompt got its languages wrong:\n%s", voice)
|
||||||
|
}
|
||||||
|
if strings.Contains(voice, "second language") {
|
||||||
|
t.Fatalf("flipped voice prompt kept the ESL framing:\n%s", voice)
|
||||||
|
}
|
||||||
|
// An English document still gets exactly the voice prompt it always got.
|
||||||
|
if got := VoiceMessages("x", EnglishTarget(zh))[0].Content; got != voiceSystemPrompt {
|
||||||
|
t.Fatalf("English voice prompt moved:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,9 +19,9 @@ const VoiceInterval = 20 * time.Second
|
|||||||
// The tone argument is accepted for a uniform pass signature but ignored: voice
|
// The tone argument is accepted for a uniform pass signature but ignored: voice
|
||||||
// consistency is judged against the document's own established voice, not an
|
// consistency is judged against the document's own established voice, not an
|
||||||
// externally-chosen register.
|
// externally-chosen register.
|
||||||
func RunVoice(ctx context.Context, client LLMClient, contentText, _ string, _ Lang) ([]RawSuggestion, error) {
|
func RunVoice(ctx context.Context, client LLMClient, contentText, _ string, t Target) ([]RawSuggestion, error) {
|
||||||
raw, err := client.Complete(ctx, CompletionRequest{
|
raw, err := client.Complete(ctx, CompletionRequest{
|
||||||
Messages: VoiceMessages(contentText),
|
Messages: VoiceMessages(contentText, t),
|
||||||
MaxTokens: 2048,
|
MaxTokens: 2048,
|
||||||
Temperature: 0.3,
|
Temperature: 0.3,
|
||||||
RepetitionPenalty: 1.15,
|
RepetitionPenalty: 1.15,
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package suggestions
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// What language is this DOCUMENT in — as opposed to this span.
|
||||||
|
//
|
||||||
|
// `readsAsPairLang` next door answers a span-level question for the purpose of
|
||||||
|
// labelling one card, and it is written to under-claim: two marker words, or
|
||||||
|
// nothing. A whole document needs the opposite temperament. A proportion, not a
|
||||||
|
// presence — one Portuguese quotation inside an English essay must not flip the
|
||||||
|
// entire pass into Portuguese, and one English sentence at the end of a
|
||||||
|
// Portuguese journal must not keep it in English.
|
||||||
|
//
|
||||||
|
// Three properties, in the order they bite:
|
||||||
|
//
|
||||||
|
// - Decided over the WHOLE document, never a chunk. The grammar checkpoint
|
||||||
|
// sends only the sentences that changed, so a verdict computed from what it
|
||||||
|
// asked about would put an English card in a Portuguese journal the moment
|
||||||
|
// she edits its one English line. The caller passes content_text, always.
|
||||||
|
//
|
||||||
|
// - Hysteresis. A bilingual paragraph sits near whatever single threshold we
|
||||||
|
// pick, and a document crossing it every few keystrokes would alternate card
|
||||||
|
// languages between passes — the same instability the mascot needed a band
|
||||||
|
// for. Flip to the pair at 70% and back only below 40%; in between, whatever
|
||||||
|
// it was last time stands. The band is the feature, not a rounding
|
||||||
|
// tolerance.
|
||||||
|
//
|
||||||
|
// - Plain code, no model call. The house rule is that the LLM is garnish,
|
||||||
|
// never a gatekeeper: a document must not become uncheckable because the
|
||||||
|
// inference box is down.
|
||||||
|
const (
|
||||||
|
docLangEnglish = "en"
|
||||||
|
docLangPair = "pair"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The band. Deliberately wide: the cost of an unnecessary flip is every card in
|
||||||
|
// the document changing language, which is far more startling than a paragraph
|
||||||
|
// of mixed writing being read as whichever language it was a minute ago.
|
||||||
|
const (
|
||||||
|
flipToPairAt = 0.70
|
||||||
|
flipToEnglishBelow = 0.40
|
||||||
|
)
|
||||||
|
|
||||||
|
// documentLang returns the language verdict for a document, given the verdict it
|
||||||
|
// carried before. `prev` is "" for a document that has never been read.
|
||||||
|
//
|
||||||
|
// The result is one of docLangEnglish / docLangPair — not a language code. Which
|
||||||
|
// language "pair" means is the writer's `pair_lang`, and keeping the stored
|
||||||
|
// verdict relative to her pair means changing her pair doesn't strand a stale
|
||||||
|
// language name on every document she owns.
|
||||||
|
func documentLang(contentText, pairLang, prev string) string {
|
||||||
|
if prev != docLangPair {
|
||||||
|
prev = docLangEnglish
|
||||||
|
}
|
||||||
|
p := normalizePairLang(pairLang)
|
||||||
|
if !hasLangTest(p) {
|
||||||
|
// No test for this pair: say English, which is what every surface did
|
||||||
|
// before this phase. A wrong flip is louder than a missing one.
|
||||||
|
return docLangEnglish
|
||||||
|
}
|
||||||
|
|
||||||
|
var pair, english int
|
||||||
|
for _, c := range splitChunks(contentText, "") {
|
||||||
|
switch sentenceLang(c.text, p) {
|
||||||
|
case docLangPair:
|
||||||
|
pair++
|
||||||
|
case docLangEnglish:
|
||||||
|
english++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
decided := pair + english
|
||||||
|
if decided == 0 {
|
||||||
|
// Nothing to go on — an empty document, a list of numbers, a title. Hold
|
||||||
|
// the previous verdict rather than resetting a Portuguese journal to
|
||||||
|
// English because she cleared it to start again.
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
|
||||||
|
ratio := float64(pair) / float64(decided)
|
||||||
|
switch {
|
||||||
|
case ratio >= flipToPairAt && corroborated(contentText, p):
|
||||||
|
return docLangPair
|
||||||
|
case ratio < flipToEnglishBelow:
|
||||||
|
return docLangEnglish
|
||||||
|
default:
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sentenceLang classifies one sentence as pair-language, English, or neither.
|
||||||
|
//
|
||||||
|
// Neither is a real answer and carries weight: a sentence with no evidence
|
||||||
|
// either way ("Bom dia.", "OK.", a heading) is left out of the ratio entirely
|
||||||
|
// rather than counted for the language it isn't. Counting the undecided as
|
||||||
|
// English is what would keep a Portuguese document in English forever, since
|
||||||
|
// short sentences carry no markers.
|
||||||
|
func sentenceLang(s, p string) string {
|
||||||
|
if p == "zh" {
|
||||||
|
han, latin := scriptCounts(s)
|
||||||
|
switch {
|
||||||
|
case han >= 2 && han > latin:
|
||||||
|
return docLangPair
|
||||||
|
case latin >= 3 && latin > han:
|
||||||
|
return docLangEnglish
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// A Latin pair shares its alphabet with English, so both sides are counted
|
||||||
|
// the same way and the larger pile of evidence wins. A tie — including no
|
||||||
|
// evidence at all — is no answer, which is why the English list below is
|
||||||
|
// curated as carefully against the pair languages as theirs is against
|
||||||
|
// English.
|
||||||
|
pair := distinctMarkers(s, latinMarkers[p])
|
||||||
|
eng := distinctMarkers(s, englishMarkers)
|
||||||
|
switch {
|
||||||
|
case pair > eng:
|
||||||
|
return docLangPair
|
||||||
|
case eng > pair:
|
||||||
|
return docLangEnglish
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// corroborated requires the document as a whole to carry real evidence of the
|
||||||
|
// pair language before the pass flips into it. A two-sentence document of
|
||||||
|
// "Sim." / "Não." would otherwise reach 100% on almost nothing; a flip changes
|
||||||
|
// every card in the document, so it has to be earned document-wide and not only
|
||||||
|
// in proportion.
|
||||||
|
func corroborated(contentText, p string) bool {
|
||||||
|
if p == "zh" {
|
||||||
|
han, _ := scriptCounts(contentText)
|
||||||
|
return han >= 8
|
||||||
|
}
|
||||||
|
return distinctMarkers(contentText, latinMarkers[p]) >= 3
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasLangTest reports whether readsAsPairLang / sentenceLang know how to test
|
||||||
|
// this pair at all. Kept beside the tests it describes so a new pair that adds
|
||||||
|
// markers without adding itself here fails loudly in review rather than quietly
|
||||||
|
// at runtime.
|
||||||
|
func hasLangTest(p string) bool {
|
||||||
|
if p == "zh" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return len(latinMarkers[p]) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// English function words, curated against the pair languages exactly as
|
||||||
|
// `latinMarkers` is curated against English.
|
||||||
|
//
|
||||||
|
// Every word here is one a Portuguese, French or Spanish sentence has no reason
|
||||||
|
// to contain. Deliberately absent, each a false English vote waiting to happen
|
||||||
|
// in someone's own language: "on" and "son" (French), "as", "a", "o", "e", "no",
|
||||||
|
// "os" (Portuguese), "no", "para", "sin" (Spanish), and "is"-alikes that are
|
||||||
|
// really other languages' words. The list is short on purpose — it does not need
|
||||||
|
// coverage, only a reliable vote in the sentences where the pair list is silent.
|
||||||
|
var englishMarkers = words(
|
||||||
|
"the", "and", "is", "are", "was", "were", "be", "been", "being",
|
||||||
|
"of", "to", "that", "this", "these", "those", "with", "from", "for",
|
||||||
|
"have", "has", "had", "they", "them", "their", "there", "then", "than",
|
||||||
|
"what", "which", "when", "where", "why", "how", "who",
|
||||||
|
"will", "would", "should", "could", "can", "about", "because",
|
||||||
|
"into", "some", "such", "only", "very", "much", "many", "other",
|
||||||
|
"our", "your", "its", "it", "he", "she", "we", "you", "but", "not",
|
||||||
|
"just", "like", "also", "most", "over", "after", "before", "between",
|
||||||
|
"through", "said", "says", "get", "got", "make", "made", "know", "think",
|
||||||
|
"thing", "things", "time", "people", "here", "always", "never", "something",
|
||||||
|
"want", "need", "feel", "day", "today", "good", "really", "still", "even",
|
||||||
|
)
|
||||||
|
|
||||||
|
// normalizeDocLang folds a stored verdict into the two values the rest of the
|
||||||
|
// code reasons about: the column holds "" for a document nothing has read yet,
|
||||||
|
// and that means English, which is what every surface did before this phase.
|
||||||
|
func normalizeDocLang(v string) string {
|
||||||
|
if strings.TrimSpace(v) == docLangPair {
|
||||||
|
return docLangPair
|
||||||
|
}
|
||||||
|
return docLangEnglish
|
||||||
|
}
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
package suggestions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/auth"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A monolingual document in either language has to be read as that language, and
|
||||||
|
// the mixed cases in between are where the whole design lives: one quotation
|
||||||
|
// must not move a document, and one leftover English line must not hold a
|
||||||
|
// journal in English.
|
||||||
|
func TestDocumentLangReadsWholeDocuments(t *testing.T) {
|
||||||
|
const ptJournal = "Hoje foi um dia muito bom. Eu gosto de escrever aqui todas as noites. " +
|
||||||
|
"A minha irmã também quer aprender. Não sei porque isso é tão difícil para mim."
|
||||||
|
const enEssay = "The weather was very cold this morning. I think that the bus was late again. " +
|
||||||
|
"She told me about the meeting, but I could not hear what they said."
|
||||||
|
const zhJournal = "今天天气很好。我和妹妹一起去公园散步。我们看到很多花。"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
text string
|
||||||
|
pairLang string
|
||||||
|
prev string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"portuguese journal", ptJournal, "pt-PT", "", docLangPair},
|
||||||
|
{"english essay", enEssay, "pt-PT", "", docLangEnglish},
|
||||||
|
{"chinese journal", zhJournal, "zh", "", docLangPair},
|
||||||
|
{"english essay, zh writer", enEssay, "zh", "", docLangEnglish},
|
||||||
|
|
||||||
|
// One English sentence at the end of a Portuguese journal is the case that
|
||||||
|
// motivated the whole phase: the pass must stay in Portuguese.
|
||||||
|
{
|
||||||
|
"portuguese with one english line",
|
||||||
|
ptJournal + " I will write more tomorrow.",
|
||||||
|
"pt-PT", "", docLangPair,
|
||||||
|
},
|
||||||
|
// And the mirror: an English essay quoting a line of Portuguese is still an
|
||||||
|
// English essay.
|
||||||
|
{
|
||||||
|
"english quoting portuguese",
|
||||||
|
enEssay + " She wrote: \"Eu não sei o que dizer.\"",
|
||||||
|
"pt-PT", "", docLangEnglish,
|
||||||
|
},
|
||||||
|
// A pair Petal has no test for cannot flip anything. Saying English is what
|
||||||
|
// every surface did before this phase.
|
||||||
|
{"untested pair", ptJournal, "de", "", docLangEnglish},
|
||||||
|
// Nothing to go on holds the previous answer rather than resetting a
|
||||||
|
// journal because she cleared it to start again.
|
||||||
|
{"emptied portuguese journal", "", "pt-PT", docLangPair, docLangPair},
|
||||||
|
{"emptied english essay", " \n ", "pt-PT", docLangEnglish, docLangEnglish},
|
||||||
|
// Proportion, not presence: a couple of Portuguese words are not a
|
||||||
|
// Portuguese document even though readsAsPairLang would label that span.
|
||||||
|
{
|
||||||
|
"english with a portuguese phrase",
|
||||||
|
enEssay + " The sign said pão com manteiga.",
|
||||||
|
"pt-PT", "", docLangEnglish,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := documentLang(tc.text, tc.pairLang, tc.prev); got != tc.want {
|
||||||
|
t.Fatalf("documentLang = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The band, from both directions. A document sitting inside it keeps whatever it
|
||||||
|
// was, and that is the point: without it, a bilingual paragraph would alternate
|
||||||
|
// its cards' language every few keystrokes as she typed across the threshold.
|
||||||
|
func TestDocumentLangHysteresis(t *testing.T) {
|
||||||
|
// Half and half: two Portuguese sentences, two English ones. Inside the band
|
||||||
|
// from either side.
|
||||||
|
const mixed = "Eu gosto muito de escrever aqui. A minha irmã não sabe porque é difícil. " +
|
||||||
|
"The weather was very cold this morning. I think that they said the same thing."
|
||||||
|
|
||||||
|
if got := documentLang(mixed, "pt-PT", docLangEnglish); got != docLangEnglish {
|
||||||
|
t.Fatalf("mixed document from english = %q, want it to stay %q", got, docLangEnglish)
|
||||||
|
}
|
||||||
|
if got := documentLang(mixed, "pt-PT", docLangPair); got != docLangPair {
|
||||||
|
t.Fatalf("mixed document from pair = %q, want it to stay %q", got, docLangPair)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Above the upper threshold it flips regardless of where it came from; below
|
||||||
|
// the lower one it flips back regardless.
|
||||||
|
const mostlyPT = "Eu gosto muito de escrever aqui. A minha irmã não sabe porque é difícil. " +
|
||||||
|
"Hoje foi um dia bom para mim. Amanhã também quero escrever mais uma coisa. " +
|
||||||
|
"I think so too."
|
||||||
|
if got := documentLang(mostlyPT, "pt-PT", docLangEnglish); got != docLangPair {
|
||||||
|
t.Fatalf("mostly-portuguese from english = %q, want %q", got, docLangPair)
|
||||||
|
}
|
||||||
|
const mostlyEN = "The weather was very cold this morning. I think that they said the same thing. " +
|
||||||
|
"She could not hear what the other people were saying about it. Eu não sei."
|
||||||
|
if got := documentLang(mostlyEN, "pt-PT", docLangPair); got != docLangEnglish {
|
||||||
|
t.Fatalf("mostly-english from pair = %q, want %q", got, docLangEnglish)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Corroboration: a proportion computed over almost nothing is not evidence. Two
|
||||||
|
// bare words at 100% must not flip a document, because a flip rewrites every
|
||||||
|
// card in it.
|
||||||
|
func TestDocumentLangNeedsCorroboration(t *testing.T) {
|
||||||
|
if got := documentLang("Não. Eu.", "pt-PT", docLangEnglish); got != docLangEnglish {
|
||||||
|
t.Fatalf("two bare words flipped the document: %q", got)
|
||||||
|
}
|
||||||
|
if got := documentLang("我。", "zh", docLangEnglish); got != docLangEnglish {
|
||||||
|
t.Fatalf("two Han runes flipped the document: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Portuguese half of the same rule, and the bug it was reported as: "the
|
||||||
|
// Portuguese option isn't translating the advice in English — it's just
|
||||||
|
// reprinting Portuguese."
|
||||||
|
//
|
||||||
|
// Nothing was wrong with targetFor when that was reported. It was reading a
|
||||||
|
// direction the account could not leave: `learnerPairs` held only zh, so every
|
||||||
|
// pt-PT writer was learning_en by force and this function correctly explained a
|
||||||
|
// Portuguese document in Portuguese. Pinned here rather than only in the auth
|
||||||
|
// package because this is where the consequence actually lands — the language
|
||||||
|
// the writer reads her advice in.
|
||||||
|
func TestTargetExplainsPortugueseInEnglishForALearner(t *testing.T) {
|
||||||
|
learner := targetFor("pt-PT", auth.DirectionLearningPair, docLangPair)
|
||||||
|
if learner.Correct.Code != "pt-PT" {
|
||||||
|
t.Fatalf("corrected in %s, want the document's own Portuguese", learner.Correct.Code)
|
||||||
|
}
|
||||||
|
if learner.Explain.Code != "en" {
|
||||||
|
t.Fatalf("explained in %s, want English", learner.Explain.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the native Portuguese speaker practising English is untouched: her
|
||||||
|
// Portuguese is still explained in Portuguese.
|
||||||
|
native := targetFor("pt-PT", auth.DirectionLearningEn, docLangPair)
|
||||||
|
if native.Correct.Code != "pt-PT" || native.Explain.Code != "pt-PT" {
|
||||||
|
t.Fatalf("learning_en on a Portuguese document: correct=%s explain=%s", native.Correct.Code, native.Explain.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two language decisions are genuinely independent, and zh was the first
|
||||||
|
// pair that could prove it — the first that could be travelled in both
|
||||||
|
// directions.
|
||||||
|
//
|
||||||
|
// A Mandarin native practising English who writes Chinese wants Chinese
|
||||||
|
// corrections explained in Chinese. An English native learning Chinese who writes
|
||||||
|
// Chinese wants the same Chinese corrections explained in English. Same document,
|
||||||
|
// same Correct, different Explain.
|
||||||
|
func TestTargetSeparatesCorrectedFromExplained(t *testing.T) {
|
||||||
|
learningEn := targetFor("zh", auth.DirectionLearningEn, docLangPair)
|
||||||
|
if learningEn.Correct.Code != "zh" || learningEn.Explain.Code != "zh" {
|
||||||
|
t.Fatalf("learning_en on a Chinese document: correct=%s explain=%s", learningEn.Correct.Code, learningEn.Explain.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
learningPair := targetFor("zh", auth.DirectionLearningPair, docLangPair)
|
||||||
|
if learningPair.Correct.Code != "zh" {
|
||||||
|
t.Fatalf("learner direction changed what gets corrected: %s", learningPair.Correct.Code)
|
||||||
|
}
|
||||||
|
if learningPair.Explain.Code != "en" {
|
||||||
|
t.Fatalf("learner direction explained in %s, want English", learningPair.Explain.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An English document is the path every account is on today, in either
|
||||||
|
// direction: English corrections, English explanations, her language still on
|
||||||
|
// the Ask Petal and translate taps.
|
||||||
|
for _, dir := range []string{auth.DirectionLearningEn, auth.DirectionLearningPair} {
|
||||||
|
got := targetFor("zh", dir, docLangEnglish)
|
||||||
|
if got.Flipped() || got.Explain.Code != "en" {
|
||||||
|
t.Fatalf("english document with direction %s: %+v", dir, got)
|
||||||
|
}
|
||||||
|
if got.Pair.Code != "zh" {
|
||||||
|
t.Fatalf("english document lost the writer's pair: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newDirectedServer seeds one writer on a given pair and direction, with a
|
||||||
|
// document of her own. Like newPairServer, but the direction is the variable.
|
||||||
|
func newDirectedServer(t *testing.T, client llm.LLMClient, pairLang, direction, text string) (http.Handler, string, *Handler) {
|
||||||
|
t.Helper()
|
||||||
|
database, err := db.Open(filepath.Join(t.TempDir(), "doclang.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { database.Close() })
|
||||||
|
|
||||||
|
const userID = "writer-directed"
|
||||||
|
if _, err := database.Exec(
|
||||||
|
`INSERT INTO users (id, email, display_name, pair_lang, direction) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
userID, "d@example.com", "Writer", pairLang, direction,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var docID string
|
||||||
|
if err := database.QueryRow(
|
||||||
|
`INSERT INTO documents (user_id, content_text) VALUES (?, ?) RETURNING id`,
|
||||||
|
userID, text,
|
||||||
|
).Scan(&docID); err != nil {
|
||||||
|
t.Fatalf("seed doc: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := New(database, client)
|
||||||
|
h.Limit = llm.NewRateLimiter(0)
|
||||||
|
h.VoiceLimit = llm.NewRateLimiter(0)
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Route("/docs", func(dr chi.Router) { h.RegisterDocRoutes(dr) })
|
||||||
|
r.Mount("/suggestions", h.Routes())
|
||||||
|
return auth.Middleware(auth.StaticResolver(userID))(r), docID, h
|
||||||
|
}
|
||||||
|
|
||||||
|
const ptDocument = "Hoje foi um dia muito bom. Eu gosto de escrever aqui todas as noites. " +
|
||||||
|
"A minha irmã também quer aprender comigo. Não sei porque isso é tão difícil para mim."
|
||||||
|
|
||||||
|
// End to end: a Portuguese document reaches the model as a Portuguese
|
||||||
|
// checkpoint. This is the observed bug from 2026-07-28 — two pt-PT sentences
|
||||||
|
// drew no cards at all, because Petal was reading them as bad English.
|
||||||
|
func TestCheckpointFollowsTheDocumentLanguage(t *testing.T) {
|
||||||
|
client := &stubClient{response: `{"suggestions":[]}`}
|
||||||
|
srv, docID, _ := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, ptDocument)
|
||||||
|
|
||||||
|
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", ""); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(client.lastPrompt, "European Portuguese") {
|
||||||
|
t.Fatalf("checkpoint didn't follow the document into Portuguese:\n%s", client.lastPrompt)
|
||||||
|
}
|
||||||
|
if strings.Contains(client.lastPrompt, "second language") {
|
||||||
|
t.Fatalf("checkpoint kept the ESL framing on a Portuguese document:\n%s", client.lastPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the voice pass, which had no language argument at all before this phase.
|
||||||
|
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/voice", ""); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("voice: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(client.lastPrompt, "European Portuguese") {
|
||||||
|
t.Fatalf("voice pass didn't follow the document:\n%s", client.lastPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The verdict is persisted, because hysteresis needs a yesterday.
|
||||||
|
func TestDocumentLangIsRemembered(t *testing.T) {
|
||||||
|
client := &stubClient{response: `{"suggestions":[]}`}
|
||||||
|
srv, docID, h := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, ptDocument)
|
||||||
|
|
||||||
|
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", ""); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var stored string
|
||||||
|
if err := h.DB.QueryRow(`SELECT doc_lang FROM documents WHERE id = ?`, docID).Scan(&stored); err != nil {
|
||||||
|
t.Fatalf("read doc_lang: %v", err)
|
||||||
|
}
|
||||||
|
if stored != docLangPair {
|
||||||
|
t.Fatalf("doc_lang = %q, want %q", stored, docLangPair)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A document that changes language re-opens every sentence. Without the verdict
|
||||||
|
// in the chunk salt, the sentences she didn't touch would keep serving cards
|
||||||
|
// written in the language the document no longer speaks.
|
||||||
|
func TestLanguageFlipReopensCheckedSentences(t *testing.T) {
|
||||||
|
client := &stubClient{response: `{"suggestions":[]}`}
|
||||||
|
const enStart = "The weather was very cold this morning."
|
||||||
|
srv, docID, h := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, enStart)
|
||||||
|
|
||||||
|
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", ""); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first check: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
first := client.calls
|
||||||
|
|
||||||
|
// She rewrites the document in Portuguese, keeping the first sentence.
|
||||||
|
setDocText(t, h, docID, enStart+" "+ptDocument)
|
||||||
|
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", ""); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("second check: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if client.calls == first {
|
||||||
|
t.Fatal("the flipped document was never sent to the model")
|
||||||
|
}
|
||||||
|
if !strings.Contains(client.lastPrompt, "The weather was very cold") {
|
||||||
|
t.Fatalf("the already-checked sentence was not re-opened by the flip:\n%s", client.lastPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The translate card, pointed the other way. She is writing her journal in
|
||||||
|
// Portuguese and drops in the one English sentence she knows; Petal renders it
|
||||||
|
// into Portuguese, and that card is a translation — not a correction to prose
|
||||||
|
// that was never wrong.
|
||||||
|
func TestEnglishSpanBecomesATranslateCardInAPortugueseDocument(t *testing.T) {
|
||||||
|
const english = "I want to say this but I don't know how to say it."
|
||||||
|
// The model volunteers "clarity", as it did for the zh case. Not consulted.
|
||||||
|
client := &stubClient{response: `{"suggestions":[
|
||||||
|
{"original":"` + english + `","replacement":"Eu quero dizer isto mas não sei como o dizer.","explanation":"Aqui está em português.","type":"clarity"}
|
||||||
|
]}`}
|
||||||
|
srv, docID, _ := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, ptDocument+" "+english)
|
||||||
|
|
||||||
|
var out []db.Suggestion
|
||||||
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("want 1 card, got %d: %+v", len(out), out)
|
||||||
|
}
|
||||||
|
if out[0].Type != db.SuggestionTypeTranslate {
|
||||||
|
t.Fatalf("card type = %q, want %q", out[0].Type, db.SuggestionTypeTranslate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the half that keeps it honest: a genuine Portuguese correction in the same
|
||||||
|
// document stays a correction. Reading the English-document test backwards would
|
||||||
|
// have called this a translation, because every Portuguese sentence also "reads
|
||||||
|
// as English" by that test's deliberately low bar.
|
||||||
|
func TestPortugueseCorrectionKeepsItsTypeInAPortugueseDocument(t *testing.T) {
|
||||||
|
client := &stubClient{response: `{"suggestions":[
|
||||||
|
{"original":"Não sei porque isso é tão difícil para mim.","replacement":"Não sei porque isto é tão difícil para mim.","explanation":"Aqui usa-se isto.","type":"grammar"}
|
||||||
|
]}`}
|
||||||
|
srv, docID, _ := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, ptDocument)
|
||||||
|
|
||||||
|
var out []db.Suggestion
|
||||||
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("want 1 card, got %d: %+v", len(out), out)
|
||||||
|
}
|
||||||
|
if out[0].Type == db.SuggestionTypeTranslate {
|
||||||
|
t.Fatal("a Portuguese correction inside a Portuguese document was labelled a translation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setDocLang writes a document's language verdict directly, so a test of the
|
||||||
|
// tap-through doesn't have to run a checkpoint through the same stub client to
|
||||||
|
// get one.
|
||||||
|
func setDocLang(t *testing.T, h *Handler, docID, lang string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := h.DB.Exec(`UPDATE documents SET doc_lang = ? WHERE id = ?`, lang, docID); err != nil {
|
||||||
|
t.Fatalf("set doc_lang: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedExplanation files one card carrying a given explanation and returns its
|
||||||
|
// id — the shape the translate tap-through needs, where only the explanation and
|
||||||
|
// the document it hangs off matter.
|
||||||
|
func seedExplanation(t *testing.T, h *Handler, docID, explanation string) string {
|
||||||
|
t.Helper()
|
||||||
|
var sugID string
|
||||||
|
if err := h.DB.QueryRow(
|
||||||
|
`INSERT INTO suggestions (doc_id, original, replacement, explanation, type, from_pos, to_pos)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 0, 5) RETURNING id`,
|
||||||
|
docID, "isso", "isto", explanation, "grammar",
|
||||||
|
).Scan(&sugID); err != nil {
|
||||||
|
t.Fatalf("seed suggestion: %v", err)
|
||||||
|
}
|
||||||
|
return sugID
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tap-through has to read the same decision the card was written under. On a
|
||||||
|
// Portuguese document by a Portuguese writer the explanation already arrived in
|
||||||
|
// Portuguese, and the old endpoint would have sent it to the model to be
|
||||||
|
// rendered into Portuguese again.
|
||||||
|
func TestTranslateSkipsWhenTheExplanationIsAlreadyHers(t *testing.T) {
|
||||||
|
client := &stubClient{response: "Não devia ser chamado."}
|
||||||
|
srv, docID, h := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, ptDocument)
|
||||||
|
setDocLang(t, h, docID, docLangPair)
|
||||||
|
sugID := seedExplanation(t, h, docID, "Aqui usa-se isto.")
|
||||||
|
|
||||||
|
rec := do(t, srv, http.MethodPost, "/suggestions/"+sugID+"/translate", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("translate: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var out translateResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if out.Translation != "" {
|
||||||
|
t.Fatalf("translation = %q, want empty: the bubble seeds from the explanation itself", out.Translation)
|
||||||
|
}
|
||||||
|
if client.calls != 0 {
|
||||||
|
t.Fatal("the model was asked to render Portuguese into Portuguese")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The learner travelling the other way is the case that proves the endpoint
|
||||||
|
// derives its destination rather than skipping whenever a document is flipped: a
|
||||||
|
// native English speaker learning Chinese, writing Chinese, gets her
|
||||||
|
// explanations in English — and the tap still has somewhere to go.
|
||||||
|
func TestTranslateStillRendersForALearnersEnglishExplanation(t *testing.T) {
|
||||||
|
const zhDocument = "今天天气很好。我早上去公园散步。下午我在家里写作业。晚上我和朋友一起吃饭。"
|
||||||
|
client := &stubClient{response: "这里应该用这个。"}
|
||||||
|
srv, docID, h := newDirectedServer(t, client, "zh", auth.DirectionLearningPair, zhDocument)
|
||||||
|
setDocLang(t, h, docID, docLangPair)
|
||||||
|
sugID := seedExplanation(t, h, docID, "This measure word doesn't fit here.")
|
||||||
|
|
||||||
|
rec := do(t, srv, http.MethodPost, "/suggestions/"+sugID+"/translate", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("translate: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var out translateResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if out.Translation == "" {
|
||||||
|
t.Fatal("a learner's English explanation was left untranslated")
|
||||||
|
}
|
||||||
|
if !strings.Contains(client.lastPrompt, "Simplified Chinese") {
|
||||||
|
t.Fatalf("translate didn't render into the pair language:\n%s", client.lastPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPassAnnouncesItsVerdict pins the header the client reads. Storing the
|
||||||
|
// verdict on the document row is not enough on its own: the editor sees that row
|
||||||
|
// only when the document is opened or saved, and the pass that decides the
|
||||||
|
// verdict runs *after* a save — so the client would always be one save behind,
|
||||||
|
// and read-aloud is reached for exactly when she has stopped typing and no
|
||||||
|
// further save is coming. Caught in a browser: a Portuguese paragraph read in an
|
||||||
|
// American voice, twice, until another keystroke went in.
|
||||||
|
//
|
||||||
|
// Asserted on both endpoints that can flip it, and on the empty-document early
|
||||||
|
// return, which answers without ever reaching the model.
|
||||||
|
func TestPassAnnouncesItsVerdict(t *testing.T) {
|
||||||
|
client := &stubClient{response: `{"suggestions":[]}`}
|
||||||
|
srv, docID, h := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, ptDocument)
|
||||||
|
|
||||||
|
for _, path := range []string{"/check", "/voice"} {
|
||||||
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+path, "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s: code=%d body=%s", path, rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("X-Petal-Doc-Lang"); got != docLangPair {
|
||||||
|
t.Fatalf("%s: X-Petal-Doc-Lang = %q, want %q", path, got, docLangPair)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An English document says so rather than saying nothing — the client has to
|
||||||
|
// be able to hear a flip back, not just a flip away.
|
||||||
|
if _, err := h.DB.Exec(
|
||||||
|
`UPDATE documents SET content_text = ?, doc_lang = '' WHERE id = ?`,
|
||||||
|
"The weather was very cold this morning. I walked to the shop and bought some bread.", docID,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("rewrite doc: %v", err)
|
||||||
|
}
|
||||||
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("X-Petal-Doc-Lang"); got != docLangEnglish {
|
||||||
|
t.Fatalf("English document: X-Petal-Doc-Lang = %q, want %q", got, docLangEnglish)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The empty-document path returns before the model call, and still answers.
|
||||||
|
if _, err := h.DB.Exec(`UPDATE documents SET content_text = '' WHERE id = ?`, docID); err != nil {
|
||||||
|
t.Fatalf("empty doc: %v", err)
|
||||||
|
}
|
||||||
|
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("empty check: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("X-Petal-Doc-Lang"); got == "" {
|
||||||
|
t.Fatal("empty document answered with no verdict header at all")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOrdinaryProseIsEnoughEvidence is the regression for what the marker lists
|
||||||
|
// were caught doing on 2026-07-29, live, in a browser: unremarkable Portuguese
|
||||||
|
// read as English, because the list was curated against English so tightly that
|
||||||
|
// it had also been curated against ordinary writing. The document below scored
|
||||||
|
// two pair markers and zero English ones, and two is below the corroboration
|
||||||
|
// floor — so a paragraph with no evidence of English in it at all came back
|
||||||
|
// English, and was corrected and read aloud as English.
|
||||||
|
//
|
||||||
|
// Every sample here is prose a person might actually write, not prose chosen to
|
||||||
|
// contain markers. That is the whole point of the test: the failure was invisible
|
||||||
|
// to a suite whose fixtures all argued their own case.
|
||||||
|
func TestOrdinaryProseIsEnoughEvidence(t *testing.T) {
|
||||||
|
samples := []struct{ name, text string }{
|
||||||
|
{"the one seen live", "Esta manhã acordei cedo e fui correr ao longo da marginal. O ar estava fresco e havia poucas pessoas na rua. Depois comprei um jornal e li-o sentado num banco ao sol."},
|
||||||
|
{"an afternoon out", "Hoje o céu estava limpo e fomos até ao jardim junto ao rio. A minha mãe trouxe uma manta velha e sentámos-nos debaixo de uma árvore."},
|
||||||
|
{"plans", "Amanhã vamos ao cinema depois do trabalho. Ontem estava demasiado cansada para sair de casa."},
|
||||||
|
}
|
||||||
|
for _, s := range samples {
|
||||||
|
if got := documentLang(s.text, "pt-PT", ""); got != docLangPair {
|
||||||
|
t.Errorf("%s: documentLang = %q, want %q — ordinary Portuguese must not read as English\n%s",
|
||||||
|
s.name, got, docLangPair, s.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnglishDidNotGetEasierToMistake is the other half, and the reason the
|
||||||
|
// additions were held to "a word an English sentence has no reason to contain".
|
||||||
|
// Widening a marker list is only safe if it widens in one direction: these are
|
||||||
|
// English documents, including ones about Portugal and ones quoting Portuguese,
|
||||||
|
// and every one of them must still come back English.
|
||||||
|
func TestEnglishDidNotGetEasierToMistake(t *testing.T) {
|
||||||
|
samples := []struct{ name, text string }{
|
||||||
|
{"plain English", "This morning I woke up early and went for a run along the seafront. The air was fresh and there were few people about. Afterwards I bought a newspaper and read it on a bench."},
|
||||||
|
{"English about Portugal", "We spent a week in Lisbon last summer. The trams were crowded but the food was wonderful, and we walked up to the castle every evening."},
|
||||||
|
{"English quoting her", "My mother always says \"até amanhã\" when she leaves, never goodbye. I asked her why once and she said it sounded less final to her."},
|
||||||
|
{"an English diary", "Today was long. I had two meetings before lunch and another one after, and by the time I got home I could not think straight. Tomorrow should be quieter."},
|
||||||
|
}
|
||||||
|
for _, s := range samples {
|
||||||
|
if got := documentLang(s.text, "pt-PT", ""); got != docLangEnglish {
|
||||||
|
t.Errorf("%s: documentLang = %q, want %q — the widened list must not pull English across\n%s",
|
||||||
|
s.name, got, docLangEnglish, s.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// And the same document must not flip once it is already sitting in English:
|
||||||
|
// the hysteresis band is only a safety net if the low side holds too.
|
||||||
|
for _, s := range samples {
|
||||||
|
if got := documentLang(s.text, "pt-PT", docLangEnglish); got != docLangEnglish {
|
||||||
|
t.Errorf("%s: held verdict flipped to %q", s.name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -247,12 +247,45 @@ func (h *Handler) collocation(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pass is the signature shared by the grammar checkpoint and the voice pass:
|
// pass is the signature shared by the grammar checkpoint and the voice pass:
|
||||||
// given the document text, the document's tone and the writer's pair language it
|
// given the document text, the document's tone and the languages this document
|
||||||
// returns the model's raw suggestions. The voice pass ignores both extras (see
|
// is to be corrected and explained in, it returns the model's raw suggestions.
|
||||||
// llm.RunVoice) and the checkpoint ignores the language — only the collocation
|
// The voice pass ignores the tone (see llm.RunVoice) and the collocation coach
|
||||||
// coach writes a word of it — but one signature keeps runPass free of special
|
// reads only the writer's pair language, but one signature keeps runPass free of
|
||||||
// cases.
|
// special cases.
|
||||||
type pass func(ctx context.Context, client llm.LLMClient, contentText, tone string, lang llm.Lang) ([]llm.RawSuggestion, error)
|
type pass func(ctx context.Context, client llm.LLMClient, contentText, tone string, t llm.Target) ([]llm.RawSuggestion, error)
|
||||||
|
|
||||||
|
// targetFor resolves the two language decisions for one pass over one document.
|
||||||
|
//
|
||||||
|
// They read different state on purpose. What gets *corrected* follows the
|
||||||
|
// document, because Portuguese prose wants Portuguese corrections. What language
|
||||||
|
// the correction is *explained* in follows the writer — the half of her pair she
|
||||||
|
// is not learning — because an explanation is teaching, and teaching lands in the
|
||||||
|
// language she reads most easily. A native Portuguese speaker practising English
|
||||||
|
// gets Portuguese explained in Portuguese; a native English speaker learning
|
||||||
|
// French gets French explained in English. Neither is trapped: the other language
|
||||||
|
// stays one tap away, in both directions.
|
||||||
|
//
|
||||||
|
// An English document keeps the pre-Phase-28 behaviour exactly — explained in
|
||||||
|
// English, with her language on the Ask Petal / translate taps — which is the
|
||||||
|
// path every account today is on.
|
||||||
|
//
|
||||||
|
// The direction lookup costs nothing today: `learnerPairs` is {"zh"}, so fr, es
|
||||||
|
// and pt-PT accounts are all learning_en and their non-learned half *is* the pair
|
||||||
|
// language. This rule therefore produces "explain in the document's language" for
|
||||||
|
// every writer who currently exists. It is written out anyway to stop the
|
||||||
|
// coincidence being baked into the prompts, the way "English is the language
|
||||||
|
// being learned" was baked into pair_lang before migration 0016.
|
||||||
|
func targetFor(pairLang, direction, docLang string) llm.Target {
|
||||||
|
pair := llm.LangFor(pairLang)
|
||||||
|
if normalizeDocLang(docLang) != docLangPair {
|
||||||
|
return llm.EnglishTarget(pair)
|
||||||
|
}
|
||||||
|
explain := pair
|
||||||
|
if direction == auth.DirectionLearningPair {
|
||||||
|
explain = llm.English
|
||||||
|
}
|
||||||
|
return llm.Target{Correct: pair, Explain: explain, Pair: pair}
|
||||||
|
}
|
||||||
|
|
||||||
// runPass is the shared body for both LLM passes. It loads the document text,
|
// runPass is the shared body for both LLM passes. It loads the document text,
|
||||||
// enforces the pass's per-document rate limit, runs the model, swaps in the
|
// enforces the pass's per-document rate limit, runs the model, swaps in the
|
||||||
@@ -262,17 +295,19 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
|||||||
docID := chi.URLParam(r, "id")
|
docID := chi.URLParam(r, "id")
|
||||||
userID := auth.UserID(r.Context())
|
userID := auth.UserID(r.Context())
|
||||||
|
|
||||||
// The writer's pair language rides along with the document rather than in a
|
// The writer's pair language and direction ride along with the document
|
||||||
// second query: it is read from the same row-scoped lookup that already
|
// rather than in a second query: they are read from the same row-scoped
|
||||||
// proves she owns this document.
|
// lookup that already proves she owns this document. `doc_lang` is the
|
||||||
var contentText, tone, pairLang string
|
// previous language verdict, which the new one needs (hysteresis).
|
||||||
|
var contentText, tone, pairLang, direction, prevLang string
|
||||||
err := h.DB.QueryRow(
|
err := h.DB.QueryRow(
|
||||||
`SELECT d.content_text, d.tone, COALESCE(u.pair_lang, '')
|
`SELECT d.content_text, d.tone, d.doc_lang,
|
||||||
|
COALESCE(u.pair_lang, ''), COALESCE(u.direction, '')
|
||||||
FROM documents d
|
FROM documents d
|
||||||
JOIN users u ON u.id = d.user_id
|
JOIN users u ON u.id = d.user_id
|
||||||
WHERE d.id = ? AND d.user_id = ?`,
|
WHERE d.id = ? AND d.user_id = ?`,
|
||||||
docID, userID,
|
docID, userID,
|
||||||
).Scan(&contentText, &tone, &pairLang)
|
).Scan(&contentText, &tone, &prevLang, &pairLang, &direction)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
|
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
|
||||||
return
|
return
|
||||||
@@ -282,10 +317,35 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What language is this document in, and so what language should its cards be
|
||||||
|
// written in? Computed from the whole content_text — never from `askText`,
|
||||||
|
// which on a chunked pass is only the sentences that changed, and would put an
|
||||||
|
// English card in a Portuguese journal the moment she edits its one English
|
||||||
|
// line.
|
||||||
|
//
|
||||||
|
// Decided before the empty-document exit so every reconcile below is told the
|
||||||
|
// same verdict. An emptied document has nothing to go on and holds whatever it
|
||||||
|
// said last (see documentLang), which is what keeps a Portuguese journal
|
||||||
|
// Portuguese while she clears it to start the entry again.
|
||||||
|
docLang := documentLang(contentText, pairLang, prevLang)
|
||||||
|
|
||||||
|
// Announce the verdict on every answer this pass gives, including the early
|
||||||
|
// ones below. This pass is the only thing that decides the value, so it is
|
||||||
|
// the only moment the client can learn it promptly — and the client needs it
|
||||||
|
// promptly for read-aloud, which is reached for exactly when she has stopped
|
||||||
|
// typing and no further save is coming. Carrying it back on the document row
|
||||||
|
// alone means the editor is always one save behind the truth, and a paragraph
|
||||||
|
// of Portuguese read in an American voice is how that sounds.
|
||||||
|
//
|
||||||
|
// A header rather than a wider body: /check and /voice answer with a bare
|
||||||
|
// array of the unified pending set, and every caller of both endpoints reads
|
||||||
|
// it as one. A verdict is metadata about the pass, not another suggestion.
|
||||||
|
w.Header().Set("X-Petal-Doc-Lang", docLang)
|
||||||
|
|
||||||
// Nothing to analyze on an empty document — skip the LLM round-trip. The
|
// Nothing to analyze on an empty document — skip the LLM round-trip. The
|
||||||
// family's rows go with the text they were about.
|
// family's rows go with the text they were about.
|
||||||
if strings.TrimSpace(contentText) == "" {
|
if strings.TrimSpace(contentText) == "" {
|
||||||
if err := h.reconcilePending(docID, contentText, pairLang, nil, scope, nil, nil, false); err != nil {
|
if err := h.reconcilePending(docID, contentText, pairLang, docLang, nil, scope, nil, nil, false); err != nil {
|
||||||
httputil.ServerError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -298,16 +358,30 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if docLang != normalizeDocLang(prevLang) {
|
||||||
|
if _, err := h.DB.Exec(
|
||||||
|
`UPDATE documents SET doc_lang = ? WHERE id = ? AND user_id = ?`,
|
||||||
|
docLang, docID, userID,
|
||||||
|
); err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
target := targetFor(pairLang, direction, docLang)
|
||||||
|
|
||||||
// Decide what to ask about before spending anything: a chunked pass asks only
|
// Decide what to ask about before spending anything: a chunked pass asks only
|
||||||
// about the sentences that changed since it last read the document, and when
|
// about the sentences that changed since it last read the document, and when
|
||||||
// none did it doesn't call the model at all — nor consume its rate-limit slot,
|
// none did it doesn't call the model at all — nor consume its rate-limit slot,
|
||||||
// so the next real edit isn't throttled by a check that had nothing to do.
|
// so the next real edit isn't throttled by a check that had nothing to do.
|
||||||
//
|
//
|
||||||
// Only a chunked pass consults that record, so only it needs the tone folded
|
// Only a chunked pass consults that record, so only it needs the tone folded
|
||||||
// into a sentence's identity.
|
// into a sentence's identity — and, next to it, the language verdict. A
|
||||||
|
// document that flips language changes every sentence's identity, so its
|
||||||
|
// old-language cards are re-checked rather than left sitting there in a
|
||||||
|
// language the rest of the document no longer speaks.
|
||||||
salt := ""
|
salt := ""
|
||||||
if scope.chunked {
|
if scope.chunked {
|
||||||
salt = tone
|
salt = tone + "\x00" + docLang
|
||||||
}
|
}
|
||||||
chunks := splitChunks(contentText, salt)
|
chunks := splitChunks(contentText, salt)
|
||||||
askText, fresh := contentText, chunks
|
askText, fresh := contentText, chunks
|
||||||
@@ -321,7 +395,7 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
|||||||
if len(changed) == 0 {
|
if len(changed) == 0 {
|
||||||
// Every sentence has already been read. Drop the rows whose sentence is
|
// Every sentence has already been read. Drop the rows whose sentence is
|
||||||
// gone, keep the rest exactly as they are, and answer immediately.
|
// gone, keep the rest exactly as they are, and answer immediately.
|
||||||
if err := h.reconcilePending(docID, contentText, pairLang, nil, scope, chunks, nil, false); err != nil {
|
if err := h.reconcilePending(docID, contentText, pairLang, docLang, nil, scope, chunks, nil, false); err != nil {
|
||||||
httputil.ServerError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -354,7 +428,7 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, err := run(r.Context(), h.Client, askText, tone, llm.LangFor(pairLang))
|
raw, err := run(r.Context(), h.Client, askText, tone, target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Allow ran before the model call, so a failed pass would otherwise hold
|
// Allow ran before the model call, so a failed pass would otherwise hold
|
||||||
// the per-document slot for the full interval — stranding the frontend's
|
// the per-document slot for the full interval — stranding the frontend's
|
||||||
@@ -366,7 +440,7 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
|||||||
|
|
||||||
// A whole-document pass re-read everything, so every one of its rows is up for
|
// A whole-document pass re-read everything, so every one of its rows is up for
|
||||||
// re-proposal; a chunked pass only puts the sentences it asked about in play.
|
// re-proposal; a chunked pass only puts the sentences it asked about in play.
|
||||||
if err := h.reconcilePending(docID, contentText, pairLang, raw, scope, chunks, fresh, !scope.chunked); err != nil {
|
if err := h.reconcilePending(docID, contentText, pairLang, docLang, raw, scope, chunks, fresh, !scope.chunked); err != nil {
|
||||||
httputil.ServerError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -764,13 +838,13 @@ func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status strin
|
|||||||
// hands over a reusable chunk, which is the only thing worth reviewing in a week.
|
// hands over a reusable chunk, which is the only thing worth reviewing in a week.
|
||||||
func (h *Handler) plant(id, userID string) {
|
func (h *Handler) plant(id, userID string) {
|
||||||
var s db.Suggestion
|
var s db.Suggestion
|
||||||
var contentText string
|
var contentText, docLang string
|
||||||
err := h.DB.QueryRow(
|
err := h.DB.QueryRow(
|
||||||
`SELECT s.type, s.original, s.replacement, s.explanation, s.doc_id, d.content_text
|
`SELECT s.type, s.original, s.replacement, s.explanation, s.doc_id, d.content_text, d.doc_lang
|
||||||
FROM suggestions s JOIN documents d ON d.id = s.doc_id
|
FROM suggestions s JOIN documents d ON d.id = s.doc_id
|
||||||
WHERE s.id = ? AND d.user_id = ?`,
|
WHERE s.id = ? AND d.user_id = ?`,
|
||||||
id, userID,
|
id, userID,
|
||||||
).Scan(&s.Type, &s.Original, &s.Replacement, &s.Explanation, &s.DocID, &contentText)
|
).Scan(&s.Type, &s.Original, &s.Replacement, &s.Explanation, &s.DocID, &contentText, &docLang)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, sql.ErrNoRows) {
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
log.Printf("suggestions: could not read %s for planting: %v", id, err)
|
log.Printf("suggestions: could not read %s for planting: %v", id, err)
|
||||||
@@ -789,6 +863,9 @@ func (h *Handler) plant(id, userID string) {
|
|||||||
Meaning: s.Explanation,
|
Meaning: s.Explanation,
|
||||||
Example: correctedSentence(contentText, s.Original, s.Replacement),
|
Example: correctedSentence(contentText, s.Original, s.Replacement),
|
||||||
DocID: &docID,
|
DocID: &docID,
|
||||||
|
// The chunk is her own sentence, corrected — so it is in the document's
|
||||||
|
// language, whatever the collocation pass was framed in.
|
||||||
|
Lang: docLang,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("suggestions: could not plant %s: %v", id, err)
|
log.Printf("suggestions: could not plant %s: %v", id, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,16 +28,35 @@ import (
|
|||||||
// common words a sentence in that language can hardly avoid and an English
|
// common words a sentence in that language can hardly avoid and an English
|
||||||
// sentence has no reason to contain.
|
// sentence has no reason to contain.
|
||||||
|
|
||||||
// isTranslation reports whether this edit is her own language rendered into
|
// isTranslation reports whether this edit is a rendering of one language into
|
||||||
// English, rather than a correction to her English. Both halves must hold: the
|
// the other, rather than a correction. Both halves must hold: the quoted span
|
||||||
// quoted span reads as the pair language, and what Petal offers back reads as
|
// reads as one language, and what Petal offers back reads as the other. The
|
||||||
// English. The second half matters — a Chinese span rewritten into different
|
// second half matters — a Chinese span rewritten into different Chinese is
|
||||||
// Chinese is something else entirely, and Petal has no business calling it a
|
// something else entirely, and Petal has no business calling it a translation.
|
||||||
// translation.
|
//
|
||||||
func isTranslation(original, replacement, pairLang string) bool {
|
// Which way it points follows the document (Phase 28). In an English document
|
||||||
|
// the translate card is her language rendered into English — she reached for a
|
||||||
|
// sentence she couldn't say yet, and Petal said it for her. In a document she
|
||||||
|
// wrote in her own language the useful card is the mirror image: an English
|
||||||
|
// sentence she dropped into her Portuguese, rendered into Portuguese. Asking the
|
||||||
|
// English-document question there would label nothing, and the card would file
|
||||||
|
// as a correction to prose that was never wrong.
|
||||||
|
//
|
||||||
|
// The flipped direction cannot be the same test read backwards. `readsAsEnglish`
|
||||||
|
// is a low bar on purpose — Latin letters, not swamped by another script — which
|
||||||
|
// every Portuguese sentence also clears, so using it on the *original* would
|
||||||
|
// call every genuine Portuguese correction a translation. The flipped test
|
||||||
|
// instead uses the sentence-level vote from doclang.go, where English has its
|
||||||
|
// own marker list and has to out-evidence the pair language to win.
|
||||||
|
func isTranslation(original, replacement, pairLang, docLang string) bool {
|
||||||
if strings.TrimSpace(original) == "" || strings.TrimSpace(replacement) == "" {
|
if strings.TrimSpace(original) == "" || strings.TrimSpace(replacement) == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if normalizeDocLang(docLang) == docLangPair {
|
||||||
|
p := normalizePairLang(pairLang)
|
||||||
|
return sentenceLang(original, p) == docLangEnglish &&
|
||||||
|
sentenceLang(replacement, p) == docLangPair
|
||||||
|
}
|
||||||
return readsAsPairLang(original, pairLang) && readsAsEnglish(replacement)
|
return readsAsPairLang(original, pairLang) && readsAsEnglish(replacement)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +155,19 @@ func distinctMarkers(s string, markers map[string]bool) int {
|
|||||||
//
|
//
|
||||||
// A single marker is not enough (see readsAsPairLang), so these lists are read
|
// A single marker is not enough (see readsAsPairLang), so these lists are read
|
||||||
// as evidence to be corroborated rather than as a decision.
|
// as evidence to be corroborated rather than as a decision.
|
||||||
|
//
|
||||||
|
// **Curated against English is not the same as curated thinly**, and the first
|
||||||
|
// version of these lists confused the two. Seen live 2026-07-29: "Esta manhã
|
||||||
|
// acordei cedo e fui correr ao longo da marginal. O ar estava fresco e havia
|
||||||
|
// poucas pessoas na rua." — unremarkable Portuguese, two marker hits, *zero*
|
||||||
|
// English hits, and a verdict of English, because the document-level floor wants
|
||||||
|
// three. The list was missing the ordinary machinery of the language: the
|
||||||
|
// contractions (ao, à, num), the past tenses a diary is written in (estava,
|
||||||
|
// havia, fomos), and the words that join two clauses (até, depois, então,
|
||||||
|
// onde). Every one of them clears the bar above — an English sentence has no
|
||||||
|
// reason to contain them — so their absence bought nothing and cost the verdict.
|
||||||
|
// The floor stays at three; what changed is that three is now reachable by
|
||||||
|
// prose rather than only by a paragraph that happens to argue with itself.
|
||||||
var latinMarkers = map[string]map[string]bool{
|
var latinMarkers = map[string]map[string]bool{
|
||||||
"fr": words(
|
"fr": words(
|
||||||
"je", "tu", "il", "elle", "ils", "elles", "nous", "vous", "est", "sont",
|
"je", "tu", "il", "elle", "ils", "elles", "nous", "vous", "est", "sont",
|
||||||
@@ -145,6 +177,10 @@ var latinMarkers = map[string]map[string]bool{
|
|||||||
"beaucoup", "toujours", "jamais", "quand", "bien", "chose", "temps",
|
"beaucoup", "toujours", "jamais", "quand", "bien", "chose", "temps",
|
||||||
"moi", "toi", "lui", "peux", "veux", "sais", "faire", "dit", "aujourd",
|
"moi", "toi", "lui", "peux", "veux", "sais", "faire", "dit", "aujourd",
|
||||||
"hui", "quelque", "chez", "tout", "tous", "rien", "déjà", "encore",
|
"hui", "quelque", "chez", "tout", "tous", "rien", "déjà", "encore",
|
||||||
|
// The same gap the pt-PT list was caught with, closed by analogy rather
|
||||||
|
// than by observation — no fr account exists yet to catch it live.
|
||||||
|
"aux", "après", "où", "avait", "étaient", "depuis", "jusqu", "chaque",
|
||||||
|
"autre", "même", "hier", "demain", "matin", "soir", "nôtre", "leurs",
|
||||||
),
|
),
|
||||||
"pt-PT": words(
|
"pt-PT": words(
|
||||||
"eu", "você", "ele", "ela", "eles", "elas", "nós", "são", "uma", "os",
|
"eu", "você", "ele", "ela", "eles", "elas", "nós", "são", "uma", "os",
|
||||||
@@ -154,6 +190,18 @@ var latinMarkers = map[string]map[string]bool{
|
|||||||
"nunca", "bem", "obrigado", "obrigada", "gosto", "tenho", "tem", "foi",
|
"nunca", "bem", "obrigado", "obrigada", "gosto", "tenho", "tem", "foi",
|
||||||
"ser", "ter", "mais", "já", "ainda", "aqui", "ali", "nada", "tudo",
|
"ser", "ter", "mais", "já", "ainda", "aqui", "ali", "nada", "tudo",
|
||||||
"todos", "para", "pela", "pelo", "sobre", "assim",
|
"todos", "para", "pela", "pelo", "sobre", "assim",
|
||||||
|
// The contractions, which no English sentence has any use for.
|
||||||
|
"ao", "aos", "à", "às", "num", "numa", "dum", "duma", "pelos", "pelas",
|
||||||
|
"neste", "nesta", "disso", "deste", "desta",
|
||||||
|
// The tenses a journal is actually written in.
|
||||||
|
"estava", "estavam", "estão", "estamos", "havia", "houve", "era", "eram",
|
||||||
|
"fui", "fomos", "foram", "vai", "vamos", "tinha", "tinham",
|
||||||
|
// The joins between two clauses.
|
||||||
|
"até", "depois", "antes", "onde", "então", "enquanto", "embora",
|
||||||
|
"sem", "quem", "entre",
|
||||||
|
// And the everyday determiners and time words a diary can hardly avoid.
|
||||||
|
"nosso", "nossa", "outro", "outra", "mesmo", "mesma", "tão",
|
||||||
|
"muitos", "muitas", "poucos", "poucas", "hoje", "ontem", "amanhã",
|
||||||
),
|
),
|
||||||
"es": words(
|
"es": words(
|
||||||
"yo", "él", "ella", "ellos", "ellas", "nosotros", "una", "los", "las",
|
"yo", "él", "ella", "ellos", "ellas", "nosotros", "una", "los", "las",
|
||||||
@@ -162,6 +210,12 @@ var latinMarkers = map[string]map[string]bool{
|
|||||||
"hacer", "siempre", "nunca", "bien", "gracias", "tengo", "tiene", "fue",
|
"hacer", "siempre", "nunca", "bien", "gracias", "tengo", "tiene", "fue",
|
||||||
"ser", "tener", "más", "aquí", "allí", "nada", "todos",
|
"ser", "tener", "más", "aquí", "allí", "nada", "todos",
|
||||||
"para", "sobre", "así", "hola", "señor", "usted", "muchas",
|
"para", "sobre", "así", "hola", "señor", "usted", "muchas",
|
||||||
|
// Likewise by analogy: no es account exists yet either. "sin" and "tan"
|
||||||
|
// stay out — both are English words, which is the one disqualification.
|
||||||
|
"al", "después", "antes", "donde", "entonces", "mientras", "aunque",
|
||||||
|
"estaba", "estaban", "están", "había", "hubo", "fuimos", "fueron",
|
||||||
|
"nuestro", "nuestra", "otro", "otra", "mismo", "misma", "quién", "quien",
|
||||||
|
"muchos", "pocas", "pocos", "hoy", "ayer", "mañana",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ package suggestions
|
|||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
// The flagship case, and the ones next to it that must NOT become translations.
|
// The flagship case, and the ones next to it that must NOT become translations.
|
||||||
|
//
|
||||||
|
// Every case here is an ENGLISH document — the path every account was on before
|
||||||
|
// Phase 28 — so `docLang` is left at "". The mirror image lives in
|
||||||
|
// TestIsTranslationInAPairLanguageDocument below.
|
||||||
func TestIsTranslation(t *testing.T) {
|
func TestIsTranslation(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -134,8 +138,93 @@ func TestIsTranslation(t *testing.T) {
|
|||||||
|
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
if got := isTranslation(c.original, c.replacement, c.pairLang); got != c.want {
|
if got := isTranslation(c.original, c.replacement, c.pairLang, ""); got != c.want {
|
||||||
t.Errorf("isTranslation(%q, %q, %q) = %v, want %v",
|
t.Errorf("isTranslation(%q, %q, %q, en) = %v, want %v",
|
||||||
|
c.original, c.replacement, c.pairLang, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mirror image (Phase 28): in a document she wrote in her own language, the
|
||||||
|
// translate card is the English sentence rendered into her language — and the
|
||||||
|
// English-document question, asked here, would label nothing.
|
||||||
|
//
|
||||||
|
// The case this file exists to pin is the third one: a genuine Portuguese
|
||||||
|
// correction inside a Portuguese document. Reading the English-document test
|
||||||
|
// backwards would call it a translation, because `readsAsEnglish` is a low bar
|
||||||
|
// that Portuguese clears too. It has to stay a correction.
|
||||||
|
func TestIsTranslationInAPairLanguageDocument(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
original string
|
||||||
|
replacement string
|
||||||
|
pairLang string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "English sentence rendered into Portuguese",
|
||||||
|
original: "I want to say this but I don't know how to say it.",
|
||||||
|
replacement: "Eu quero dizer isso mas não sei como o dizer.",
|
||||||
|
pairLang: "pt-PT",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "English sentence rendered into Chinese",
|
||||||
|
original: "I don't know how to say this in Chinese.",
|
||||||
|
replacement: "我不知道这句话用中文怎么说。",
|
||||||
|
pairLang: "zh",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The one that matters. Portuguese in, Portuguese out, inside a
|
||||||
|
// Portuguese document: a correction, and nothing else.
|
||||||
|
name: "Portuguese corrected as Portuguese",
|
||||||
|
original: "Eu quero dizer isso mas não sei como.",
|
||||||
|
replacement: "Eu quero dizer isto mas não sei como.",
|
||||||
|
pairLang: "pt-PT",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// And its Chinese twin, which the script test already caught.
|
||||||
|
name: "Chinese corrected as Chinese",
|
||||||
|
original: "我想说这句话",
|
||||||
|
replacement: "我要说这句话",
|
||||||
|
pairLang: "zh",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The old direction, asked in the new document. She quoted English in
|
||||||
|
// her Portuguese and Petal rendered it into Portuguese — which IS a
|
||||||
|
// translation, and is the case above. This is its reverse: Portuguese
|
||||||
|
// out of an English document that isn't one. No label.
|
||||||
|
name: "Portuguese rendered into English is not this document's translation",
|
||||||
|
original: "Eu quero dizer isso mas não sei como.",
|
||||||
|
replacement: "I want to say this but I don't know how.",
|
||||||
|
pairLang: "pt-PT",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// English prose without enough evidence to vote. Silence, not a guess.
|
||||||
|
name: "too short to read as English",
|
||||||
|
original: "OK",
|
||||||
|
replacement: "Está bem, muito obrigado.",
|
||||||
|
pairLang: "pt-PT",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown pair language declines in both directions",
|
||||||
|
original: "I don't know how to say that.",
|
||||||
|
replacement: "Ich weiß nicht wie man das sagt.",
|
||||||
|
pairLang: "de",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := isTranslation(c.original, c.replacement, c.pairLang, docLangPair); got != c.want {
|
||||||
|
t.Errorf("isTranslation(%q, %q, %q, pair) = %v, want %v",
|
||||||
c.original, c.replacement, c.pairLang, got, c.want)
|
c.original, c.replacement, c.pairLang, got, c.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -184,3 +184,60 @@ func TestCorrectedSentence(t *testing.T) {
|
|||||||
t.Errorf("unterminated doc: got %q", got)
|
t.Errorf("unterminated doc: got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPlantedPhraseCarriesTheDocumentLanguage: a chunk planted out of a document
|
||||||
|
// written in her own language is a card in that language. The collocation pass
|
||||||
|
// itself deliberately did not flip in Phase 28 — its prompt is per-language
|
||||||
|
// knowledge, not framing — but the phrase it hands over is still lifted from her
|
||||||
|
// prose, so the card has to know what language that prose was in or the garden
|
||||||
|
// will read it aloud in the wrong voice.
|
||||||
|
func TestPlantedPhraseCarriesTheDocumentLanguage(t *testing.T) {
|
||||||
|
srv, docID, h := newTestServer(t, &stubClient{})
|
||||||
|
if _, err := h.DB.Exec(`UPDATE documents SET doc_lang = 'pair' WHERE id = ?`, docID); err != nil {
|
||||||
|
t.Fatalf("set doc_lang: %v", err)
|
||||||
|
}
|
||||||
|
id := seedSuggestion(t, h, docID,
|
||||||
|
"Ontem foi difícil. Tive de tomar uma decisão sobre o trabalho.",
|
||||||
|
db.SuggestionTypeCollocation, "tomar uma decisão", "tomar uma decisão",
|
||||||
|
"Em português diz-se “tomar” uma decisão.")
|
||||||
|
|
||||||
|
if rec := do(t, srv, http.MethodPost, "/suggestions/"+id+"/accept", ""); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("accept: got %d, want 204", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lang string
|
||||||
|
if err := h.DB.QueryRow(
|
||||||
|
`SELECT lang FROM vocab_words WHERE user_id = ? AND word = ?`,
|
||||||
|
db.LocalUserID, "tomar uma decisão",
|
||||||
|
).Scan(&lang); err != nil {
|
||||||
|
t.Fatalf("read planted card: %v", err)
|
||||||
|
}
|
||||||
|
if lang != "pair" {
|
||||||
|
t.Fatalf("planted card lang = %q, want pair", lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlantedPhraseOnAnEnglishDocumentIsUnchanged is the other half, and the one
|
||||||
|
// every account is on today: an English document plants an English card, and the
|
||||||
|
// empty backfill and 'en' both mean that.
|
||||||
|
func TestPlantedPhraseOnAnEnglishDocumentIsUnchanged(t *testing.T) {
|
||||||
|
srv, docID, h := newTestServer(t, &stubClient{})
|
||||||
|
id := seedSuggestion(t, h, docID, "I had to do a decision about the job.",
|
||||||
|
db.SuggestionTypeCollocation, "do a decision", "make a decision",
|
||||||
|
"English pairs “make” with “decision”.")
|
||||||
|
|
||||||
|
if rec := do(t, srv, http.MethodPost, "/suggestions/"+id+"/accept", ""); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("accept: got %d, want 204", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lang string
|
||||||
|
if err := h.DB.QueryRow(
|
||||||
|
`SELECT lang FROM vocab_words WHERE user_id = ? AND word = ?`,
|
||||||
|
db.LocalUserID, "make a decision",
|
||||||
|
).Scan(&lang); err != nil {
|
||||||
|
t.Fatalf("read planted card: %v", err)
|
||||||
|
}
|
||||||
|
if lang == "pair" {
|
||||||
|
t.Fatalf("planted card lang = %q on an English document, want en or empty", lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,10 +131,12 @@ func reposition(tx *sql.Tx, row pendingRow, from, to int, chunkHash string) erro
|
|||||||
// collocation coach — where every row is up for re-proposal because the model
|
// collocation coach — where every row is up for re-proposal because the model
|
||||||
// just re-read everything.
|
// just re-read everything.
|
||||||
//
|
//
|
||||||
// `pairLang` is the writer's own language, needed only to type a finding that
|
// `pairLang` is the writer's own language and `docLang` this document's language
|
||||||
// turns out to be her language rendered into English (see language.go).
|
// verdict; between them they type a finding that turns out to be one language
|
||||||
|
// rendered into the other, in whichever direction this document makes useful
|
||||||
|
// (see language.go).
|
||||||
func (h *Handler) reconcilePending(
|
func (h *Handler) reconcilePending(
|
||||||
docID, contentText, pairLang string,
|
docID, contentText, pairLang, docLang string,
|
||||||
raw []llm.RawSuggestion,
|
raw []llm.RawSuggestion,
|
||||||
scope pendingScope,
|
scope pendingScope,
|
||||||
chunks, fresh []chunk,
|
chunks, fresh []chunk,
|
||||||
@@ -235,7 +237,7 @@ func (h *Handler) reconcilePending(
|
|||||||
typ := scope.forceType
|
typ := scope.forceType
|
||||||
if typ == "" {
|
if typ == "" {
|
||||||
typ = normalizeType(s.Type)
|
typ = normalizeType(s.Type)
|
||||||
if isTranslation(s.Original, s.Replacement, pairLang) {
|
if isTranslation(s.Original, s.Replacement, pairLang, docLang) {
|
||||||
typ = db.SuggestionTypeTranslate
|
typ = db.SuggestionTypeTranslate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,23 +17,37 @@ type translateResponse struct {
|
|||||||
Translation string `json:"translation"`
|
Translation string `json:"translation"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// translate renders a suggestion's English explanation into Simplified Chinese
|
// translate renders a suggestion's explanation into the other half of the
|
||||||
// for the Ask Petal bubble, so the ESL reader sees the "why" in her first
|
// writer's pair for the Ask Petal bubble, so she sees the "why" in the language
|
||||||
// language instead of a second copy of the same English text. The explanation is
|
// she reads most easily instead of a second copy of the same text. The
|
||||||
// loaded server-side from the suggestion id (scoped to the local user) and never
|
// explanation is loaded server-side from the suggestion id (scoped to the
|
||||||
// trusted from the client, mirroring chat (spec Note #10).
|
// caller) and never trusted from the client, mirroring chat (spec Note #10).
|
||||||
|
//
|
||||||
|
// Which language it renders into cannot be assumed (Phase 28). Before that phase
|
||||||
|
// every explanation was English and every rendering went into her language, so
|
||||||
|
// "the pair language" was a safe constant. Now the explanation's language is a
|
||||||
|
// decision — `targetFor`, from the document's verdict and her direction — and
|
||||||
|
// this endpoint has to read the same decision back, or it round-trips Portuguese
|
||||||
|
// into Portuguese and calls it a translation.
|
||||||
|
//
|
||||||
|
// So: render into whichever half the explanation is NOT already in, and when the
|
||||||
|
// explanation already arrived in the language this bubble exists to reach her
|
||||||
|
// in, skip the model call and answer "". The client seeds the bubble with the
|
||||||
|
// explanation itself when the translation comes back empty, which is exactly
|
||||||
|
// right — there is nothing to add.
|
||||||
func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
|
||||||
sugID := chi.URLParam(r, "id")
|
sugID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
var explanation, pairLang string
|
var explanation, pairLang, direction, docLang string
|
||||||
err := h.DB.QueryRow(
|
err := h.DB.QueryRow(
|
||||||
`SELECT s.explanation, COALESCE(u.pair_lang, '')
|
`SELECT s.explanation, COALESCE(u.pair_lang, ''),
|
||||||
|
COALESCE(u.direction, ''), d.doc_lang
|
||||||
FROM suggestions s
|
FROM suggestions s
|
||||||
JOIN documents d ON d.id = s.doc_id
|
JOIN documents d ON d.id = s.doc_id
|
||||||
JOIN users u ON u.id = d.user_id
|
JOIN users u ON u.id = d.user_id
|
||||||
WHERE s.id = ? AND d.user_id = ?`,
|
WHERE s.id = ? AND d.user_id = ?`,
|
||||||
sugID, auth.UserID(r.Context()),
|
sugID, auth.UserID(r.Context()),
|
||||||
).Scan(&explanation, &pairLang)
|
).Scan(&explanation, &pairLang, &direction, &docLang)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
httputil.ErrorJSON(w, http.StatusNotFound, "suggestion not found")
|
httputil.ErrorJSON(w, http.StatusNotFound, "suggestion not found")
|
||||||
return
|
return
|
||||||
@@ -49,7 +63,21 @@ func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := llm.RunTranslate(r.Context(), h.Client, explanation, llm.LangFor(pairLang))
|
// The explanation's own language, recovered from the same rule that chose it
|
||||||
|
// when the card was written. A card written before this phase — or on a
|
||||||
|
// document whose verdict has since flipped — is read as whatever the rule says
|
||||||
|
// today; the alternative is a language column on every suggestion row, and the
|
||||||
|
// cost of being wrong is one bubble seeded in the language it was already in.
|
||||||
|
target := targetFor(pairLang, direction, docLang)
|
||||||
|
if target.Explain.Code == target.Pair.Code {
|
||||||
|
// Already in her language. The other half is English — the language she is
|
||||||
|
// practising — and an unasked-for English rendering of an explanation she
|
||||||
|
// can already read is not a seed, it's noise.
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, translateResponse{Translation: ""})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := llm.RunTranslate(r.Context(), h.Client, explanation, target.Pair)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httputil.UpstreamError(w, "translate", err)
|
httputil.UpstreamError(w, "translate", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ type Word struct {
|
|||||||
Phonetic string `json:"phonetic"`
|
Phonetic string `json:"phonetic"`
|
||||||
Example string `json:"example"`
|
Example string `json:"example"`
|
||||||
DocID *string `json:"doc_id"`
|
DocID *string `json:"doc_id"`
|
||||||
|
// Lang is '' | 'en' | 'pair' — the language of the document the word was met
|
||||||
|
// in (see migration 0018). '' reads as English, like everywhere else this
|
||||||
|
// vocabulary appears. The client needs it to pick a read-aloud voice: "comum"
|
||||||
|
// is unguessable from its letters, so the card has to carry the answer.
|
||||||
|
Lang string `json:"lang"`
|
||||||
DueAt time.Time `json:"due_at"`
|
DueAt time.Time `json:"due_at"`
|
||||||
IntervalDays int `json:"interval_days"`
|
IntervalDays int `json:"interval_days"`
|
||||||
Ease float64 `json:"ease"`
|
Ease float64 `json:"ease"`
|
||||||
@@ -54,7 +59,7 @@ func (h *Handler) Routes() chi.Router {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
const vocabColumns = `id, word, gloss, definition, phonetic, example, doc_id,
|
const vocabColumns = `id, word, gloss, definition, phonetic, example, doc_id, lang,
|
||||||
due_at, interval_days, ease, reps, lapses, last_reviewed, created_at`
|
due_at, interval_days, ease, reps, lapses, last_reviewed, created_at`
|
||||||
|
|
||||||
func scanWord(s interface {
|
func scanWord(s interface {
|
||||||
@@ -62,7 +67,7 @@ func scanWord(s interface {
|
|||||||
}) (Word, error) {
|
}) (Word, error) {
|
||||||
var w Word
|
var w Word
|
||||||
err := s.Scan(
|
err := s.Scan(
|
||||||
&w.ID, &w.Word, &w.Gloss, &w.Definition, &w.Phonetic, &w.Example, &w.DocID,
|
&w.ID, &w.Word, &w.Gloss, &w.Definition, &w.Phonetic, &w.Example, &w.DocID, &w.Lang,
|
||||||
&w.DueAt, &w.IntervalDays, &w.Ease, &w.Reps, &w.Lapses, &w.LastReviewed, &w.CreatedAt,
|
&w.DueAt, &w.IntervalDays, &w.Ease, &w.Reps, &w.Lapses, &w.LastReviewed, &w.CreatedAt,
|
||||||
)
|
)
|
||||||
return w, err
|
return w, err
|
||||||
@@ -165,15 +170,22 @@ func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
|
|||||||
// would hit the foreign key and leak a raw "FOREIGN KEY constraint" 500
|
// would hit the foreign key and leak a raw "FOREIGN KEY constraint" 500
|
||||||
// instead of a clean 400 (and, once auth lands, would let a word be attached
|
// instead of a clean 400 (and, once auth lands, would let a word be attached
|
||||||
// to another user's document).
|
// to another user's document).
|
||||||
|
//
|
||||||
|
// The same row-scoped lookup answers what language the card is in: a word is
|
||||||
|
// met inside a document, so the document's verdict is the word's language.
|
||||||
|
// Asking the document rather than trusting a `lang` in the request body is
|
||||||
|
// the same choice `runPass` makes — the client never gets to name a language
|
||||||
|
// the server can already read. A word with no document is '', which reads as
|
||||||
|
// English.
|
||||||
|
lang := ""
|
||||||
if req.DocID != nil {
|
if req.DocID != nil {
|
||||||
if strings.TrimSpace(*req.DocID) == "" {
|
if strings.TrimSpace(*req.DocID) == "" {
|
||||||
req.DocID = nil
|
req.DocID = nil
|
||||||
} else {
|
} else {
|
||||||
var ok int
|
|
||||||
err := h.DB.QueryRow(
|
err := h.DB.QueryRow(
|
||||||
`SELECT 1 FROM documents WHERE id = ? AND user_id = ?`,
|
`SELECT doc_lang FROM documents WHERE id = ? AND user_id = ?`,
|
||||||
*req.DocID, userID,
|
*req.DocID, userID,
|
||||||
).Scan(&ok)
|
).Scan(&lang)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
httputil.ErrorJSON(w, http.StatusBadRequest, "unknown doc_id")
|
httputil.ErrorJSON(w, http.StatusBadRequest, "unknown doc_id")
|
||||||
return
|
return
|
||||||
@@ -189,15 +201,19 @@ func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
|
|||||||
// schedule (due_at/reps/interval/ease) alone so re-looking-up a word never
|
// schedule (due_at/reps/interval/ease) alone so re-looking-up a word never
|
||||||
// resets its progress.
|
// resets its progress.
|
||||||
_, err := h.DB.Exec(
|
_, err := h.DB.Exec(
|
||||||
`INSERT INTO vocab_words (user_id, word, gloss, definition, phonetic, example, doc_id, due_at, interval_days)
|
`INSERT INTO vocab_words (user_id, word, gloss, definition, phonetic, example, doc_id, lang, due_at, interval_days)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now', '+1 day'), 1)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now', '+1 day'), 1)
|
||||||
ON CONFLICT(user_id, word) DO UPDATE SET
|
ON CONFLICT(user_id, word) DO UPDATE SET
|
||||||
gloss = excluded.gloss,
|
gloss = excluded.gloss,
|
||||||
definition = excluded.definition,
|
definition = excluded.definition,
|
||||||
phonetic = excluded.phonetic,
|
phonetic = excluded.phonetic,
|
||||||
example = CASE WHEN excluded.example != '' THEN excluded.example ELSE vocab_words.example END,
|
example = CASE WHEN excluded.example != '' THEN excluded.example ELSE vocab_words.example END,
|
||||||
doc_id = COALESCE(excluded.doc_id, vocab_words.doc_id)`,
|
doc_id = COALESCE(excluded.doc_id, vocab_words.doc_id),
|
||||||
userID, word, req.Gloss, req.Definition, req.Phonetic, req.Example, req.DocID,
|
-- lang travels with doc_id, and for the same reason: it is the new
|
||||||
|
-- context or it is nothing. A lookup made outside any document must
|
||||||
|
-- not relabel a card that was captured inside one.
|
||||||
|
lang = CASE WHEN excluded.doc_id IS NOT NULL THEN excluded.lang ELSE vocab_words.lang END`,
|
||||||
|
userID, word, req.Gloss, req.Definition, req.Phonetic, req.Example, req.DocID, lang,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httputil.ServerError(w, err)
|
httputil.ServerError(w, err)
|
||||||
|
|||||||
@@ -253,3 +253,89 @@ func TestDocLinkSurvivesDocDelete(t *testing.T) {
|
|||||||
t.Fatalf("doc_id should be nulled after doc delete, got %v", *all[0].DocID)
|
t.Fatalf("doc_id should be nulled after doc delete, got %v", *all[0].DocID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCaptureTakesLanguageFromItsDocument is the garden's half of Phase 28: a
|
||||||
|
// word met inside a document written in her own language is a card in that
|
||||||
|
// language, and the server reads that off the document rather than being told.
|
||||||
|
//
|
||||||
|
// The three cases are the three the client can actually produce: a lookup inside
|
||||||
|
// a flipped document, a lookup inside an English one, and a lookup with no
|
||||||
|
// document at all (the search box) — the last of which is '', which reads as
|
||||||
|
// English everywhere this value is used.
|
||||||
|
func TestCaptureTakesLanguageFromItsDocument(t *testing.T) {
|
||||||
|
srv, database := newTestServer(t)
|
||||||
|
|
||||||
|
seed := func(lang string) string {
|
||||||
|
t.Helper()
|
||||||
|
var id string
|
||||||
|
if err := database.QueryRow(
|
||||||
|
`INSERT INTO documents (user_id, content_text, doc_lang) VALUES (?, 'hi', ?) RETURNING id`,
|
||||||
|
db.LocalUserID, lang,
|
||||||
|
).Scan(&id); err != nil {
|
||||||
|
t.Fatalf("seed doc: %v", err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
pairDoc, enDoc := seed("pair"), seed("en")
|
||||||
|
|
||||||
|
capture := func(word, body string) Word {
|
||||||
|
t.Helper()
|
||||||
|
rec := do(t, srv, http.MethodPost, "/vocab", body)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("capture %s: code=%d body=%s", word, rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var w Word
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &w); err != nil {
|
||||||
|
t.Fatalf("decode %s: %v", word, err)
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := capture("comum", `{"word":"comum","doc_id":"`+pairDoc+`"}`); got.Lang != "pair" {
|
||||||
|
t.Fatalf("word from a flipped document: lang=%q, want pair", got.Lang)
|
||||||
|
}
|
||||||
|
if got := capture("reception", `{"word":"reception","doc_id":"`+enDoc+`"}`); got.Lang != "en" {
|
||||||
|
t.Fatalf("word from an English document: lang=%q, want en", got.Lang)
|
||||||
|
}
|
||||||
|
if got := capture("orphan", `{"word":"orphan"}`); got.Lang != "" {
|
||||||
|
t.Fatalf("word with no document: lang=%q, want empty", got.Lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A client that names a language is ignored: the document is the authority,
|
||||||
|
// the same way runPass never lets the request pick its own target.
|
||||||
|
if got := capture("comum", `{"word":"comum","lang":"en","doc_id":"`+pairDoc+`"}`); got.Lang != "pair" {
|
||||||
|
t.Fatalf("client-supplied lang should not win: lang=%q, want pair", got.Lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecaptureOutsideADocumentKeepsItsLanguage pins the one asymmetry in the
|
||||||
|
// upsert. Re-looking-up a word refreshes its context, but a lookup made with no
|
||||||
|
// document carries no verdict — and relabelling a Portuguese card English
|
||||||
|
// because she checked the word again from the search box would silently move it
|
||||||
|
// to the wrong voice. lang travels with doc_id, or it doesn't travel.
|
||||||
|
func TestRecaptureOutsideADocumentKeepsItsLanguage(t *testing.T) {
|
||||||
|
srv, database := newTestServer(t)
|
||||||
|
var docID string
|
||||||
|
if err := database.QueryRow(
|
||||||
|
`INSERT INTO documents (user_id, content_text, doc_lang) VALUES (?, 'olá', 'pair') RETURNING id`,
|
||||||
|
db.LocalUserID,
|
||||||
|
).Scan(&docID); err != nil {
|
||||||
|
t.Fatalf("seed doc: %v", err)
|
||||||
|
}
|
||||||
|
if rec := do(t, srv, http.MethodPost, "/vocab",
|
||||||
|
`{"word":"saudade","gloss":"","doc_id":"`+docID+`"}`); rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"saudade","gloss":"longing"}`)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("recapture: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var w Word
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &w)
|
||||||
|
if w.Lang != "pair" {
|
||||||
|
t.Fatalf("recapture outside a document: lang=%q, want pair held", w.Lang)
|
||||||
|
}
|
||||||
|
if w.Gloss != "longing" {
|
||||||
|
t.Fatalf("recapture should still refresh the gloss, got %q", w.Gloss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ type Phrase struct {
|
|||||||
Meaning string // why it's better — the suggestion's explanation
|
Meaning string // why it's better — the suggestion's explanation
|
||||||
Example string // the sentence she met it in, already corrected
|
Example string // the sentence she met it in, already corrected
|
||||||
DocID *string // where, so "where did I see this?" stays one tap
|
DocID *string // where, so "where did I see this?" stays one tap
|
||||||
|
// Lang is the document's verdict ('' | 'en' | 'pair'), because the chunk is
|
||||||
|
// lifted out of her own prose and is therefore in whatever language that
|
||||||
|
// prose is. See migration 0018.
|
||||||
|
Lang string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phrase-card caps. A collocation is a short chunk; anything longer is a
|
// Phrase-card caps. A collocation is a short chunk; anything longer is a
|
||||||
@@ -84,13 +88,14 @@ func Plant(ex Execer, userID string, p Phrase) (bool, error) {
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
res, err := ex.Exec(
|
res, err := ex.Exec(
|
||||||
`INSERT INTO vocab_words (user_id, word, gloss, definition, phonetic, example, doc_id, due_at, interval_days)
|
`INSERT INTO vocab_words (user_id, word, gloss, definition, phonetic, example, doc_id, lang, due_at, interval_days)
|
||||||
VALUES (?, ?, '', ?, '', ?, ?, datetime('now', '+1 day'), 1)
|
VALUES (?, ?, '', ?, '', ?, ?, ?, datetime('now', '+1 day'), 1)
|
||||||
ON CONFLICT(user_id, word) DO NOTHING`,
|
ON CONFLICT(user_id, word) DO NOTHING`,
|
||||||
userID, key,
|
userID, key,
|
||||||
clamp(strings.TrimSpace(p.Meaning), maxDefinitionLen),
|
clamp(strings.TrimSpace(p.Meaning), maxDefinitionLen),
|
||||||
clamp(strings.TrimSpace(p.Example), maxExampleLen),
|
clamp(strings.TrimSpace(p.Example), maxExampleLen),
|
||||||
p.DocID,
|
p.DocID,
|
||||||
|
p.Lang,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
|
|||||||
+41
-3
@@ -1,5 +1,14 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { api, type DocSummary, type DocUpdate, type Document, type Suggestion, type Tag, type TagColor } from './api/client'
|
import {
|
||||||
|
api,
|
||||||
|
onDocLang,
|
||||||
|
type DocSummary,
|
||||||
|
type DocUpdate,
|
||||||
|
type Document,
|
||||||
|
type Suggestion,
|
||||||
|
type Tag,
|
||||||
|
type TagColor,
|
||||||
|
} from './api/client'
|
||||||
import { useAutoSave } from './hooks/useAutoSave'
|
import { useAutoSave } from './hooks/useAutoSave'
|
||||||
import { findingKey, useCheckpoint } from './hooks/useCheckpoint'
|
import { findingKey, useCheckpoint } from './hooks/useCheckpoint'
|
||||||
import { useSpellChecker } from './hooks/useSpellChecker'
|
import { useSpellChecker } from './hooks/useSpellChecker'
|
||||||
@@ -81,13 +90,41 @@ export default function App() {
|
|||||||
return wordCountRef.current === 0 && (t === '' || t === 'Untitled')
|
return wordCountRef.current === 0 && (t === '' || t === 'Untitled')
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null)
|
// The pass announces its verdict the moment it decides one, which is the only
|
||||||
|
// moment that is prompt enough: read-aloud is reached for when she has stopped
|
||||||
|
// typing, so waiting for the next save means waiting for a save that isn't
|
||||||
|
// coming. Registered once, and it updates the same one field the save path
|
||||||
|
// does — whichever arrives first wins, and they agree.
|
||||||
|
useEffect(() => {
|
||||||
|
onDocLang((docId, lang) =>
|
||||||
|
setCurrentDoc((prev) => (prev && prev.id === docId && prev.doc_lang !== lang ? { ...prev, doc_lang: lang } : prev)),
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Only `doc_lang` is lifted out of the save response, and only when it moved.
|
||||||
|
// It is the one field the server decides on its own — the checkpoint pass reads
|
||||||
|
// the whole document and writes back whether it is English or hers — so it is
|
||||||
|
// the one field that would otherwise go stale under her while she writes. Read
|
||||||
|
// -aloud is what notices: a Portuguese paragraph read in an American voice.
|
||||||
|
// Everything else in the response is what the client just sent, and copying it
|
||||||
|
// back mid-keystroke would be a way to lose a character, not to gain one.
|
||||||
|
const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null, (saved) =>
|
||||||
|
setCurrentDoc((prev) =>
|
||||||
|
prev && prev.id === saved.id && prev.doc_lang !== saved.doc_lang ? { ...prev, doc_lang: saved.doc_lang } : prev,
|
||||||
|
),
|
||||||
|
)
|
||||||
// The Chinese word list, for a writer going the other way through the zh pair.
|
// The Chinese word list, for a writer going the other way through the zh pair.
|
||||||
// Gated on the account's own setting rather than on anything in the text: a
|
// Gated on the account's own setting rather than on anything in the text: a
|
||||||
// Mandarin native drafting English quotes Chinese constantly, and none of that
|
// Mandarin native drafting English quotes Chinese constantly, and none of that
|
||||||
// is what segmentation is for. Declared above the checkpoint because the
|
// is what segmentation is for. Declared above the checkpoint because the
|
||||||
// offline 错别字 pass reads it.
|
// offline 错别字 pass reads it.
|
||||||
const segmenter = useSegmenter(me?.direction === 'learning_pair')
|
//
|
||||||
|
// Both halves of the gate matter now that Chinese is not the only pair with a
|
||||||
|
// learner direction. `learning_pair` alone used to imply zh; a writer learning
|
||||||
|
// Portuguese is also learning_pair and has no use for a megabyte of Chinese
|
||||||
|
// word list — nor for the hanzi hover it turns on, which would ask /api/hanzi
|
||||||
|
// about Portuguese words.
|
||||||
|
const segmenter = useSegmenter(me?.direction === 'learning_pair' && me?.pair_lang === 'zh')
|
||||||
|
|
||||||
const {
|
const {
|
||||||
suggestions,
|
suggestions,
|
||||||
@@ -615,6 +652,7 @@ export default function App() {
|
|||||||
<EditorCore
|
<EditorCore
|
||||||
key={`${currentDoc.id}:${editorEpoch}`}
|
key={`${currentDoc.id}:${editorEpoch}`}
|
||||||
docId={currentDoc.id}
|
docId={currentDoc.id}
|
||||||
|
docLang={currentDoc.doc_lang}
|
||||||
initialContent={currentDoc.content}
|
initialContent={currentDoc.content}
|
||||||
onChange={handleEditorChange}
|
onChange={handleEditorChange}
|
||||||
segmenter={segmenter}
|
segmenter={segmenter}
|
||||||
|
|||||||
+40
-4
@@ -44,8 +44,18 @@ export interface Document {
|
|||||||
// When true, this document's automatic snapshots are never pruned, so its
|
// When true, this document's automatic snapshots are never pruned, so its
|
||||||
// full writing trail survives as authorship evidence (see the passport).
|
// full writing trail survives as authorship evidence (see the passport).
|
||||||
preserve_history: boolean
|
preserve_history: boolean
|
||||||
|
// Which language this document is written in, as decided server-side by the
|
||||||
|
// checkpoint pass: '' | 'en' | 'pair' ('' reads as English). Read-only — the
|
||||||
|
// editor never sends it. It is here so read-aloud can use the right voice on a
|
||||||
|
// document written in her own language.
|
||||||
|
doc_lang: DocLang
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The document-language verdict, shared by documents and garden cards. 'pair'
|
||||||
|
// names the writer's own language rather than a language code, so changing her
|
||||||
|
// pair re-reads her documents instead of stranding a stale name on them.
|
||||||
|
export type DocLang = '' | 'en' | 'pair'
|
||||||
|
|
||||||
// Fields the editor sends on auto-save. All optional so a rename can send title
|
// Fields the editor sends on auto-save. All optional so a rename can send title
|
||||||
// alone; the editor sends the full set.
|
// alone; the editor sends the full set.
|
||||||
export interface DocUpdate {
|
export interface DocUpdate {
|
||||||
@@ -125,6 +135,9 @@ export interface VocabWord {
|
|||||||
phonetic: string
|
phonetic: string
|
||||||
example: string
|
example: string
|
||||||
doc_id: string | null
|
doc_id: string | null
|
||||||
|
// The language of the document this word was met in — the card's own language
|
||||||
|
// (migration 0018). Read-aloud needs it: "comum" is unguessable from letters.
|
||||||
|
lang: DocLang
|
||||||
due_at: string
|
due_at: string
|
||||||
interval_days: number
|
interval_days: number
|
||||||
ease: number
|
ease: number
|
||||||
@@ -263,11 +276,34 @@ function signedOut(): UnauthorizedError {
|
|||||||
return new UnauthorizedError()
|
return new UnauthorizedError()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
// The document-language verdict is decided by the checkpoint pass, so the pass's
|
||||||
|
// own response is the first moment the client can know it. It rides on a header
|
||||||
|
// (the pass answers with a bare array of suggestions, and every caller reads it
|
||||||
|
// as one), and reaches the app through a handler registered here — the same
|
||||||
|
// shape onUnauthorized already uses, for the same reason: it is one fact from
|
||||||
|
// deep inside a request that a component several layers up needs.
|
||||||
|
//
|
||||||
|
// Without it the editor learns the verdict only from a document save, which is
|
||||||
|
// always one save behind the pass — and read-aloud is reached for precisely when
|
||||||
|
// she has stopped typing and no further save is coming.
|
||||||
|
let docLangHandler: ((docId: string, lang: DocLang) => void) | null = null
|
||||||
|
|
||||||
|
export function onDocLang(handler: (docId: string, lang: DocLang) => void) {
|
||||||
|
docLangHandler = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// `verdictFor` names the document whose language this response may announce.
|
||||||
|
// Only the three pass endpoints pass it; everything else has no verdict to carry
|
||||||
|
// and never touches the handler.
|
||||||
|
async function req<T>(path: string, init?: RequestInit, verdictFor?: string): Promise<T> {
|
||||||
const res = await fetch(`/api${path}`, {
|
const res = await fetch(`/api${path}`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
...init,
|
...init,
|
||||||
})
|
})
|
||||||
|
if (verdictFor && res.ok) {
|
||||||
|
const lang = res.headers.get('X-Petal-Doc-Lang')
|
||||||
|
if (lang === '' || lang === 'en' || lang === 'pair') docLangHandler?.(verdictFor, lang)
|
||||||
|
}
|
||||||
if (res.status === 401) throw signedOut()
|
if (res.status === 401) throw signedOut()
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const detail = await res.text().catch(() => '')
|
const detail = await res.text().catch(() => '')
|
||||||
@@ -309,14 +345,14 @@ export const api = {
|
|||||||
// Rate-limited per document server-side (returns the existing set if too soon).
|
// Rate-limited per document server-side (returns the existing set if too soon).
|
||||||
// Both passes return the UNIFIED pending set (grammar + voice), so the client
|
// Both passes return the UNIFIED pending set (grammar + voice), so the client
|
||||||
// never drops one family's highlights when the other refreshes.
|
// never drops one family's highlights when the other refreshes.
|
||||||
checkDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/check`, { method: 'POST' }),
|
checkDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/check`, { method: 'POST' }, id),
|
||||||
// Voice-consistency pass: whole-document, explicit-action, slower. Returns the
|
// Voice-consistency pass: whole-document, explicit-action, slower. Returns the
|
||||||
// unified pending set too. Rate-limited per document server-side.
|
// unified pending set too. Rate-limited per document server-side.
|
||||||
voiceDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/voice`, { method: 'POST' }),
|
voiceDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/voice`, { method: 'POST' }, id),
|
||||||
// Collocation coach: whole-document, explicit-action pass flagging non-native
|
// Collocation coach: whole-document, explicit-action pass flagging non-native
|
||||||
// word pairings ("do a decision" → "make a decision"). Returns the unified
|
// word pairings ("do a decision" → "make a decision"). Returns the unified
|
||||||
// pending set too. Rate-limited per document server-side.
|
// pending set too. Rate-limited per document server-side.
|
||||||
collocationDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/collocation`, { method: 'POST' }),
|
collocationDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/collocation`, { method: 'POST' }, id),
|
||||||
// Mechanics pass: persist the client-detected deterministic fixes as the
|
// Mechanics pass: persist the client-detected deterministic fixes as the
|
||||||
// 'mechanics' family and return the unified pending set. Not rate-limited (it's
|
// 'mechanics' family and return the unified pending set. Not rate-limited (it's
|
||||||
// free, local detection); runs alongside the grammar checkpoint.
|
// free, local detection); runs alongside the grammar checkpoint.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
import { nativeLang, speak, stopSpeech } from './speech'
|
import { docLang, nativeLang, speak, stopSpeech } from './speech'
|
||||||
import { resetPackForTests, setPackLang } from '../i18n'
|
import { resetPackForTests, setPackLang } from '../i18n'
|
||||||
|
|
||||||
// Read-aloud has two jobs beyond "make a sound": ask for the right pace, and ask
|
// Read-aloud has two jobs beyond "make a sound": ask for the right pace, and ask
|
||||||
@@ -85,3 +85,33 @@ describe('nativeLang', () => {
|
|||||||
expect(bodies.at(-1)).toMatchObject({ text: 'chat', lang: 'fr-FR' })
|
expect(bodies.at(-1)).toMatchObject({ text: 'chat', lang: 'fr-FR' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('docLang', () => {
|
||||||
|
// The document's verdict is the answer for a passage lifted out of it, because
|
||||||
|
// for a Latin pair there is no other answer available: an English sentence and
|
||||||
|
// a Portuguese one are the same letters.
|
||||||
|
it('reads a flipped document in her own language', () => {
|
||||||
|
setPackLang('pt-PT')
|
||||||
|
expect(docLang('Ontem foi difícil.', 'pair')).toBe('pt-PT')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads an English document in English, and treats the backfill as English', () => {
|
||||||
|
setPackLang('pt-PT')
|
||||||
|
expect(docLang('Yesterday was hard.', 'en')).toBe('en-US')
|
||||||
|
expect(docLang('Yesterday was hard.', '')).toBe('en-US')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets the script win over the verdict, so quoted Chinese is never spelled out', () => {
|
||||||
|
// An English document quoting Chinese is 'en' by verdict, and the English
|
||||||
|
// voice reads Han characters one "Chinese letter" at a time — the one
|
||||||
|
// failure worse than silence.
|
||||||
|
setPackLang('zh')
|
||||||
|
expect(docLang('你好世界', 'en')).toBe('zh-CN')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is what the editor selection and the garden card ask for', () => {
|
||||||
|
setPackLang('fr')
|
||||||
|
speak('Le chat dort.', docLang('Le chat dort.', 'pair'))
|
||||||
|
expect(bodies.at(-1)).toMatchObject({ text: 'Le chat dort.', lang: 'fr-FR' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -90,6 +90,24 @@ export function nativeLang(): string {
|
|||||||
return pack().locale
|
return pack().locale
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// docLang turns a document-language verdict ('' | 'en' | 'pair', decided
|
||||||
|
// server-side — see internal/suggestions/doclang.go) into a locale for a passage
|
||||||
|
// taken out of that document. It is what the editor's read-aloud and the garden's
|
||||||
|
// review card ask instead of guessing.
|
||||||
|
//
|
||||||
|
// The script test still wins, and that is not redundant with the verdict. A
|
||||||
|
// Chinese sentence quoted inside an English document is 'en' by verdict and
|
||||||
|
// still has to be read by the Chinese voice: the English voice spells Han
|
||||||
|
// characters out one "Chinese letter" at a time, which is the one failure loud
|
||||||
|
// enough to be worse than no audio. In the other direction there is nothing to
|
||||||
|
// test — an English sentence inside Portuguese prose looks exactly like the
|
||||||
|
// Portuguese around it — so the document's verdict is the only answer available,
|
||||||
|
// and it is the answer this phase decided on.
|
||||||
|
export function docLang(text: string, verdict: string): string {
|
||||||
|
if (CJK.test(text)) return 'zh-CN'
|
||||||
|
return verdict === 'pair' ? pack().locale : 'en-US'
|
||||||
|
}
|
||||||
|
|
||||||
// speak reads `text` aloud, cancelling anything already in flight so rapid taps
|
// speak reads `text` aloud, cancelling anything already in flight so rapid taps
|
||||||
// don't queue up. `lang` defaults to a guess from the text (Chinese vs English)
|
// don't queue up. `lang` defaults to a guess from the text (Chinese vs English)
|
||||||
// so callers can just pass the selection; pass an explicit locale to override.
|
// so callers can just pass the selection; pass an explicit locale to override.
|
||||||
|
|||||||
@@ -88,13 +88,18 @@ export function PetalCompanion({
|
|||||||
const [pickerOpen, setPickerOpen] = useState(false)
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
const rootRef = useRef<HTMLDivElement>(null)
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
const badgeRef = useRef<HTMLButtonElement>(null)
|
const badgeRef = useRef<HTMLButtonElement>(null)
|
||||||
|
// Stand-in for the badge's corner, used only for measuring — see
|
||||||
|
// useCardOverlap. It sits exactly where the badge sits but never bobs, shrinks
|
||||||
|
// or hovers, so what the mascot yields to can't depend on whether it is
|
||||||
|
// currently yielding.
|
||||||
|
const probeRef = useRef<HTMLSpanElement>(null)
|
||||||
|
|
||||||
// When suggestion cards stack down into the corner, the kitten fades to
|
// When suggestion cards stack down into the corner, the kitten fades to
|
||||||
// translucent and shrinks a step so the card stays readable and clickable.
|
// translucent and shrinks a step so the card stays readable and clickable.
|
||||||
// It wakes back up whenever it has something to say (bubble) or is being
|
// It wakes back up whenever it has something to say (bubble) or is being
|
||||||
// interacted with (picker open) — except under an open History or Garden
|
// interacted with (picker open) — except under an open History or Garden
|
||||||
// panel, where even a cheer would cover the controls she just reached for.
|
// panel, where even a cheer would cover the controls she just reached for.
|
||||||
const crowded = useCardOverlap(badgeRef)
|
const crowded = useCardOverlap(probeRef)
|
||||||
const faded = crowded.modal || (crowded.cards && !pickerOpen && !bubble)
|
const faded = crowded.modal || (crowded.cards && !pickerOpen && !bubble)
|
||||||
|
|
||||||
// Awake companions (no sleeping clip) don't visibly nap — when the engine
|
// Awake companions (no sleeping clip) don't visibly nap — when the engine
|
||||||
@@ -135,7 +140,7 @@ export function PetalCompanion({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
className="pointer-events-none fixed bottom-4 right-4 z-40 flex flex-col items-end gap-2"
|
className="petal-corner pointer-events-none fixed bottom-4 right-4 z-40 flex flex-col items-end gap-2"
|
||||||
>
|
>
|
||||||
{pickerOpen && (
|
{pickerOpen && (
|
||||||
<div
|
<div
|
||||||
@@ -251,6 +256,16 @@ export function PetalCompanion({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<span
|
||||||
|
ref={probeRef}
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute bottom-0 right-0"
|
||||||
|
style={{
|
||||||
|
width: 'var(--petal-companion-size, 9rem)',
|
||||||
|
height: 'var(--petal-companion-size, 9rem)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
ref={badgeRef}
|
ref={badgeRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, type RefObject } from 'react'
|
import { useEffect, useRef, useState, type RefObject } from 'react'
|
||||||
|
|
||||||
// How often to re-measure outside of scroll/resize events. Cards re-pack when
|
// How often to re-measure outside of scroll/resize events. Cards re-pack when
|
||||||
// suggestions arrive, expand, or get accepted — none of which fire an event we
|
// suggestions arrive, expand, or get accepted — none of which fire an event we
|
||||||
@@ -11,8 +11,20 @@ const POLL_MS = 500
|
|||||||
// means a future drawer is covered the day it's written, without a list to keep
|
// means a future drawer is covered the day it's written, without a list to keep
|
||||||
// in sync. Anything that doesn't actually reach the corner still won't trip the
|
// in sync. Anything that doesn't actually reach the corner still won't trip the
|
||||||
// rect test below.
|
// rect test below.
|
||||||
|
// How far a card must retreat before an already-yielded mascot comes back. Wide
|
||||||
|
// enough to cover a re-pack or a browser bar appearing, small enough that a card
|
||||||
|
// genuinely scrolled away still wakes it.
|
||||||
|
const HOLD_PX = 24
|
||||||
|
|
||||||
const CARD = '.petal-rail-card'
|
const CARD = '.petal-rail-card'
|
||||||
const MODAL = '[role="dialog"][aria-modal="true"]'
|
// The mobile sidebar drawer is named outright because it is the one overlay that
|
||||||
|
// isn't a dialog. It slides over the page behind a scrim exactly as History and
|
||||||
|
// Garden do, but it is the app's own navigation rather than something opened on
|
||||||
|
// purpose, so it carries no modal role for the selector above to catch — and the
|
||||||
|
// kitten sat in its bottom corner, over the last two rows of the language
|
||||||
|
// picker. On a 390px phone that put "Español" and "I am learning Português"
|
||||||
|
// under the halo: visibly there, and only partly tappable.
|
||||||
|
const MODAL = '[role="dialog"][aria-modal="true"], .petal-sidebar.petal-drawer-open'
|
||||||
|
|
||||||
export interface CardOverlap {
|
export interface CardOverlap {
|
||||||
// A suggestion card reaches the mascot. It should get out of the way, but may
|
// A suggestion card reaches the mascot. It should get out of the way, but may
|
||||||
@@ -25,11 +37,21 @@ export interface CardOverlap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reports what, if anything, the mascot should yield to at the given element.
|
// Reports what, if anything, the mascot should yield to at the given element.
|
||||||
|
//
|
||||||
|
// Pass the probe span, never the badge itself. The badge shrinks when it yields
|
||||||
|
// (.petal-companion-faded) and bobs continuously (petal-bob), and
|
||||||
|
// getBoundingClientRect reports the *transformed* box — so measuring the badge
|
||||||
|
// lets it shrink out of its own overlap test, wake up, overlap again, and pulse
|
||||||
|
// forever against a card resting at its edge. The probe holds the corner box
|
||||||
|
// still whatever the mascot is doing, which is what makes the test settle.
|
||||||
// Used to fade the corner mascot out of the way when a card or panel reaches
|
// Used to fade the corner mascot out of the way when a card or panel reaches
|
||||||
// into its corner, so nothing is ever hidden (or made unclickable) by the
|
// into its corner, so nothing is ever hidden (or made unclickable) by the
|
||||||
// kitten.
|
// kitten.
|
||||||
export function useCardOverlap(ref: RefObject<HTMLElement | null>): CardOverlap {
|
export function useCardOverlap(ref: RefObject<HTMLElement | null>): CardOverlap {
|
||||||
const [overlap, setOverlap] = useState<CardOverlap>({ cards: false, modal: false })
|
const [overlap, setOverlap] = useState<CardOverlap>({ cards: false, modal: false })
|
||||||
|
// check() runs on a timer and reads the previous answer, which state alone
|
||||||
|
// wouldn't hand it without re-subscribing every render.
|
||||||
|
const current = useRef(overlap)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let raf = 0
|
let raf = 0
|
||||||
@@ -38,16 +60,28 @@ export function useCardOverlap(ref: RefObject<HTMLElement | null>): CardOverlap
|
|||||||
const el = ref.current
|
const el = ref.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const r = el.getBoundingClientRect()
|
const r = el.getBoundingClientRect()
|
||||||
const hits = (selector: string) => {
|
// Cards settle a pixel or two from the corner all the time — the rail
|
||||||
|
// re-packs, the window resizes, a browser bar appears. Yielding is a
|
||||||
|
// visible move, so once yielded, hold it until the card has clearly gone:
|
||||||
|
// decide on a box grown by HOLD_PX rather than flipping on the exact edge.
|
||||||
|
const hits = (selector: string, held: boolean) => {
|
||||||
|
const pad = held ? HOLD_PX : 0
|
||||||
for (const other of document.querySelectorAll(selector)) {
|
for (const other of document.querySelectorAll(selector)) {
|
||||||
const b = other.getBoundingClientRect()
|
const b = other.getBoundingClientRect()
|
||||||
if (b.left < r.right && b.right > r.left && b.top < r.bottom && b.bottom > r.top) {
|
if (
|
||||||
|
b.left < r.right + pad &&
|
||||||
|
b.right > r.left - pad &&
|
||||||
|
b.top < r.bottom + pad &&
|
||||||
|
b.bottom > r.top - pad
|
||||||
|
) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const next = { cards: hits(CARD), modal: hits(MODAL) }
|
const prev = current.current
|
||||||
|
const next = { cards: hits(CARD, prev.cards), modal: hits(MODAL, prev.modal) }
|
||||||
|
current.current = next
|
||||||
// Same-value object identity would re-render on every poll tick.
|
// Same-value object identity would re-render on every poll tick.
|
||||||
setOverlap((prev) =>
|
setOverlap((prev) =>
|
||||||
prev.cards === next.cards && prev.modal === next.modal ? prev : next,
|
prev.cards === next.cards && prev.modal === next.modal ? prev : next,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useScrollEdge } from './useScrollEdge'
|
||||||
|
|
||||||
// ChromeStrip is the row of document pills (tone, history, export) on a screen
|
// ChromeStrip is the row of document pills (tone, history, export) on a screen
|
||||||
// too narrow to hold them. It scrolls within itself rather than letting the
|
// too narrow to hold them. It scrolls within itself rather than letting the
|
||||||
@@ -15,7 +15,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
// The fade is a mask rather than a gradient overlay so it works on whatever is
|
// The fade is a mask rather than a gradient overlay so it works on whatever is
|
||||||
// behind it (the cream page, the night theme, a falling petal) without knowing
|
// behind it (the cream page, the night theme, a falling petal) without knowing
|
||||||
// the background colour.
|
// the background colour.
|
||||||
type Edge = 'none' | 'left' | 'right' | 'both'
|
//
|
||||||
|
// The measuring itself lives in useScrollEdge, shared with the formatting
|
||||||
|
// toolbar — which has to say the same thing for the same reason.
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string
|
className?: string
|
||||||
@@ -23,36 +25,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ChromeStrip({ className = '', children }: Props) {
|
export function ChromeStrip({ className = '', children }: Props) {
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const { ref, edge } = useScrollEdge<HTMLDivElement>()
|
||||||
const [edge, setEdge] = useState<Edge>('none')
|
|
||||||
|
|
||||||
// A pixel of slack: scrollLeft is fractional under browser zoom and on
|
|
||||||
// high-DPI screens, so an exactly-scrolled-to-the-end strip can report
|
|
||||||
// something like 0.5px remaining and fade an edge that has nothing behind it.
|
|
||||||
const measure = useCallback(() => {
|
|
||||||
const el = ref.current
|
|
||||||
if (!el) return
|
|
||||||
const more = el.scrollWidth - el.clientWidth - el.scrollLeft > 1
|
|
||||||
const less = el.scrollLeft > 1
|
|
||||||
setEdge(less && more ? 'both' : less ? 'left' : more ? 'right' : 'none')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current
|
|
||||||
if (!el) return
|
|
||||||
measure()
|
|
||||||
el.addEventListener('scroll', measure, { passive: true })
|
|
||||||
// Both halves of "does it fit" can change without a scroll: the window
|
|
||||||
// resizes, or the labels themselves change when she switches her pair
|
|
||||||
// language and every pill in the row grows or shrinks at once.
|
|
||||||
const ro = new ResizeObserver(measure)
|
|
||||||
ro.observe(el)
|
|
||||||
for (const child of Array.from(el.children)) ro.observe(child)
|
|
||||||
return () => {
|
|
||||||
el.removeEventListener('scroll', measure)
|
|
||||||
ro.disconnect()
|
|
||||||
}
|
|
||||||
}, [measure])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} data-edge={edge} className={`petal-chrome-strip ${className}`}>
|
<div ref={ref} data-edge={edge} className={`petal-chrome-strip ${className}`}>
|
||||||
|
|||||||
@@ -32,9 +32,11 @@ import { Typography } from './Typography'
|
|||||||
import { Composition } from './Composition'
|
import { Composition } from './Composition'
|
||||||
import { RewritePreview, type RewriteStatus } from './RewritePreview'
|
import { RewritePreview, type RewriteStatus } from './RewritePreview'
|
||||||
import { planBatch } from './acceptBatch'
|
import { planBatch } from './acceptBatch'
|
||||||
import { api, type Suggestion, type SuggestionType, type WordInfo } from '../../api/client'
|
import { entryId, idAfterRemoval, stepId, type Direction, type Span } from './triage'
|
||||||
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 type { SpellChecker } from '../../hooks/useSpellChecker'
|
||||||
|
import { fromIME } from '../../lib/ime'
|
||||||
import type { Segmenter } from '../../lib/segment'
|
import type { Segmenter } from '../../lib/segment'
|
||||||
import { hanziWordAt, hanziToWordInfo, hanziPinyin } from './hanziWord'
|
import { hanziWordAt, hanziToWordInfo, hanziPinyin } from './hanziWord'
|
||||||
import { usePack } from '../../i18n'
|
import { usePack } from '../../i18n'
|
||||||
@@ -63,6 +65,13 @@ export interface EditorChange {
|
|||||||
interface Props {
|
interface Props {
|
||||||
// Changing docId reloads the editor with that document's content.
|
// Changing docId reloads the editor with that document's content.
|
||||||
docId: string
|
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
|
initialContent: string
|
||||||
onChange: (change: EditorChange) => void
|
onChange: (change: EditorChange) => void
|
||||||
// LLM suggestions to highlight; accept/dismiss notify the parent for the API
|
// LLM suggestions to highlight; accept/dismiss notify the parent for the API
|
||||||
@@ -204,6 +213,9 @@ interface HoverState {
|
|||||||
suggestion: Suggestion
|
suggestion: Suggestion
|
||||||
top: number
|
top: number
|
||||||
left: number
|
left: number
|
||||||
|
// Opened by a keystroke rather than a pointer: the card takes focus and
|
||||||
|
// answers the triage keys itself (item 8).
|
||||||
|
keyboard?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// The inline hover gloss: the word under the resting pointer, its Chinese
|
// The inline hover gloss: the word under the resting pointer, its Chinese
|
||||||
@@ -250,6 +262,7 @@ interface RewriteState {
|
|||||||
// decoration layer. Hovering a highlight opens its SuggestionCard.
|
// decoration layer. Hovering a highlight opens its SuggestionCard.
|
||||||
export function EditorCore({
|
export function EditorCore({
|
||||||
docId,
|
docId,
|
||||||
|
docLang,
|
||||||
initialContent,
|
initialContent,
|
||||||
onChange,
|
onChange,
|
||||||
suggestions,
|
suggestions,
|
||||||
@@ -646,7 +659,7 @@ export function EditorCore({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const openCardFor = useCallback(
|
const openCardFor = useCallback(
|
||||||
(id: string, el: HTMLElement) => {
|
(id: string, el: HTMLElement, keyboard = false) => {
|
||||||
const wrapper = wrapperRef.current
|
const wrapper = wrapperRef.current
|
||||||
if (!wrapper) return
|
if (!wrapper) return
|
||||||
const suggestion = suggestions.find((s) => s.id === id)
|
const suggestion = suggestions.find((s) => s.id === id)
|
||||||
@@ -664,7 +677,7 @@ export function EditorCore({
|
|||||||
setHover((prev) => {
|
setHover((prev) => {
|
||||||
// Moving to a different highlight resets any Ask Petal pin.
|
// Moving to a different highlight resets any Ask Petal pin.
|
||||||
if (prev && prev.suggestion.id !== suggestion.id) setPinned(false)
|
if (prev && prev.suggestion.id !== suggestion.id) setPinned(false)
|
||||||
return { suggestion, top, left }
|
return { suggestion, top, left, keyboard }
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[suggestions],
|
[suggestions],
|
||||||
@@ -721,6 +734,140 @@ export function EditorCore({
|
|||||||
|
|
||||||
const keepOpen = useCallback(() => clearTimeout(closeTimer.current), [])
|
const keepOpen = useCallback(() => clearTimeout(closeTimer.current), [])
|
||||||
|
|
||||||
|
// ——— Keyboard triage (item 8) ———
|
||||||
|
//
|
||||||
|
// The whole queue can be walked, answered and left without a pointer:
|
||||||
|
// Ctrl/Cmd+. and Ctrl/Cmd+, step through the underlines from anywhere in the
|
||||||
|
// editor, and the card that opens takes focus and answers Tab / Enter / Del /
|
||||||
|
// Esc itself (SuggestionCard). It is the *anchored* card in both layouts,
|
||||||
|
// rail or no rail — item 7's finding, that the popover at the word is the
|
||||||
|
// primary surface, is what lets one keyboard flow cover both.
|
||||||
|
//
|
||||||
|
// Why a chord and not the item's bare Tab or n/p: this is a text editor, and
|
||||||
|
// an unmodified letter is a letter. Tab is available only once the card holds
|
||||||
|
// focus, which is exactly where the item asks for it, and getting there needs
|
||||||
|
// a key that is safe to press mid-sentence — in the middle of a Chinese
|
||||||
|
// composition, even, which is why the IME guard is here too.
|
||||||
|
|
||||||
|
// Where triage lands once the open card is answered. Recorded before the
|
||||||
|
// action, because the queue has to be read while the answered card is still in
|
||||||
|
// it; consumed when the new suggestion list arrives. A `null` id means the
|
||||||
|
// queue is empty and triage is over.
|
||||||
|
const triageNextRef = useRef<{ id: string | null } | null>(null)
|
||||||
|
|
||||||
|
// Every underline on screen, in document order, with its document position.
|
||||||
|
// The queue is the decorations rather than the suggestion list: a suggestion
|
||||||
|
// the editor couldn't anchor has no underline, and a triage stop she cannot
|
||||||
|
// see is worse than one she never visits.
|
||||||
|
const orderedSpans = useCallback((): Span[] => {
|
||||||
|
const wrapper = wrapperRef.current
|
||||||
|
if (!wrapper || !editor) return []
|
||||||
|
const spans: Span[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
wrapper.querySelectorAll<HTMLElement>('.petal-suggestion[data-suggestion-id]').forEach((el) => {
|
||||||
|
const id = el.getAttribute('data-suggestion-id')
|
||||||
|
if (!id || seen.has(id)) return
|
||||||
|
seen.add(id)
|
||||||
|
let pos = 0
|
||||||
|
try {
|
||||||
|
pos = editor.view.posAtDOM(el, 0)
|
||||||
|
} catch {
|
||||||
|
// A node the view no longer owns. Only the caret-relative entry point
|
||||||
|
// reads `pos`; document order is what walking uses, and that still holds.
|
||||||
|
}
|
||||||
|
spans.push({ id, pos })
|
||||||
|
})
|
||||||
|
return spans
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
// Open a suggestion as a triage stop: scrolled to, emphasized, and focused.
|
||||||
|
// False means its underline is gone, which is triage's cue to stop.
|
||||||
|
const openTriageAt = useCallback(
|
||||||
|
(id: string): boolean => {
|
||||||
|
const el = wrapperRef.current?.querySelector(
|
||||||
|
`.petal-suggestion[data-suggestion-id="${CSS.escape(id)}"]`,
|
||||||
|
) as HTMLElement | null
|
||||||
|
if (!el) return false
|
||||||
|
openCardFor(id, el, true)
|
||||||
|
setActiveId(id)
|
||||||
|
// The anchored card carries everything the rail card does, so expanding a
|
||||||
|
// second copy of it in the margin would be the redundancy item 7 refused.
|
||||||
|
setRailExpandedId(null)
|
||||||
|
el.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
[openCardFor],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Leave triage and hand the keyboard back to the text, with the caret just
|
||||||
|
// past the span she was reading about — so the next thing she types continues
|
||||||
|
// the sentence she was looking at rather than wherever she last clicked.
|
||||||
|
const exitTriage = useCallback(() => {
|
||||||
|
const open = hover
|
||||||
|
closeCard()
|
||||||
|
setActiveId(null)
|
||||||
|
if (!editor) return
|
||||||
|
const range = open ? findRange(editor.state.doc, open.suggestion.original) : null
|
||||||
|
if (range) editor.chain().focus().setTextSelection(range.to).run()
|
||||||
|
else editor.commands.focus()
|
||||||
|
}, [editor, hover, closeCard])
|
||||||
|
|
||||||
|
const stepTriage = useCallback(
|
||||||
|
(dir: Direction) => {
|
||||||
|
const spans = orderedSpans()
|
||||||
|
if (spans.length === 0) return
|
||||||
|
const next = hover
|
||||||
|
? stepId(
|
||||||
|
spans.map((s) => s.id),
|
||||||
|
hover.suggestion.id,
|
||||||
|
dir,
|
||||||
|
)
|
||||||
|
: entryId(spans, editor?.state.selection.from ?? 0, dir)
|
||||||
|
if (next) openTriageAt(next)
|
||||||
|
},
|
||||||
|
[orderedSpans, openTriageAt, hover, editor],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Note the stop after the one being answered, while its underline is still in
|
||||||
|
// the queue. `answered[0]` is the card she is on; an Accept-all settles a whole
|
||||||
|
// category at once, and triage resumes after *her* card, not after whichever
|
||||||
|
// of the batch happened to be last.
|
||||||
|
const queueTriageAfter = useCallback(
|
||||||
|
(answered: string[]) => {
|
||||||
|
const gone = new Set(answered)
|
||||||
|
const remaining = new Set(suggestions.filter((s) => !gone.has(s.id)).map((s) => s.id))
|
||||||
|
triageNextRef.current = {
|
||||||
|
id: idAfterRemoval(
|
||||||
|
orderedSpans().map((s) => s.id),
|
||||||
|
answered[0],
|
||||||
|
remaining,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[orderedSpans, suggestions],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Open the next stop once the answered suggestion has actually left the list
|
||||||
|
// and the decorations have repainted around the edit. An empty queue ends
|
||||||
|
// triage the same way Escape does, which is the point at which the document is
|
||||||
|
// fully triaged and she is back in her text.
|
||||||
|
useEffect(() => {
|
||||||
|
const pending = triageNextRef.current
|
||||||
|
if (!pending) return
|
||||||
|
triageNextRef.current = null
|
||||||
|
const { id } = pending
|
||||||
|
if (!id) {
|
||||||
|
exitTriage()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!openTriageAt(id)) exitTriage()
|
||||||
|
})
|
||||||
|
// Driven by the suggestion list alone: the callbacks are rebuilt in the same
|
||||||
|
// render, so this closure is never the stale one.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [suggestions])
|
||||||
|
|
||||||
// Accept applies the replacement to the document, plays a little confetti
|
// Accept applies the replacement to the document, plays a little confetti
|
||||||
// burst over the flagged text, then notifies the parent. The confetti is
|
// burst over the flagged text, then notifies the parent. The confetti is
|
||||||
// anchored to the highlight itself (captured before the replacement removes it),
|
// anchored to the highlight itself (captured before the replacement removes it),
|
||||||
@@ -748,6 +895,7 @@ export function EditorCore({
|
|||||||
const handleAccept = useCallback(
|
const handleAccept = useCallback(
|
||||||
(s: Suggestion) => {
|
(s: Suggestion) => {
|
||||||
let burst = burstAt(s.id)
|
let burst = burstAt(s.id)
|
||||||
|
if (hover?.keyboard) queueTriageAfter([s.id])
|
||||||
if (editor && s.replacement.trim() !== '') {
|
if (editor && s.replacement.trim() !== '') {
|
||||||
const range = findRange(editor.state.doc, s.original)
|
const range = findRange(editor.state.doc, s.original)
|
||||||
if (range) {
|
if (range) {
|
||||||
@@ -761,7 +909,7 @@ export function EditorCore({
|
|||||||
setRailExpandedId(null)
|
setRailExpandedId(null)
|
||||||
onAccept(s)
|
onAccept(s)
|
||||||
},
|
},
|
||||||
[editor, onAccept, closeCard, hover, burstAt, showConfetti],
|
[editor, onAccept, closeCard, hover, burstAt, showConfetti, queueTriageAfter],
|
||||||
)
|
)
|
||||||
|
|
||||||
// How many pending cards of each type could be accepted in one go. A card
|
// How many pending cards of each type could be accepted in one go. A card
|
||||||
@@ -794,6 +942,14 @@ export function EditorCore({
|
|||||||
const first = plan.steps[plan.steps.length - 1]
|
const first = plan.steps[plan.steps.length - 1]
|
||||||
const burst = first ? burstAt(first.suggestion.id) : null
|
const burst = first ? burstAt(first.suggestion.id) : null
|
||||||
|
|
||||||
|
// Resume after the card she pressed it on, not after the batch's last
|
||||||
|
// member — the queue she is walking is in document order, and the rest of
|
||||||
|
// the category may sit anywhere in it.
|
||||||
|
if (hover?.keyboard) {
|
||||||
|
const answered = settled.map((s) => s.id)
|
||||||
|
queueTriageAfter([hover.suggestion.id, ...answered.filter((id) => id !== hover.suggestion.id)])
|
||||||
|
}
|
||||||
|
|
||||||
if (plan.steps.length > 0) {
|
if (plan.steps.length > 0) {
|
||||||
let chain = editor.chain().focus()
|
let chain = editor.chain().focus()
|
||||||
for (const step of plan.steps) {
|
for (const step of plan.steps) {
|
||||||
@@ -806,15 +962,16 @@ export function EditorCore({
|
|||||||
setRailExpandedId(null)
|
setRailExpandedId(null)
|
||||||
onAcceptMany(settled)
|
onAcceptMany(settled)
|
||||||
},
|
},
|
||||||
[editor, suggestions, onAcceptMany, closeCard, burstAt, showConfetti],
|
[editor, suggestions, onAcceptMany, closeCard, burstAt, showConfetti, hover, queueTriageAfter],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDismiss = useCallback(
|
const handleDismiss = useCallback(
|
||||||
(s: Suggestion) => {
|
(s: Suggestion) => {
|
||||||
|
if (hover?.keyboard) queueTriageAfter([s.id])
|
||||||
closeCard()
|
closeCard()
|
||||||
onDismiss(s)
|
onDismiss(s)
|
||||||
},
|
},
|
||||||
[onDismiss, closeCard],
|
[onDismiss, closeCard, hover, queueTriageAfter],
|
||||||
)
|
)
|
||||||
|
|
||||||
// openMisspellAt resolves the word at a document position and, if the checker
|
// openMisspellAt resolves the word at a document position and, if the checker
|
||||||
@@ -949,7 +1106,17 @@ export function EditorCore({
|
|||||||
// actually knows (a real gloss or definition), so accidental lookups of
|
// actually knows (a real gloss or definition), so accidental lookups of
|
||||||
// typos or proper nouns don't clutter the garden. Looking words up IS
|
// typos or proper nouns don't clutter the garden. Looking words up IS
|
||||||
// the data source; this costs the writer nothing.
|
// the data source; this costs the writer nothing.
|
||||||
const known = !!info.gloss || info.definitions.length > 0
|
//
|
||||||
|
// "Knows" has to include the reverse reading, or a word of her own
|
||||||
|
// language can never be captured at all: the forward lookup of "carro"
|
||||||
|
// answers with nothing, and everything the card shows her comes out of
|
||||||
|
// `reverse`. Before Phase 28 that was academic, because every document
|
||||||
|
// was English; on a Portuguese document it silently emptied the garden
|
||||||
|
// of every word she actually met. Caught in a browser 2026-07-29 —
|
||||||
|
// right-clicking "carro" showed a full card and stored nothing.
|
||||||
|
const rev = info.reverse
|
||||||
|
const known =
|
||||||
|
!!info.gloss || info.definitions.length > 0 || !!rev?.gloss || (rev?.definitions?.length ?? 0) > 0
|
||||||
// Reflect the saved state optimistically so the heart shows 💚 the
|
// Reflect the saved state optimistically so the heart shows 💚 the
|
||||||
// moment a known word loads, rather than flashing 🤍 until the capture
|
// moment a known word loads, rather than flashing 🤍 until the capture
|
||||||
// round-trips. vocabId is filled in when recordVocab returns.
|
// round-trips. vocabId is filled in when recordVocab returns.
|
||||||
@@ -959,12 +1126,18 @@ export function EditorCore({
|
|||||||
.recordVocab({
|
.recordVocab({
|
||||||
word: range.word,
|
word: range.word,
|
||||||
gloss: info.gloss,
|
gloss: info.gloss,
|
||||||
definition: info.definitions[0]?.definition ?? '',
|
// `definition` is the English sense the review card falls back to
|
||||||
|
// when there is no gloss in her language — and for a word that *is*
|
||||||
|
// her language, the reverse gloss is exactly that: "carro" →
|
||||||
|
// "car; automobile; machine". The Portuguese monolingual definition
|
||||||
|
// underneath it explains the word in the language she already knows
|
||||||
|
// it in, which is not what a flashcard is for.
|
||||||
|
definition: info.definitions[0]?.definition ?? rev?.gloss ?? '',
|
||||||
// The garden's pronunciation field holds whichever this word has:
|
// The garden's pronunciation field holds whichever this word has:
|
||||||
// IPA for an English word, pinyin for a Chinese one. Both answer
|
// IPA for an English word, pinyin for a Chinese one. Both answer
|
||||||
// the same question on a review card — how do I say this — and a
|
// the same question on a review card — how do I say this — and a
|
||||||
// second column would only be a second thing to keep in sync.
|
// second column would only be a second thing to keep in sync.
|
||||||
phonetic: pinyin || info.phonetic,
|
phonetic: pinyin || info.phonetic || rev?.phonetic || '',
|
||||||
example,
|
example,
|
||||||
doc_id: docId,
|
doc_id: docId,
|
||||||
})
|
})
|
||||||
@@ -1265,11 +1438,21 @@ export function EditorCore({
|
|||||||
// into the editor's content anyway)
|
// into the editor's content anyway)
|
||||||
// Ctrl/Cmd+D — look up the word at the caret (the keyboard "right-click")
|
// Ctrl/Cmd+D — look up the word at the caret (the keyboard "right-click")
|
||||||
// Ctrl/Cmd+J — rewrite the current selection more naturally (✨更自然)
|
// Ctrl/Cmd+J — rewrite the current selection more naturally (✨更自然)
|
||||||
|
// Ctrl/Cmd+. — walk to the next suggestion (Ctrl/Cmd+, for the previous),
|
||||||
|
// which is how keyboard triage is entered from the text
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (!(e.ctrlKey || e.metaKey) || e.altKey) return
|
if (!(e.ctrlKey || e.metaKey) || e.altKey) return
|
||||||
const k = e.key.toLowerCase()
|
const k = e.key.toLowerCase()
|
||||||
|
if (k === '.' || k === ',') {
|
||||||
|
// Chinese IMEs use , and . to page their candidate window. While one is
|
||||||
|
// open the keystroke belongs to the composition, not to Petal.
|
||||||
|
if (fromIME(e)) return
|
||||||
|
e.preventDefault()
|
||||||
|
stepTriage(k === '.' ? 1 : -1)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (k === 'f') {
|
if (k === 'f') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setFindOpen(true)
|
setFindOpen(true)
|
||||||
@@ -1285,7 +1468,7 @@ export function EditorCore({
|
|||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [editor, openWordLookup, handleRewrite])
|
}, [editor, openWordLookup, handleRewrite, stepTriage])
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
clearTimeout(closeTimer.current)
|
clearTimeout(closeTimer.current)
|
||||||
@@ -1373,8 +1556,10 @@ export function EditorCore({
|
|||||||
<SelectionBubble
|
<SelectionBubble
|
||||||
style={{ top: selection.top, left: selection.left, transform: 'translateY(calc(-100% - 8px))' }}
|
style={{ top: selection.top, left: selection.left, transform: 'translateY(calc(-100% - 8px))' }}
|
||||||
onRewrite={handleRewrite}
|
onRewrite={handleRewrite}
|
||||||
onSpeak={speechSupported() ? () => speak(selection.text) : null}
|
onSpeak={speechSupported() ? () => speak(selection.text, docLocale(selection.text, docLang)) : null}
|
||||||
onSpeakSlow={speechSupported() ? () => speak(selection.text, undefined, true) : null}
|
onSpeakSlow={
|
||||||
|
speechSupported() ? () => speak(selection.text, docLocale(selection.text, docLang), true) : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{rewrite && (
|
{rewrite && (
|
||||||
@@ -1397,6 +1582,7 @@ export function EditorCore({
|
|||||||
saved={wordInfo.saved}
|
saved={wordInfo.saved}
|
||||||
onToggleSave={toggleSaveWord}
|
onToggleSave={toggleSaveWord}
|
||||||
pinyin={wordInfo.pinyin}
|
pinyin={wordInfo.pinyin}
|
||||||
|
lang={docLocale(wordInfo.word, docLang)}
|
||||||
style={{ top: wordInfo.top, left: wordInfo.left }}
|
style={{ top: wordInfo.top, left: wordInfo.left }}
|
||||||
onReplace={replaceWord}
|
onReplace={replaceWord}
|
||||||
/>
|
/>
|
||||||
@@ -1422,6 +1608,9 @@ export function EditorCore({
|
|||||||
onPointerLeave={scheduleClose}
|
onPointerLeave={scheduleClose}
|
||||||
onExpandChange={setPinned}
|
onExpandChange={setPinned}
|
||||||
onExtent={setCardExtent}
|
onExtent={setCardExtent}
|
||||||
|
keyboard={hover.keyboard}
|
||||||
|
onStep={stepTriage}
|
||||||
|
onExit={exitTriage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{railEnabled && railItems.length > 0 && (
|
{railEnabled && railItems.length > 0 && (
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { Suggestion, SuggestionType } from '../../api/client'
|
import type { Suggestion, SuggestionType } from '../../api/client'
|
||||||
import { usePack } from '../../i18n'
|
import { usePack } from '../../i18n'
|
||||||
|
import { fromIME } from '../../lib/ime'
|
||||||
import { AskPetal } from './AskPetal'
|
import { AskPetal } from './AskPetal'
|
||||||
import { TYPE_META, batchLabel, typeLabel } from './suggestionMeta'
|
import { TYPE_META, batchLabel, typeLabel } from './suggestionMeta'
|
||||||
|
import type { Direction } from './triage'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
suggestion: Suggestion
|
suggestion: Suggestion
|
||||||
@@ -25,6 +27,13 @@ interface Props {
|
|||||||
// under it and its Accept button simply can't be reached. 0 means "nothing to
|
// under it and its Accept button simply can't be reached. 0 means "nothing to
|
||||||
// cover", which is what an unmounted card reports on its way out.
|
// cover", which is what an unmounted card reports on its way out.
|
||||||
onExtent?: (bottom: number) => void
|
onExtent?: (bottom: number) => void
|
||||||
|
// Keyboard triage (item 8). The card was opened by a keystroke rather than a
|
||||||
|
// pointer, so it takes focus and answers the keys itself: the writer is
|
||||||
|
// walking the underlines and never touches the mouse.
|
||||||
|
keyboard?: boolean
|
||||||
|
// Move to the next/previous underline, and leave triage entirely.
|
||||||
|
onStep?: (dir: Direction) => void
|
||||||
|
onExit?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// SuggestionCard is the hover panel for a single suggestion: a colored type tag,
|
// SuggestionCard is the hover panel for a single suggestion: a colored type tag,
|
||||||
@@ -42,6 +51,9 @@ export function SuggestionCard({
|
|||||||
onPointerLeave,
|
onPointerLeave,
|
||||||
onExpandChange,
|
onExpandChange,
|
||||||
onExtent,
|
onExtent,
|
||||||
|
keyboard = false,
|
||||||
|
onStep,
|
||||||
|
onExit,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const pack = usePack()
|
const pack = usePack()
|
||||||
const meta = TYPE_META[suggestion.type]
|
const meta = TYPE_META[suggestion.type]
|
||||||
@@ -67,6 +79,60 @@ export function SuggestionCard({
|
|||||||
}
|
}
|
||||||
}, [onExtent])
|
}, [onExtent])
|
||||||
|
|
||||||
|
// In triage the card is where the keys land, so it has to hold focus — and it
|
||||||
|
// has to re-take it on every step, because stepping keeps this same component
|
||||||
|
// mounted and only swaps the suggestion inside it. `preventScroll` for the
|
||||||
|
// reason AskPetal's input gives: the card is an absolutely-positioned overlay,
|
||||||
|
// and letting the browser "reveal" it would jump the document out from under
|
||||||
|
// the sentence she is reading. The span is scrolled to deliberately elsewhere.
|
||||||
|
useEffect(() => {
|
||||||
|
if (keyboard) cardRef.current?.focus({ preventScroll: true })
|
||||||
|
}, [keyboard, suggestion.id])
|
||||||
|
|
||||||
|
// The triage keys. Only bound in keyboard mode: a card opened by the pointer
|
||||||
|
// never holds focus, and stealing Tab from one that somehow did would break
|
||||||
|
// ordinary focus movement for no gain.
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (!keyboard || fromIME(e)) return
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
// Ask Petal's question field is a text input inside this card. While it has
|
||||||
|
// focus it owns every key it can use — Tab, Enter and the letters are hers
|
||||||
|
// to type — and only Escape is taken, to step back out to the card.
|
||||||
|
const typing = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
// Escape also leaves distraction-free mode (App's window listener), which
|
||||||
|
// would restore the sidebar and pull the rail out from under her mid-
|
||||||
|
// triage. In triage this key means "this card", or "triage" — never "the
|
||||||
|
// writing mode".
|
||||||
|
e.stopPropagation()
|
||||||
|
if (typing) cardRef.current?.focus({ preventScroll: true })
|
||||||
|
else onExit?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typing) return
|
||||||
|
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
e.preventDefault()
|
||||||
|
onStep?.(e.shiftKey ? -1 : 1)
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
// An awareness-only card has nothing to accept; Enter on it does nothing
|
||||||
|
// rather than quietly meaning something else.
|
||||||
|
if (!hasReplacement) return
|
||||||
|
e.preventDefault()
|
||||||
|
onAccept(suggestion)
|
||||||
|
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
|
e.preventDefault()
|
||||||
|
onDismiss(suggestion)
|
||||||
|
} else if (e.key === '?') {
|
||||||
|
e.preventDefault()
|
||||||
|
// Opening hands focus to the panel's own input; Escape there comes back
|
||||||
|
// here, and ? then closes it again.
|
||||||
|
toggleAsking()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleAsking() {
|
function toggleAsking() {
|
||||||
setAsking((prev) => {
|
setAsking((prev) => {
|
||||||
const next = !prev
|
const next = !prev
|
||||||
@@ -80,13 +146,18 @@ export function SuggestionCard({
|
|||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label={`${label} suggestion`}
|
aria-label={`${label} suggestion`}
|
||||||
|
tabIndex={keyboard ? -1 : undefined}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
onMouseEnter={onPointerEnter}
|
onMouseEnter={onPointerEnter}
|
||||||
onMouseLeave={onPointerLeave}
|
onMouseLeave={onPointerLeave}
|
||||||
className="petal-suggestion-card absolute z-20 p-3.5 text-sm"
|
className="petal-suggestion-card absolute z-20 p-3.5 text-sm focus:outline-none"
|
||||||
style={{
|
style={{
|
||||||
width: asking ? 340 : 300,
|
width: asking ? 340 : 300,
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
border: '1px solid var(--color-border)',
|
// In triage the card is the only thing holding focus, and the writer has
|
||||||
|
// no pointer under it to say so. The accent border is that answer — the
|
||||||
|
// browser's own focus ring on a 300px panel reads as an error state.
|
||||||
|
border: `1px solid ${keyboard ? 'var(--color-accent)' : 'var(--color-border)'}`,
|
||||||
borderRadius: 'var(--radius-card)',
|
borderRadius: 'var(--radius-card)',
|
||||||
boxShadow: 'var(--shadow-soft)',
|
boxShadow: 'var(--shadow-soft)',
|
||||||
...style,
|
...style,
|
||||||
@@ -161,6 +232,16 @@ export function SuggestionCard({
|
|||||||
{batchLabel(suggestion.type, batchCount)}
|
{batchLabel(suggestion.type, batchCount)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{keyboard && (
|
||||||
|
<div
|
||||||
|
className="mt-2.5 border-t pt-2 text-[0.65rem] leading-tight"
|
||||||
|
style={{ borderColor: 'var(--color-border)', color: 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
|
<div>{pack.editor.triageHint.native}</div>
|
||||||
|
<div>{pack.editor.triageHint.en}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
import { usePack } from '../../i18n'
|
import { usePack } from '../../i18n'
|
||||||
import { useAnchoredMenu } from './anchoredMenu'
|
import { useAnchoredMenu } from './anchoredMenu'
|
||||||
@@ -36,18 +37,20 @@ export function ToneSelect({ value, onChange }: Props) {
|
|||||||
const pk = usePack()
|
const pk = usePack()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const { triggerRef, style: menuStyle } = useAnchoredMenu(open, 200)
|
const { triggerRef, panelRef, style: menuStyle } = useAnchoredMenu(open, 200)
|
||||||
const current = TONES.find((t) => t.value === value) ?? TONES[0]
|
const current = TONES.find((t) => t.value === value) ?? TONES[0]
|
||||||
|
|
||||||
// Click outside closes the menu.
|
// Click outside closes the menu. The list is portalled to <body>, so a tap on
|
||||||
|
// an option is not inside `ref` and has to be asked about separately.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const onDown = (e: MouseEvent) => {
|
const onDown = (e: MouseEvent) => {
|
||||||
if (!ref.current?.contains(e.target as Node)) setOpen(false)
|
const target = e.target as Node
|
||||||
|
if (!ref.current?.contains(target) && !panelRef.current?.contains(target)) setOpen(false)
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', onDown)
|
document.addEventListener('mousedown', onDown)
|
||||||
return () => document.removeEventListener('mousedown', onDown)
|
return () => document.removeEventListener('mousedown', onDown)
|
||||||
}, [open])
|
}, [open, panelRef])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="shrink-0">
|
<div ref={ref} className="shrink-0">
|
||||||
@@ -75,8 +78,10 @@ export function ToneSelect({ value, onChange }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open &&
|
||||||
|
createPortal(
|
||||||
<div
|
<div
|
||||||
|
ref={panelRef}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
className="petal-word-card p-1.5"
|
className="petal-word-card p-1.5"
|
||||||
style={{
|
style={{
|
||||||
@@ -117,7 +122,8 @@ export function ToneSelect({ value, onChange }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,11 +22,31 @@ interface Props {
|
|||||||
// is spelled in letters, and the slashes would say something untrue about it
|
// is spelled in letters, and the slashes would say something untrue about it
|
||||||
// in the one place a learner is looking for the truth about pronunciation.
|
// in the one place a learner is looking for the truth about pronunciation.
|
||||||
pinyin?: string
|
pinyin?: string
|
||||||
|
// The locale to pronounce the headword in — the document's language, decided
|
||||||
|
// by the caller (see docLang in audio/speech.ts).
|
||||||
|
//
|
||||||
|
// It has to be passed rather than guessed. `speak` falls back to detecting the
|
||||||
|
// script, and that test can only tell Han characters from letters: it reads
|
||||||
|
// "comum" and "casa" as English, so every read-aloud in a Portuguese document
|
||||||
|
// came out in the English voice. That is the same mistake the "also in" block
|
||||||
|
// below was built to avoid, arriving through the one button nobody had told
|
||||||
|
// about the document.
|
||||||
|
lang?: string
|
||||||
style: React.CSSProperties
|
style: React.CSSProperties
|
||||||
onReplace: (synonym: string) => void
|
onReplace: (synonym: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WordCard({ word, info, loading, saved, onToggleSave, pinyin, style, onReplace }: Props) {
|
export function WordCard({
|
||||||
|
word,
|
||||||
|
info,
|
||||||
|
loading,
|
||||||
|
saved,
|
||||||
|
onToggleSave,
|
||||||
|
pinyin,
|
||||||
|
lang,
|
||||||
|
style,
|
||||||
|
onReplace,
|
||||||
|
}: Props) {
|
||||||
const t = usePack()
|
const t = usePack()
|
||||||
const definitions = info?.definitions ?? []
|
const definitions = info?.definitions ?? []
|
||||||
const synonyms = info?.synonyms ?? []
|
const synonyms = info?.synonyms ?? []
|
||||||
@@ -90,7 +110,7 @@ export function WordCard({ word, info, loading, saved, onToggleSave, pinyin, sty
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => speak(word)}
|
onClick={() => speak(word, lang)}
|
||||||
aria-label={`Pronounce ${word}`}
|
aria-label={`Pronounce ${word}`}
|
||||||
title={t.editor.readAloud}
|
title={t.editor.readAloud}
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-full text-sm"
|
className="flex h-7 w-7 items-center justify-center rounded-full text-sm"
|
||||||
@@ -104,7 +124,7 @@ export function WordCard({ word, info, loading, saved, onToggleSave, pinyin, sty
|
|||||||
slowing the tape, so it stays a voice rather than a groan. */}
|
slowing the tape, so it stays a voice rather than a groan. */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => speak(word, undefined, true)}
|
onClick={() => speak(word, lang, true)}
|
||||||
aria-label={`Pronounce ${word} slowly`}
|
aria-label={`Pronounce ${word} slowly`}
|
||||||
title={t.editor.readSlowly}
|
title={t.editor.readSlowly}
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-full text-sm"
|
className="flex h-7 w-7 items-center justify-center rounded-full text-sm"
|
||||||
|
|||||||
@@ -6,15 +6,40 @@ import { useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
|
|||||||
// button's wrapper, which was fine until that wrapper became ChromeStrip — a
|
// button's wrapper, which was fine until that wrapper became ChromeStrip — a
|
||||||
// horizontal scroller, and so a box that clips what overflows it. An absolute
|
// horizontal scroller, and so a box that clips what overflows it. An absolute
|
||||||
// menu inside it is 36px tall and scrolls away with the pills. Positioning the
|
// menu inside it is 36px tall and scrolls away with the pills. Positioning the
|
||||||
// menu against the viewport instead takes it out of the strip's hands entirely:
|
// menu against the viewport instead takes it out of the strip's hands entirely.
|
||||||
// nothing clips a fixed box unless an ancestor has a transform, and none of the
|
//
|
||||||
// editor's chrome does.
|
// Or rather: it does once the menu is also *portalled out* of it, which is the
|
||||||
|
// part this originally got wrong. `position: fixed` is only relative to the
|
||||||
|
// viewport while no ancestor establishes a containing block for it — and a
|
||||||
|
// `mask-image` does, exactly like a transform. Both scrollers fade their edges
|
||||||
|
// with a mask (that is how each says "there is more this way"), so on any screen
|
||||||
|
// narrow enough for the fade to appear — i.e. every phone — the menu was pulled
|
||||||
|
// back inside the very box it was trying to escape: painted underneath the
|
||||||
|
// toolbar, and untappable. It looked open and did nothing.
|
||||||
|
//
|
||||||
|
// So the panel is rendered through a portal into <body>. Nothing above it can
|
||||||
|
// clip it, stack over it, or contain it, whatever the chrome does with masks
|
||||||
|
// later. `panelRef` is returned for the outside-tap test, which can no longer
|
||||||
|
// rely on the panel being a DOM descendant of the trigger's wrapper.
|
||||||
//
|
//
|
||||||
// The trade is that a fixed box doesn't follow its anchor, so anything that
|
// The trade is that a fixed box doesn't follow its anchor, so anything that
|
||||||
// moves the button — the page scrolling under it, the strip scrolling, the
|
// moves the button — the page scrolling under it, the strip scrolling, the
|
||||||
// window resizing — has to re-place the menu.
|
// window resizing — has to re-place the menu.
|
||||||
export function useAnchoredMenu(open: boolean, width: number) {
|
//
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
// The element type is a parameter because the two kinds of caller anchor
|
||||||
|
// against different things: the tone and export pills hand it their own
|
||||||
|
// <button>, while the toolbar's popovers anchor against the wrapper that holds
|
||||||
|
// trigger and panel together (it is that wrapper an outside-tap test already
|
||||||
|
// asks about, so measuring anything else would be a second source of truth).
|
||||||
|
export function useAnchoredMenu<T extends HTMLElement = HTMLButtonElement>(
|
||||||
|
open: boolean,
|
||||||
|
width: number,
|
||||||
|
) {
|
||||||
|
const triggerRef = useRef<T>(null)
|
||||||
|
// The portalled panel. Attach it to the element the style is spread onto, so
|
||||||
|
// an outside-tap test can ask "was this inside the menu?" of a node that is no
|
||||||
|
// longer beneath the trigger in the tree.
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
// Nothing to place before the first measurement; keeping it off-screen rather
|
// Nothing to place before the first measurement; keeping it off-screen rather
|
||||||
// than at 0,0 means no flash in the top-left corner on open.
|
// than at 0,0 means no flash in the top-left corner on open.
|
||||||
const [style, setStyle] = useState<CSSProperties>({ position: 'fixed', top: -9999, left: -9999 })
|
const [style, setStyle] = useState<CSSProperties>({ position: 'fixed', top: -9999, left: -9999 })
|
||||||
@@ -41,5 +66,5 @@ export function useAnchoredMenu(open: boolean, width: number) {
|
|||||||
}
|
}
|
||||||
}, [open, width])
|
}, [open, width])
|
||||||
|
|
||||||
return { triggerRef, style }
|
return { triggerRef, panelRef, style }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { entryId, idAfterRemoval, stepId, type Span } from './triage'
|
||||||
|
|
||||||
|
const spans = (...pairs: [string, number][]): Span[] => pairs.map(([id, pos]) => ({ id, pos }))
|
||||||
|
|
||||||
|
describe('stepId — walking the queue', () => {
|
||||||
|
const order = ['a', 'b', 'c']
|
||||||
|
|
||||||
|
it('moves forward and backward', () => {
|
||||||
|
expect(stepId(order, 'a', 1)).toBe('b')
|
||||||
|
expect(stepId(order, 'c', -1)).toBe('b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wraps at both ends, so the queue is a ring and never a dead end', () => {
|
||||||
|
expect(stepId(order, 'c', 1)).toBe('a')
|
||||||
|
expect(stepId(order, 'a', -1)).toBe('c')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enters at the near end when there is no current card', () => {
|
||||||
|
expect(stepId(order, null, 1)).toBe('a')
|
||||||
|
expect(stepId(order, null, -1)).toBe('c')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats a card that has left the queue as no card at all', () => {
|
||||||
|
// She accepted from the rail while a triage card was open, or an edit
|
||||||
|
// dissolved the span. Stepping should still land somewhere real.
|
||||||
|
expect(stepId(order, 'gone', 1)).toBe('a')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has nowhere to go in an empty queue', () => {
|
||||||
|
expect(stepId([], null, 1)).toBeNull()
|
||||||
|
expect(stepId([], 'a', -1)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stays put on a queue of one', () => {
|
||||||
|
expect(stepId(['only'], 'only', 1)).toBe('only')
|
||||||
|
expect(stepId(['only'], 'only', -1)).toBe('only')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('entryId — where triage starts from the caret', () => {
|
||||||
|
const order = spans(['a', 10], ['b', 40], ['c', 90])
|
||||||
|
|
||||||
|
it('goes forward to the first underline at or after the caret', () => {
|
||||||
|
expect(entryId(order, 0, 1)).toBe('a')
|
||||||
|
expect(entryId(order, 11, 1)).toBe('b')
|
||||||
|
expect(entryId(order, 40, 1)).toBe('b') // caret sitting on the span itself
|
||||||
|
})
|
||||||
|
|
||||||
|
it('goes back to the last underline at or before the caret', () => {
|
||||||
|
expect(entryId(order, 100, -1)).toBe('c')
|
||||||
|
expect(entryId(order, 39, -1)).toBe('a')
|
||||||
|
expect(entryId(order, 40, -1)).toBe('b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wraps rather than refusing when the caret is past every span', () => {
|
||||||
|
expect(entryId(order, 500, 1)).toBe('a')
|
||||||
|
expect(entryId(order, 0, -1)).toBe('c')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has nowhere to enter in an empty document', () => {
|
||||||
|
expect(entryId([], 0, 1)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('idAfterRemoval — where the answered card hands over to', () => {
|
||||||
|
const order = ['a', 'b', 'c', 'd']
|
||||||
|
|
||||||
|
it('carries on with the next one still standing', () => {
|
||||||
|
expect(idAfterRemoval(order, 'b', new Set(['a', 'c', 'd']))).toBe('c')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips everything an Accept-all took with it', () => {
|
||||||
|
// Accept all of a category: b, c and d go together, so triage resumes at
|
||||||
|
// the only survivor rather than at a card that no longer exists.
|
||||||
|
expect(idAfterRemoval(order, 'b', new Set(['a']))).toBe('a')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wraps to the front when the answered card was last', () => {
|
||||||
|
expect(idAfterRemoval(order, 'd', new Set(['a', 'b', 'c']))).toBe('a')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ends triage when nothing is left', () => {
|
||||||
|
expect(idAfterRemoval(order, 'b', new Set())).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never hands back the card that was just answered', () => {
|
||||||
|
// The server may still be reporting it for a moment; the writer has already
|
||||||
|
// said what she thinks of it.
|
||||||
|
expect(idAfterRemoval(order, 'b', new Set(['b']))).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the whole queue when the answered card was never in it', () => {
|
||||||
|
// A provisional rule-pack card can be answered before its underline has been
|
||||||
|
// painted (item 3b's 250ms pass). There is still a queue to carry on with.
|
||||||
|
expect(idAfterRemoval(order, 'unlisted', new Set(['c', 'd']))).toBe('c')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Keyboard triage: walking the underlines without a mouse.
|
||||||
|
//
|
||||||
|
// The queue is the underlines themselves, in document order — not the
|
||||||
|
// suggestion list. A suggestion whose span the editor couldn't anchor has no
|
||||||
|
// underline, and a triage stop she cannot see is worse than one she never
|
||||||
|
// visits. Reading the order off the decoration DOM also means the queue is
|
||||||
|
// exactly what is on screen, which is the thing she is being asked to walk.
|
||||||
|
//
|
||||||
|
// Everything here is pure and takes the order as an argument, so the arithmetic
|
||||||
|
// (wrap-around, entry from the caret, where to land after a card is answered)
|
||||||
|
// can be tested without a ProseMirror document or a layout.
|
||||||
|
|
||||||
|
export type Direction = 1 | -1
|
||||||
|
|
||||||
|
// A span in the queue: its suggestion id and where it sits in the document.
|
||||||
|
export interface Span {
|
||||||
|
id: string
|
||||||
|
pos: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next stop from `current`, wrapping at both ends. A `current` that is no
|
||||||
|
// longer in the queue (or absent) enters at whichever end the direction implies,
|
||||||
|
// so the first press of "next" lands on the first underline and "previous" on
|
||||||
|
// the last.
|
||||||
|
export function stepId(order: readonly string[], current: string | null, dir: Direction): string | null {
|
||||||
|
if (order.length === 0) return null
|
||||||
|
const at = current === null ? -1 : order.indexOf(current)
|
||||||
|
if (at === -1) return dir === 1 ? order[0] : order[order.length - 1]
|
||||||
|
return order[(at + dir + order.length) % order.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where triage starts when it is entered from the editor rather than continued:
|
||||||
|
// the nearest underline in the direction she asked for, measured from the caret,
|
||||||
|
// so she picks up from where she is reading rather than being thrown to the top
|
||||||
|
// of a document she has scrolled halfway down. Wraps around when the caret is
|
||||||
|
// past them all, which is the same wrap `stepId` gives once she is walking.
|
||||||
|
export function entryId(order: readonly Span[], caret: number, dir: Direction): string | null {
|
||||||
|
if (order.length === 0) return null
|
||||||
|
if (dir === 1) {
|
||||||
|
for (const span of order) if (span.pos >= caret) return span.id
|
||||||
|
return order[0].id
|
||||||
|
}
|
||||||
|
for (let i = order.length - 1; i >= 0; i--) if (order[i].pos <= caret) return order[i].id
|
||||||
|
return order[order.length - 1].id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where to land after the card she was on is answered (accepted, dismissed, or
|
||||||
|
// swept up by an Accept-all). The order is the one read *before* the action, so
|
||||||
|
// "the next one" means the next in the queue she was walking; `remaining` is
|
||||||
|
// what actually survived. Falls back to scanning forward and then round to the
|
||||||
|
// front, and returns null when nothing is left — which is triage finishing, not
|
||||||
|
// an error.
|
||||||
|
export function idAfterRemoval(
|
||||||
|
order: readonly string[],
|
||||||
|
answered: string,
|
||||||
|
remaining: ReadonlySet<string>,
|
||||||
|
): string | null {
|
||||||
|
const at = order.indexOf(answered)
|
||||||
|
const rest = at === -1 ? order : [...order.slice(at + 1), ...order.slice(0, at)]
|
||||||
|
for (const id of rest) if (id !== answered && remaining.has(id)) return id
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
// Which end of a horizontal scroller has more behind it.
|
||||||
|
//
|
||||||
|
// Extracted from ChromeStrip when the formatting toolbar needed the same
|
||||||
|
// answer. Both rows are in the same situation and it is a harsher one than most
|
||||||
|
// scrollers face: the row is the *only* way to reach what is in it, so a control
|
||||||
|
// that has scrolled out of sight is indistinguishable from a control that does
|
||||||
|
// not exist. Fading the edge that has more behind it is the one cue that tells
|
||||||
|
// those two apart.
|
||||||
|
//
|
||||||
|
// The caller owns the element and the styling; this hook only measures. Apply
|
||||||
|
// the returned `edge` as a `data-edge` attribute and let CSS decide what a
|
||||||
|
// faded edge looks like — the two rows sit on different backgrounds and mask
|
||||||
|
// themselves at slightly different insets.
|
||||||
|
export type Edge = 'none' | 'left' | 'right' | 'both'
|
||||||
|
|
||||||
|
export function useScrollEdge<T extends HTMLElement = HTMLDivElement>() {
|
||||||
|
const ref = useRef<T>(null)
|
||||||
|
const [edge, setEdge] = useState<Edge>('none')
|
||||||
|
|
||||||
|
// A pixel of slack: scrollLeft is fractional under browser zoom and on
|
||||||
|
// high-DPI screens, so an exactly-scrolled-to-the-end strip can report
|
||||||
|
// something like 0.5px remaining and fade an edge that has nothing behind it.
|
||||||
|
const measure = useCallback(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
const more = el.scrollWidth - el.clientWidth - el.scrollLeft > 1
|
||||||
|
const less = el.scrollLeft > 1
|
||||||
|
setEdge(less && more ? 'both' : less ? 'left' : more ? 'right' : 'none')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
measure()
|
||||||
|
el.addEventListener('scroll', measure, { passive: true })
|
||||||
|
// Both halves of "does it fit" can change without a scroll: the window
|
||||||
|
// resizes, or the labels themselves change when she switches her pair
|
||||||
|
// language and every control in the row grows or shrinks at once.
|
||||||
|
const ro = new ResizeObserver(measure)
|
||||||
|
ro.observe(el)
|
||||||
|
for (const child of Array.from(el.children)) ro.observe(child)
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('scroll', measure)
|
||||||
|
ro.disconnect()
|
||||||
|
}
|
||||||
|
}, [measure])
|
||||||
|
|
||||||
|
return { ref, edge }
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { api, type ExportFormat } from '../../api/client'
|
import { api, type ExportFormat } from '../../api/client'
|
||||||
import { usePack } from '../../i18n'
|
import { usePack } from '../../i18n'
|
||||||
import { useAnchoredMenu } from '../Editor/anchoredMenu'
|
import { useAnchoredMenu } from '../Editor/anchoredMenu'
|
||||||
@@ -29,16 +30,20 @@ export function ExportMenu({ docId }: Props) {
|
|||||||
const t = usePack()
|
const t = usePack()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const { triggerRef, style: menuStyle } = useAnchoredMenu(open, 220)
|
const { triggerRef, panelRef, style: menuStyle } = useAnchoredMenu(open, 220)
|
||||||
|
|
||||||
|
// The menu is portalled to <body>, so a tap on a format is outside `ref` and
|
||||||
|
// has to be asked about separately or it would close the menu instead of
|
||||||
|
// exporting.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const onDown = (e: MouseEvent) => {
|
const onDown = (e: MouseEvent) => {
|
||||||
if (!ref.current?.contains(e.target as Node)) setOpen(false)
|
const target = e.target as Node
|
||||||
|
if (!ref.current?.contains(target) && !panelRef.current?.contains(target)) setOpen(false)
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', onDown)
|
document.addEventListener('mousedown', onDown)
|
||||||
return () => document.removeEventListener('mousedown', onDown)
|
return () => document.removeEventListener('mousedown', onDown)
|
||||||
}, [open])
|
}, [open, panelRef])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="shrink-0">
|
<div ref={ref} className="shrink-0">
|
||||||
@@ -63,8 +68,10 @@ export function ExportMenu({ docId }: Props) {
|
|||||||
<span style={{ color: 'var(--color-muted)' }}>· Export</span>
|
<span style={{ color: 'var(--color-muted)' }}>· Export</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open &&
|
||||||
|
createPortal(
|
||||||
<div
|
<div
|
||||||
|
ref={panelRef}
|
||||||
role="menu"
|
role="menu"
|
||||||
className="petal-word-card p-1.5"
|
className="petal-word-card p-1.5"
|
||||||
style={{
|
style={{
|
||||||
@@ -116,7 +123,8 @@ export function ExportMenu({ docId }: Props) {
|
|||||||
Print / PDF
|
Print / PDF
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { api, type VocabGrade, type VocabWord } from '../../api/client'
|
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 { useFocusTrap } from '../../hooks/useFocusTrap'
|
||||||
import { usePack, type Line } from '../../i18n'
|
import { usePack, type Line } from '../../i18n'
|
||||||
import { JournalView } from './JournalView'
|
import { JournalView } from './JournalView'
|
||||||
@@ -314,7 +314,21 @@ function GardenView({
|
|||||||
{blossom(w.reps)}
|
{blossom(w.reps)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex min-w-0 flex-1 flex-col">
|
<span className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
<span className="truncate text-sm font-bold text-plum">{w.word}</span>
|
<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) && (
|
{(w.gloss || w.definition) && (
|
||||||
<span
|
<span
|
||||||
className="truncate text-xs"
|
className="truncate text-xs"
|
||||||
@@ -354,7 +368,7 @@ function GardenView({
|
|||||||
{speechSupported() && (
|
{speechSupported() && (
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="rounded-full px-2.5 py-1 text-xs font-semibold"
|
||||||
style={{ background: 'var(--color-surface-alt)' }}
|
style={{ background: 'var(--color-surface-alt)' }}
|
||||||
>
|
>
|
||||||
@@ -477,7 +491,7 @@ function ReviewSession({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => speak(card.word)}
|
onClick={() => speak(card.word, docLocale(card.word, card.lang))}
|
||||||
aria-label={`Pronounce ${card.word}`}
|
aria-label={`Pronounce ${card.word}`}
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-full text-xs"
|
className="flex h-6 w-6 items-center justify-center rounded-full text-xs"
|
||||||
style={{ background: 'var(--color-surface)' }}
|
style={{ background: 'var(--color-surface)' }}
|
||||||
@@ -488,7 +502,7 @@ function ReviewSession({
|
|||||||
worth hearing stretched out. */}
|
worth hearing stretched out. */}
|
||||||
<button
|
<button
|
||||||
type="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`}
|
aria-label={`Pronounce ${card.word} slowly`}
|
||||||
title={t.garden.readSlowly}
|
title={t.garden.readSlowly}
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-full text-xs"
|
className="flex h-6 w-6 items-center justify-center rounded-full text-xs"
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { Editor } from '@tiptap/react'
|
import type { Editor } from '@tiptap/react'
|
||||||
import { useEditorState } from '@tiptap/react'
|
import { useEditorState } from '@tiptap/react'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { uploadImageInto } from '../Editor/EditorCore'
|
import { uploadImageInto } from '../Editor/EditorCore'
|
||||||
|
import { useAnchoredMenu } from '../Editor/anchoredMenu'
|
||||||
|
import { useScrollEdge } from '../Editor/useScrollEdge'
|
||||||
import { usePack } from '../../i18n'
|
import { usePack } from '../../i18n'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -56,9 +59,26 @@ const Divider = () => (
|
|||||||
<span className="mx-1 h-5 w-px" style={{ background: 'var(--color-border)' }} />
|
<span className="mx-1 h-5 w-px" style={{ background: 'var(--color-border)' }} />
|
||||||
)
|
)
|
||||||
|
|
||||||
// A popover anchored under its trigger. The trigger + panel share a relative
|
// A popover anchored under its trigger. The trigger + panel share a wrapper;
|
||||||
// wrapper; `open`/`onClose` are owned by the toolbar so only one is open at once.
|
// `open`/`onClose` are owned by the toolbar so only one is open at once. A
|
||||||
// A pointer-down outside the wrapper closes it.
|
// pointer-down outside the wrapper closes it.
|
||||||
|
//
|
||||||
|
// The panel is placed in viewport coordinates rather than absolutely inside
|
||||||
|
// that wrapper, for the same two reasons ChromeStrip's menus were (see
|
||||||
|
// useAnchoredMenu) — and on a phone both of them bite at once:
|
||||||
|
//
|
||||||
|
// * The toolbar clips what overflows it. On a desktop that clip is lifted on
|
||||||
|
// hover, which is where an absolutely-positioned panel got away with it for
|
||||||
|
// as long as it did; a touchscreen never hovers, so tapping A or H opened a
|
||||||
|
// panel that was simply not on the screen. The button lit up and nothing
|
||||||
|
// else happened, which is the worst shape a bug can take — it reads as the
|
||||||
|
// feature not existing.
|
||||||
|
// * `left-0` hangs a 200px panel off the right edge of a 390px phone when its
|
||||||
|
// trigger sits near the end of the row. useAnchoredMenu clamps it back
|
||||||
|
// inside the window instead.
|
||||||
|
//
|
||||||
|
// The panel stays a DOM child of the wrapper (fixed, not portalled) so the
|
||||||
|
// outside-tap test below keeps working on containment alone.
|
||||||
function Popover({
|
function Popover({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -72,23 +92,27 @@ function Popover({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
width?: number
|
width?: number
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const { triggerRef: ref, panelRef, style } = useAnchoredMenu<HTMLDivElement>(open, width)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const onDown = (e: MouseEvent) => {
|
const onDown = (e: MouseEvent) => {
|
||||||
if (!ref.current?.contains(e.target as Node)) onClose()
|
const target = e.target as Node
|
||||||
|
// The panel is portalled to <body>, so "inside" is either half.
|
||||||
|
if (!ref.current?.contains(target) && !panelRef.current?.contains(target)) onClose()
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', onDown)
|
document.addEventListener('mousedown', onDown)
|
||||||
return () => document.removeEventListener('mousedown', onDown)
|
return () => document.removeEventListener('mousedown', onDown)
|
||||||
}, [open, onClose])
|
}, [open, onClose, ref, panelRef])
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="relative flex items-center">
|
<div ref={ref} className="flex items-center">
|
||||||
{trigger}
|
{trigger}
|
||||||
{open && (
|
{open &&
|
||||||
|
createPortal(
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 top-full z-40 mt-1.5 p-2"
|
ref={panelRef}
|
||||||
|
className="p-2"
|
||||||
style={{
|
style={{
|
||||||
width,
|
...style,
|
||||||
borderRadius: 'var(--radius-card)',
|
borderRadius: 'var(--radius-card)',
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
@@ -96,7 +120,8 @@ function Popover({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -175,6 +200,10 @@ export function Toolbar({ editor, onVoiceCheck, voicing, onCollocationCheck, col
|
|||||||
const [menu, setMenu] = useState<'color' | 'highlight' | 'size' | 'link' | 'table' | 'outline' | null>(null)
|
const [menu, setMenu] = useState<'color' | 'highlight' | 'size' | 'link' | 'table' | 'outline' | null>(null)
|
||||||
const [linkUrl, setLinkUrl] = useState('')
|
const [linkUrl, setLinkUrl] = useState('')
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
// Which end of the row still has controls behind it. Only ever visible on a
|
||||||
|
// coarse pointer, where the row scrolls instead of expanding on hover — see
|
||||||
|
// the .petal-toolbar rules in index.css.
|
||||||
|
const { ref: toolbarRef, edge } = useScrollEdge<HTMLDivElement>()
|
||||||
|
|
||||||
const state = useEditorState({
|
const state = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
@@ -255,6 +284,8 @@ export function Toolbar({ editor, onVoiceCheck, voicing, onCollocationCheck, col
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={toolbarRef}
|
||||||
|
data-edge={edge}
|
||||||
className="petal-toolbar mb-4 flex items-center gap-0.5 self-start px-2 py-1.5"
|
className="petal-toolbar mb-4 flex items-center gap-0.5 self-start px-2 py-1.5"
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 'var(--radius-card)',
|
borderRadius: 'var(--radius-card)',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { api, UnauthorizedError, type DocUpdate } from '../api/client'
|
import { api, UnauthorizedError, type Document, type DocUpdate } from '../api/client'
|
||||||
import { clearDraft, stashDraft } from '../lib/drafts'
|
import { clearDraft, stashDraft } from '../lib/drafts'
|
||||||
|
|
||||||
export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error' | 'signed-out'
|
export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error' | 'signed-out'
|
||||||
@@ -10,7 +10,14 @@ const SAVED_FADE_MS = 3000
|
|||||||
// useAutoSave debounces document saves. Call schedule() on every edit; it fires
|
// useAutoSave debounces document saves. Call schedule() on every edit; it fires
|
||||||
// PUT /api/docs/:id 1.5s after the last change. status drives the StatusBar:
|
// PUT /api/docs/:id 1.5s after the last change. status drives the StatusBar:
|
||||||
// pending → saving → saved (fades to idle after 3s).
|
// pending → saving → saved (fades to idle after 3s).
|
||||||
export function useAutoSave(docId: string | null) {
|
//
|
||||||
|
// `onSaved` receives the row the server wrote back. The save response used to be
|
||||||
|
// discarded, which was fine while every field in it was one the client had just
|
||||||
|
// sent — and stopped being fine when `doc_lang` arrived, a field only the server
|
||||||
|
// can decide. Without this the verdict reached the editor on open and never
|
||||||
|
// again, so a document that turned Portuguese while she typed went on being read
|
||||||
|
// aloud in English until the next reload.
|
||||||
|
export function useAutoSave(docId: string | null, onSaved?: (doc: Document) => void) {
|
||||||
const [status, setStatus] = useState<SaveStatus>('idle')
|
const [status, setStatus] = useState<SaveStatus>('idle')
|
||||||
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
@@ -25,6 +32,11 @@ export function useAutoSave(docId: string | null) {
|
|||||||
// the loop stops here and the writing waits in localStorage instead.
|
// the loop stops here and the writing waits in localStorage instead.
|
||||||
const signedOutRef = useRef(false)
|
const signedOutRef = useRef(false)
|
||||||
|
|
||||||
|
// Read through a ref so a caller passing an inline arrow doesn't have to
|
||||||
|
// memoize it to keep flush stable.
|
||||||
|
const onSavedRef = useRef(onSaved)
|
||||||
|
onSavedRef.current = onSaved
|
||||||
|
|
||||||
const flush = useCallback(async () => {
|
const flush = useCallback(async () => {
|
||||||
const id = docIdRef.current
|
const id = docIdRef.current
|
||||||
const body = pendingRef.current
|
const body = pendingRef.current
|
||||||
@@ -39,7 +51,8 @@ export function useAutoSave(docId: string | null) {
|
|||||||
|
|
||||||
setStatus('saving')
|
setStatus('saving')
|
||||||
try {
|
try {
|
||||||
await api.updateDoc(id, body)
|
const saved = await api.updateDoc(id, body)
|
||||||
|
onSavedRef.current?.(saved)
|
||||||
clearDraft(id) // it's on the server now; the rescue copy is redundant
|
clearDraft(id) // it's on the server now; the rescue copy is redundant
|
||||||
setStatus('saved')
|
setStatus('saved')
|
||||||
clearTimeout(fadeRef.current)
|
clearTimeout(fadeRef.current)
|
||||||
|
|||||||
@@ -203,6 +203,22 @@ describe('the zh pack', () => {
|
|||||||
expect(p.status.petalsToPolish(2).en).toContain('petals to polish')
|
expect(p.status.petalsToPolish(2).en).toContain('petals to polish')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The triage legend is the only place a Petal binding is written down, so a
|
||||||
|
// pack that drops a key drops the feature for that pair: nothing else on
|
||||||
|
// screen says Tab moves to the next underline. The key caps themselves stay
|
||||||
|
// as they are printed on the keyboard, which is why the English half is not
|
||||||
|
// the interesting one — a pack may well translate "Entrée" and be right to.
|
||||||
|
it.each(PACKS)('names every triage key in both halves ($code)', (p) => {
|
||||||
|
const { native, en } = p.editor.triageHint
|
||||||
|
expect(native, `${p.code} has no pair-language triage legend`).toBeTruthy()
|
||||||
|
expect(en, `${p.code} has no English triage legend`).toBeTruthy()
|
||||||
|
expect(en).toBe('Tab next · Enter accept · Del dismiss · ? Ask Petal · Esc exit')
|
||||||
|
// Five bindings, five entries — in whatever the pack calls the keys.
|
||||||
|
expect(native.split('·'), `${p.code} lists the wrong number of keys`).toHaveLength(5)
|
||||||
|
expect(native, `${p.code} loses the Tab key`).toContain('Tab')
|
||||||
|
expect(native, `${p.code} loses the Ask Petal key`).toContain('?')
|
||||||
|
})
|
||||||
|
|
||||||
it.each(PACKS)('labels every companion, tone and style ($code)', async (p) => {
|
it.each(PACKS)('labels every companion, tone and style ($code)', async (p) => {
|
||||||
const { COMPANIONS } = await import('../components/Companion/companions')
|
const { COMPANIONS } = await import('../components/Companion/companions')
|
||||||
for (const c of COMPANIONS) {
|
for (const c of COMPANIONS) {
|
||||||
@@ -414,7 +430,12 @@ describe('the es pack', () => {
|
|||||||
}
|
}
|
||||||
walk(es, '')
|
walk(es, '')
|
||||||
expect(lines.length).toBeGreaterThan(40)
|
expect(lines.length).toBeGreaterThan(40)
|
||||||
|
// The one exemption, and it is not a question: `triageHint` is a legend of
|
||||||
|
// key caps, and its "?" is the key she presses to ask Petal — the same
|
||||||
|
// literal printed on the keyboard, no more Spanish punctuation than "Esc".
|
||||||
|
const keyCaps = new Set(['editor.triageHint'])
|
||||||
for (const { native, where } of lines) {
|
for (const { native, where } of lines) {
|
||||||
|
if (keyCaps.has(where)) continue
|
||||||
if (native.includes('?')) expect(native, `${where} closes ? without ¿`).toContain('¿')
|
if (native.includes('?')) expect(native, `${where} closes ? without ¿`).toContain('¿')
|
||||||
if (native.includes('!')) expect(native, `${where} closes ! without ¡`).toContain('¡')
|
if (native.includes('!')) expect(native, `${where} closes ! without ¡`).toContain('¡')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -374,6 +374,11 @@ export const es: Pack = {
|
|||||||
replace: 'Reemplazar',
|
replace: 'Reemplazar',
|
||||||
replaceAll: 'Todo',
|
replaceAll: 'Todo',
|
||||||
translateLabel: 'Traducción · Translate',
|
translateLabel: 'Traducción · Translate',
|
||||||
|
// Intro, Supr, Esc — as they are printed on a Spanish keyboard.
|
||||||
|
triageHint: {
|
||||||
|
native: 'Tab siguiente · Intro aceptar · Supr descartar · ? preguntar a Petal · Esc salir',
|
||||||
|
en: 'Tab next · Enter accept · Del dismiss · ? Ask Petal · Esc exit',
|
||||||
|
},
|
||||||
spelling: 'Ortografía · Spelling',
|
spelling: 'Ortografía · Spelling',
|
||||||
noSuggestions: 'Sin sugerencias · No suggestions',
|
noSuggestions: 'Sin sugerencias · No suggestions',
|
||||||
addToDictionary: 'Agregar al diccionario · Add to dictionary',
|
addToDictionary: 'Agregar al diccionario · Add to dictionary',
|
||||||
|
|||||||
@@ -316,6 +316,13 @@ export const fr: Pack = {
|
|||||||
replace: 'Remplacer',
|
replace: 'Remplacer',
|
||||||
replaceAll: 'Tout',
|
replaceAll: 'Tout',
|
||||||
translateLabel: 'Traduction · Translate',
|
translateLabel: 'Traduction · Translate',
|
||||||
|
// The key names are the ones printed on a French keyboard — Entrée, Suppr,
|
||||||
|
// Échap — not their English equivalents. A legend she has to translate back
|
||||||
|
// to find the key is not a legend.
|
||||||
|
triageHint: {
|
||||||
|
native: 'Tab suivant · Entrée accepter · Suppr ignorer · ? demander à Petal · Échap quitter',
|
||||||
|
en: 'Tab next · Enter accept · Del dismiss · ? Ask Petal · Esc exit',
|
||||||
|
},
|
||||||
spelling: 'Orthographe · Spelling',
|
spelling: 'Orthographe · Spelling',
|
||||||
noSuggestions: 'Aucune suggestion · No suggestions',
|
noSuggestions: 'Aucune suggestion · No suggestions',
|
||||||
addToDictionary: 'Ajouter au dictionnaire · Add to dictionary',
|
addToDictionary: 'Ajouter au dictionnaire · Add to dictionary',
|
||||||
|
|||||||
@@ -42,6 +42,25 @@ export const ptPT: Pack = {
|
|||||||
nativeName: 'Português',
|
nativeName: 'Português',
|
||||||
locale: 'pt-PT',
|
locale: 'pt-PT',
|
||||||
|
|
||||||
|
// pt-PT is the second pair Petal can be *learned* toward: spaces do the
|
||||||
|
// segmenting a Latin script needs, and dict.db already reads Portuguese into
|
||||||
|
// English (see auth.learnerPairs for both halves of that argument).
|
||||||
|
//
|
||||||
|
// Each label is written for whoever would pick it, which is why they are not
|
||||||
|
// in the same language as each other. A native Portuguese speaker practising
|
||||||
|
// English reads the first; an English speaker learning Portuguese reads the
|
||||||
|
// second, and would not be helped by being told "Português" in Portuguese.
|
||||||
|
//
|
||||||
|
// This is also the switch that decides which language Petal *explains* in, so
|
||||||
|
// it is the difference between a Portuguese document annotated in Portuguese
|
||||||
|
// and the same document annotated in English.
|
||||||
|
learner: {
|
||||||
|
label: 'Estou a aprender · I am learning',
|
||||||
|
toEn: 'inglês',
|
||||||
|
toPair: 'Portuguese',
|
||||||
|
failed: 'Não foi possível mudar · Couldn’t switch — nothing changed',
|
||||||
|
},
|
||||||
|
|
||||||
app: {
|
app: {
|
||||||
duplicateTitle: (title) => `${title} (cópia)`,
|
duplicateTitle: (title) => `${title} (cópia)`,
|
||||||
garden: 'Jardim de palavras',
|
garden: 'Jardim de palavras',
|
||||||
@@ -294,6 +313,10 @@ export const ptPT: Pack = {
|
|||||||
replace: 'Substituir',
|
replace: 'Substituir',
|
||||||
replaceAll: 'Tudo',
|
replaceAll: 'Tudo',
|
||||||
translateLabel: 'Tradução · Translate',
|
translateLabel: 'Tradução · Translate',
|
||||||
|
triageHint: {
|
||||||
|
native: 'Tab seguinte · Enter aceitar · Del ignorar · ? perguntar à Petal · Esc sair',
|
||||||
|
en: 'Tab next · Enter accept · Del dismiss · ? Ask Petal · Esc exit',
|
||||||
|
},
|
||||||
spelling: 'Ortografia · Spelling',
|
spelling: 'Ortografia · Spelling',
|
||||||
noSuggestions: 'Sem sugestões · No suggestions',
|
noSuggestions: 'Sem sugestões · No suggestions',
|
||||||
addToDictionary: 'Adicionar ao dicionário · Add to dictionary',
|
addToDictionary: 'Adicionar ao dicionário · Add to dictionary',
|
||||||
|
|||||||
@@ -205,6 +205,10 @@ export const zh: Pack = {
|
|||||||
replace: '替换',
|
replace: '替换',
|
||||||
replaceAll: '全部',
|
replaceAll: '全部',
|
||||||
translateLabel: '翻译 · Translate',
|
translateLabel: '翻译 · Translate',
|
||||||
|
triageHint: {
|
||||||
|
native: 'Tab 下一处 · Enter 采纳 · Del 忽略 · ? 问问 Petal · Esc 退出',
|
||||||
|
en: 'Tab next · Enter accept · Del dismiss · ? Ask Petal · Esc exit',
|
||||||
|
},
|
||||||
spelling: '拼写 · Spelling',
|
spelling: '拼写 · Spelling',
|
||||||
noSuggestions: '没有建议 · No suggestions',
|
noSuggestions: '没有建议 · No suggestions',
|
||||||
addToDictionary: '添加到词典 · Add to dictionary',
|
addToDictionary: '添加到词典 · Add to dictionary',
|
||||||
|
|||||||
@@ -195,6 +195,13 @@ export interface Pack {
|
|||||||
// opposite case — its whole subject is her own language — so it says so in
|
// opposite case — its whole subject is her own language — so it says so in
|
||||||
// her language first. The pack holds the rendered string, separator and all.
|
// her language first. The pack holds the rendered string, separator and all.
|
||||||
translateLabel: string
|
translateLabel: string
|
||||||
|
// The key map shown along the bottom of a card opened by keyboard triage.
|
||||||
|
// Unlike the card's buttons — Accept, Dismiss, Ask Petal, which stay English
|
||||||
|
// because they name the thing she is learning to talk about — this is an
|
||||||
|
// instruction for using Petal, so it is bilingual like the status bar. The
|
||||||
|
// key names themselves (Tab, Enter, Esc) are what is printed on her
|
||||||
|
// keyboard, so they don't translate.
|
||||||
|
triageHint: Line
|
||||||
spelling: string
|
spelling: string
|
||||||
noSuggestions: string
|
noSuggestions: string
|
||||||
addToDictionary: string
|
addToDictionary: string
|
||||||
|
|||||||
+75
-1
@@ -422,10 +422,15 @@ button, a, input {
|
|||||||
/* --- Companion kitten -------------------------------------------------------
|
/* --- Companion kitten -------------------------------------------------------
|
||||||
The cozy corner mascot. Gently bobs while awake, settles and sways slowly
|
The cozy corner mascot. Gently bobs while awake, settles and sways slowly
|
||||||
while napping; its speech bubble pops in; little zzz drift up when asleep. */
|
while napping; its speech bubble pops in; little zzz drift up when asleep. */
|
||||||
.petal-companion {
|
/* The whole corner, badge and bubble and measuring probe alike. The size lives
|
||||||
|
here rather than on the badge so the probe — a sibling, not a child — is sized
|
||||||
|
from the same number the mascot is. */
|
||||||
|
.petal-corner {
|
||||||
/* Mascot size scales with the viewport width: ~original on a laptop, up to
|
/* Mascot size scales with the viewport width: ~original on a laptop, up to
|
||||||
~2× on a large desktop. Tune the middle (vw) term to taste. */
|
~2× on a large desktop. Tune the middle (vw) term to taste. */
|
||||||
--petal-companion-size: clamp(9rem, 15.3vw, 18rem);
|
--petal-companion-size: clamp(9rem, 15.3vw, 18rem);
|
||||||
|
}
|
||||||
|
.petal-companion {
|
||||||
animation: petal-bob 3.2s ease-in-out infinite;
|
animation: petal-bob 3.2s ease-in-out infinite;
|
||||||
/* Shrink toward its corner when fading out of a card's way. `scale` is a
|
/* Shrink toward its corner when fading out of a card's way. `scale` is a
|
||||||
separate property from `transform` so it composes with the bob keyframes. */
|
separate property from `transform` so it composes with the bob keyframes. */
|
||||||
@@ -559,6 +564,64 @@ button, a, input {
|
|||||||
padding-top: 0.25rem;
|
padding-top: 0.25rem;
|
||||||
padding-bottom: 0.25rem;
|
padding-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The formatting toolbar reaches everything it holds by expanding on hover
|
||||||
|
(see .petal-toolbar above). A touchscreen never hovers, so that rule never
|
||||||
|
fired here and the row stayed clipped at `overflow: hidden` for good: on a
|
||||||
|
390px phone roughly 750px of it — every heading, both lists, all three
|
||||||
|
alignments, link, image, table, outline, and both AI passes — could not be
|
||||||
|
reached at all. The faded edge said "there is more this way" and there was
|
||||||
|
no way.
|
||||||
|
|
||||||
|
So on a coarse pointer the row does what the pill strip does one line
|
||||||
|
above it: keeps every control and scrolls sideways, but only itself.
|
||||||
|
overscroll-behavior stops a swipe that runs out of buttons from dragging
|
||||||
|
the page of writing along with it, and the scrollbar is hidden because a
|
||||||
|
half-visible button at the edge is the affordance. The panels that hang off
|
||||||
|
these buttons are placed in viewport coordinates (see Popover in
|
||||||
|
Toolbar.tsx), so nothing here clips them. */
|
||||||
|
.petal-toolbar {
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
-webkit-mask-image: none;
|
||||||
|
mask-image: none;
|
||||||
|
}
|
||||||
|
.petal-toolbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
/* :hover can still be reported on a touchscreen — a tap leaves a lingering
|
||||||
|
hover state on the last thing touched — and the desktop rule would answer
|
||||||
|
it by unwrapping the row mid-scroll. Hold the scrolling shape instead. */
|
||||||
|
.petal-toolbar:hover,
|
||||||
|
.petal-toolbar:focus-within {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
/* Which edge has more behind it, from the same measurement the pill strip
|
||||||
|
uses (useScrollEdge → data-edge). A row whose buttons all fit is left
|
||||||
|
unmasked, so the fade only ever appears when it means something. */
|
||||||
|
.petal-toolbar[data-edge='right'] {
|
||||||
|
-webkit-mask-image: linear-gradient(to right, #000 92%, transparent 100%);
|
||||||
|
mask-image: linear-gradient(to right, #000 92%, transparent 100%);
|
||||||
|
}
|
||||||
|
.petal-toolbar[data-edge='left'] {
|
||||||
|
-webkit-mask-image: linear-gradient(to left, #000 92%, transparent 100%);
|
||||||
|
mask-image: linear-gradient(to left, #000 92%, transparent 100%);
|
||||||
|
}
|
||||||
|
.petal-toolbar[data-edge='both'] {
|
||||||
|
-webkit-mask-image: linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent 0%,
|
||||||
|
#000 8%,
|
||||||
|
#000 92%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
mask-image: linear-gradient(to right, transparent 0%, #000 8%, #000 92%, transparent 100%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Responsive sidebar (narrow screens) ------------------------------------
|
/* --- Responsive sidebar (narrow screens) ------------------------------------
|
||||||
@@ -599,6 +662,17 @@ button, a, input {
|
|||||||
z-index: 20;
|
z-index: 20;
|
||||||
background: rgba(61, 46, 57, 0.18);
|
background: rgba(61, 46, 57, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sit the mascot above the status bar rather than on top of it.
|
||||||
|
--petal-companion-size bottoms out at 9rem, which is most of a phone's
|
||||||
|
width, and at `bottom-4` the bottom of that circle lands inside the 2.75rem
|
||||||
|
status bar — directly over "Hide falling petals", which could not be tapped
|
||||||
|
at all. The kitten yields to cards and panels (useCardOverlap) but the
|
||||||
|
status bar is neither: it is always there, so yielding to it would mean
|
||||||
|
fading forever. Moving up once is the honest fix. */
|
||||||
|
.petal-corner {
|
||||||
|
bottom: calc(2.75rem + 0.5rem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Small phones only: see the header in App.tsx for why the wordmark yields.
|
/* Small phones only: see the header in App.tsx for why the wordmark yields.
|
||||||
|
|||||||
Reference in New Issue
Block a user