Merge fix/rail-follows-mode: the rail follows the mode, and Ask Petal answers in both languages

Item 7 (the rail is a mode, not a screen size; click opens the anchored
card even with the rail up) and item 6 (bilingual Ask Petal answers with
room to read) from the 2026-07-27 UX review.
This commit is contained in:
prosolis
2026-07-28 07:04:23 -07:00
15 changed files with 677 additions and 46 deletions
+248 -8
View File
@@ -513,6 +513,119 @@ sane max-height (~50vh) before scrolling.
**Acceptance:** an Ask Petal answer shows 中文 + English; a 3-paragraph **Acceptance:** an Ask Petal answer shows 中文 + English; a 3-paragraph
answer is readable without scrolling a ~100 px box. answer is readable without scrolling a ~100 px box.
### 6 — DONE (eighth session). The answer was English-only on purpose, and the box was the smaller half of the item.
**The first half was one sentence in a prompt.** `askPetalSystemTemplate` said
*"Detect the language of the user's message and respond in that same language…
Never mix languages in a single response."* Self-consistent, and it made the
English-only answer inevitable: ask in English — which she does, because she is
practising — and the explanation that goes deepest into the "why" is the one
surface that gives her nothing in her own language. It now asks for both halves
every time, pair language first, and the old sentence is gone (a model handed
both instructions picks one at random).
**Which half is the lesson is not Petal's to assume.** The first draft of this
justified the change as "her language is the safety net, English is what she's
learning" — wrong, and wrong in a way the code would have carried for good. The
pair is (English + X) and Petal is used from both ends: an English speaker
learning French needs the French half for exactly the reason a Mandarin speaker
learning English needs the English one. So the prompt asks for both and says it
does not know which way round, and nothing in the wording, the rendering or the
comments assigns the halves a role. The ordering still holds either way — the
pair language leads, English follows, which is the pack's order everywhere else.
**The blank line between the halves is a contract, and a soft one.**
`bilingualReply.ts` splits on the first blank line to render the two halves the
way the companion renders its two lines. It is deliberately forgiving because
the reply streams in token by token from a small local model: a half-arrived
reply is all "native" and the English simply appears beneath it when the break
lands; a model that ignores the instruction renders as one ordinary block. The
one thing it will never do is drop text. A separator with nothing on one side of
it is a stray newline, not a split, and is kept whole.
- Petal's bubbles now take the card's full width. Two languages in the 85% a
chat reserves to show who is talking wrapped a sentence into a paragraph, and
the tint and alignment already say who is talking.
- `chatFailed` moved into the packs. It is the only message the panel writes
without the model, and it was English-only — telling the half of the pair that
can't read English nothing at all, in the one situation where nothing else is
on screen. It is written blank-line separated, so it renders through the same
two-half bubble as a real reply.
**The height half was the larger one, and the first fix was wrong.** The item
asks for ~50vh. Because the anchored card opens under the flagged word and never
flips above it, the first version took the *smaller* of 50vh and the room left
below the card, so it could never overhang the screen. Measured on the running
build, that gave **176 px against a 442 px answer** — worse than the 220 px it
replaced. The card's own pill, diff, explanation and action row already spend
~290 px of an 810 px window: "fits below the word" and "room to read" are not
both available, and the clamp silently chose the wrong one.
So the ceiling is flat 50vh and the overhang is made navigable instead — item 4's
answer to the same conflict, in its own words: *make the overhang navigable, not
shrink what each card says*. Both surfaces that host the panel now report their
reach, so the column grows and the page can scroll to what hangs below:
- `SuggestionCard.tsx``onExtent`, a ResizeObserver rather than a one-shot
measure, because the card grows twice after it mounts: the panel opens, and
then the reply streams into it. It reports 0 as it unmounts.
- `EditorCore.tsx``cardExtent` beside `railExtent`, resolved to one
`overhang` (whichever reaches lower) that feeds the wrapper's `minHeight`. The
rail's contribution stays conditional on the rail being mounted; the card's
does not, because it withdraws its own.
- The rail needed nothing: it already re-measures on `expandedId` and a
per-card ResizeObserver, so a rail card whose conversation grows reports it.
**Verified in a real browser at the review's own 1517×810**, against a stand-in
model server (no VPN, no GPU) returning a deliberately long three-paragraph
bilingual reply:
- *Anchored card.* Box **176 → 362 px** (50vh), card overhangs by 165 px, the
column gained 209 px of scroll where the same card previously had 20, and
Accept is fully on screen after scrolling to it. No horizontal overflow.
- *Rail card.* Box 334 px, overhang 104 px, 204 px of scroll room, Accept
reachable. The rail's own extent pipeline covered it, as read.
- *On a taller window* the whole 494 px answer fits with no scrollbar at all.
**Honest limit:** at 810 px a genuinely long answer still scrolls — 362 px of
442. The item's "readable without scrolling a ~100 px box" is met in the sense
that mattered (the box is no longer a peephole and the rest is a short scroll,
not a hunt), but a three-paragraph *bilingual* answer is roughly twice the text
the item imagined, and half a small screen does not hold it.
**A trap worth recording, because it cost two false measurements.** The restart
script used `pkill -f 'scratchpad/petal$'`, which never matched: the process was
started as `./petal` after a `cd`, so its command line doesn't contain the path.
Every "restart" after the first therefore failed to bind the port and died
quietly, while the *original* binary kept serving the *original* bundle — and
the page loaded fine, the app worked, and the numbers looked plausible. Two
rounds of "the fix didn't take" were measurements of code that was never
running. What caught it was the inline `max-height` reading `176.417px`, a value
the new code cannot produce. **Check the served bundle hash, not that the page
loads** (`curl -s localhost:PORT/ | grep -o 'index-[A-Za-z0-9_-]*\.js'` against
`web/dist/index.html`).
**Deliberately not done:**
- No auto-scroll to the card when the panel opens. The conversation starts short
and grows; scrolling the page out from under her the moment she clicks Ask
Petal would move the sentence she is reading about, to solve a problem she
does not have yet.
- The seed bubble stays single-language. It is the pair-language rendering of
the English explanation printed directly above it in the same card — the card
is already bilingual across those two lines, and repeating the English inside
the bubble would be the "same text twice" the seed exists to avoid.
Coverage: `bilingualReply.test.ts` (both scripts, mid-stream, one-language,
extra blank lines, whitespace-only separator, empty, single newlines inside a
half), `lang_test.go`'s `TestAskPetalAnswersInBothLanguages` (all four pairs ask
for both languages, name the pair language first, keep the separator, and no
longer carry the sentence forbidding it), and an `i18n.test.ts` case that every
pack's `chatFailed` has two non-empty halves and keeps its English one in
English. **The extent wiring has no unit test**, for the reason item 7 recorded:
jsdom has no layout, every rect is zero, and a test there would pass whatever
the code did. It is browser-verified only, and is written down as such.
## 7. Inline popover at the underline (verify, then strengthen) ## 7. Inline popover at the underline (verify, then strengthen)
Grammarly's core gesture is click-the-word → popup at the word. Grammarly's core gesture is click-the-word → popup at the word.
@@ -531,6 +644,99 @@ whichever half is missing.
Accept, without needing the rail; hovering a rail card glows its span and Accept, without needing the rail; hovering a rail card glows its span and
vice versa. vice versa.
### 7 — DONE (seventh session). The rail isn't a screen-size fact; it's a mode.
The item says to confirm first and fix whichever half is missing. Confirming
first is what mattered, because the interesting defect wasn't either half.
**Settled first: the contradiction items 4 and 5 left behind.** The fifth
session measured rail cards at 1517px; the sixth found no rail at all at the
same width and wrote down a margin of 258. Both were right. The editor is a
fixed 720px column centred in the pane, and the doc-list sidebar is 280px, so
at 1517px the right margin is **258 with the sidebar open and 406 without**
either side of the rail's 348 threshold. What moves between them is
distraction-free mode, which engages *on its own* the moment the editor takes
focus. So the rail is not a property of her screen. It appears when she starts
writing and disappears when she stops, and both sessions had simply caught it
in different states.
**The bug that fell out of that.** `recomputeRail` was triggered by a
ResizeObserver on the wrapper, a window `resize`, or a change to the suggestion
set. Entering or leaving distraction-free mode is none of the three: the
wrapper is a fixed 720px column, so re-centring it changes its *position* and
never its *size*, and a ResizeObserver reports only size. `railEnabled` therefore
kept whatever value it last had.
Leaving distraction-free with the rail up is the bad direction, and it is not
subtle — measured in Chrome at 1517×810: the 300px column stayed mounted in the
266px margin the restored sidebar left behind, **overhanging the viewport by
66px**, cards clipped mid-sentence ("use "an": "a…"), and the page grew a
horizontal scrollbar it never has otherwise. The other direction is only a loss:
she starts typing, the margin opens to 406, and no rail arrives. Both persisted
indefinitely — dispatching a lone `resize` event was enough to correct either,
which is what proved the measurement was the only thing missing.
**Implemented:**
- `EditorCore.tsx` — the ResizeObserver now watches `.petal-scrollport` as well
as the wrapper. The scrollport spans the pane, so it resizes whenever the
chrome around the editor does; the wrapper, being fixed-width, never does. It
is the element the sticky-pin code already reaches for, so it needed no new
handle, and unlike threading `focusMode` down as a prop it also covers any
future chrome that moves the editor.
- `railFit.ts``RAIL_MIN_MARGIN` and `railFitsBeside` lifted out of the
measurement callback. A bare `>=` doesn't need a name; this one earns it,
because the number picks between two entirely different suggestion surfaces
and the margin it reads moves for reasons unrelated to window size.
- `EditorCore.tsx` — **clicking a highlight now opens the anchored card even
when the rail is up**, which is the item's own acceptance criterion and was
previously false by design. Hover still defers to the rail: an unbidden
floating card next to a margin card saying the same thing is noise, and that
earlier reasoning was about hover and still holds. A click isn't. The rail card
glows instead of expanding, so the suggestion is never open in two places, and
a click-opened card keeps its glow after the pointer leaves (it closes on a
click away) so the margin and the open card don't disagree about what she's
reading.
**Measured, not estimated.** The item guessed ~400px of eye travel from
underline to rail card. At 1517px in distraction-free mode the real distance
from the first flagged span's right edge to its card is **651px**. After the
change the card lands 6px under the word.
**Verified in a real browser at the review's own 1517×810**, driving the local
build with the rule pack from item 3b so no model or VPN was involved:
- *Rail follows the mode, with no resize event anywhere.* Click into the prose →
sidebar collapses, margin 406, rail mounts with its cards, no overflow. Escape
→ sidebar restores, margin 258, rail unmounts, no overflow, no horizontal
scroll. Re-focus → it comes back. Re-run after the `railFit` extraction.
- *Click with the rail up.* Popover opens flush under "a apple" (6px gap, left
edges aligned), carrying the type pill, the diff, the full explanation, Ask
Petal, Accept and Dismiss; it fits the viewport; exactly one rail card glows
and **none is expanded**.
- *Accept from that popover.* Text became "an apple", the popover closed, the
rail went 6 cards → 5, and the other four kept their id, position and wording
— item 2's stability holding under a path it hadn't been exercised on.
- *The two halves the item asked about were already fine.* Span hover lights its
rail card, card hover lights its span (both directions, checked via the
`-active` classes). And with the rail off, clicking an underline already gave
an anchored popover — richer than the item's "one-line reason + more", since
it carries the whole explanation and Ask Petal. Nothing to build there.
**Deliberately not done:** no "more" affordance linking the popover to a rail
card. The item imagined the popover as a teaser for the rail's fuller version;
there is no fuller version — both surfaces render the same explanation, and the
popover has Ask Petal too. Adding a control that expands a second copy of what
she is already reading would be the redundancy the hover rule exists to avoid.
Coverage: `railFit.test.ts` pins the threshold to the margins actually measured
in Chrome — 406 fits, 258 and the mid-animation 266 don't, the bound is
inclusive, 1920-with-sidebar fits, narrow windows never do. **The observer wiring
itself has no unit test and can't have a useful one**: jsdom has no layout, so
every `getBoundingClientRect()` is zero, `railFitsBeside(0, 0)` is false, and the
rail branch is unreachable there. That half is browser-verified only, and is
written down as such rather than covered by a test that would pass regardless.
## 8. Smaller items (each small, do opportunistically) ## 8. Smaller items (each small, do opportunistically)
- **Accept All per category.** Five tense fixes = five clicks today. Add - **Accept All per category.** Five tense fixes = five clicks today. Add
@@ -611,6 +817,26 @@ renders invisibly, and at her actual viewport the rail is disabled — the inlin
hover card is what she sees, which inverts item 7's premise. Untouched: 6, 7, 8, hover card is what she sees, which inverts item 7's premise. Untouched: 6, 7, 8,
item 3's incremental half.)* item 3's incremental half.)*
*(Seventh session: item 7 done — see the subsection under it. Two things there
are worth carrying forward. First, **the rail is a mode, not a screen size**:
distraction-free engages by itself on editor focus and moves the margin across
the rail's threshold, so "does she see the rail?" has no fixed answer at a given
width — items 4 and 5 disagreed only because they caught it in different states.
Second, the layout invariant that bit here is the same shape as the one item 4
recorded: **a fixed-width column that gets re-centred changes position without
changing size**, and neither a ResizeObserver on it nor a window resize will say
so. Untouched: 6, 8, item 3's incremental half.)*
*(Eighth session: item 6 done — see the subsection under it. Two things to carry
forward. First, **the pair is symmetric**: Petal is used from both ends, so
"her language" and "the language being learned" are not interchangeable terms,
and any copy or prompt that assigns the two halves a role is wrong for half the
users — the wording here was corrected mid-session for exactly that. Second,
**check the served bundle hash before believing a browser measurement**: a
restart that silently failed left an old binary serving an old bundle through
two rounds of measurement, and nothing about the running app looked wrong.
Untouched: 8, item 3's incremental half.)*
**Migration 0015 on the live database.** It rebuilds the suggestions table, so **Migration 0015 on the live database.** It rebuilds the suggestions table, so
unlike 0014 it could have dropped her rows. Backed up first — and the backup unlike 0014 it could have dropped her rows. Backed up first — and the backup
had to be the whole WAL set (`petal.db`, `-wal`, `-shm` in had to be the whole WAL set (`petal.db`, `-wal`, `-shm` in
@@ -625,14 +851,28 @@ CHECK, and every existing row still carrying the label she has already read (the
seven `clarity` rows include the mislabelled Chinese one — by design, only new seven `clarity` rows include the mislabelled Chinese one — by design, only new
findings get the new type; if you want that card relabelled, edit the sentence). findings get the new type; if you want that card relabelled, edit the sentence).
**Suggested next (sixth session onward):** items 6, 7, 8 are all untouched **Suggested next (eighth session onward):** only **item 8**'s four small ones
and all small; item 5's re-scoped Translate card type is the cheapest and item 3's incremental half remain. **Dismissal persistence** is still the one
*visible* win (see its Status note — the span is already detected and with real value now that item 2 gives suggestions stable identity across checks;
already rendered in English, it's only mislabeled as Clarity), and the status-bar summary is the cheapest. Item 3's incremental surfacing needs
`internal/suggestions/translate.go` already exists — read it before streaming, which the current `/check` response shape doesn't do — it remains the
designing a new type. Item 3's incremental-surfacing half now has the largest of what's left.
chunking it was waiting on, but still needs streaming, which the current
`/check` response shape doesn't do. *(Superseded, kept for the reading list: the seventh session recommended item 6,
which is now done.)* **item 6** was the obvious pick —
it is the last small one, it is self-contained (prompt the answer path to reply
in both pair languages, then let the answer area grow to ~50vh instead of a
~100px scrollbox), and item 7 just made the surface it lives on more prominent:
Ask Petal now opens inside a card anchored at the word in *both* layouts, so a
cramped English-only answer is more visible than when the review was written.
Item 8's four are all still small and independent; **dismissal persistence** is
the one with real value now that item 2 gives suggestions stable identity across
checks. Item 3's incremental-surfacing half still needs streaming, which the
current `/check` response shape doesn't do — it remains the largest of what's
left.
*(Superseded, kept for the reading list: the sixth session recommended item 5's
Translate card type, which is now done and live.)*
*The advice below was written for the second session and is kept for its *The advice below was written for the second session and is kept for its
reading list, not its recommendation: item 3b is done and deployed.* It was reading list, not its recommendation: item 3b is done and deployed.* It was
+37
View File
@@ -83,3 +83,40 @@ func TestDefaultPairStillReadsAsBefore(t *testing.T) {
t.Fatalf("zh ask-petal lost its Mandarin \"why\":\n%s", got) t.Fatalf("zh ask-petal lost its Mandarin \"why\":\n%s", got)
} }
} }
// UX item 6: the Ask Petal answer is bilingual, pair language first, halves
// separated by one blank line. That separator is not a stylistic preference —
// AskPetal.tsx splits on it to render the two halves the way the companion
// renders its two lines — so the instruction has to survive prompt edits.
//
// The direction the writer is learning in is deliberately not encoded: the pair
// is (English + X), and an English speaker learning French needs the same two
// halves a Mandarin speaker learning English does. The prompt asks for both and
// lets the reader choose, so there is nothing here that names one half the
// answer and the other a courtesy.
func TestAskPetalAnswersInBothLanguages(t *testing.T) {
for _, code := range []string{"zh", "pt-PT", "fr", "es"} {
lang := LangFor(code)
ask := AskPetalSystemPrompt("a", "b", "grammar", "d", "e", lang)
if !strings.Contains(ask, "BOTH languages") {
t.Fatalf("%s: ask-petal no longer asks for both languages:\n%s", code, ask)
}
if !strings.Contains(ask, "single blank line") {
t.Fatalf("%s: ask-petal lost the blank-line separator the client splits on:\n%s", code, ask)
}
// Order matters to the rendering: the pair language is the prominent
// half, English the muted one beneath it.
if !strings.Contains(ask, "first the whole answer in "+lang.Name) {
t.Fatalf("%s: ask-petal doesn't put %s first:\n%s", code, lang.Name, ask)
}
// The instruction it replaced. Left in place it directly contradicts the
// new one, and a model given both will pick one at random.
if strings.Contains(ask, "Never mix languages") {
t.Fatalf("%s: ask-petal still forbids the bilingual reply it now asks for:\n%s", code, ask)
}
if strings.Contains(ask, "%!") {
t.Fatalf("%s: ask-petal prompt has a formatting error:\n%s", code, ask)
}
}
}
+29 -4
View File
@@ -144,6 +144,25 @@ func CollocationMessages(contentText, tone string, lang Lang) []Message {
// askPetalSystemTemplate is the Ask Petal tutor prompt. The suggestion context // askPetalSystemTemplate is the Ask Petal tutor prompt. The suggestion context
// is interpolated in; the user's own messages are appended after this system // is interpolated in; the user's own messages are appended after this system
// turn by the caller. // turn by the caller.
//
// The reply is bilingual, the pair language first. Until UX item 6 it mirrored
// the language of the question instead — self-consistent, but it meant asking in
// one language cost you the other, and the writer doesn't always know which one
// the answer will be clearer in. Which half is the safety net and which is the
// lesson depends on who is writing: the pair is (English + X) either way, and an
// English speaker learning French wants the French half for the same reason a
// Mandarin speaker learning English wants the English one. Petal cannot tell
// them apart from a chat message, and doesn't need to — every other explanation
// surface already gives both (the card's English body, the seeded bubble in the
// pair language). The answer that goes deepest into the "why" was the one place
// that didn't.
//
// The blank line between the halves is a contract with the client: AskPetal.tsx
// splits on the first one to render her language prominently and the English
// beneath it, mirroring the companion's bubble. A model that ignores the
// instruction and writes one language degrades to a single plain block — the
// answer is still readable, which is why the split is a rendering nicety and
// never a parse the reply depends on.
const askPetalSystemTemplate = `You are Petal, a warm and patient English writing tutor helping someone who is learning English ` + const askPetalSystemTemplate = `You are Petal, a warm and patient English writing tutor helping someone who is learning English ` +
`as a second language. You are currently discussing a specific writing suggestion. `as a second language. You are currently discussing a specific writing suggestion.
@@ -154,15 +173,21 @@ Suggestion context:
- Initial explanation: "%[4]s" - Initial explanation: "%[4]s"
- Surrounding paragraph: "%[5]s" - Surrounding paragraph: "%[5]s"
The user wants to understand this suggestion better. Detect the language of the user's message ` + The user wants to understand this suggestion better. Answer in BOTH languages, every time, ` +
`and respond in that same language. If they write in %[6]s, respond entirely in ` + `whichever language they asked their question in: first the whole answer in %[6]s, then the ` +
`%[6]s. If they write in English, respond in English. Never mix languages in a single response. `same answer again in English. Separate the two with a single blank line. Do not label them, ` +
`do not use a blank line anywhere else, and do not mix the two languages within one half — ` +
`each half is complete on its own.
One of those two languages is the one they are surest in and the other is the one they are ` +
`working in — you do not know which way round, so give both and let them choose. Both halves ` +
`say the same thing: do not put a point in one that is missing from the other.
Explain clearly and kindly. Use simple language appropriate to the user's message. Give examples ` + Explain clearly and kindly. Use simple language appropriate to the user's message. Give examples ` +
`when helpful. If they ask "why" (or "%[7]s"), explain the grammar rule or idiom behind it. ` + `when helpful. If they ask "why" (or "%[7]s"), explain the grammar rule or idiom behind it. ` +
`If they suggest an alternative phrasing, evaluate it honestly. `If they suggest an alternative phrasing, evaluate it honestly.
Keep responses concise (2-4 sentences). This is a chat, not an essay. Be encouraging — ` + Keep each half concise (2-3 sentences). This is a chat, not an essay. Be encouraging — ` +
`learning a language is hard and they're doing great.` `learning a language is hard and they're doing great.`
// AskPetalSystemPrompt fills the tutor prompt with one suggestion's context and // AskPetalSystemPrompt fills the tutor prompt with one suggestion's context and
+80 -14
View File
@@ -1,27 +1,51 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { api, streamSuggestionChat, type ChatMessage } from '../../api/client' import { api, streamSuggestionChat, type ChatMessage } from '../../api/client'
import { usePack } from '../../i18n' import { usePack } from '../../i18n'
import { splitBilingual } from './bilingualReply'
interface Props { interface Props {
suggestionId: string suggestionId: string
// The English explanation (shown in the card body). Petal's opening bubble is // The English explanation (shown in the card body). Petal's opening bubble is
// its Simplified-Chinese translation, fetched on open — so the panel doesn't // its translation into the pair language, fetched on open — so the panel
// just repeat the same English text twice. Falls back to this on failure. // doesn't just repeat the same English text twice. Falls back to this on
// failure.
explanation: string explanation: string
} }
// CJK fallback stack — Nunito has no Chinese glyphs, and the user asks questions // CJK fallback stack — Nunito has no Chinese glyphs, and on the zh pair both
// in Mandarin (spec Note #17). Applied to the bubbles specifically, not the // the questions and half of every answer are in Mandarin (spec Note #17). The
// serif editor body. // Latin pairs fall through to Nunito as before. Applied to the bubbles
// specifically, not the serif editor body.
const CHAT_FONT = "'Nunito', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif" const CHAT_FONT = "'Nunito', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif"
// How tall the conversation may grow (UX item 6: "room to read"). A bilingual
// three-paragraph answer in a 220px box was a scrollbar with a sentence in it.
//
// An earlier version of this took the smaller of half the viewport and the room
// left below the card, so the card could never overhang the screen. Measured, it
// gave 176px against a 442px answer — the card's own pill, diff, explanation and
// action row already spend ~290px of an 810px screen, so "fits below the word"
// and "room to read" are simply not both available.
//
// So this is the flat ceiling, and the overhang is made navigable instead —
// item 4's answer to the same conflict, and its words for it: "the answer is to
// make the overhang navigable, not to shrink what each card says". Both surfaces
// that host this panel report their reach to the editor wrapper (SuggestionCard
// via onExtent, the rail via its own measureTick), which grows the column, so a
// conversation that runs past the fold has real page under it and the Accept
// button below it can be scrolled to.
const CHAT_MAX_FRACTION = 0.5
// Below this a max-height stops being a reading area and becomes a peephole —
// the floor for a very short window, where half of it is not worth having.
const CHAT_MIN_PX = 160
// AskPetal is the mini chat panel inside an expanded SuggestionCard. The whole // AskPetal is the mini chat panel inside an expanded SuggestionCard. The whole
// 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, explanation }: Props) {
const t = usePack() const t = usePack()
// Opening bubble starts empty (caret-only) and fills with the Mandarin // Opening bubble starts empty (caret-only) and fills with the pair-language
// translation once it lands; `seeding` drives that loading caret. // translation once it lands; `seeding` drives that loading caret.
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)
@@ -36,6 +60,19 @@ export function AskPetal({ suggestionId, explanation }: Props) {
if (el) el.scrollTop = el.scrollHeight if (el) el.scrollTop = el.scrollHeight
}, [messages]) }, [messages])
// How tall the conversation may grow. A share of the window, so a laptop and a
// large monitor both give the answer a sensible amount of themselves — and a
// window she resizes mid-conversation is answered live.
const [maxHeight, setMaxHeight] = useState(() =>
Math.max(CHAT_MIN_PX, window.innerHeight * CHAT_MAX_FRACTION),
)
useEffect(() => {
const onResize = () =>
setMaxHeight(Math.max(CHAT_MIN_PX, window.innerHeight * CHAT_MAX_FRACTION))
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [])
// Focus the input when the panel opens. preventScroll: the card is already on // Focus the input when the panel opens. preventScroll: the card is already on
// screen as an absolutely-positioned overlay, and a default focus() would make // screen as an absolutely-positioned overlay, and a default focus() would make
// the browser scroll its ancestor to "reveal" the input — jumping the document // the browser scroll its ancestor to "reveal" the input — jumping the document
@@ -44,7 +81,8 @@ export function AskPetal({ suggestionId, explanation }: Props) {
inputRef.current?.focus({ preventScroll: true }) inputRef.current?.focus({ preventScroll: true })
}, []) }, [])
// Fetch the Chinese translation of the explanation to seed the first bubble. // Fetch the pair-language translation of the explanation to seed the first
// bubble.
// 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 always opens with this one assistant turn). Falls back to the
// English explanation if the translation can't be fetched. // English explanation if the translation can't be fetched.
@@ -91,10 +129,10 @@ export function AskPetal({ suggestionId, explanation }: Props) {
} catch (err) { } catch (err) {
setMessages((prev) => { setMessages((prev) => {
const next = prev.slice() const next = prev.slice()
next[next.length - 1] = { // Bilingual, from the pack, and blank-line separated like a real reply —
role: 'assistant', // so the one message Petal writes without the model still renders
content: 'Sorry, I had trouble responding just now. Please try again. 🌸', // through the same two-half bubble as every message with it.
} next[next.length - 1] = { role: 'assistant', content: t.editor.chatFailed }
return next return next
}) })
console.error('Ask Petal chat failed:', err) console.error('Ask Petal chat failed:', err)
@@ -116,7 +154,7 @@ export function AskPetal({ suggestionId, explanation }: Props) {
<div <div
ref={scrollRef} ref={scrollRef}
className="flex flex-col gap-2 overflow-y-auto pr-1" className="flex flex-col gap-2 overflow-y-auto pr-1"
style={{ maxHeight: 220 }} style={{ maxHeight }}
> >
{messages.map((m, i) => ( {messages.map((m, i) => (
<Bubble <Bubble
@@ -164,6 +202,19 @@ export function AskPetal({ suggestionId, explanation }: Props) {
// Bubble renders one chat turn: Petal rose-tinted and left-aligned, the user // Bubble renders one chat turn: Petal rose-tinted and left-aligned, the user
// lavender and right-aligned. A trailing caret marks the actively streaming // lavender and right-aligned. A trailing caret marks the actively streaming
// reply until its first token lands. // reply until its first token lands.
//
// Petal's turns are bilingual (see bilingualReply.ts) and are laid out the way
// the companion lays out its own two lines: the pair language first and plainly
// readable, the English beneath it in the muted tone. That order is the pack's
// order everywhere else in the UI, and it holds whichever direction the writer
// is learning in — the muted half is the one they can already read, and which
// half that is isn't Petal's to decide. The writer's own turns are their own
// words in whichever language they typed them, so they are never split.
//
// Petal's bubble also takes the full width the card offers rather than the 85%
// a chat normally reserves to show who is talking — the alignment and the
// tint already say that, and two languages in a 4/5-width column wraps a
// sentence-length answer into a paragraph-shaped one.
function Bubble({ function Bubble({
role, role,
content, content,
@@ -174,10 +225,11 @@ function Bubble({
streaming: boolean streaming: boolean
}) { }) {
const isPetal = role === 'assistant' const isPetal = role === 'assistant'
const reply = isPetal ? splitBilingual(content) : null
return ( return (
<div className={`flex ${isPetal ? 'justify-start' : 'justify-end'}`}> <div className={`flex ${isPetal ? 'justify-start' : 'justify-end'}`}>
<div <div
className="max-w-[85%] rounded-2xl px-3 py-1.5 text-xs leading-snug" className={`${isPetal ? 'w-full' : 'max-w-[85%]'} rounded-2xl px-3 py-2 leading-snug`}
style={{ style={{
background: isPetal ? 'var(--color-surface-alt)' : 'var(--color-lavender)', background: isPetal ? 'var(--color-surface-alt)' : 'var(--color-lavender)',
color: 'var(--color-plum)', color: 'var(--color-plum)',
@@ -185,7 +237,21 @@ function Bubble({
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
}} }}
> >
{content} {reply ? (
<>
<span className="text-[0.8rem]">{reply.native}</span>
{reply.en !== '' && (
<span
className="mt-1.5 block text-xs"
style={{ color: 'var(--color-muted)' }}
>
{reply.en}
</span>
)}
</>
) : (
<span className="text-xs">{content}</span>
)}
{streaming && content === '' && ( {streaming && content === '' && (
<span className="petal-chat-caret" aria-hidden> <span className="petal-chat-caret" aria-hidden>
+52 -17
View File
@@ -19,6 +19,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react
import { Toolbar } from '../Toolbar/Toolbar' import { Toolbar } from '../Toolbar/Toolbar'
import { SuggestionCard } from './SuggestionCard' import { SuggestionCard } from './SuggestionCard'
import { SuggestionRail, type RailItem } from './SuggestionRail' import { SuggestionRail, type RailItem } from './SuggestionRail'
import { railFitsBeside } from './railFit'
import { SuggestionHighlight, setSuggestions, setActiveSuggestion, findRange } from './SuggestionHighlight' import { SuggestionHighlight, setSuggestions, setActiveSuggestion, findRange } from './SuggestionHighlight'
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck' import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
import { MisspellCard } from './MisspellCard' import { MisspellCard } from './MisspellCard'
@@ -278,6 +279,24 @@ export function EditorCore({
// without this the column below the last line of text isn't scrollable and any // without this the column below the last line of text isn't scrollable and any
// card that lands there is unreachable, not merely far from its sentence. // card that lands there is unreachable, not merely far from its sentence.
const [railExtent, setRailExtent] = useState(0) const [railExtent, setRailExtent] = useState(0)
// The same report from the anchored card, which is absolutely positioned for
// the same reason and so has the same problem: an open Ask Petal conversation
// can reach well past the last line of a short document, and its Accept button
// goes with it. 0 whenever no card is open.
const [cardExtent, setCardExtent] = useState(0)
// How far down the column has to reach to cover its floating surfaces. Both
// reach past the prose for the same reason and are answered the same way, so
// they resolve to one number: whichever is lower wins, and 0 means the text
// alone decides the height.
//
// The rail's extent is conditional on the rail being mounted — a stale measure
// from a rail that has since been dismissed would leave a document padded with
// blank scroll. The card's is not: it reports 0 as it unmounts.
const overhang = Math.max(
railEnabled && railExtent > 0 ? railExtent + RAIL_TAIL : 0,
cardExtent > 0 ? cardExtent + RAIL_TAIL : 0,
)
// Sticky offset for the text column, or null when it should sit in normal flow. // Sticky offset for the text column, or null when it should sit in normal flow.
// Set only while the stack overhangs the text: scrolling down to reach the lower // Set only while the stack overhangs the text: scrolling down to reach the lower
// cards would otherwise carry every sentence off the top of the screen. // cards would otherwise carry every sentence off the top of the screen.
@@ -425,9 +444,7 @@ export function EditorCore({
const wrapper = wrapperRef.current const wrapper = wrapperRef.current
if (!wrapper) return if (!wrapper) return
const wrapRect = wrapper.getBoundingClientRect() const wrapRect = wrapper.getBoundingClientRect()
// Need room for the 300px column + its 32px gutter (see .petal-rail), plus setRailEnabled(railFitsBeside(window.innerWidth, wrapRect.right))
// a little breathing space to the viewport edge.
setRailEnabled(window.innerWidth - wrapRect.right >= 348)
const seen = new Set<string>() const seen = new Set<string>()
const items: RailItem[] = [] const items: RailItem[] = []
wrapper.querySelectorAll<HTMLElement>('.petal-suggestion[data-suggestion-id]').forEach((el) => { wrapper.querySelectorAll<HTMLElement>('.petal-suggestion[data-suggestion-id]').forEach((el) => {
@@ -448,11 +465,24 @@ export function EditorCore({
// Re-anchor when the suggestion set changes (after the decorations repaint), // Re-anchor when the suggestion set changes (after the decorations repaint),
// and keep the rail in sync with viewport/editor width changes (room + reflow). // and keep the rail in sync with viewport/editor width changes (room + reflow).
//
// The scrollport is observed as well as the wrapper, and it is not redundant:
// the wrapper is a fixed 720px column, so entering or leaving distraction-free
// mode *moves* it (the pane re-centres) without ever changing its size. A
// ResizeObserver on the wrapper alone reports nothing, no window resize fires,
// and `railEnabled` keeps whatever value it had — leaving the 300px rail
// rendered into the 266px margin a restored sidebar leaves behind, cards
// clipped mid-sentence and the page scrolling sideways. The scrollport spans
// the pane, so it resizes whenever the chrome around the editor does.
useEffect(() => { useEffect(() => {
recomputeRail() recomputeRail()
const wrapper = wrapperRef.current const wrapper = wrapperRef.current
const port = wrapper?.closest('.petal-scrollport')
const ro = wrapper ? new ResizeObserver(() => recomputeRail()) : null const ro = wrapper ? new ResizeObserver(() => recomputeRail()) : null
if (wrapper && ro) ro.observe(wrapper) if (wrapper && ro) {
ro.observe(wrapper)
if (port) ro.observe(port)
}
window.addEventListener('resize', recomputeRail) window.addEventListener('resize', recomputeRail)
return () => { return () => {
ro?.disconnect() ro?.disconnect()
@@ -590,12 +620,15 @@ export function EditorCore({
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (!(e.target as HTMLElement).closest('.petal-suggestion')) return if (!(e.target as HTMLElement).closest('.petal-suggestion')) return
if (railEnabled) { if (railEnabled) {
setActiveId(null) // A click-opened card outlives the pointer (it closes on a click away),
// so its rail card keeps the glow — otherwise the open card and the
// margin stop agreeing about which suggestion is being read.
if (!hover) setActiveId(null)
return return
} }
scheduleClose() scheduleClose()
}, },
[scheduleClose, railEnabled], [scheduleClose, railEnabled, hover],
) )
const keepOpen = useCallback(() => clearTimeout(closeTimer.current), []) const keepOpen = useCallback(() => clearTimeout(closeTimer.current), [])
@@ -685,15 +718,15 @@ export function EditorCore({
if (suggestionEl) { if (suggestionEl) {
const id = suggestionEl.getAttribute('data-suggestion-id') const id = suggestionEl.getAttribute('data-suggestion-id')
if (id) { if (id) {
// With the rail open, a tap emphasizes and expands its margin card // Clicking a highlight always opens the card at the word. Hover still
// instead of opening a floating one. // defers to the rail (see handleMouseOver) — an unbidden floating card
if (railEnabled) { // beside a margin card that already says the same thing is noise. But a
// click is her asking to deal with *this* word, and answering it 650px
// away in the periphery is the gesture item 7 is about. The rail card
// glows rather than expanding, so the suggestion is never open twice.
setActiveId(id) setActiveId(id)
setRailExpandedId(id)
} else {
openCardFor(id, suggestionEl) openCardFor(id, suggestionEl)
} }
}
return return
} }
if (!editor) return if (!editor) return
@@ -1134,10 +1167,11 @@ export function EditorCore({
<div <div
ref={wrapperRef} ref={wrapperRef}
className="relative flex-1" className="relative flex-1"
// Grown to cover the card stack when it overhangs the prose, so the space // Grown to cover whichever absolutely-positioned surface reaches lowest —
// those cards occupy is actually scrollable. `minHeight` never shrinks the // the rail's card stack, or an open anchored card — so the space those
// column, so a rail that fits beside its text changes nothing. // cards occupy is actually scrollable. `minHeight` never shrinks the
style={railEnabled && railExtent > 0 ? { minHeight: railExtent + RAIL_TAIL } : undefined} // column, so a rail or card that fits beside its text changes nothing.
style={overhang > 0 ? { minHeight: overhang } : undefined}
onMouseOver={handleMouseOver} onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut} onMouseOut={handleMouseOut}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
@@ -1210,7 +1244,7 @@ export function EditorCore({
onAdd={addMisspellingToDict} onAdd={addMisspellingToDict}
/> />
)} )}
{hover && !railEnabled && ( {hover && (
<SuggestionCard <SuggestionCard
suggestion={hover.suggestion} suggestion={hover.suggestion}
style={{ top: hover.top, left: hover.left }} style={{ top: hover.top, left: hover.left }}
@@ -1219,6 +1253,7 @@ export function EditorCore({
onPointerEnter={keepOpen} onPointerEnter={keepOpen}
onPointerLeave={scheduleClose} onPointerLeave={scheduleClose}
onExpandChange={setPinned} onExpandChange={setPinned}
onExtent={setCardExtent}
/> />
)} )}
{railEnabled && railItems.length > 0 && ( {railEnabled && railItems.length > 0 && (
+28 -1
View File
@@ -1,4 +1,4 @@
import { useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { Suggestion } from '../../api/client' import type { Suggestion } from '../../api/client'
import { usePack } from '../../i18n' import { usePack } from '../../i18n'
import { AskPetal } from './AskPetal' import { AskPetal } from './AskPetal'
@@ -14,6 +14,13 @@ interface Props {
// Pins the card open while the Ask Petal panel is expanded, so the chat isn't // Pins the card open while the Ask Petal panel is expanded, so the chat isn't
// dismissed by the hover-close timer when the pointer drifts away. // dismissed by the hover-close timer when the pointer drifts away.
onExpandChange: (expanded: boolean) => void onExpandChange: (expanded: boolean) => void
// How far the card reaches below the wrapper's top, in wrapper coordinates —
// the same report the rail makes (item 4). The card is absolutely positioned
// and so adds no layout height of its own; without this, an Ask Petal
// conversation that runs past the last line of text has no scrollable page
// under it and its Accept button simply can't be reached. 0 means "nothing to
// cover", which is what an unmounted card reports on its way out.
onExtent?: (bottom: number) => void
} }
// SuggestionCard is the hover panel for a single suggestion: a colored type tag, // SuggestionCard is the hover panel for a single suggestion: a colored type tag,
@@ -28,12 +35,31 @@ export function SuggestionCard({
onPointerEnter, onPointerEnter,
onPointerLeave, onPointerLeave,
onExpandChange, onExpandChange,
onExtent,
}: Props) { }: Props) {
const pack = usePack() const pack = usePack()
const meta = TYPE_META[suggestion.type] const meta = TYPE_META[suggestion.type]
const label = typeLabel(suggestion.type, pack) const label = typeLabel(suggestion.type, pack)
const hasReplacement = suggestion.replacement.trim() !== '' const hasReplacement = suggestion.replacement.trim() !== ''
const [asking, setAsking] = useState(false) const [asking, setAsking] = useState(false)
const cardRef = useRef<HTMLDivElement>(null)
// Report the card's reach while it is open, and withdraw it on the way out.
// A ResizeObserver rather than a one-shot measure because the card grows
// after it is mounted: the Ask Petal panel opens, and then the reply streams
// into it token by token.
useEffect(() => {
const el = cardRef.current
if (!el || !onExtent) return
const report = () => onExtent(el.offsetTop + el.offsetHeight)
report()
const observer = new ResizeObserver(report)
observer.observe(el)
return () => {
observer.disconnect()
onExtent(0)
}
}, [onExtent])
function toggleAsking() { function toggleAsking() {
setAsking((prev) => { setAsking((prev) => {
@@ -45,6 +71,7 @@ export function SuggestionCard({
return ( return (
<div <div
ref={cardRef}
role="dialog" role="dialog"
aria-label={`${label} suggestion`} aria-label={`${label} suggestion`}
onMouseEnter={onPointerEnter} onMouseEnter={onPointerEnter}
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { splitBilingual } from './bilingualReply'
// The contract these tests defend is "never hide an answer", not "parse the
// model". Every case that isn't cleanly two halves must still come back whole.
describe('splitBilingual', () => {
it('splits the pair language from the English at the blank line', () => {
const { native, en } = splitBilingual(
'“by foots” 不是固定说法,正确的是 “on foot”。\n\n"By foots" isnt a set phrase — the idiom is "on foot".',
)
expect(native).toBe('“by foots” 不是固定说法,正确的是 “on foot”。')
expect(en).toBe('"By foots" isnt a set phrase — the idiom is "on foot".')
})
it('works the same for a Latin pair, where both halves are Latin script', () => {
const { native, en } = splitBilingual(
'Dizemos "on foot", não "by foots".\n\nWe say "on foot", not "by foots".',
)
expect(native).toBe('Dizemos "on foot", não "by foots".')
expect(en).toBe('We say "on foot", not "by foots".')
})
it('renders a half-streamed reply as the pair language until the break arrives', () => {
// Mid-stream: the English half hasn't been written yet. The partial text is
// the whole bubble, not an empty one.
expect(splitBilingual('“by foots” 不是固定')).toEqual({
native: '“by foots” 不是固定',
en: '',
})
})
it('keeps a one-language reply whole', () => {
// A model that ignores the instruction costs styling, never content.
const single = 'We say "on foot" because the idiom is fixed.'
expect(splitBilingual(single)).toEqual({ native: single, en: '' })
})
it('treats extra blank lines as part of the English half', () => {
const { native, en } = splitBilingual('中文回答。\n\nFirst English point.\n\nSecond one.')
expect(native).toBe('中文回答。')
expect(en).toBe('First English point.\n\nSecond one.')
})
it('does not split on a blank line with nothing on one side', () => {
// A leading or trailing stray newline is not a separator; styling half of
// this as a translation of nothing would be worse than not splitting.
expect(splitBilingual('\n\nWe say "on foot".')).toEqual({
native: 'We say "on foot".',
en: '',
})
expect(splitBilingual('We say "on foot".\n\n')).toEqual({
native: 'We say "on foot".',
en: '',
})
})
it('accepts a separator line that carries whitespace', () => {
// Models emit "\n \n" often enough that requiring a bare "\n\n" would drop
// the split for a reply that followed the instruction.
const { native, en } = splitBilingual('中文回答。\n \nThe English answer.')
expect(native).toBe('中文回答。')
expect(en).toBe('The English answer.')
})
it('handles an empty reply', () => {
expect(splitBilingual('')).toEqual({ native: '', en: '' })
})
it('leaves single newlines inside a half alone', () => {
const { native, en } = splitBilingual('第一行\n第二行\n\nLine one\nLine two')
expect(native).toBe('第一行\n第二行')
expect(en).toBe('Line one\nLine two')
})
})
@@ -0,0 +1,43 @@
// Splitting Petal's chat reply into the two languages it was asked for.
//
// The Ask Petal prompt (internal/llm/prompts.go) asks for the pair language
// first, then the same answer in English, separated by one blank line. This is
// the reader of that contract — and it is deliberately forgiving, because the
// reply arrives from a small local model, token by token, and a rendering rule
// must never be able to hide an answer the writer could otherwise read.
//
// So there is exactly one failure mode and it is benign: anything that doesn't
// look like two halves is returned as `native` alone, which renders as one
// ordinary block. Nothing is dropped, ever.
export interface BilingualReply {
/** The pair language — or the whole reply, when there is only one half. */
native: string
/** The English half; '' when the reply hasn't reached the blank line yet. */
en: string
}
/**
* splitBilingual divides a reply at its first blank line.
*
* Streaming is the reason this splits at the *first* blank line rather than
* validating the shape: while tokens arrive the text is a native half with no
* separator yet, so it renders as the pair language and the English simply
* appears beneath it when the blank line lands. Any further blank lines stay
* inside the English half rather than starting a third section with nowhere to
* go.
*/
export function splitBilingual(content: string): BilingualReply {
const match = /\n[ \t]*\n/.exec(content)
if (!match) return { native: content, en: '' }
const native = content.slice(0, match.index).trim()
const en = content.slice(match.index + match[0].length).trim()
// A blank line with nothing on one side of it isn't two halves — it's a
// stray newline. Keep the reply whole rather than styling half of it as a
// translation of nothing.
if (native === '' || en === '') return { native: content.trim(), en: '' }
return { native, en }
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { RAIL_MIN_MARGIN, railFitsBeside } from './railFit'
// The numbers below are not invented: they were measured in Chrome at the UX
// review's own 1517x810 viewport, with the 280px sidebar open and closed. They
// are here so the two layouts stay distinguishable if the constant is ever tuned.
describe('railFitsBeside', () => {
it('fits in distraction-free mode, where the pane spans the window', () => {
// 1517px window, sidebar collapsed: the 720px column centres at left 391,
// so its right edge is 1111 and 406px of margin remain.
expect(railFitsBeside(1517, 1111)).toBe(true)
})
it('does not fit with the document list open at the same window size', () => {
// Same window, 280px sidebar in flow: the column re-centres to right 1259 and
// the margin falls to 258 — the measurement item 5 reported. This is the case
// that must return false; rendering the rail here overhangs the viewport.
expect(railFitsBeside(1517, 1259)).toBe(false)
})
it('rejects the mid-animation width too, not just the settled one', () => {
// The sidebar animates over 280ms, so the recompute can land on an
// intermediate margin (266 was observed one frame in). Anything under the
// threshold has to read as "no rail", or the column flickers back in.
expect(railFitsBeside(1517, 1251)).toBe(false)
})
it('treats the threshold as inclusive', () => {
expect(railFitsBeside(1000, 1000 - RAIL_MIN_MARGIN)).toBe(true)
expect(railFitsBeside(1000, 1000 - RAIL_MIN_MARGIN + 1)).toBe(false)
})
it('has room to spare on a wide desktop', () => {
// 1920px maximised, sidebar open: margin 468.
expect(railFitsBeside(1920, 1452)).toBe(true)
})
it('never fits on a narrow window, whatever the column does', () => {
expect(railFitsBeside(900, 810)).toBe(false)
expect(railFitsBeside(768, 744)).toBe(false)
})
})
+19
View File
@@ -0,0 +1,19 @@
// Whether the margin rail has room to sit beside the editor.
//
// The rail is a 300px column with a 32px gutter (see `.petal-rail` in index.css);
// RAIL_MIN_MARGIN adds a little breathing space to the viewport edge. Below it the
// editor falls back to the inline card anchored under the word.
//
// This is a bare comparison, but it earns a name: the number decides which of two
// entirely different suggestion surfaces she gets, and the margin it measures moves
// for reasons that have nothing to do with the window size. The editor is a fixed
// 720px column centred in the pane, so collapsing the 280px sidebar (distraction-free
// mode) re-centres it and changes this margin by 140px without resizing anything.
// See the ResizeObserver in EditorCore for the other half of that story.
export const RAIL_MIN_MARGIN = 348
// `wrapperRight` and `innerWidth` are both viewport coordinates — i.e. exactly
// `wrapper.getBoundingClientRect().right` and `window.innerWidth`.
export function railFitsBeside(innerWidth: number, wrapperRight: number): boolean {
return innerWidth - wrapperRight >= RAIL_MIN_MARGIN
}
+16
View File
@@ -164,6 +164,22 @@ describe('the zh pack', () => {
if (p.code === 'pt-PT') expect(p.locale).toBe('pt-PT') // never pt-BR if (p.code === 'pt-PT') expect(p.locale).toBe('pt-PT') // never pt-BR
}) })
// The chat-failure line is the only message the Ask Petal panel writes without
// the model, and it renders through the same bilingual bubble as a real reply
// (splitBilingual, blank line between the halves). A pack that writes it as
// one language gets a bubble with a muted empty half — and, worse, tells the
// half of the pair that can't read that language nothing at all.
it.each(PACKS)('says the chat-failure line in both halves of the pair ($code)', async (p) => {
const { splitBilingual } = await import('../components/Editor/bilingualReply')
const { native, en } = splitBilingual(p.editor.chatFailed)
expect(native, `${p.code} chatFailed has no pair-language half`).not.toBe('')
expect(en, `${p.code} chatFailed has no English half`).not.toBe('')
// The English half is the one every reader of every pack shares, so it is
// the one worth pinning: a pack that translated it has lost the point.
expect(en).toMatch(/ask me again/i)
expect(native).not.toBe(en)
})
it.each(PACKS)('labels every companion, tone and style ($code)', async (p) => { it.each(PACKS)('labels every companion, tone and style ($code)', async (p) => {
const { COMPANIONS } = await import('../components/Companion/companions') const { COMPANIONS } = await import('../components/Companion/companions')
for (const c of COMPANIONS) { for (const c of COMPANIONS) {
+1
View File
@@ -307,6 +307,7 @@ export const fr: Pack = {
editor: { editor: {
askPlaceholder: 'Ask why… / Demande pourquoi…', askPlaceholder: 'Ask why… / Demande pourquoi…',
chatFailed: 'Je nai pas réussi à répondre — repose-moi la question, sil te plaît. 🌸\n\nI had trouble answering just now — please ask me again. 🌸',
findPlaceholder: 'Rechercher · Find', findPlaceholder: 'Rechercher · Find',
findNone: 'Rien · 0', findNone: 'Rien · 0',
matchCase: 'Match case · Respecter la casse', matchCase: 'Match case · Respecter la casse',
+1
View File
@@ -285,6 +285,7 @@ export const ptPT: Pack = {
editor: { editor: {
askPlaceholder: 'Ask why… / Pergunta porquê…', askPlaceholder: 'Ask why… / Pergunta porquê…',
chatFailed: 'Não consegui responder agora — pergunta-me outra vez, se faz favor. 🌸\n\nI had trouble answering just now — please ask me again. 🌸',
findPlaceholder: 'Localizar · Find', findPlaceholder: 'Localizar · Find',
findNone: 'Nada · 0', findNone: 'Nada · 0',
matchCase: 'Match case · Maiúsculas/minúsculas', matchCase: 'Match case · Maiúsculas/minúsculas',
+1
View File
@@ -185,6 +185,7 @@ export const zh: Pack = {
editor: { editor: {
askPlaceholder: 'Ask why… / 问为什么…', askPlaceholder: 'Ask why… / 问为什么…',
chatFailed: '我这会儿没答上来,再问我一次好吗?🌸\n\nI had trouble answering just now — please ask me again. 🌸',
findPlaceholder: '查找 · Find', findPlaceholder: '查找 · Find',
findNone: '无 · 0', findNone: '无 · 0',
matchCase: 'Match case · 区分大小写', matchCase: 'Match case · 区分大小写',
+4
View File
@@ -155,6 +155,10 @@ export interface Pack {
editor: { editor: {
askPlaceholder: string askPlaceholder: string
// Shown in Petal's own chat bubble when the reply never arrives. It is
// the one line in that panel Petal writes without the model, so the pack
// owns it — and it is bilingual like every answer beside it.
chatFailed: string
findPlaceholder: string findPlaceholder: string
findNone: string findNone: string
matchCase: string matchCase: string