Compare commits
11
Commits
1fdc206576
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d961a89bf8 | ||
|
|
7fa98d03c7 | ||
|
|
6bed33c27e | ||
|
|
7383bdb403 | ||
|
|
3cc23b8ea4 | ||
|
|
6026d98598 | ||
|
|
15398eab4d | ||
|
|
2c5b05b398 | ||
|
|
acb35108c0 | ||
|
|
e67f77eb05 | ||
|
|
c719effe1d |
+9
-1
File diff suppressed because one or more lines are too long
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,3 +429,108 @@ func TestTranslateStillRendersForALearnersEnglishExplanation(t *testing.T) {
|
|||||||
t.Fatalf("translate didn't render into the pair language:\n%s", client.lastPrompt)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -329,6 +329,19 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
|||||||
// Portuguese while she clears it to start the entry again.
|
// Portuguese while she clears it to start the entry again.
|
||||||
docLang := documentLang(contentText, pairLang, prevLang)
|
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) == "" {
|
||||||
|
|||||||
@@ -155,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",
|
||||||
@@ -164,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",
|
||||||
@@ -173,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",
|
||||||
@@ -181,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",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+40
-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,
|
||||||
|
|||||||
+27
-4
@@ -276,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(() => '')
|
||||||
@@ -322,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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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}`}>
|
||||||
|
|||||||
@@ -1106,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.
|
||||||
@@ -1116,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,
|
||||||
})
|
})
|
||||||
@@ -1566,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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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,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,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)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user