What broke, and the shape of the fix
The in-run masthead is the running head over /play: the wordmark, the objective strip (icon · title · era), and the run gauge + sound/dark/record/reset controls. It packed all of that onto one flex row and never reflowed, so it broke at the two most common small widths — in both light and dark, Chromium and WebKit.
At a phone's 375px the controls printed straight over the objective title. The fix is small and structural: below desktop, the objective drops to its own full-width row, so it always reads in full.
Before
After
The masthead reflows below 1024px
The whole change to the header is one media query. flex-wrap lets the row break; order keeps the wordmark and controls together on the first line; flex-basis: 100% forces the objective onto its own line; and margin-left: auto pins the controls to the right edge of the first line. Above the breakpoint, nothing changes — desktop keeps its single row.
The only structural change to the header — a reflow, not a rewrite.
pages/play.vue · 561 lines
⋯ 417 lines hidden (lines 1–417)
⋯ 132 lines hidden (lines 430–561)
Before
After
The coach mark never lands on the running head
The first-run “who & when” coach mark had the same root cause. It floats a card beside the control it teaches, trying placements in order and picking the first that fits the viewport. But the figure picker fills the whole compose column, so no side placement fits and the only one that clears the viewport is above it — squarely on the masthead. The card dropped over the very controls it was teaching.
Two moves fix it. safeTop() measures the masthead's foot and uses it as the floor for placements, so “above the tall control” no longer counts as fitting. And when nothing fits beside the control, useBottomSheet is true and the card becomes the same bottom sheet phones already use — which the leaf scrolls clear of.
safeTop() as the placement floor; useBottomSheet when nothing fits beside the control; cardStyle short-circuits to the sheet.
components/CoachMark.vue · 293 lines
⋯ 116 lines hidden (lines 1–116)
⋯ 9 lines hidden (lines 152–160)
⋯ 129 lines hidden (lines 165–293)
Before
After
Placement geometry, pulled into a tested util
The placement math used to live inline in the component, where a layout-less test runner (happy-dom) can't exercise it. It now sits in a pure module: firstFittingPlacement returns the first side that fits, or null — the signal to drop to the bottom sheet. fits carries the minTop floor that the masthead feeds.
Pure, framework-free: rects in, a placement (or null) out.
utils/coach-placement.ts · 88 lines
⋯ 44 lines hidden (lines 1–44)
⋯ 16 lines hidden (lines 60–75)
That makes the load-bearing case directly testable. The discriminating test pins exactly the bug: a tall control where the only viewport-fitting spot is the masthead. With the floor it returns null (→ bottom sheet); lift the floor and it would place “top” — straight over the header. The second assertion is what proves the floor is doing the work.
The floor is load-bearing: same geometry, opposite outcome.
tests/unit/utils/coach-placement.spec.ts · 65 lines
⋯ 49 lines hidden (lines 1–49)
The leaf reserves room for the sheet
The bottom sheet needs the leaf to scroll and leave clearance, so the taught control can lift above the card instead of hiding under it. That used to be phone-only; now it's gated on the body flag the coach sets, so it applies at any width the sheet appears — and stays inert (leaf untouched) when the card is anchored on desktop.
Clearance follows the sheet, not the breakpoint.