Three layout bugs and a mobile experience that had no wayfinding. The `#proposal` section was clipped, not merely cramped: the submit button carried `whitespace-nowrap` from the shared button base, giving it a min-content width of 358px, which sized the section's implicit `auto` grid track to 406px inside a 343px container. `overflow-hidden` then sliced the copy and every form field off the right edge of a 375px screen. Fixed at the root — `whitespace-nowrap` moves out of `base` and into `headerSize`, where buttons are sized by their label — plus an explicit `grid-cols-[minmax(0,1fr)]` on the section. The market stat cards put their source line in absolute position against a guessed min-height, so the longest label ran underneath it. It is in the flow now, pinned by `mt-auto`. Below 980px the header nav is not rendered, which left seventeen screens of scroll with no way to navigate them. The header gives that width back to the page — logo and call button only — and <ThumbBar /> takes over: scroll progress, a live section indicator that opens a section sheet, and one short CTA, hidden over the hero and over the form. Card rows now say what they are. Rows of unordered peers keep scrolling sideways and gain a measured segment indicator (<Rail />), replacing the two prose "swipe sideways" hints. The launch timeline goes the other way: sideways scrolling hid week 3-5 from someone reading week 1-2, so below 900px it becomes four disclosures on a spine — which also recovers the scroll length the taller hero spent. Below 560px the type scale steps down half a level and the display tracking opens back up, and the hero photo zooms past `cover` onto the wave wall, since fitting by height left only ceiling tiles above the copy. Also: the route card inside the market section split into two 165px columns at 760px, so that split now waits for 980px. Desktop above 980px is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
55 lines
1.7 KiB
TypeScript
55 lines
1.7 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
|
|
/**
|
|
* Which section the reader is currently in, plus how far through the page they
|
|
* are. Driven by scroll position rather than by an IntersectionObserver: every
|
|
* section here is taller than a phone viewport, so the question is not "is this
|
|
* visible" but "which one is under the reading line", and a threshold-based
|
|
* observer cannot answer that.
|
|
*
|
|
* `hrefs` must be a stable array — pass a module-level constant.
|
|
*/
|
|
export function useReadingPosition(hrefs: readonly string[]) {
|
|
const [index, setIndex] = useState(0)
|
|
const [progress, setProgress] = useState(0)
|
|
|
|
useEffect(() => {
|
|
let frame = 0
|
|
|
|
const measure = () => {
|
|
frame = 0
|
|
|
|
/* A third of the way down the screen: the line the eye actually reads
|
|
from, not the very top edge. */
|
|
const line = window.scrollY + window.innerHeight * 0.3
|
|
let current = 0
|
|
|
|
hrefs.forEach((href, position) => {
|
|
const element = document.querySelector(href)
|
|
if (element && element.getBoundingClientRect().top + window.scrollY <= line) current = position
|
|
})
|
|
|
|
const scrollable = document.documentElement.scrollHeight - window.innerHeight
|
|
|
|
setIndex(current)
|
|
setProgress(scrollable > 0 ? Math.min(1, window.scrollY / scrollable) : 0)
|
|
}
|
|
|
|
const onScroll = () => {
|
|
if (!frame) frame = requestAnimationFrame(measure)
|
|
}
|
|
|
|
measure()
|
|
window.addEventListener('scroll', onScroll, { passive: true })
|
|
window.addEventListener('resize', onScroll)
|
|
|
|
return () => {
|
|
window.removeEventListener('scroll', onScroll)
|
|
window.removeEventListener('resize', onScroll)
|
|
if (frame) cancelAnimationFrame(frame)
|
|
}
|
|
}, [hrefs])
|
|
|
|
return { index, progress }
|
|
}
|