Files
exodevices/medcenterpersonal/src/hooks/useReadingPosition.ts
T
yuriy.pandClaude Opus 5 9b4f93bb16 Redesign the phone layer of the landing
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>
2026-08-29 11:32:28 +06:00

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 }
}