Half the editor was on the phone and none of it could be touched

Reported as "quite a few options don't function on mobile", and measured
in a real touch emulation rather than a narrow window — the distinction
matters, because two of the three causes need `pointer: coarse` to
appear at all. Three causes, one theme: the chrome was built for a
pointer that hovers.

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

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

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

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

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
This commit is contained in:
prosolis
2026-07-29 18:39:53 -07:00
parent 15398eab4d
commit 6026d98598
8 changed files with 241 additions and 71 deletions
@@ -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 -32
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useScrollEdge } from './useScrollEdge'
// ChromeStrip is the row of document pills (tone, history, export) on a screen // ChromeStrip is the row of document pills (tone, history, export) on a screen
// too narrow to hold them. It scrolls within itself rather than letting the // too narrow to hold them. It scrolls within itself rather than letting the
@@ -15,7 +15,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'
// The fade is a mask rather than a gradient overlay so it works on whatever is // The fade is a mask rather than a gradient overlay so it works on whatever is
// behind it (the cream page, the night theme, a falling petal) without knowing // behind it (the cream page, the night theme, a falling petal) without knowing
// the background colour. // the background colour.
type Edge = 'none' | 'left' | 'right' | 'both' //
// The measuring itself lives in useScrollEdge, shared with the formatting
// toolbar — which has to say the same thing for the same reason.
interface Props { interface Props {
className?: string className?: string
@@ -23,36 +25,7 @@ interface Props {
} }
export function ChromeStrip({ className = '', children }: Props) { export function ChromeStrip({ className = '', children }: Props) {
const ref = useRef<HTMLDivElement>(null) const { ref, edge } = useScrollEdge<HTMLDivElement>()
const [edge, setEdge] = useState<Edge>('none')
// A pixel of slack: scrollLeft is fractional under browser zoom and on
// high-DPI screens, so an exactly-scrolled-to-the-end strip can report
// something like 0.5px remaining and fade an edge that has nothing behind it.
const measure = useCallback(() => {
const el = ref.current
if (!el) return
const more = el.scrollWidth - el.clientWidth - el.scrollLeft > 1
const less = el.scrollLeft > 1
setEdge(less && more ? 'both' : less ? 'left' : more ? 'right' : 'none')
}, [])
useEffect(() => {
const el = ref.current
if (!el) return
measure()
el.addEventListener('scroll', measure, { passive: true })
// Both halves of "does it fit" can change without a scroll: the window
// resizes, or the labels themselves change when she switches her pair
// language and every pill in the row grows or shrinks at once.
const ro = new ResizeObserver(measure)
ro.observe(el)
for (const child of Array.from(el.children)) ro.observe(child)
return () => {
el.removeEventListener('scroll', measure)
ro.disconnect()
}
}, [measure])
return ( return (
<div ref={ref} data-edge={edge} className={`petal-chrome-strip ${className}`}> <div ref={ref} data-edge={edge} className={`petal-chrome-strip ${className}`}>
+12 -6
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { usePack } from '../../i18n' import { usePack } from '../../i18n'
import { useAnchoredMenu } from './anchoredMenu' import { useAnchoredMenu } from './anchoredMenu'
@@ -36,18 +37,20 @@ export function ToneSelect({ value, onChange }: Props) {
const pk = usePack() const pk = usePack()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const { triggerRef, style: menuStyle } = useAnchoredMenu(open, 200) const { triggerRef, panelRef, style: menuStyle } = useAnchoredMenu(open, 200)
const current = TONES.find((t) => t.value === value) ?? TONES[0] const current = TONES.find((t) => t.value === value) ?? TONES[0]
// Click outside closes the menu. // Click outside closes the menu. The list is portalled to <body>, so a tap on
// an option is not inside `ref` and has to be asked about separately.
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const onDown = (e: MouseEvent) => { const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false) const target = e.target as Node
if (!ref.current?.contains(target) && !panelRef.current?.contains(target)) setOpen(false)
} }
document.addEventListener('mousedown', onDown) document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown) return () => document.removeEventListener('mousedown', onDown)
}, [open]) }, [open, panelRef])
return ( return (
<div ref={ref} className="shrink-0"> <div ref={ref} className="shrink-0">
@@ -75,8 +78,10 @@ export function ToneSelect({ value, onChange }: Props) {
</span> </span>
</button> </button>
{open && ( {open &&
createPortal(
<div <div
ref={panelRef}
role="listbox" role="listbox"
className="petal-word-card p-1.5" className="petal-word-card p-1.5"
style={{ style={{
@@ -117,7 +122,8 @@ export function ToneSelect({ value, onChange }: Props) {
</button> </button>
) )
})} })}
</div> </div>,
document.body,
)} )}
</div> </div>
) )
+31 -6
View File
@@ -6,15 +6,40 @@ import { useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
// button's wrapper, which was fine until that wrapper became ChromeStrip — a // button's wrapper, which was fine until that wrapper became ChromeStrip — a
// horizontal scroller, and so a box that clips what overflows it. An absolute // horizontal scroller, and so a box that clips what overflows it. An absolute
// menu inside it is 36px tall and scrolls away with the pills. Positioning the // menu inside it is 36px tall and scrolls away with the pills. Positioning the
// menu against the viewport instead takes it out of the strip's hands entirely: // menu against the viewport instead takes it out of the strip's hands entirely.
// nothing clips a fixed box unless an ancestor has a transform, and none of the //
// editor's chrome does. // Or rather: it does once the menu is also *portalled out* of it, which is the
// part this originally got wrong. `position: fixed` is only relative to the
// viewport while no ancestor establishes a containing block for it — and a
// `mask-image` does, exactly like a transform. Both scrollers fade their edges
// with a mask (that is how each says "there is more this way"), so on any screen
// narrow enough for the fade to appear — i.e. every phone — the menu was pulled
// back inside the very box it was trying to escape: painted underneath the
// toolbar, and untappable. It looked open and did nothing.
//
// So the panel is rendered through a portal into <body>. Nothing above it can
// clip it, stack over it, or contain it, whatever the chrome does with masks
// later. `panelRef` is returned for the outside-tap test, which can no longer
// rely on the panel being a DOM descendant of the trigger's wrapper.
// //
// The trade is that a fixed box doesn't follow its anchor, so anything that // The trade is that a fixed box doesn't follow its anchor, so anything that
// moves the button — the page scrolling under it, the strip scrolling, the // moves the button — the page scrolling under it, the strip scrolling, the
// window resizing — has to re-place the menu. // window resizing — has to re-place the menu.
export function useAnchoredMenu(open: boolean, width: number) { //
const triggerRef = useRef<HTMLButtonElement>(null) // The element type is a parameter because the two kinds of caller anchor
// against different things: the tone and export pills hand it their own
// <button>, while the toolbar's popovers anchor against the wrapper that holds
// trigger and panel together (it is that wrapper an outside-tap test already
// asks about, so measuring anything else would be a second source of truth).
export function useAnchoredMenu<T extends HTMLElement = HTMLButtonElement>(
open: boolean,
width: number,
) {
const triggerRef = useRef<T>(null)
// The portalled panel. Attach it to the element the style is spread onto, so
// an outside-tap test can ask "was this inside the menu?" of a node that is no
// longer beneath the trigger in the tree.
const panelRef = useRef<HTMLDivElement>(null)
// Nothing to place before the first measurement; keeping it off-screen rather // Nothing to place before the first measurement; keeping it off-screen rather
// than at 0,0 means no flash in the top-left corner on open. // than at 0,0 means no flash in the top-left corner on open.
const [style, setStyle] = useState<CSSProperties>({ position: 'fixed', top: -9999, left: -9999 }) const [style, setStyle] = useState<CSSProperties>({ position: 'fixed', top: -9999, left: -9999 })
@@ -41,5 +66,5 @@ export function useAnchoredMenu(open: boolean, width: number) {
} }
}, [open, width]) }, [open, width])
return { triggerRef, style } return { triggerRef, panelRef, style }
} }
@@ -0,0 +1,51 @@
import { useCallback, useEffect, useRef, useState } from 'react'
// Which end of a horizontal scroller has more behind it.
//
// Extracted from ChromeStrip when the formatting toolbar needed the same
// answer. Both rows are in the same situation and it is a harsher one than most
// scrollers face: the row is the *only* way to reach what is in it, so a control
// that has scrolled out of sight is indistinguishable from a control that does
// not exist. Fading the edge that has more behind it is the one cue that tells
// those two apart.
//
// The caller owns the element and the styling; this hook only measures. Apply
// the returned `edge` as a `data-edge` attribute and let CSS decide what a
// faded edge looks like — the two rows sit on different backgrounds and mask
// themselves at slightly different insets.
export type Edge = 'none' | 'left' | 'right' | 'both'
export function useScrollEdge<T extends HTMLElement = HTMLDivElement>() {
const ref = useRef<T>(null)
const [edge, setEdge] = useState<Edge>('none')
// A pixel of slack: scrollLeft is fractional under browser zoom and on
// high-DPI screens, so an exactly-scrolled-to-the-end strip can report
// something like 0.5px remaining and fade an edge that has nothing behind it.
const measure = useCallback(() => {
const el = ref.current
if (!el) return
const more = el.scrollWidth - el.clientWidth - el.scrollLeft > 1
const less = el.scrollLeft > 1
setEdge(less && more ? 'both' : less ? 'left' : more ? 'right' : 'none')
}, [])
useEffect(() => {
const el = ref.current
if (!el) return
measure()
el.addEventListener('scroll', measure, { passive: true })
// Both halves of "does it fit" can change without a scroll: the window
// resizes, or the labels themselves change when she switches her pair
// language and every control in the row grows or shrinks at once.
const ro = new ResizeObserver(measure)
ro.observe(el)
for (const child of Array.from(el.children)) ro.observe(child)
return () => {
el.removeEventListener('scroll', measure)
ro.disconnect()
}
}, [measure])
return { ref, edge }
}
+13 -5
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { api, type ExportFormat } from '../../api/client' import { api, type ExportFormat } from '../../api/client'
import { usePack } from '../../i18n' import { usePack } from '../../i18n'
import { useAnchoredMenu } from '../Editor/anchoredMenu' import { useAnchoredMenu } from '../Editor/anchoredMenu'
@@ -29,16 +30,20 @@ export function ExportMenu({ docId }: Props) {
const t = usePack() const t = usePack()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const { triggerRef, style: menuStyle } = useAnchoredMenu(open, 220) const { triggerRef, panelRef, style: menuStyle } = useAnchoredMenu(open, 220)
// The menu is portalled to <body>, so a tap on a format is outside `ref` and
// has to be asked about separately or it would close the menu instead of
// exporting.
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const onDown = (e: MouseEvent) => { const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false) const target = e.target as Node
if (!ref.current?.contains(target) && !panelRef.current?.contains(target)) setOpen(false)
} }
document.addEventListener('mousedown', onDown) document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown) return () => document.removeEventListener('mousedown', onDown)
}, [open]) }, [open, panelRef])
return ( return (
<div ref={ref} className="shrink-0"> <div ref={ref} className="shrink-0">
@@ -63,8 +68,10 @@ export function ExportMenu({ docId }: Props) {
<span style={{ color: 'var(--color-muted)' }}>· Export</span> <span style={{ color: 'var(--color-muted)' }}>· Export</span>
</button> </button>
{open && ( {open &&
createPortal(
<div <div
ref={panelRef}
role="menu" role="menu"
className="petal-word-card p-1.5" className="petal-word-card p-1.5"
style={{ style={{
@@ -116,7 +123,8 @@ export function ExportMenu({ docId }: Props) {
Print / PDF Print / PDF
</span> </span>
</button> </button>
</div> </div>,
document.body,
)} )}
</div> </div>
) )
+42 -11
View File
@@ -1,7 +1,10 @@
import type { Editor } from '@tiptap/react' import type { Editor } from '@tiptap/react'
import { useEditorState } from '@tiptap/react' import { useEditorState } from '@tiptap/react'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { uploadImageInto } from '../Editor/EditorCore' import { uploadImageInto } from '../Editor/EditorCore'
import { useAnchoredMenu } from '../Editor/anchoredMenu'
import { useScrollEdge } from '../Editor/useScrollEdge'
import { usePack } from '../../i18n' import { usePack } from '../../i18n'
interface Props { interface Props {
@@ -56,9 +59,26 @@ const Divider = () => (
<span className="mx-1 h-5 w-px" style={{ background: 'var(--color-border)' }} /> <span className="mx-1 h-5 w-px" style={{ background: 'var(--color-border)' }} />
) )
// A popover anchored under its trigger. The trigger + panel share a relative // A popover anchored under its trigger. The trigger + panel share a wrapper;
// wrapper; `open`/`onClose` are owned by the toolbar so only one is open at once. // `open`/`onClose` are owned by the toolbar so only one is open at once. A
// A pointer-down outside the wrapper closes it. // pointer-down outside the wrapper closes it.
//
// The panel is placed in viewport coordinates rather than absolutely inside
// that wrapper, for the same two reasons ChromeStrip's menus were (see
// useAnchoredMenu) — and on a phone both of them bite at once:
//
// * The toolbar clips what overflows it. On a desktop that clip is lifted on
// hover, which is where an absolutely-positioned panel got away with it for
// as long as it did; a touchscreen never hovers, so tapping A or H opened a
// panel that was simply not on the screen. The button lit up and nothing
// else happened, which is the worst shape a bug can take — it reads as the
// feature not existing.
// * `left-0` hangs a 200px panel off the right edge of a 390px phone when its
// trigger sits near the end of the row. useAnchoredMenu clamps it back
// inside the window instead.
//
// The panel stays a DOM child of the wrapper (fixed, not portalled) so the
// outside-tap test below keeps working on containment alone.
function Popover({ function Popover({
open, open,
onClose, onClose,
@@ -72,23 +92,27 @@ function Popover({
children: React.ReactNode children: React.ReactNode
width?: number width?: number
}) { }) {
const ref = useRef<HTMLDivElement>(null) const { triggerRef: ref, panelRef, style } = useAnchoredMenu<HTMLDivElement>(open, width)
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const onDown = (e: MouseEvent) => { const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) onClose() const target = e.target as Node
// The panel is portalled to <body>, so "inside" is either half.
if (!ref.current?.contains(target) && !panelRef.current?.contains(target)) onClose()
} }
document.addEventListener('mousedown', onDown) document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown) return () => document.removeEventListener('mousedown', onDown)
}, [open, onClose]) }, [open, onClose, ref, panelRef])
return ( return (
<div ref={ref} className="relative flex items-center"> <div ref={ref} className="flex items-center">
{trigger} {trigger}
{open && ( {open &&
createPortal(
<div <div
className="absolute left-0 top-full z-40 mt-1.5 p-2" ref={panelRef}
className="p-2"
style={{ style={{
width, ...style,
borderRadius: 'var(--radius-card)', borderRadius: 'var(--radius-card)',
background: 'var(--color-surface)', background: 'var(--color-surface)',
border: '1px solid var(--color-border)', border: '1px solid var(--color-border)',
@@ -96,7 +120,8 @@ function Popover({
}} }}
> >
{children} {children}
</div> </div>,
document.body,
)} )}
</div> </div>
) )
@@ -175,6 +200,10 @@ export function Toolbar({ editor, onVoiceCheck, voicing, onCollocationCheck, col
const [menu, setMenu] = useState<'color' | 'highlight' | 'size' | 'link' | 'table' | 'outline' | null>(null) const [menu, setMenu] = useState<'color' | 'highlight' | 'size' | 'link' | 'table' | 'outline' | null>(null)
const [linkUrl, setLinkUrl] = useState('') const [linkUrl, setLinkUrl] = useState('')
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
// Which end of the row still has controls behind it. Only ever visible on a
// coarse pointer, where the row scrolls instead of expanding on hover — see
// the .petal-toolbar rules in index.css.
const { ref: toolbarRef, edge } = useScrollEdge<HTMLDivElement>()
const state = useEditorState({ const state = useEditorState({
editor, editor,
@@ -255,6 +284,8 @@ export function Toolbar({ editor, onVoiceCheck, voicing, onCollocationCheck, col
return ( return (
<div <div
ref={toolbarRef}
data-edge={edge}
className="petal-toolbar mb-4 flex items-center gap-0.5 self-start px-2 py-1.5" className="petal-toolbar mb-4 flex items-center gap-0.5 self-start px-2 py-1.5"
style={{ style={{
borderRadius: 'var(--radius-card)', borderRadius: 'var(--radius-card)',
+69
View File
@@ -564,6 +564,64 @@ button, a, input {
padding-top: 0.25rem; padding-top: 0.25rem;
padding-bottom: 0.25rem; padding-bottom: 0.25rem;
} }
/* The formatting toolbar reaches everything it holds by expanding on hover
(see .petal-toolbar above). A touchscreen never hovers, so that rule never
fired here and the row stayed clipped at `overflow: hidden` for good: on a
390px phone roughly 750px of it — every heading, both lists, all three
alignments, link, image, table, outline, and both AI passes — could not be
reached at all. The faded edge said "there is more this way" and there was
no way.
So on a coarse pointer the row does what the pill strip does one line
above it: keeps every control and scrolls sideways, but only itself.
overscroll-behavior stops a swipe that runs out of buttons from dragging
the page of writing along with it, and the scrollbar is hidden because a
half-visible button at the edge is the affordance. The panels that hang off
these buttons are placed in viewport coordinates (see Popover in
Toolbar.tsx), so nothing here clips them. */
.petal-toolbar {
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
scrollbar-width: none;
-ms-overflow-style: none;
-webkit-mask-image: none;
mask-image: none;
}
.petal-toolbar::-webkit-scrollbar {
display: none;
}
/* :hover can still be reported on a touchscreen — a tap leaves a lingering
hover state on the last thing touched — and the desktop rule would answer
it by unwrapping the row mid-scroll. Hold the scrolling shape instead. */
.petal-toolbar:hover,
.petal-toolbar:focus-within {
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
}
/* Which edge has more behind it, from the same measurement the pill strip
uses (useScrollEdge → data-edge). A row whose buttons all fit is left
unmasked, so the fade only ever appears when it means something. */
.petal-toolbar[data-edge='right'] {
-webkit-mask-image: linear-gradient(to right, #000 92%, transparent 100%);
mask-image: linear-gradient(to right, #000 92%, transparent 100%);
}
.petal-toolbar[data-edge='left'] {
-webkit-mask-image: linear-gradient(to left, #000 92%, transparent 100%);
mask-image: linear-gradient(to left, #000 92%, transparent 100%);
}
.petal-toolbar[data-edge='both'] {
-webkit-mask-image: linear-gradient(
to right,
transparent 0%,
#000 8%,
#000 92%,
transparent 100%
);
mask-image: linear-gradient(to right, transparent 0%, #000 8%, #000 92%, transparent 100%);
}
} }
/* --- Responsive sidebar (narrow screens) ------------------------------------ /* --- Responsive sidebar (narrow screens) ------------------------------------
@@ -604,6 +662,17 @@ button, a, input {
z-index: 20; z-index: 20;
background: rgba(61, 46, 57, 0.18); background: rgba(61, 46, 57, 0.18);
} }
/* Sit the mascot above the status bar rather than on top of it.
--petal-companion-size bottoms out at 9rem, which is most of a phone's
width, and at `bottom-4` the bottom of that circle lands inside the 2.75rem
status bar — directly over "Hide falling petals", which could not be tapped
at all. The kitten yields to cards and panels (useCardOverlap) but the
status bar is neither: it is always there, so yielding to it would mean
fading forever. Moving up once is the honest fix. */
.petal-corner {
bottom: calc(2.75rem + 0.5rem);
}
} }
/* Small phones only: see the header in App.tsx for why the wordmark yields. /* Small phones only: see the header in App.tsx for why the wordmark yields.