{packs.map((p) => {
diff --git a/web/src/components/Editor/ChromeStrip.tsx b/web/src/components/Editor/ChromeStrip.tsx
new file mode 100644
index 0000000..b1258dd
--- /dev/null
+++ b/web/src/components/Editor/ChromeStrip.tsx
@@ -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
(null)
+ const [edge, setEdge] = useState('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 (
+
+ {children}
+
+ )
+}
diff --git a/web/src/components/Editor/ToneSelect.tsx b/web/src/components/Editor/ToneSelect.tsx
index 2036fe7..b7c7645 100644
--- a/web/src/components/Editor/ToneSelect.tsx
+++ b/web/src/components/Editor/ToneSelect.tsx
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { usePack } from '../../i18n'
+import { useAnchoredMenu } from './anchoredMenu'
// 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
@@ -35,6 +36,7 @@ export function ToneSelect({ value, onChange }: Props) {
const pk = usePack()
const [open, setOpen] = useState(false)
const ref = useRef(null)
+ const { triggerRef, style: menuStyle } = useAnchoredMenu(open, 200)
const current = TONES.find((t) => t.value === value) ?? TONES[0]
// Click outside closes the menu.
@@ -48,8 +50,9 @@ export function ToneSelect({ value, onChange }: Props) {
}, [open])
return (
-
+