diff --git a/src/components/Landing.tsx b/src/components/Landing.tsx index fb47b7f..c0e7d5b 100644 --- a/src/components/Landing.tsx +++ b/src/components/Landing.tsx @@ -1475,7 +1475,7 @@ export function Landing() { useEffect(() => { const updateStroke = () => { const convo = convoRef.current; - const mascot = document.getElementById('mascot-core'); // Targets the 260x260 container + const mascot = document.getElementById('mascot-core'); // Targets the mascot's square container const container = pageContainerRef.current; const features = document.getElementById('features'); if (!convo || !mascot || !container || !features) return; @@ -1548,7 +1548,7 @@ export function Landing() { // Scroll-spy: highlight the nav item for whichever section the reader is in. Active = the LAST // section whose top has crossed ~38% of the viewport height (document order: - // philosophy -> how-it-works -> Mr.OneHook -> get-app). Above the first section nothing highlights. + // philosophy -> how-it-works -> Intelligence -> get-app). Above the first section nothing highlights. useEffect(() => { const ids = ['philosophy', 'features', 'under-the-hood', 'get-app']; let raf = 0; @@ -1935,7 +1935,7 @@ export function Landing() { Philosophy How it works - Mr.OneHook + Intelligence Get the app Pricing @@ -1993,7 +1993,7 @@ export function Landing() { onClick={() => scrollToSection('under-the-hood')} {...trackProps(UiElement.LANDING_SECTION_NAV, { value: 'under_the_hood' })} className={deskNav('under-the-hood')} > - Mr.OneHook + Intelligence {/* The Mascot */}
setShowMascotVideo((open) => !open)} @@ -2979,7 +2984,8 @@ export function Landing() {
{/* Social Links Below Mascot */} -
+ {/* On narrow screens the sparkle's lower labels sit either side of this pill: start it below them. */} +
MR.ONEHOOK'S SOCIALS:
diff --git a/src/components/common/SiteHeader.tsx b/src/components/common/SiteHeader.tsx index 6727e88..2ef9cc5 100644 --- a/src/components/common/SiteHeader.tsx +++ b/src/components/common/SiteHeader.tsx @@ -10,6 +10,9 @@ function isMobileDevice(): boolean { return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent); } +/** The long-form story pages share one header layout (see `fitTitles`). */ +export const STORY_PAGE_TITLES = ['Science', 'Messenger'] as const; + /** * Lightweight top header used on standalone pages (login, redeem, legal pages). The brand * mark returns to the landing page. On mobile devices an "Open in app" action @@ -17,8 +20,18 @@ function isMobileDevice(): boolean { * * @param pageTitle - Optional page title to display in the center (e.g., "Privacy", "Terms", "Pricing") * @param onApply - Optional callback for Apply button (e.g., on Careers page). Shows button next to close button. + * @param fitTitles - Sibling pages that should share one header layout. The full-or-compact choice is + * made for the widest of these titles, so every page in the group switches at the same width. */ -export function SiteHeader({ pageTitle, onApply }: { pageTitle?: string; onApply?: () => void }) { +export function SiteHeader({ + pageTitle, + onApply, + fitTitles, +}: { + pageTitle?: string; + onApply?: () => void; + fitTitles?: readonly string[]; +}) { const navigate = useNavigate(); const mobile = isMobileDevice(); @@ -30,6 +43,7 @@ export function SiteHeader({ pageTitle, onApply }: { pageTitle?: string; onApply const actionsRef = useRef(null); const measureRef = useRef(null); const measureTitleRef = useRef(null); + const fitKey = (fitTitles ?? []).join('|'); const [compact, setCompact] = useState(false); useLayoutEffect(() => { const row = rowRef.current; @@ -42,7 +56,11 @@ export function SiteHeader({ pageTitle, onApply }: { pageTitle?: string; onApply const actionsLeft = actionsRef.current?.getBoundingClientRect().left ?? rowBox.right; const GAP = 16; const markRight = left + measure.scrollWidth; - const titleW = measureTitleRef.current?.scrollWidth ?? 0; + // The widest of the measured titles (this page's, plus any sibling pages sharing the layout). + const titleW = Math.max( + 0, + ...[...(measureTitleRef.current?.children ?? [])].map((el) => (el as HTMLElement).scrollWidth) + ); let next: boolean; if (titleW) { const centre = rowBox.left + rowBox.width / 2; @@ -64,7 +82,7 @@ export function SiteHeader({ pageTitle, onApply }: { pageTitle?: string; onApply ro.disconnect(); window.removeEventListener('resize', check); }; - }, [pageTitle]); + }, [pageTitle, fitKey]); return (
@@ -75,7 +93,11 @@ export function SiteHeader({ pageTitle, onApply }: { pageTitle?: string; onApply
{pageTitle && ( )}
diff --git a/src/components/mascot/AlienScanner.tsx b/src/components/mascot/AlienScanner.tsx index 1d2702d..c4c730f 100644 --- a/src/components/mascot/AlienScanner.tsx +++ b/src/components/mascot/AlienScanner.tsx @@ -60,7 +60,7 @@ export const AlienScanner: React.FC = ({ className, style, onClick, - hoverLabel = 'Click to watch me explain more about onehook.club', + hoverLabel = 'Click to watch Mr.OneHook explain more about onehook.club', forceExcited = false, hideTooltips = false, settled = false, @@ -297,7 +297,7 @@ export const AlienScanner: React.FC = ({ {!bare && } {/* Excited hover tooltip (plain conditional: exit-coordination could leave diff --git a/src/components/messenger/Messenger.tsx b/src/components/messenger/Messenger.tsx index 7aa8c89..cd5ea6b 100644 --- a/src/components/messenger/Messenger.tsx +++ b/src/components/messenger/Messenger.tsx @@ -1,6 +1,6 @@ -import { Fragment, createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import { Fragment, createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; -import { AnimatePresence, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useScroll, useTransform, type MotionValue } from 'motion/react'; +import { AnimatePresence, MotionConfig, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useScroll, useTransform, type MotionValue } from 'motion/react'; import { ArrowRight, Check, @@ -33,7 +33,7 @@ import { CheckCheck, MessageCircleMore, } from 'lucide-react'; -import { SiteHeader } from '../common/SiteHeader'; +import { STORY_PAGE_TITLES, SiteHeader } from '../common/SiteHeader'; import { SiteFooter } from '../common/SiteFooter'; import { Seo } from '../common/Seo'; import { ROUTE_SEO } from '../../config/seo-routes'; @@ -43,6 +43,7 @@ import { ProgressBar } from '../science/Science'; import { StoryTimelineProvider, bandIndex, + isRepeatedAttempt, isStoryComplete, makeEqualBands, SHOW_MIN_MS, @@ -54,7 +55,6 @@ import { useSceneProgress, useStoryInstant, useScrollLimit, - useScrollSpeed, useStoryPlayback, useTimeline, } from './storyTimeline'; @@ -2833,6 +2833,80 @@ function SceneProgress({ index }: { index: number }) { ); } +/** The stage's layout: illustration (flex-1), words, buttons, with room for a collapsed phone toolbar. */ +const STAGE_LAYOUT = + 'flex flex-col items-center overflow-hidden px-4 pb-[calc(100lvh-100svh+12px)] pt-1 sm:px-6 sm:pb-[calc(100lvh-100svh+32px)] sm:pt-6'; + +/** + * One loaded screen as a page: the same layout as the stage, in its finished state, placed at + * `(i - pos)` screens from the top so the strip slides with the scroll. `buttons` is an invisible + * copy of the stage's button row, so every slide lays out exactly like the stage (the real buttons + * stay pinned above the strip). + */ +function StorySlide({ i, pos, buttons }: { i: number; pos: MotionValue; buttons: ReactNode }) { + const y = useTransform(pos, (v) => `${(i - v) * 100}%`); + const scene = SCENES[i]; + const n = SCENES.length; + const finale = i === n - 1; + return ( + + {i === 5 && ( + <> + + + + )} + {i === 4 && } +
+ {i <= 4 ? ( +
+ +
+ ) : scene.fullScreen ? ( +
{scene.art()}
+ ) : ( + // the finale's heart has faded into the background by the time the screen has played +
+ +
{scene.art()}
+
+
+ )} +
+
+ {!finale && ( +
+

+ {String(i + 1).padStart(2, '0')} / {String(n).padStart(2, '0')} +

+ +

{scene.title}

+

{scene.line}

+
+ )} +
+ {finale && ( +
+

+ {String(n).padStart(2, '0')} / {String(n).padStart(2, '0')} +

+ +

+ {scene.title} +

+

{scene.line}

+
+ )} +
+ {buttons} +
+
+ ); +} + export function Messenger() { const [lite] = useState(detectLite); const navigate = useNavigate(); @@ -2854,9 +2928,8 @@ export function Messenger() { // footer while the stage still shows screen 3. Until screen 8 is up, the page simply ends at the // bottom of the track. Hiding it again is safe: whenever an earlier screen is on stage the reader is // inside the track, above it, so nothing in view moves. - // how long a screen you've already seen stays up follows your scroll speed (see storyTimeline.tsx) - const scrollSpeed = useScrollSpeed(!reduce); - const { index, instant, play, line } = useStoryPlayback(scrollYProgress, STORY_BANDS, SCENE_PLAY_SECONDS, Boolean(reduce), scrollSpeed); + // new screens play once on the stage; screens already seen are a normal page above it (see storyTimeline.tsx) + const { index, instant, play, line } = useStoryPlayback(scrollYProgress, STORY_BANDS, SCENE_PLAY_SECONDS, Boolean(reduce)); // the footer only once the last screen is actually on stage (it can still be finishing an earlier one) // ...and only once screen 8 has had its time (its animation done, then SHOW_MIN_MS on the finished // screen). Otherwise a reader still scrolling when it arrives is carried straight past it into the @@ -2893,10 +2966,40 @@ export function Messenger() { const storyDone = endingOpen && index === n - 1; // The page can't run ahead of the stage: at most to the end of the next screen's section while a // screen plays (the cap moves on as each screen comes up; none on the last screen). + // A reader who keeps trying to scroll while a screen plays is told why the page is holding: the + // illustration is still playing, and scrolling continues once it finishes. + const attempts = useRef([]); + const nudgeTimer = useRef(0); + const [nudge, setNudge] = useState(false); + const onBlocked = useCallback(() => { + const list = attempts.current; + list.push(performance.now()); + if (list.length > 40) list.shift(); + if (!isRepeatedAttempt(list)) return; + setNudge(true); + // it goes away on its own once the reader stops trying + window.clearTimeout(nudgeTimer.current); + nudgeTimer.current = window.setTimeout(() => setNudge(false), 2500); + }, []); + useEffect(() => { + // a new screen on stage, or this one done playing: the hold is over + const clear = () => { + attempts.current = []; + window.clearTimeout(nudgeTimer.current); + setNudge(false); + }; + clear(); + const off = play[index].on('change', (v) => v >= 1 && clear()); + return () => { + off(); + window.clearTimeout(nudgeTimer.current); + }; + }, [index, play]); useScrollLimit({ scroll: scrollYProgress, maxProgress: scrollLimit(STORY_BANDS, index), enabled: !reduce, + onBlocked, }); const threadHeight = useTransform(scrollYProgress, (v) => `${Math.min(1, Math.max(0, v)) * 100}%`); // the screen we came from (updated after render, so during a render it is the previous screen) @@ -2909,6 +3012,54 @@ export function Messenger() { [scrollYProgress, play, line, reduce, instant] ); + // Screens already seen are a normal page. `pos` is the reader's place in screens (screen i is + // centred at pos = i), held at the stage's screen: at or below it the stage itself is showing; + // above it, a strip of the loaded screens, finished, slides with the scroll like any page. + const indexRef = useRef(index); + indexRef.current = index; + const pos = useTransform(scrollYProgress, (p) => Math.min(Math.max(0, p) * n, indexRef.current)); + // the loaded screen at the top of the view while the strip shows (null: the stage is showing) + const [strip, setStrip] = useState(null); + useMotionValueEvent(pos, 'change', (v) => { + const next = !reduce && v < indexRef.current - 0.001 ? Math.floor(v) : null; + setStrip((prev) => (prev === next ? prev : next)); + }); + // the screen the reader is looking at (for the thread and the backdrop glow) + const view = strip === null ? index : Math.min(index, Math.round(pos.get())); + const hideStage = strip === null ? undefined : ('hidden' as const); + const slideStory = useMemo(() => ({ ...story, instant: true }), [story]); + + // the same three buttons, always in the same place (also laid out, invisibly, in each page slide) + const buttonsRow = ( + + + Get the app + + +
+ Support + + + ); + const scene = SCENES[index]; // Last screen ("Free forever"): the heart blurs and fades into the background, and the words take // over the whole stage—large and centred—with the buttons staying in their usual place. @@ -2939,7 +3090,7 @@ export function Messenger() {
- +

OneHook Messenger — the new-age, end-to-end encrypted messenger, free forever

@@ -2962,40 +3113,61 @@ export function Messenger() { // toolbar collapsed (100lvh), but everything in it is laid out for the screen WITH the // toolbar (100svh), so nothing re-sizes or moves when the toolbar shows or hides while // scrolling. The extra strip the collapsed toolbar uncovers holds "Start with an invite". - className="sticky flex flex-col items-center overflow-hidden px-4 pb-[calc(100lvh-100svh+12px)] pt-1 sm:px-6 sm:pb-[calc(100lvh-100svh+32px)] sm:pt-6" + className={`sticky ${STAGE_LAYOUT}`} style={{ top: headerH, height: `calc(100lvh - ${headerH}px)` }} > {/* screen 6: Mr OneHook peeking in from the edges */} - - {index === 5 && ( - - - - - )} - +
+ + {index === 5 && ( + + + + + )} + +
{/* screen 5: the supported-language list, rolling the full height of the screen */} - - {index === 4 && ( - - - - )} - +
+ + {index === 4 && ( + + + + )} + +
{/* soft backdrop glow that drifts with the story */}
diff --git a/src/components/messenger/storyTimeline.tsx b/src/components/messenger/storyTimeline.tsx index f09f687..5f1141a 100644 --- a/src/components/messenger/storyTimeline.tsx +++ b/src/components/messenger/storyTimeline.tsx @@ -5,14 +5,14 @@ * * 1. The story track is split into equal bands, one per screen; the scroll position decides which * screen the stage is heading for. - * 2. The stage never skips: it walks there ONE SCREEN AT A TIME, however far or fast the reader scrolls. - * 3. The first time a screen arrives going FORWARD, its choreography plays at its own authored pace - * (a 0..1 playhead animated over the screen's seconds), and the stage stays on it until that is - * done. Scroll speed never changes the pace or cuts it short. - * 4. A screen plays once. Arriving on it again — or arriving by scrolling UP — switches to its - * finished state (playhead at 1, entrance/exit animations skipped). How long it stays up follows - * the reader's scroll speed (about the time they take to scroll one screen), never less than - * SHOW_MIN_MS, so a fast scroll through loaded screens still shows each one. + * 2. The stage only ever moves FORWARD, onto screens that have not played yet, one at a time. The + * first time a screen arrives, its choreography plays at its own authored pace (a 0..1 playhead + * animated over the screen's seconds), and the stage stays on it until that is done (at least + * MIN_SHOW_MS). Scroll speed never changes the pace or cuts it short. + * 3. Screens that have played are a normal page. Scrolling back above the stage's screen, the page + * itself shows them, finished, sliding with the scroll (the Messenger page renders that strip); + * the stage stays on the furthest screen and simply finishes it. Scrolling back down meets the + * stage again exactly where it was. * * Cost: a playhead only changes during its one playthrough, so finished screens do no per-frame work; * there is no follower loop and no scroll-driven re-computation of choreography. Screens shown @@ -20,7 +20,7 @@ * * With reduced motion every screen is simply shown finished. */ -import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { MotionGlobalConfig, animate, @@ -33,11 +33,23 @@ import { /** A screen that is playing for the first time stays at least this long (its animation usually longer). */ export const MIN_SHOW_MS = 2000; -/** A loaded screen stays about as long as the reader takes to scroll one screen, within these bounds. */ +/** After the last screen's first play, the footer opens no sooner than this, so it is seen finished. */ export const SHOW_MIN_MS = 700; -export const SHOW_MAX_MS = 2000; -/** Scroll speed is measured over this recent window. */ -const SPEED_WINDOW_MS = 400; +/** Stopped scroll attempts spread over at least this long count as "trying repeatedly". */ +export const NUDGE_AFTER_MS = 600; +/** Attempts further apart than this start a new count. */ +export const NUDGE_RESET_MS = 1500; + +/** + * Whether stopped scroll attempts (timestamps, oldest first) amount to the reader trying repeatedly: + * attempts with no gap over NUDGE_RESET_MS, spanning at least NUDGE_AFTER_MS. One wheel tick or a + * single short swipe never qualifies. + */ +export function isRepeatedAttempt(times: readonly number[]): boolean { + let start = times.length - 1; + while (start > 0 && times[start] - times[start - 1] <= NUDGE_RESET_MS) start -= 1; + return times.length > 0 && times[times.length - 1] - times[start] >= NUDGE_AFTER_MS; +} /** The scroll cap sits this far inside the next section, so the cap itself never selects the one after. */ const LIMIT_EPSILON = 0.0005; /** A 'show' switch skips every animation started this soon after it (covers AnimatePresence's @@ -98,76 +110,6 @@ export function arrivalMode(prev: number, next: number, played: ReadonlySet prev && !played.has(next) ? 'play' : 'show'; } -/** - * How long a loaded screen stays on stage at a scroll speed of `screensPerSecond`: the time it takes - * to scroll one screen at that speed, kept between SHOW_MIN_MS and SHOW_MAX_MS. - */ -export function showDwellMs(screensPerSecond: number): number { - const speed = Math.abs(screensPerSecond); - if (!(speed > 0)) return SHOW_MAX_MS; - return Math.min(SHOW_MAX_MS, Math.max(SHOW_MIN_MS, 1000 / speed)); -} - -/** - * The reader's recent scroll speed, in screens per second, over the last SPEED_WINDOW_MS. It counts - * scroll INTENT, not only movement: wheel and finger input are measured even when the scroll cap - * blocks them, so pushing against the cap still reads as fast. Scroll movement from elsewhere - * (scrollbar, keys, momentum after a swipe) is measured from the page itself. - */ -export function useScrollSpeed(enabled: boolean) { - const meter = useMemo(() => { - const samples: { at: number; px: number }[] = []; - return { - add(px: number, at = performance.now()) { - samples.push({ at, px: Math.abs(px) }); - while (samples.length && at - samples[0].at > SPEED_WINDOW_MS) samples.shift(); - }, - screensPerSecond(at = performance.now()) { - while (samples.length && at - samples[0].at > SPEED_WINDOW_MS) samples.shift(); - const px = samples.reduce((sum, x) => sum + x.px, 0); - const screen = typeof window === 'undefined' ? 1 : window.innerHeight || 1; - return px / screen / (SPEED_WINDOW_MS / 1000); - }, - }; - }, []); - useEffect(() => { - if (!enabled) return; - let lastY = window.scrollY; - let lastInput = 0; // wheel/touch already counted: their scroll movement isn't counted twice - let touchY = 0; - const onWheel = (e: WheelEvent) => { - const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? window.innerHeight : 1; - meter.add(e.deltaY * unit); - lastInput = performance.now(); - }; - const onTouchStart = (e: TouchEvent) => { - touchY = e.touches[0]?.clientY ?? 0; - }; - const onTouchMove = (e: TouchEvent) => { - const y = e.touches[0]?.clientY ?? touchY; - meter.add(touchY - y); - touchY = y; - lastInput = performance.now(); - }; - const onScroll = () => { - const y = window.scrollY; - if (performance.now() - lastInput > 100) meter.add(y - lastY); - lastY = y; - }; - window.addEventListener('wheel', onWheel, { passive: true }); - window.addEventListener('touchstart', onTouchStart, { passive: true }); - window.addEventListener('touchmove', onTouchMove, { passive: true }); - window.addEventListener('scroll', onScroll, { passive: true }); - return () => { - window.removeEventListener('wheel', onWheel); - window.removeEventListener('touchstart', onTouchStart); - window.removeEventListener('touchmove', onTouchMove); - window.removeEventListener('scroll', onScroll); - }; - }, [enabled, meter]); - return meter; -} - /** What the playback controller needs from the outside world (Motion and the DOM in the app, fakes in tests). */ export type StoryPlayerDeps = { bands: Bands; @@ -176,8 +118,6 @@ export type StoryPlayerDeps = { line: readonly { set(v: number): void }[]; /** Current scroll progress (0..1). */ progress: () => number; - /** The reader's recent scroll speed (screens per second), for how long loaded screens stay. */ - speed: () => number; /** Current time in ms. */ now: () => number; /** Run `value` 0 → 1 linearly over `duration` s; call `onComplete` at the end. Returns a stop function. */ @@ -192,13 +132,11 @@ export type StoryPlayerDeps = { /** * The story's playback rules (see `useStoryPlayback`), independent of React and Motion. * - * The stage walks toward the screen the reader has scrolled to, one screen at a time: + * The stage stays on the furthest screen reached and only moves forward: * - Forward onto a screen that has not played: it plays at its own pace, and the next forward step - * waits until its animation is done (it stays at least MIN_SHOW_MS either way). - * - Backward, or onto a screen that has played: it is shown finished, animations skipped, and stays - * for `showDwellMs(speed)` — shorter the faster the reader scrolls, never under SHOW_MIN_MS. The - * speed is re-read on every scroll, so speeding up mid-wait moves on sooner. - * - A screen left going backward before its animation finished counts as loaded. + * waits until its animation is done (it stays at least MIN_SHOW_MS). + * - Scrolled back above it: the screen on stage is finished at once (it counts as loaded) and the + * stage stays put; the page shows the loaded screens. * - Everything before the screen on stage is in the past of the story, so it is finished too. */ export function createStoryPlayer(d: StoryPlayerDeps) { @@ -206,7 +144,7 @@ export function createStoryPlayer(d: StoryPlayerDeps) { let committed = bandIndex(bands, d.progress()); const played = new Set(); let playing: { scene: number; stop: () => void } | null = null; - let dwell = 0; // timer: the screen on stage still has time to serve + let dwell = 0; // timer: a first-play screen still has time to serve let shownAt = 0; let shownMode: 'play' | 'show' = 'play'; @@ -257,24 +195,26 @@ export function createStoryPlayer(d: StoryPlayerDeps) { shownMode = mode; step(); // arms the dwell timer for this screen }; - /** One screen toward the scroll position, unless the screen on stage still needs its time. */ + /** Forward toward the scroll position, one first play at a time. */ const step = () => { const target = bandIndex(bands, d.progress()); - if (target === committed) return; - // every screen gets its time: a first play MIN_SHOW_MS, a loaded screen as long as the scroll speed asks - const need = shownMode === 'play' ? MIN_SHOW_MS : showDwellMs(d.speed()); - const left = need - (d.now() - shownAt); d.clearTimer(dwell); dwell = 0; - if (left > 0) { + // Scrolled back above the stage: the page shows the loaded screens; the stage's own screen is + // finished, so it is complete whenever the reader comes back down to it. + if (target < committed) return finishPlaying(); + if (target === committed) return; + // every first play gets its time: at least MIN_SHOW_MS, and the whole animation + const left = MIN_SHOW_MS - (d.now() - shownAt); + if (shownMode === 'play' && left > 0) { dwell = d.setTimer(() => { dwell = 0; step(); }, left); return; } - if (target > committed && playing) return; // forward waits for the animation to finish - const next = committed + (target > committed ? 1 : -1); + if (playing) return; // forward waits for the animation to finish + const next = committed + 1; commit(next, arrivalMode(committed, next, played)); }; @@ -311,8 +251,7 @@ export function useStoryPlayback( scroll: MotionValue, bands: Bands, seconds: readonly number[], - reduce: boolean, - speed: { screensPerSecond(): number } + reduce: boolean ) { const n = bands.start.length; const play = useMemo(() => Array.from({ length: n }, () => motionValue(0)), [n]); @@ -335,7 +274,6 @@ export function useStoryPlayback( play, line, progress: () => scroll.get(), - speed: () => speed.screensPerSecond(), now: () => performance.now(), run: (value, duration, onComplete) => { const controls = animate(value as MotionValue, 1, { duration, ease: 'linear', onComplete }); @@ -358,15 +296,16 @@ export function useStoryPlayback( window.clearTimeout(skipTimer); MotionGlobalConfig.skipAnimations = false; }; - }, [scroll, bands, seconds, reduce, play, line, speed]); + }, [scroll, bands, seconds, reduce, play, line]); return { index: state.index, instant: state.instant, play, line }; } /** - * The furthest the page may scroll (story progress) while screen `index` is on stage: the end of the - * NEXT screen's section. The reader can scroll ahead into the next section while a screen plays, but - * not past it — so the page never runs screens ahead of the stage. Null on the last screen (no cap). + * The furthest the page may scroll (story progress) while screen `index` is on stage (the stage is + * always on the furthest screen reached): the end of the NEXT screen's section. Loaded screens above + * scroll freely; the reader can scroll ahead into the next section while a screen plays, but not past + * it, so the page never runs screens ahead of the stage. Null on the last screen (no cap). */ export function scrollLimit(bands: Bands, index: number): number | null { const next = index + 1; @@ -391,12 +330,17 @@ export function useScrollLimit({ scroll, maxProgress, enabled, + onBlocked, }: { /** Story scroll progress (0..1) — the same MotionValue the stage is driven by. */ scroll: MotionValue; maxProgress: number | null; enabled: boolean; + /** Called each time a scroll past the limit is stopped (wheel, touch, keys or scrollbar). */ + onBlocked?: () => void; }) { + const blocked = useRef(onBlocked); + blocked.current = onBlocked; useEffect(() => { if (!enabled || maxProgress === null) return; const cap = maxProgress; @@ -406,7 +350,10 @@ export function useScrollLimit({ let lastGoodY = window.scrollY; const clamp = () => { if (scroll.get() > cap) { - if (window.scrollY !== lastGoodY) window.scrollTo(0, lastGoodY); + if (window.scrollY !== lastGoodY) { + window.scrollTo(0, lastGoodY); + blocked.current?.(); + } } else { lastGoodY = window.scrollY; } @@ -424,13 +371,19 @@ export function useScrollLimit({ const y = e.touches[0]?.clientY ?? touchY; const down = y < touchY; // finger moving up scrolls the page down touchY = y; - if (down && atLimit() && e.cancelable) e.preventDefault(); + if (down && atLimit() && e.cancelable) { + e.preventDefault(); + blocked.current?.(); + } }; const DOWN_KEYS = new Set(['ArrowDown', 'PageDown', 'End', ' ', 'Spacebar']); const onKey = (e: KeyboardEvent) => { const target = e.target as HTMLElement | null; if (target?.closest('input, textarea, select, [contenteditable="true"]')) return; - if (DOWN_KEYS.has(e.key) && !e.shiftKey && atLimit()) e.preventDefault(); + if (DOWN_KEYS.has(e.key) && !e.shiftKey && atLimit()) { + e.preventDefault(); + blocked.current?.(); + } }; clamp(); // Driven by the progress MotionValue's own change event (fired after Motion reads the fresh scroll diff --git a/src/components/science/OrbitNav.tsx b/src/components/science/OrbitNav.tsx deleted file mode 100644 index 1332bff..0000000 --- a/src/components/science/OrbitNav.tsx +++ /dev/null @@ -1,380 +0,0 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { motion, useMotionValueEvent, useReducedMotion, useSpring } from 'motion/react'; -import { UiElement, trackProps, trackUi } from '../../lib/analytics/analytics'; -import { BRAND, ENGINE_META, type ChapterDef } from './ScienceUi'; - -/** - * Chapter scroller in the floating bottom bar: the original side "drum", turned horizontal. - * - * It takes the place of the "Get the app" bar and behaves exactly like it: it appears once you have - * scrolled past the first screen and keep scrolling down, hides when you scroll up, and never sits - * over the footer or an open Mr OneHook chat. Its width matches that bar at every screen size. - * - * The chapters sit on a ring that rotates sideways as you scroll. The current chapter is at the - * front — centred and full size; its neighbours turn away to the left and right into the dark slot, - * foreshortened, smaller and fainter. A tapered ribbon runs along the slot: hairline where chapters - * disappear into it, widest at the front, which sells the depth. - * - * Drag it sideways, or scroll / swipe over it, to turn the drum and jump to that chapter. While the - * bar is moving the page it stays on screen, even when that means scrolling back up. - */ - -const BAR_H = 46; // height of the dark slot -const RIBBON_Y = BAR_H - 7; // the ribbon runs along the slot's lower edge -const WIDE = 6; // ribbon thickness at the front (px) -const THIN = 0.75; // ribbon thickness where it enters the slot (px) -const MAX_DEG = 84; // beyond this a chapter has rotated into the slot - -/** While the bar itself is scrolling the page (drag, wheel, tap), keep it visible until this time. */ -let holdVisibleUntil = 0; - -/** - * Same show/hide rule as the floating "Get the app" bar (StickyCta): visible only while scrolling - * DOWN past the first screen; hidden when scrolling up, over the footer, or while Mr OneHook is open. - */ -function useBottomBarVisible(): boolean { - const [show, setShow] = useState(false); - useEffect(() => { - let lastY = window.scrollY; - const onScroll = () => { - const y = window.scrollY; - const goingDown = y > lastY + 2; // small threshold to ignore jitter - const goingUp = y < lastY - 2; - lastY = y; - const pastFirstScreen = y > window.innerHeight * 0.7; - const footer = document.querySelector('footer'); - const footerVisible = footer ? footer.getBoundingClientRect().top < window.innerHeight : false; - const chatbotOpen = !!document.querySelector('.chatbot-window'); - // Gone for good once the section after Promises (the finale) comes into view. - const finale = document.querySelector('[aria-labelledby="finale-title"]'); - const pastPromises = finale ? finale.getBoundingClientRect().top < window.innerHeight : false; - if (chatbotOpen || footerVisible || pastPromises || !pastFirstScreen) setShow(false); - else if (goingDown || Date.now() < holdVisibleUntil) setShow(true); - else if (goingUp) setShow(false); - }; - onScroll(); - window.addEventListener('scroll', onScroll, { passive: true }); - window.addEventListener('chatbotToggle', onScroll); - return () => { - window.removeEventListener('scroll', onScroll); - window.removeEventListener('chatbotToggle', onScroll); - }; - }, []); - return show; -} - -/** - * The floating bar's width at each screen size (measured): full width minus 2rem on phones; 737px - * from md up in landscape, where its tagline shows; otherwise 393px. - */ -function useBarWidth(): number { - const [width, setWidth] = useState(737); - useLayoutEffect(() => { - const portrait = window.matchMedia('(orientation: portrait)'); - const measure = () => { - const w = window.innerWidth; - setWidth(w < 640 ? w - 32 : w >= 768 && !portrait.matches ? Math.min(737, w - 32) : Math.min(393, w - 32)); - }; - measure(); - window.addEventListener('resize', measure); - portrait.addEventListener('change', measure); - return () => { - window.removeEventListener('resize', measure); - portrait.removeEventListener('change', measure); - }; - }, []); - return width; -} - -/** - * Continuous reading position: chapter index plus fraction through it, measured at the line 45% - * down the viewport. −1 while the hero is on screen; stays on the last chapter through the finale. - */ -function useChapterPosition(ids: readonly string[]) { - const [pos, setPos] = useState(-1); - useEffect(() => { - let raf = 0; - const measure = () => { - raf = 0; - const line = window.innerHeight * 0.45; - let p = -1; - ids.forEach((id, i) => { - const el = document.getElementById(id); - if (!el) return; - const r = el.getBoundingClientRect(); - if (r.top <= line) p = i + Math.min(1, Math.max(0, (line - r.top) / Math.max(1, r.height))) - 0.5; - }); - const next = Math.min(p, ids.length - 1); - setPos((prev) => (Math.abs(prev - next) < 0.001 ? prev : next)); - }; - const onScroll = () => { - if (!raf) raf = requestAnimationFrame(measure); - }; - measure(); - window.addEventListener('scroll', onScroll, { passive: true }); - window.addEventListener('resize', onScroll); - return () => { - cancelAnimationFrame(raf); - window.removeEventListener('scroll', onScroll); - window.removeEventListener('resize', onScroll); - }; - }, [ids]); - return pos; -} - -export function OrbitNav({ chapters, onSelect }: { chapters: readonly ChapterDef[]; onSelect: (id: string) => void }) { - const reduce = useReducedMotion() ?? false; - const shown = useBottomBarVisible(); - const barW = useBarWidth(); - const idKey = chapters.map((c) => c.id).join('|'); - const ids = useMemo(() => idKey.split('|'), [idKey]); - const rawPos = useChapterPosition(ids); - - // Spring the ring so it turns like a physical drum instead of snapping. - const spring = useSpring(rawPos, reduce ? { stiffness: 1000, damping: 100 } : { stiffness: 110, damping: 22, mass: 0.7 }); - const [pos, setPos] = useState(rawPos); - useEffect(() => spring.set(rawPos), [rawPos, spring]); - useMotionValueEvent(spring, 'change', setPos); - - const [hover, setHover] = useState(false); - const visible = shown && rawPos > -0.6; - - // Drag / wheel navigation. While dragging, the drum follows the finger instead of the page. - const slotRef = useRef(null); - const dragRef = useRef<{ x: number; start: number; moved: boolean; id: number } | null>(null); - const suppressClick = useRef(false); - const targetRef = useRef<{ index: number; at: number } | null>(null); - const [dragPos, setDragPos] = useState(null); - const last = chapters.length - 1; - const clampPos = (v: number) => Math.min(last, Math.max(0, v)); - const go = (index: number) => { - const i = Math.round(clampPos(index)); - targetRef.current = { index: i, at: Date.now() }; - holdVisibleUntil = Date.now() + 1500; - onSelect(ids[i]); - }; - - // Scrolling over the bar (wheel, trackpad, either axis) steps one chapter at a time. Native, - // non-passive listener: the page must not also scroll, and a horizontal swipe must not trigger the - // browser's back gesture. - const goRef = useRef(go); - goRef.current = go; - const activeRef = useRef(0); - activeRef.current = Math.max(0, Math.min(last, Math.round(rawPos))); - useEffect(() => { - const el = slotRef.current; - if (!el) return; - let acc = 0; - let lastStep = 0; - const onWheel = (e: WheelEvent) => { - const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; - if (!delta) return; - e.preventDefault(); - acc += delta; - const now = Date.now(); - if (Math.abs(acc) < 30 || now - lastStep < 420) return; - const dir = Math.sign(acc); - acc = 0; - lastStep = now; - // Keep stepping from where the last step was heading, even before the page has caught up. - const t = targetRef.current; - const base = t && now - t.at < 1200 ? t.index : activeRef.current; - trackUi(UiElement.LANDING_DEMO_INTERACT, { value: 'science_chapter' }); - goRef.current(base + dir); - }; - el.addEventListener('wheel', onWheel, { passive: false }); - return () => el.removeEventListener('wheel', onWheel); - }, []); - const accents = [ENGINE_META.engagement.color, BRAND.silver, ENGINE_META.meaning.color]; - const activeIndex = Math.max(0, Math.min(chapters.length - 1, Math.round(rawPos))); - const activeAccent = accents[activeIndex % accents.length]; - - // Ring geometry follows the slot: a narrow phone slot spaces chapters further apart on the ring, - // so the front label never collides with its neighbours. - const CX = barW / 2; - const R = barW / 2 - 24; - const STEP = barW < 520 ? 48 : 32; - const pxPerStep = R * Math.sin((STEP * Math.PI) / 180); // drag distance that turns one chapter - const drumPos = dragPos ?? pos; - - // After a drag the drum holds on the chosen chapter; let go once the page has arrived there. - useEffect(() => { - if (dragPos === null || dragRef.current) return; - if (Math.round(rawPos) === dragPos) { - setDragPos(null); - return; - } - const t = window.setTimeout(() => setDragPos(null), 1600); - return () => window.clearTimeout(t); - }, [dragPos, rawPos]); - - // Tapered ribbon along the slot: its thickness follows the ring's foreshortening (cos), so it is a - // hairline where it disappears into the slot and thickest at the front. - const SAMPLES = 64; - const upper: string[] = []; - const lower: string[] = []; - for (let s = 0; s <= SAMPLES; s++) { - const x = CX - R + (s / SAMPLES) * (2 * R); - const c = Math.sqrt(Math.max(0, 1 - ((x - CX) / R) ** 2)); - const hh = (THIN + (WIDE - THIN) * c) / 2; - upper.push(`${x.toFixed(2)},${(RIBBON_Y - hh).toFixed(2)}`); - lower.unshift(`${x.toFixed(2)},${(RIBBON_Y + hh).toFixed(2)}`); - } - const ribbon = `M ${upper.join(' L ')} L ${lower.join(' L ')} Z`; - const fade = `linear-gradient(to right, transparent 0, #000 ${barW * 0.2}px, #000 ${barW * 0.8}px, transparent ${barW}px)`; - - return ( - -
{ - if (e.pointerType === 'mouse' && e.button !== 0) return; - dragRef.current = { x: e.clientX, start: clampPos(pos), moved: false, id: e.pointerId }; - }} - onPointerMove={(e) => { - const d = dragRef.current; - if (!d || d.id !== e.pointerId) return; - const dx = e.clientX - d.x; - if (!d.moved) { - if (Math.abs(dx) < 6) return; // a tap, not a drag - d.moved = true; - e.currentTarget.setPointerCapture(e.pointerId); - } - setDragPos(clampPos(d.start - dx / pxPerStep)); - }} - onPointerUp={(e) => { - const d = dragRef.current; - dragRef.current = null; - if (!d?.moved) return; - const landed = clampPos(d.start - (e.clientX - d.x) / pxPerStep); - suppressClick.current = true; - window.setTimeout(() => { - suppressClick.current = false; - }, 60); - setDragPos(Math.round(landed)); // hold on the chosen chapter until the page arrives - trackUi(UiElement.LANDING_DEMO_INTERACT, { value: 'science_chapter' }); - go(landed); - }} - onPointerCancel={() => { - dragRef.current = null; - setDragPos(null); - }} - style={{ - width: barW, - height: BAR_H, - touchAction: 'pan-y', // vertical swipes still scroll the page; sideways ones turn the drum - pointerEvents: visible ? 'auto' : 'none', - background: `linear-gradient(180deg, ${BRAND.ink}fa 0%, ${BRAND.ink}f0 100%)`, - boxShadow: '0 18px 50px -24px rgba(0,0,0,0.7), inset 0 1px 0 rgba(255,255,255,0.06)', - }} - onMouseEnter={() => setHover(true)} - onMouseLeave={() => setHover(false)} - onFocusCapture={() => setHover(true)} - onBlurCapture={() => setHover(false)} - > - {/* tapered line along the slot; its ends fade into the dark */} - - - {chapters.map((c, i) => { - const deg = (i - drumPos) * STEP; // angle on the ring; 0 = front - if (Math.abs(deg) > MAX_DEG) return null; - const rad = (deg * Math.PI) / 180; - const cos = Math.cos(rad); // 1 at the front, -> 0 as it rotates into the slot - const x = CX + R * Math.sin(rad); - const on = Math.round(drumPos) === i && visible; - const accent = accents[i % accents.length]; - const dot = 3 + 4 * cos; - const labelOpacity = Math.max(0, hover ? Math.max(0.45, cos) : cos ** 1.6); - // Turned away while it emerges, facing you at the front. - const labelTransform = `perspective(420px) rotateY(${deg}deg) scale(${(0.72 + 0.28 * cos).toFixed(3)})`; - return ( - - ); - })} -
-
- ); -} diff --git a/src/components/science/Science.tsx b/src/components/science/Science.tsx index a4d4c46..da2d6bb 100644 --- a/src/components/science/Science.tsx +++ b/src/components/science/Science.tsx @@ -1,60 +1,38 @@ -import { useEffect, useRef, useState, type KeyboardEvent } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { motion, useInView, useReducedMotion, useScroll, useSpring, useTransform } from 'motion/react'; -import { ArrowRight, MoreHorizontal, Smartphone } from 'lucide-react'; -import { SiteHeader } from '../common/SiteHeader'; +import { motion, useScroll, useSpring } from 'motion/react'; +import { STORY_PAGE_TITLES, SiteHeader } from '../common/SiteHeader'; import { SiteFooter } from '../common/SiteFooter'; import { Seo } from '../common/Seo'; import { ROUTE_SEO } from '../../config/seo-routes'; -import { Accent, BRAND, Chapter, ENGINE_META, Kicker, Panel, TryIt, labelClass, pillClass, type ChapterDef } from './ScienceUi'; -import { Constellation, MORE_SIGNALS_CAPTION, PipelineDiagram, SIGNALS, type DiagramSignal } from './ScienceDiagrams'; +import { BRAND, ENGINE_META, type ChapterDef } from './ScienceUi'; import { ScienceHero } from './ScienceHero'; import { ScienceOverview } from './ScienceOverview'; -import { OrbitNav } from './OrbitNav'; -import { UiElement, trackProps, trackUi } from '../../lib/analytics/analytics'; -import { EngagementLab, IntentLab, MeaningLab, MrOneHookLab, ProfileViewLab, ReplyLab, RhythmLab } from './ScienceLabs'; -import type { EngineId } from './science-model'; +import { ScienceProcess } from './ScienceProcess'; /** - * /science — how OneHook Intelligence works, told as the journey of one signal. + * /science: one moment moving through the complete OneHook process. * - * Narrative spine (each chapter hands off to the next): - * hero (three orbits around the logo) → 01 a single moment → 02 its journey → 03 the three minds - * → 04 time turns moments into intent → 05 Mr OneHook, someone to talk your matches through with - * → 06 the promises, including erasure → finale (every orbit collapses into the logo). - * - * Composition is deliberately symmetric: centred headings, mirrored hairlines, equal columns, - * and the OneHook love logo as the fixed centre of every diagram. - * - * Every number in the interactive labs comes from ./science-model.ts, a faithful port of the - * production engines. Every control is instrumented as `landing.demo_interact` (science_* values) in - * the telemetry catalog. + * The hero introduces the six minds. The overview provides direct chapter links. From "A moment" + * onward, one persistent diagram changes emphasis while the six semantic chapter sections scroll + * beside it. The process stays spatially stable; only its state and annotations change. */ const CHAPTERS: ChapterDef[] = [ { id: 'signal', number: '01', title: 'A moment' }, { id: 'journey', number: '02', title: 'The journey' }, - { id: 'minds', number: '03', title: 'Three minds' }, + { id: 'minds', number: '03', title: 'Six minds' }, { id: 'time', number: '04', title: 'Time & intent' }, { id: 'mr-onehook', number: '05', title: 'Mr OneHook' }, { id: 'promises', number: '06', title: 'Promises' }, ]; -const guitarImage = '/media/man-playing-guitar-close-up-1600.jpg'; -const cityImage = '/media/aerial-view-of-the-city-in-the-fog-5ZBZNUT-1600.jpg'; -const webinarImage = '/media/photodune-33277756-pleased-redhead-woman-student-watches-training-webinar-1600.jpg'; -const coupleImage = '/media/young-couple-in-love-on-a-romantic-date-2022-03-31-17-43-11-utc-poster.jpg'; - - function scrollToId(id: string) { const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; - document.getElementById(id)?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' }); + document + .getElementById(id) + ?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' }); } -/* ── Reading progress + chapter rail (the page's continuity thread) ──────────── */ - -/** Thin bar across the top that fills as you read. Also used by the Messenger page, which passes a - * darker `background` because the default silver centre disappears on a light page. */ +/** Thin reading-progress bar, also shared by the Messenger page. */ export function ProgressBar({ background }: { background?: string } = {}) { const { scrollYProgress } = useScroll(); const scaleX = useSpring(scrollYProgress, { stiffness: 120, damping: 30, mass: 0.3 }); @@ -64,547 +42,25 @@ export function ProgressBar({ background }: { background?: string } = {}) { className="fixed left-0 right-0 top-0 z-50 h-[2px] origin-center" style={{ scaleX, - background: background ?? `linear-gradient(90deg, ${ENGINE_META.engagement.color}, ${BRAND.silver} 50%, ${ENGINE_META.meaning.color})`, + background: + background ?? + `linear-gradient(90deg, ${ENGINE_META.engagement.color}, ${BRAND.silver} 50%, ${ENGINE_META.meaning.color})`, }} /> ); } -/* ── Full-bleed photographic interlude ─────────────────────────────────────── */ - -function Interlude({ image, quote, by }: { image: string; quote: string; by: string }) { - const reduce = useReducedMotion(); - const ref = useRef(null); - const { scrollYProgress } = useScroll({ target: ref, offset: ['start end', 'end start'] }); - // Gentle parallax: the photo drifts from -18% to 0% while the interlude crosses the viewport. - const y = useTransform(scrollYProgress, [0, 1], ['-18%', '0%']); - return ( -
-