Compare commits

6 Commits
Author SHA1 Message Date
prosolis d961a89bf8 Merge: the advice tap points both ways
Claude-Session: https://claude.ai/code/session_01KGACAtTPjvZ2PipDZ5qD99
2026-08-02 12:03:26 -07:00
prosolis 7fa98d03c7 Both sections of the advice arrived in the language she is not learning
Reported from the phone: "I have my main language set as Portuguese and I say
I'm learning English but yet Petal presents the Ask Petal advice in both
sections as Portuguese."

Nothing was wrong with targetFor again. A Portuguese document by a Portuguese
writer is explained in Portuguese, which is the whole point of Phase 28. The
card was right. What was wrong was the tap underneath it: /suggestions/{id}
/translate answered "" for exactly that case, on the reasoning that an
unasked-for English rendering of an explanation she can already read is not a
seed but noise. That reasoning had the writer facing the wrong way. She is
learning English. The half she is *practising* is the half worth a tap, and the
bubble sits directly beneath the explanation inside the same card, so answering
"" left her with Portuguese, the same Portuguese again, and no English anywhere
on the card. targetFor's own comment promised the other language stays one tap
away in both directions; only one direction had ever been built.

So the endpoint keeps the one rule it always claimed: render into whichever
half the explanation is not already in. English explanation into her language,
as before; her language into the English she is learning, which is new. Both
ends of that are now parameters — TranslateMessages took the source language
for granted as English because until Phase 28 it always was. The zh prompt is
unchanged byte for byte, which its test still pins.

The client fallback was the same symptom from a different cause and would have
survived the server fix: an empty answer, or an unreachable model, seeded the
bubble with the explanation itself — a verbatim repeat of the line two above
it, which reads as Petal replying in the language the tap was pressed to
escape. With the endpoint always having somewhere to go, empty now means only
that the model didn't answer, so the panel opens with no bubble at all and the
input where she can ask. AskPetal no longer takes the explanation as a prop; it
never needed anything but the id.

The test that pinned the refusal now pins the rendering, and carries the report.

Claude-Session: https://claude.ai/code/session_01KGACAtTPjvZ2PipDZ5qD99
2026-08-02 12:03:19 -07:00
prosolis 6bed33c27e Merge: touch reach, the advice language, and the Portuguese read-aloud button
Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-29 18:45:33 -07:00
prosolis 7383bdb403 An American voice reading "comum" from the one button nobody had told
Reported as "Portuguese docs still use English voices". Not the server:
piper-pt is up, has its own model loaded, and synthesizes "Ao fim da
tarde fomos ver o mar" into 63KB of RIFF when asked directly. The
request never asked it.

The word card's pronounce buttons called speak(word) with no locale,
which falls back to detectLang — a test that can only tell Han
characters from letters. "comum", "carro", "gaivotas" all read as
English, so every read-aloud from a word card in a Portuguese document
came out in the en_US voice, and Piper's Portuguese sat idle behind a
route nothing pointed at.

The card was the last surface still guessing. The selection bubble and
the garden both pass a locale decided from the document's verdict;
WordCard was never given the document, so it had nothing to pass. It
takes one now.

This is the same mistake the "also in her language" block three
elements below it was built to avoid — its comment says so outright,
that an English voice reading "comum" is the thing that block exists to
prevent — arriving through the button directly above it. A rule held in
one place and not the other.

Verified by intercepting /api/tts: both paths now ask for pt-PT.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-29 18:40:41 -07:00
prosolis 3cc23b8ea4 The advice arrived in the language she was trying to read her way out of
Reported as "the Portuguese option isn't translating the advice in
English — it's just reprinting Portuguese." Nothing was wrong with
targetFor. It was reading a direction the account could not leave.

learnerPairs held only zh, so SetPair refused learning_pair for pt-PT
and every Portuguese account was learning_en by force. targetFor then
did exactly what it says: explanations follow the half of the pair she
is not learning, which for a forced learning_en account is Portuguese.
A Portuguese document, corrected in Portuguese, explained in
Portuguese, with no way to ask for English — correct behaviour derived
from a fact about the roster that was no longer true.

The note in learnerPairs was written one phase too early to see it. It
said turning a pair around needs a word list and a dictionary reading
into English, and that fr, es and pt-PT had neither. Portuguese has
both. Word boundaries are spaces — the megabyte jieba needs is a
property of a writing system that doesn't use them, not a debt every
pair owes. And the dictionary arrived with dict.db, which reads pt→en
as readily as en→pt; dreamProvider.reverse has been answering that
question since the pair shipped. What was actually blocking the pair a
native English speaker learning Portuguese needs was this list.

So pt-PT joins it, and the pt-PT pack gets the learner copy the control
renders from — each label in the language of whoever would pick it,
since someone on the wrong side of that switch cannot read the side
they are reaching for. fr and es clear the same two bars through the
same dict.db and stay out: their packs carry no learner block yet,
which is a translation question rather than a data one, and the server
should keep saying no until one is written.

Two things that assumed learning_pair meant Chinese, now that it
doesn't. The segmenter gate reads the pair as well as the direction, or
a Portuguese learner would load a megabyte of Chinese word list and
hover Portuguese words at /api/hanzi. And that endpoint's own comment
justified skipping providerFor with a guarantee it no longer has; the
real guarantee was always the caller's — it is only ever asked about
tokens the Chinese segmenter found — and a stray lookup was already
safe, answering a miss with an empty 200.

Tests in both packages. The auth test that pinned pt-PT's refusal now
pins its acceptance, with fr and es still refused; the suggestions test
pins the consequence where it actually lands, which is the language she
reads her advice in.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-29 18:40:22 -07:00
prosolis 6026d98598 Half the editor was on the phone and none of it could be touched
Reported as "quite a few options don't function on mobile", and measured
in a real touch emulation rather than a narrow window — the distinction
matters, because two of the three causes need `pointer: coarse` to
appear at all. Three causes, one theme: the chrome was built for a
pointer that hovers.

The toolbar reveals itself on :hover — flex-wrap: wrap, overflow:
visible. A touchscreen never hovers, so that rule never fired and the
row stayed clipped at overflow: hidden permanently. Fourteen of its
twenty-three controls could not be reached by any gesture: every
heading, both lists, all three alignments, link, image, table, outline,
and both AI passes. The faded right edge promised there was more that
way, and there was no way. It now scrolls sideways on a coarse pointer,
keeping every control, exactly as .petal-chrome-strip does one line
above it — the same trade that comment already argued for, applied to
the row it was comparing itself to.

That move was only safe once the panels could get out. Every dropdown
in the editor chrome — tone, export, colour, highlight, size — was
already broken on a phone for a subtler reason: `position: fixed` is
relative to the viewport only while no ancestor establishes a
containing block, and mask-image does, exactly like transform. Both
scrollers fade their edges with a mask. So on any screen narrow enough
for the fade to appear, the menu was pulled back inside the very box it
was escaping, painted under the toolbar and untappable. The comment in
anchoredMenu.ts said "nothing clips a fixed box unless an ancestor has
a transform, and none of the editor's chrome does"; it was true when it
was written and had quietly stopped being true. The panels now portal
to <body>, where nothing above them can clip, stack over or contain
them whatever the chrome does with masks later, and the outside-tap
tests ask about both halves.

The kitten took the last two. She covered "Español" and "I am learning
Português" in the drawer, and "Hide falling petals" in the status bar
outright — three sample points across it, all three landing on the cat.
She already knows how to yield: useCardOverlap fades her for cards and
for anything with the modal role, and the drawer is the one overlay
that has neither, being navigation rather than something opened on
purpose. It is named there now. The status bar is a different problem —
it is always present, so yielding to it would mean fading forever — and
the honest fix is to sit above it on a narrow screen instead of
negotiating with it every frame.

Measuring, not eyeballing: a sweep of every visible, enabled control
reports zero off-screen and zero blocked, against fourteen and three
before. Desktop is deliberately untouched — at rest the slim clipped
line with its mask, on hover the wrapped row with all twenty-three
buttons on screen — and the edge measurement ChromeStrip already did is
now shared with the toolbar rather than written twice.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-29 18:39:53 -07:00
23 changed files with 488 additions and 157 deletions
+39 -5
View File
@@ -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
View File
@@ -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 {
+13 -4
View File
@@ -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)
+2 -2
View File
@@ -44,7 +44,7 @@ func TestPromptsNameTheWritersLanguage(t *testing.T) {
t.Fatalf("collocation prompt lost its tone guidance:\n%s", collocation) t.Fatalf("collocation prompt lost its tone guidance:\n%s", collocation)
} }
translate := TranslateMessages("Try a shorter sentence here.", pt)[0].Content translate := TranslateMessages("Try a shorter sentence here.", English, pt)[0].Content
if !strings.Contains(translate, "European Portuguese") || strings.Contains(translate, "Chinese") { if !strings.Contains(translate, "European Portuguese") || strings.Contains(translate, "Chinese") {
t.Fatalf("translate prompt targets the wrong language:\n%s", translate) t.Fatalf("translate prompt targets the wrong language:\n%s", translate)
} }
@@ -76,7 +76,7 @@ func TestDefaultPairStillReadsAsBefore(t *testing.T) {
if got := CollocationMessages("x", "", EnglishTarget(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", English, zh)[0].Content; !strings.Contains(got, "natural, friendly Simplified Chinese (Mandarin)") {
t.Fatalf("zh translate target changed:\n%s", got) t.Fatalf("zh translate target changed:\n%s", got)
} }
if got := AskPetalSystemPrompt("a", "b", "c", "d", "e", zh); !strings.Contains(got, "为什么") { if got := AskPetalSystemPrompt("a", "b", "c", "d", "e", zh); !strings.Contains(got, "为什么") {
+19 -13
View File
@@ -329,23 +329,29 @@ func RewriteMessages(text, style string) []Message {
} }
// translateSystemPrompt drives the explanation translator: it renders a // translateSystemPrompt drives the explanation translator: it renders a
// suggestion's English explanation into the writer's own language so an ESL // suggestion's explanation into the half of the pair the explanation is not
// reader sees the "why" in her first language. Strict about returning ONLY the // already in, so the "why" is readable from both sides. Strict about returning
// translation (no quotes, no romanisation, no English echo) so it can drop // ONLY the translation (no quotes, no romanisation, no echo of the source) so it
// straight into the chat bubble. Kept warm and plain — these are short, friendly // can drop straight into the chat bubble. Kept warm and plain — these are short,
// one-liners. // friendly one-liners.
const translateSystemPrompt = `You are Petal, a warm writing assistant. Translate the English text the user ` + //
`sends into natural, friendly %[1]s. It is a short explanation of a writing ` + // Both languages are parameters because neither end is a constant. Until Phase
`suggestion, written for a native %[1]s speaker learning English. // 28 the source was always English and the destination always hers; a document
// written in her own language is explained in her own language, and then the tap
// runs the other way, into the English she is practising.
const translateSystemPrompt = `You are Petal, a warm writing assistant. Translate the %[1]s text the user ` +
`sends into natural, friendly %[2]s. It is a short explanation of a writing ` +
`suggestion, written for someone who is learning one of %[1]s and %[2]s and reads the other most easily.
Respond with ONLY the %[1]s translation. No quotation marks, no romanisation, no English, no preamble — ` + Respond with ONLY the %[2]s translation. No quotation marks, no romanisation, no %[1]s, no preamble — ` +
`just the translated sentence.` `just the translated sentence.`
// TranslateMessages builds the message array for translating one short English // TranslateMessages builds the message array for rendering one short
// explanation into the writer's own language. // explanation out of the language it arrived in and into the other half of the
func TranslateMessages(text string, lang Lang) []Message { // writer's pair.
func TranslateMessages(text string, from, to Lang) []Message {
return []Message{ return []Message{
{Role: "system", Content: fmt.Sprintf(translateSystemPrompt, lang.Name)}, {Role: "system", Content: fmt.Sprintf(translateSystemPrompt, from.Name, to.Name)},
{Role: "user", Content: text}, {Role: "user", Content: text},
} }
} }
+7 -6
View File
@@ -4,13 +4,14 @@ import (
"context" "context"
) )
// RunTranslate renders a short English explanation into the writer's own // RunTranslate renders a short explanation out of the language it was written
// language. It is a one-shot Complete (the result seeds the Ask Petal bubble), // in and into the other half of the writer's pair. It is a one-shot Complete
// kept at a low temperature so the translation is faithful rather than creative. Output is // (the result seeds the Ask Petal bubble), kept at a low temperature so the
// trimmed of any stray surrounding quotes the model may add. // translation is faithful rather than creative. Output is trimmed of any stray
func RunTranslate(ctx context.Context, client LLMClient, text string, lang Lang) (string, error) { // surrounding quotes the model may add.
func RunTranslate(ctx context.Context, client LLMClient, text string, from, to Lang) (string, error) {
out, err := client.Complete(ctx, CompletionRequest{ out, err := client.Complete(ctx, CompletionRequest{
Messages: TranslateMessages(text, lang), Messages: TranslateMessages(text, from, to),
MaxTokens: 512, MaxTokens: 512,
Temperature: 0.2, Temperature: 0.2,
TopP: 0.9, TopP: 0.9,
+46 -10
View File
@@ -119,8 +119,36 @@ func TestDocumentLangNeedsCorroboration(t *testing.T) {
} }
} }
// The two language decisions are genuinely independent, and only the zh pair can // The Portuguese half of the same rule, and the bug it was reported as: "the
// prove it today — it is the one pair that can be travelled in both directions. // 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 // A Mandarin native practising English who writes Chinese wants Chinese
// corrections explained in Chinese. An English native learning Chinese who writes // corrections explained in Chinese. An English native learning Chinese who writes
@@ -343,10 +371,15 @@ func seedExplanation(t *testing.T, h *Handler, docID, explanation string) string
// The tap-through has to read the same decision the card was written under. On a // 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 document by a Portuguese writer the explanation already arrived in
// Portuguese, and the old endpoint would have sent it to the model to be // Portuguese, so the destination is the other half of the pair — the English she
// rendered into Portuguese again. // is practising — and never Portuguese into Portuguese again.
func TestTranslateSkipsWhenTheExplanationIsAlreadyHers(t *testing.T) { //
client := &stubClient{response: "Não devia ser chamado."} // This pins the report that "Petal presents the Ask Petal advice in both
// sections as Portuguese": the endpoint used to answer "" here, which left the
// card Portuguese, the bubble beneath it the same Portuguese, and no English on
// the card at all for a writer whose whole reason for the pair is English.
func TestTranslateRendersHerExplanationIntoTheEnglishSheIsLearning(t *testing.T) {
client := &stubClient{response: "Use this one here."}
srv, docID, h := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, ptDocument) srv, docID, h := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, ptDocument)
setDocLang(t, h, docID, docLangPair) setDocLang(t, h, docID, docLangPair)
sugID := seedExplanation(t, h, docID, "Aqui usa-se isto.") sugID := seedExplanation(t, h, docID, "Aqui usa-se isto.")
@@ -359,11 +392,14 @@ func TestTranslateSkipsWhenTheExplanationIsAlreadyHers(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err) t.Fatalf("decode: %v", err)
} }
if out.Translation != "" { if out.Translation == "" {
t.Fatalf("translation = %q, want empty: the bubble seeds from the explanation itself", out.Translation) t.Fatal("a Portuguese explanation left the tap with nowhere to go")
} }
if client.calls != 0 { if !strings.Contains(client.lastPrompt, "into natural, friendly English") {
t.Fatal("the model was asked to render Portuguese into Portuguese") t.Fatalf("translate didn't render into English:\n%s", client.lastPrompt)
}
if strings.Contains(client.lastPrompt, "into natural, friendly European Portuguese") {
t.Fatalf("the model was asked to render Portuguese into Portuguese:\n%s", client.lastPrompt)
} }
} }
+18 -12
View File
@@ -30,11 +30,18 @@ type translateResponse struct {
// this endpoint has to read the same decision back, or it round-trips Portuguese // this endpoint has to read the same decision back, or it round-trips Portuguese
// into Portuguese and calls it a translation. // into Portuguese and calls it a translation.
// //
// So: render into whichever half the explanation is NOT already in, and when the // So: render into whichever half the explanation is NOT already in and that
// explanation already arrived in the language this bubble exists to reach her // is the whole rule, in both directions. When it first shipped this endpoint
// in, skip the model call and answer "". The client seeds the bubble with the // answered "" for a Portuguese explanation on the reasoning that an English
// explanation itself when the translation comes back empty, which is exactly // rendering she hadn't asked for was noise. It was reported as the opposite: a
// right — there is nothing to add. // writer whose pair is Portuguese and English, learning English, met a
// Portuguese card with a Portuguese bubble under it and no English anywhere on
// the card. The tap is the one place the other language was promised, and the
// half she is *practising* is exactly the half worth a tap.
//
// The bubble sits directly beneath the explanation inside the card, so between
// the two the writer always has both languages, whichever way round the document
// put them.
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")
@@ -69,15 +76,14 @@ func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
// today; the alternative is a language column on every suggestion row, and the // 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. // cost of being wrong is one bubble seeded in the language it was already in.
target := targetFor(pairLang, direction, docLang) target := targetFor(pairLang, direction, docLang)
if target.Explain.Code == target.Pair.Code { from, to := target.Explain, target.Pair
// Already in her language. The other half is English — the language she is if from.Code == to.Code {
// practising — and an unasked-for English rendering of an explanation she // The explanation is already in her language, so the half this tap has to
// can already read is not a seed, it's noise. // reach is the other one: the English she is practising.
httputil.WriteJSON(w, http.StatusOK, translateResponse{Translation: ""}) to = llm.English
return
} }
out, err := llm.RunTranslate(r.Context(), h.Client, explanation, target.Pair) out, err := llm.RunTranslate(r.Context(), h.Client, explanation, from, to)
if err != nil { if err != nil {
httputil.UpstreamError(w, "translate", err) httputil.UpstreamError(w, "translate", err)
return return
+7 -1
View File
@@ -118,7 +118,13 @@ export default function App() {
// 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,
@@ -17,7 +17,14 @@ const POLL_MS = 500
const HOLD_PX = 24 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
+24 -16
View File
@@ -5,12 +5,11 @@ import { splitBilingual } from './bilingualReply'
import { fromIME } from '../../lib/ime' import { fromIME } from '../../lib/ime'
interface Props { interface Props {
// The card's explanation is deliberately NOT passed in. Petal's opening
// bubble is that explanation rendered into the other half of the pair,
// fetched on open from this id — and when that can't be had, the panel has
// nothing to say rather than a second copy of the card.
suggestionId: string suggestionId: string
// The English explanation (shown in the card body). Petal's opening bubble is
// its translation into the pair language, fetched on open — so the panel
// doesn't just repeat the same English text twice. Falls back to this on
// failure.
explanation: string
} }
// CJK fallback stack — Nunito has no Chinese glyphs, and on the zh pair both // CJK fallback stack — Nunito has no Chinese glyphs, and on the zh pair both
@@ -44,10 +43,11 @@ const CHAT_MIN_PX = 160
// conversation lives in this component's state — nothing is persisted; closing // conversation lives in this component's state — nothing is persisted; closing
// the card (unmounting) clears it. Each send streams Petal's reply token-by- // the card (unmounting) clears it. Each send streams Petal's reply token-by-
// token into the latest assistant bubble. // token into the latest assistant bubble.
export function AskPetal({ suggestionId, explanation }: Props) { export function AskPetal({ suggestionId }: Props) {
const t = usePack() const t = usePack()
// Opening bubble starts empty (caret-only) and fills with the pair-language // Opening bubble starts empty (caret-only) and fills with the other half of
// translation once it lands; `seeding` drives that loading caret. // the pair once it lands; `seeding` drives that loading caret, and the bubble
// goes away entirely if there turns out to be nothing to put in it.
const [messages, setMessages] = useState<ChatMessage[]>([{ role: 'assistant', content: '' }]) const [messages, setMessages] = useState<ChatMessage[]>([{ role: 'assistant', content: '' }])
const [seeding, setSeeding] = useState(true) const [seeding, setSeeding] = useState(true)
const [input, setInput] = useState('') const [input, setInput] = useState('')
@@ -82,23 +82,31 @@ export function AskPetal({ suggestionId, explanation }: Props) {
inputRef.current?.focus({ preventScroll: true }) inputRef.current?.focus({ preventScroll: true })
}, []) }, [])
// Fetch the pair-language translation of the explanation to seed the first // Fetch the explanation rendered into the other half of her pair to seed the
// bubble. // first bubble — English under a Portuguese card, her language under an
// English one; the server decides which way round (suggestions/translate.go).
// Only replaces the seed bubble if the user hasn't started chatting yet (the // Only replaces the seed bubble if the user hasn't started chatting yet (the
// conversation always opens with this one assistant turn). Falls back to the // conversation opens with this one assistant turn and nothing else).
// English explanation if the translation can't be fetched. //
// When it can't be fetched the panel opens empty rather than falling back to
// the explanation, which sits two lines higher in the card: a bubble that
// repeats the card verbatim reads as Petal answering in the language the tap
// was pressed to escape. Nothing to add, nothing shown.
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
const seed = (text: string) =>
setMessages((prev) =>
prev.length !== 1 ? prev : text === '' ? [] : [{ role: 'assistant', content: text }],
)
api api
.translateSuggestion(suggestionId) .translateSuggestion(suggestionId)
.then((res) => { .then((res) => {
if (cancelled) return if (cancelled) return
const text = res.translation.trim() || explanation seed(res.translation.trim())
setMessages((prev) => (prev.length === 1 ? [{ role: 'assistant', content: text }] : prev))
}) })
.catch(() => { .catch(() => {
if (cancelled) return if (cancelled) return
setMessages((prev) => (prev.length === 1 ? [{ role: 'assistant', content: explanation }] : prev)) seed('')
}) })
.finally(() => { .finally(() => {
if (!cancelled) setSeeding(false) if (!cancelled) setSeeding(false)
@@ -106,7 +114,7 @@ export function AskPetal({ suggestionId, explanation }: Props) {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [suggestionId, explanation]) }, [suggestionId])
async function send() { async function send() {
const text = input.trim() const text = input.trim()
+5 -32
View File
@@ -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}`}>
+1
View File
@@ -1582,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}
/> />
+1 -1
View File
@@ -197,7 +197,7 @@ export function SuggestionCard({
{asking ? 'Hide Petal' : 'Ask Petal ✨'} {asking ? 'Hide Petal' : 'Ask Petal ✨'}
</button> </button>
{asking && <AskPetal suggestionId={suggestion.id} explanation={suggestion.explanation} />} {asking && <AskPetal suggestionId={suggestion.id} />}
<div className="mt-3 flex items-center gap-2"> <div className="mt-3 flex items-center gap-2">
{hasReplacement && ( {hasReplacement && (
+1 -1
View File
@@ -217,7 +217,7 @@ const RailCard = forwardRef<HTMLDivElement, CardProps>(function RailCard(
</span> </span>
</button> </button>
{expanded && <AskPetal suggestionId={suggestion.id} explanation={suggestion.explanation} />} {expanded && <AskPetal suggestionId={suggestion.id} />}
<div className="mt-2.5 flex items-center gap-2"> <div className="mt-2.5 flex items-center gap-2">
{hasReplacement && ( {hasReplacement && (
+12 -6
View File
@@ -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>
) )
+23 -3
View File
@@ -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"
+31 -6
View File
@@ -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,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 }
}
+13 -5
View File
@@ -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>
) )
+42 -11
View File
@@ -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)',
+19
View File
@@ -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 · Couldnt switch — nothing changed',
},
app: { app: {
duplicateTitle: (title) => `${title} (cópia)`, duplicateTitle: (title) => `${title} (cópia)`,
garden: 'Jardim de palavras', garden: 'Jardim de palavras',
+69
View File
@@ -564,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) ------------------------------------
@@ -604,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.