French dragged her whole page sideways

The bug reads as a language-picker bug — switch off Mandarin on a phone and
the writing starts scrolling left and right — but the picker only changes
how wide Petal's own chrome wants to be. Every pill says its thing twice,
历史 · History against Historique · History, and every pill is nowrap and
shrink-0. So the title row's width is a property of the langpack: 459px in
Chinese, 551px in French, 506px in Portuguese, against the 338px column a
390px phone gives it. That row lives in the editor pane, and a pane that is
overflow-y: auto has an overflow-x of auto too, whatever the stylesheet
says. The overflow had nowhere to go but the page of writing.

Chinese was already 120px over. French is simply where it stopped being
possible to ignore.

The pills now live in a strip that scrolls itself, with overscroll-behavior
so a swipe off the end doesn't turn into a page gesture. What that buys is
the thing worth keeping: every label stays bilingual at every width. The
first version of this fix dropped the English half on phones, which fixed
the geometry by taking away the half she is learning from — on the device
she writes on most.

A scrolled pill that has left the screen is indistinguishable from a pill
that isn't there, so each edge with more behind it fades, the same hint
.petal-toolbar gives with its clipped right edge, except this row can be
scrolled from either end and has to point the right way. ChromeStrip sets
data-edge from the scroll position and re-measures when the pills resize —
which is also what catches every label changing width at once when she
switches her pair.

The tone and export menus had to leave with them. A scroll container clips
its absolutely-positioned children, so both menus would have been trapped in
a 36px-tall box; they anchor against the viewport now.

Two smaller ones the same measurement turned up:

  - The header overflowed the viewport itself below 360px — a real
    page-level scroll, 40px of it at 320px, off 🌷 Jardim de palavras ·
    Garden. Narrower padding on phones, and under 360px the wordmark
    yields. Of everything in that row it is the one thing that can go: she
    is already inside the app, and the blossom stays.
  - The language picker wanted 307px inside a 280px drawer and spilled out
    of it. It wraps now. That one was broken in Chinese too, at 291px.

Verified in a real browser rather than by arithmetic: 310px to 780px in 10px
steps, in all three packs, no page overflow and no editor-pane overflow at
any width. Edge fades flip correctly, the export menu opens unclipped with
the strip scrolled to its end, tsc clean, 195 frontend tests pass. Desktop
is untouched — one 40px header row, as before.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
prosolis
2026-07-27 20:10:17 -07:00
parent 3e714b6f00
commit ac1c6cddb0
7 changed files with 224 additions and 32 deletions
+23 -9
View File
@@ -7,6 +7,7 @@ import { useTags } from './hooks/useTags'
import { DocList } from './components/DocList/DocList' import { DocList } from './components/DocList/DocList'
import { EditorCore, type EditorChange } from './components/Editor/EditorCore' import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
import { ToneSelect } from './components/Editor/ToneSelect' import { ToneSelect } from './components/Editor/ToneSelect'
import { ChromeStrip } from './components/Editor/ChromeStrip'
import { ExportMenu } from './components/Export/ExportMenu' import { ExportMenu } from './components/Export/ExportMenu'
import { HistoryPanel } from './components/History/HistoryPanel' import { HistoryPanel } from './components/History/HistoryPanel'
import { GardenPanel } from './components/Garden/GardenPanel' import { GardenPanel } from './components/Garden/GardenPanel'
@@ -435,7 +436,7 @@ export default function App() {
<PetalFall night={night} /> <PetalFall night={night} />
<header <header
onMouseDown={handleChromeDown} onMouseDown={handleChromeDown}
className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-5" className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-3 md:px-5"
style={{ borderBottom: '1px solid var(--color-border)' }} style={{ borderBottom: '1px solid var(--color-border)' }}
> >
<button <button
@@ -451,7 +452,15 @@ export default function App() {
</button> </button>
<span className="text-xl">🌸</span> <span className="text-xl">🌸</span>
<span className="text-lg font-extrabold text-plum">Petal</span> {/* The wordmark is the first thing to go on a small phone. The header
holds a hamburger, a name and the garden button, and the garden
button's label is a langpack string: 词汇花园 · Garden is 130px
where Jardim de palavras · Garden is nearly 200, which is the
difference between fitting a 320px screen and scrolling the whole
app sideways. Of everything in this row, the one that can be spared
is the app's own name — she is already inside the app, and the
blossom stays. */}
<span className="petal-wordmark text-lg font-extrabold text-plum">Petal</span>
<button <button
type="button" type="button"
onClick={() => setGardenOpen(true)} onClick={() => setGardenOpen(true)}
@@ -503,24 +512,30 @@ export default function App() {
className="flex flex-1 flex-col overflow-y-auto px-6 py-8" className="flex flex-1 flex-col overflow-y-auto px-6 py-8"
> >
<div ref={canvasRef} className="mx-auto flex w-full max-w-[720px] flex-1 flex-col"> <div ref={canvasRef} className="mx-auto flex w-full max-w-[720px] flex-1 flex-col">
<div className="mb-5 flex items-center gap-3"> {/* Title, then the three chrome pills. Their labels are
bilingual and don't shrink, so how much width this row
wants is a property of the langpack — 历史 is two glyphs
where Historique is ten — and on a phone no language's
version of it fits. The pills therefore live in a strip
that scrolls itself; the title takes its own line below
the drawer breakpoint so it keeps its full width. */}
<div className="mb-5 flex flex-wrap items-center gap-2 md:gap-3">
<input <input
value={title} value={title}
onChange={(e) => handleTitleChange(e.target.value)} onChange={(e) => handleTitleChange(e.target.value)}
placeholder="Untitled" placeholder="Untitled"
aria-label="Document title" aria-label="Document title"
className="min-w-0 flex-1 bg-transparent text-3xl font-extrabold text-plum focus:outline-none" className="min-w-0 flex-1 basis-full bg-transparent text-3xl font-extrabold text-plum focus:outline-none md:basis-0"
style={{ fontFamily: 'var(--font-ui)' }} style={{ fontFamily: 'var(--font-ui)' }}
/> />
<div className="petal-no-print shrink-0"> <ChromeStrip className="petal-no-print flex items-center gap-2 py-0.5 md:gap-3">
<ToneSelect value={tone} onChange={handleToneChange} /> <ToneSelect value={tone} onChange={handleToneChange} />
</div>
<button <button
type="button" type="button"
onClick={() => setHistoryOpen(true)} onClick={() => setHistoryOpen(true)}
aria-label="Version history" aria-label="Version history"
title="Browse and restore earlier versions" title="Browse and restore earlier versions"
className="petal-no-print inline-flex h-9 shrink-0 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold" className="inline-flex h-9 shrink-0 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
style={{ style={{
borderRadius: 'var(--radius-pill)', borderRadius: 'var(--radius-pill)',
background: 'var(--color-surface)', background: 'var(--color-surface)',
@@ -532,9 +547,8 @@ export default function App() {
<span>{t.app.history}</span> <span>{t.app.history}</span>
<span style={{ color: 'var(--color-muted)' }}>· History</span> <span style={{ color: 'var(--color-muted)' }}>· History</span>
</button> </button>
<div className="petal-no-print">
<ExportMenu docId={currentDoc.id} /> <ExportMenu docId={currentDoc.id} />
</div> </ChromeStrip>
</div> </div>
<EditorCore <EditorCore
key={`${currentDoc.id}:${editorEpoch}`} key={`${currentDoc.id}:${editorEpoch}`}
@@ -47,7 +47,15 @@ export function LanguagePicker() {
return ( return (
<div className="flex flex-col gap-1 px-1"> <div className="flex flex-col gap-1 px-1">
<div className="flex items-center gap-2 text-xs" style={{ color: 'var(--color-muted)' }}> {/* Label and buttons wrap as a pair: the label is itself bilingual
("Langue · Language"), and three self-naming buttons beside it need
more than the drawer is wide in every language Petal ships. When they
don't fit the buttons drop to their own line instead of spilling out
of the drawer. */}
<div
className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs"
style={{ color: 'var(--color-muted)' }}
>
<span className="shrink-0 font-semibold">{t.docs.language}</span> <span className="shrink-0 font-semibold">{t.docs.language}</span>
<div className="ml-auto flex shrink-0 gap-1"> <div className="ml-auto flex shrink-0 gap-1">
{packs.map((p) => { {packs.map((p) => {
+62
View File
@@ -0,0 +1,62 @@
import { useCallback, useEffect, useRef, useState } from 'react'
// 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
// overflow escape into the editor pane, and it says which way the rest of the
// row is.
//
// Saying so matters more here than in most scrollers: the pills are the only
// way to reach three features, and a pill that has scrolled out of sight is
// indistinguishable from a pill that doesn't exist. So each edge with more
// behind it fades — the same "there is more this way" language .petal-toolbar
// speaks with its clipped right edge, except this row can be scrolled from
// either end and has to point in the right direction.
//
// 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
// the background colour.
type Edge = 'none' | 'left' | 'right' | 'both'
interface Props {
className?: string
children: React.ReactNode
}
export function ChromeStrip({ className = '', children }: Props) {
const ref = useRef<HTMLDivElement>(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 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 (
<div ref={ref} data-edge={edge} className={`petal-chrome-strip ${className}`}>
{children}
</div>
)
}
+6 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { usePack } from '../../i18n' import { usePack } from '../../i18n'
import { useAnchoredMenu } from './anchoredMenu'
// ToneSelect lets the writer set the document's target tone, which steers the // ToneSelect lets the writer set the document's target tone, which steers the
// grammar-checkpoint LLM toward the right register (an academic essay vs a casual // grammar-checkpoint LLM toward the right register (an academic essay vs a casual
@@ -35,6 +36,7 @@ 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 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.
@@ -48,8 +50,9 @@ export function ToneSelect({ value, onChange }: Props) {
}, [open]) }, [open])
return ( return (
<div ref={ref} className="relative shrink-0"> <div ref={ref} className="shrink-0">
<button <button
ref={triggerRef}
type="button" type="button"
aria-label="Document tone" aria-label="Document tone"
aria-haspopup="listbox" aria-haspopup="listbox"
@@ -75,9 +78,9 @@ export function ToneSelect({ value, onChange }: Props) {
{open && ( {open && (
<div <div
role="listbox" role="listbox"
className="petal-word-card absolute right-0 z-30 mt-1.5 p-1.5" className="petal-word-card p-1.5"
style={{ style={{
width: 200, ...menuStyle,
background: 'var(--color-surface)', background: 'var(--color-surface)',
border: '1px solid var(--color-border)', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-card)', borderRadius: 'var(--radius-card)',
+45
View File
@@ -0,0 +1,45 @@
import { useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
// A dropdown that hangs off its own button in viewport coordinates.
//
// The tone and export menus used to be absolutely positioned inside their
// 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
// 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:
// nothing clips a fixed box unless an ancestor has a transform, and none of the
// editor's chrome does.
//
// 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
// window resizing — has to re-place the menu.
export function useAnchoredMenu(open: boolean, width: number) {
const triggerRef = useRef<HTMLButtonElement>(null)
// 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.
const [style, setStyle] = useState<CSSProperties>({ position: 'fixed', top: -9999, left: -9999 })
useLayoutEffect(() => {
if (!open) return
const place = () => {
const el = triggerRef.current
if (!el) return
const r = el.getBoundingClientRect()
// Right-aligned under the button, as it always was — but pulled back
// inside the window if that would hang the menu off a narrow screen.
const left = Math.max(8, Math.min(r.right - width, window.innerWidth - width - 8))
setStyle({ position: 'fixed', top: r.bottom + 6, left, width, zIndex: 40 })
}
place()
window.addEventListener('resize', place)
// Capture, so this hears the editor pane and the pill strip scrolling —
// neither of which bubbles a scroll event to the window.
window.addEventListener('scroll', place, true)
return () => {
window.removeEventListener('resize', place)
window.removeEventListener('scroll', place, true)
}
}, [open, width])
return { triggerRef, style }
}
+6 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
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'
// ExportMenu is the "get your writing out of Petal" dropdown. File formats are // ExportMenu is the "get your writing out of Petal" dropdown. File formats are
// plain <a download> links to the server's export endpoint (which sets the // plain <a download> links to the server's export endpoint (which sets the
@@ -28,6 +29,7 @@ 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)
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
@@ -39,8 +41,9 @@ export function ExportMenu({ docId }: Props) {
}, [open]) }, [open])
return ( return (
<div ref={ref} className="relative shrink-0"> <div ref={ref} className="shrink-0">
<button <button
ref={triggerRef}
type="button" type="button"
aria-label="Export document" aria-label="Export document"
aria-haspopup="menu" aria-haspopup="menu"
@@ -63,9 +66,9 @@ export function ExportMenu({ docId }: Props) {
{open && ( {open && (
<div <div
role="menu" role="menu"
className="petal-word-card absolute right-0 z-30 mt-1.5 p-1.5" className="petal-word-card p-1.5"
style={{ style={{
width: 220, ...menuStyle,
background: 'var(--color-surface)', background: 'var(--color-surface)',
border: '1px solid var(--color-border)', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-card)', borderRadius: 'var(--radius-card)',
+57
View File
@@ -571,6 +571,63 @@ button, a, input {
} }
} }
/* Small phones only: see the header in App.tsx for why the wordmark yields.
The threshold is low on purpose — with the header's narrow-screen padding the
longest garden label Petal ships still fits a 360px phone with its name
beside it, so only the smallest screens ever pay for the room. */
@media (max-width: 360px) {
.petal-wordmark {
display: none;
}
}
/* The document's chrome pills (tone, history, export) as a strip that scrolls
within itself. Their labels say everything twice — 历史 · History, Historique
· History — so how wide the row wants to be is a property of the langpack:
~460px in Chinese, ~550px in French, against the ~340px a phone gives it.
Something has to give, and the choice here is *not* the second half of the
label: the English is what she is learning, and a pill that quietly stops
teaching on the device she writes on most is the wrong trade.
So the row keeps every word and scrolls sideways — but only itself. Before
this, the overflow escaped to the editor pane and the whole page of writing
dragged sideways with it, which is the bug this fixes. overscroll-behavior
keeps a swipe that runs out of pills from turning into a page-level gesture,
and the scrollbar is hidden because a half-visible pill at the edge is the
affordance, the same hint .petal-toolbar gives with its faded edge. */
.petal-chrome-strip {
max-width: 100%;
overflow-x: auto;
overscroll-behavior-x: contain;
scrollbar-width: none;
-ms-overflow-style: none;
}
.petal-chrome-strip::-webkit-scrollbar {
display: none;
}
/* Which edge has more behind it. ChromeStrip sets data-edge from the scroll
position; a strip whose pills all fit is left alone entirely, so the fade
only ever appears when it means something. The mask has to clear the pills'
soft shadow, hence the fade starting a little inside the edge. */
.petal-chrome-strip[data-edge='right'] {
-webkit-mask-image: linear-gradient(to right, #000 88%, transparent 100%);
mask-image: linear-gradient(to right, #000 88%, transparent 100%);
}
.petal-chrome-strip[data-edge='left'] {
-webkit-mask-image: linear-gradient(to left, #000 88%, transparent 100%);
mask-image: linear-gradient(to left, #000 88%, transparent 100%);
}
.petal-chrome-strip[data-edge='both'] {
-webkit-mask-image: linear-gradient(
to right,
transparent 0%,
#000 12%,
#000 88%,
transparent 100%
);
mask-image: linear-gradient(to right, transparent 0%, #000 12%, #000 88%, transparent 100%);
}
/* Print / Save-as-PDF: strip every bit of app chrome and editing decoration so /* Print / Save-as-PDF: strip every bit of app chrome and editing decoration so
only the title and the writing itself reach the page. This is Petal's PDF only the title and the writing itself reach the page. This is Petal's PDF
path — it uses the browser's own fonts, so CJK renders correctly with no path — it uses the browser's own fonts, so CJK renders correctly with no