diff --git a/src/components/mascot/AiSparkle.css b/src/components/mascot/AiSparkle.css index 623ddb3..6b8f91e 100644 --- a/src/components/mascot/AiSparkle.css +++ b/src/components/mascot/AiSparkle.css @@ -74,6 +74,15 @@ animation: burst-tip-pop 700ms cubic-bezier(0.34, 1.56, 0.64, 1) both; } +/* already seen: the finished ring, no pop-in */ +.burst[data-settled="true"] .burst-core, +.burst[data-settled="true"] .burst-tip, +.burst[data-settled="true"] .burst-stream { + animation: none !important; + opacity: 1; + stroke-dashoffset: 0; +} + @media (prefers-reduced-motion: reduce) { .burst .burst-core, .burst .burst-tip, diff --git a/src/components/mascot/AiSparkle.tsx b/src/components/mascot/AiSparkle.tsx index 17975c5..0f877bc 100644 --- a/src/components/mascot/AiSparkle.tsx +++ b/src/components/mascot/AiSparkle.tsx @@ -13,6 +13,8 @@ export interface AiSparkleProps { size?: number | string; // controls the overall size of the component className?: string; triggerAnimation?: boolean; // force animation to run manually if desired + /** Show the finished ring at once (no pop-in), e.g. when a story screen is revisited. */ + settled?: boolean; } const DEFAULT_BRANCHES: SparkleBranch[] = [ @@ -35,7 +37,8 @@ export const AiSparkle: React.FC = ({ branches = DEFAULT_BRANCHES, size = "100%", className = "", - triggerAnimation = false + triggerAnimation = false, + settled = false, }) => { const wrapperRef = useRef(null); const observerRef = useRef(null); @@ -63,6 +66,7 @@ export const AiSparkle: React.FC = ({
diff --git a/src/components/mascot/AlienScanner.tsx b/src/components/mascot/AlienScanner.tsx index 401a3e4..1d2702d 100644 --- a/src/components/mascot/AlienScanner.tsx +++ b/src/components/mascot/AlienScanner.tsx @@ -48,6 +48,8 @@ interface AlienScannerProps { * paint brush, the paint bucket, the ground shadow or the silver sparkle behind it. */ bare?: boolean; + /** Show the silver sparkle ring already finished (no pop-in), e.g. on a revisited story screen. */ + settled?: boolean; } export const AlienScanner: React.FC = ({ @@ -61,6 +63,7 @@ export const AlienScanner: React.FC = ({ hoverLabel = 'Click to watch me explain more about onehook.club', forceExcited = false, hideTooltips = false, + settled = false, }) => { const containerRef = useRef(null); const pointerFocusRef = useRef(false); @@ -292,6 +295,7 @@ export const AlienScanner: React.FC = ({ > {/* Ai Sparkle Background Effect */} {!bare && } diff --git a/src/components/messenger/Messenger.tsx b/src/components/messenger/Messenger.tsx index 3f5504a..5340592 100644 --- a/src/components/messenger/Messenger.tsx +++ b/src/components/messenger/Messenger.tsx @@ -24,6 +24,14 @@ import { Tent, TreePine, Video, + Phone, + SmilePlus, + Timer, + Reply, + ScreenShare, + Trash2, + CheckCheck, + MessageCircleMore, } from 'lucide-react'; import { SiteHeader } from '../common/SiteHeader'; import { SiteFooter } from '../common/SiteFooter'; @@ -31,8 +39,25 @@ import { Seo } from '../common/Seo'; import { ROUTE_SEO } from '../../config/seo-routes'; import { AlienScanner } from '../mascot'; import { ManifestoPhone } from '../Landing'; -import { Finale, ProgressBar, useFullScreenEnding } from '../science/Science'; -import { StoryTimelineProvider, bandIndex, makeBands, usePassed, useSceneClock, useSceneProgress, useTimeline, useVisualProgress } from './storyTimeline'; +import { ProgressBar } from '../science/Science'; +import { + StoryTimelineProvider, + bandIndex, + isStoryComplete, + makeEqualBands, + SHOW_MIN_MS, + scrollLimit, + storyTrackHeight, + usePassed, + useSceneClock, + useSceneLine, + useSceneProgress, + useStoryInstant, + useScrollLimit, + useScrollSpeed, + useStoryPlayback, + useTimeline, +} from './storyTimeline'; import { AppleIcon, GoogleIcon } from '../common/BrandIcons'; import { UiElement, trackProps } from '../../lib/analytics/analytics'; import { GetAppLink } from '../common/GetAppLink'; @@ -122,6 +147,7 @@ function FitCanvas({ portView?: { y: number; h: number }; children: (layout: 'land' | 'port', width: number) => ReactNode; }) { + const instant = useStoryInstant(); const ref = useRef(null); const [box, setBox] = useState({ w: 0, h: 0 }); useLayoutEffect(() => { @@ -156,7 +182,7 @@ function FitCanvas({ height: dim.h, transform: `translate(-50%, -50%) translateY(${shiftY}px) scale(${scale})`, transformOrigin: 'center', - transition: 'transform 0.9s cubic-bezier(0.22, 1, 0.36, 1)', + transition: instant ? 'none' : 'transform 0.9s cubic-bezier(0.22, 1, 0.36, 1)', }} > {children(layout, width)} @@ -617,51 +643,56 @@ function SyncDevices({ layout, phone, W }: { layout: 'land' | 'port'; phone: Pos return ( - {/* full lines: cloud → iPad, cloud → Android, and the QR from the main phone to the laptop */} - - - - - {/* the cloud can't reach the laptop or the camping phone; no device's QR reaches the camping phone */} - - - - - - {/* a key on each line leaving the cloud — it pulses as that device's key arrives */} - {[ - { at: { x: xL, y: (C.y + ipadTop) / 2 }, opacity: ipadKeyOpacity, scale: ipadKeyScale }, - { at: { x: xR, y: (C.y + androidTop) / 2 }, opacity: androidKeyOpacity, scale: androidKeyScale }, - ].map((b, i) => ( + {/* the wiring between the devices: on the way to screen 5 it fades at once, so it never hangs in + the air as an empty frame while the devices fly into the phone */} + + {/* full lines: cloud → iPad, cloud → Android, and the QR from the main phone to the laptop */} + + + + + {/* the cloud can't reach the laptop or the camping phone; no device's QR reaches the camping phone */} + + + + + + {/* a key on each line leaving the cloud — it pulses as that device's key arrives */} + {[ + { at: { x: xL, y: (C.y + ipadTop) / 2 }, opacity: ipadKeyOpacity, scale: ipadKeyScale }, + { at: { x: xR, y: (C.y + androidTop) / 2 }, opacity: androidKeyOpacity, scale: androidKeyScale }, + ].map((b, i) => ( + + + + ))} + + {/* the QR, sitting in the middle of the line from the main phone to the laptop */} - + - ))} - {/* the QR, sitting in the middle of the line from the main phone to the laptop */} - - - + {/* the moment the new message lands everywhere at once: no sync needed for new messages */} + - {/* the moment the new message lands everywhere at once: no sync needed for new messages */} - {/* top-left · iPad — synced from the cloud */} @@ -988,6 +1019,35 @@ const SCENE_RECOVER = 2; const SCENE_SYNC = 3; /** Scene index of screen 5 (live, translated captions), scrubbed by scroll. */ const SCENE_LANG = 4; +/** Arriving on screen 5 the phone is gone this many seconds in (its exit: 0.45 s delay + 0.55 s). */ +const PHONE_GONE = 1.0; + +/** Real seconds since this mounted (huge when `active` is false, so `min(clock, since)` is just `clock`). */ +function useSinceMount(active: boolean): MotionValue { + const since = useMotionValue(active ? 0 : Number.MAX_SAFE_INTEGER); + useEffect(() => { + if (!active) return; + const start = performance.now(); + let raf = 0; + const tick = (now: number) => { + const s = (now - start) / 1000; + since.set(s); + if (s < 10) raf = requestAnimationFrame(tick); // well past every beat + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [active, since]); + return since; +} + +/** + * `clock` held back to the real seconds since arrival, so scroll-driven beats can never get ahead of a + * time-driven exit that plays on arrival. Slow scrolling: simply `clock`. A brisk scroll: the beats + * play at their designed pace instead of popping in. + */ +function useHandoffClock(clock: MotionValue, since: MotionValue): MotionValue { + return useTransform(() => Math.min(clock.get(), since.get())); +} const PW = 230; // phone width in canvas px const PH = 473; // phone height (230 wide, 9:19.5 screen + bezel) @@ -1142,17 +1202,19 @@ function PhonesStage({ index }: { index: number }) { const syncing = index >= 3; const lang = index >= 4; // screen 5: everything gathers into the phone, which becomes the caption window const k = reduce ? 1 / 3 : 1; + // switched to without animation (scrolling up, or a screen that has already played) + const instant = useStoryInstant(); // Arriving straight onto screen 4 (scrolling back up from screen 5): the new phone is simply there — // no slide-in, no unboxing, history already on it. const direct = useRef(index >= 3).current; - // The new phone (with its box) is rebuilt every time we arrive on screen 3, so the unboxing plays - // again whichever way you scroll in. It only starts "already unboxed" when the stage itself first - // mounts on screen 4/5 (scrolling back up from further down). + // The new phone (with its box) is built when we arrive on screen 3 coming FORWARD, so its unboxing + // plays then. Coming back up from screen 4 it is already there, finished — nothing is rebuilt. It + // starts "already unboxed" when the stage itself first mounts on screen 4/5. const [freshGen, setFreshGen] = useState(0); const [freshDirect, setFreshDirect] = useState(index >= 3); const prevIndex = useRef(index); useEffect(() => { - if (index === 2 && prevIndex.current !== 2) { + if (index === 2 && prevIndex.current < 2) { setFreshGen((g) => g + 1); setFreshDirect(false); } @@ -1427,8 +1489,13 @@ function PhonesStage({ index }: { index: number }) { )} - {/* Isha's phone: drops in once, then glides between poses — and on screen 3 falls & breaks */} + {/* Isha's phone: drops in once, then glides between poses — and on screen 3 falls & breaks. Once + everything has moved on to the new phone (screen 4+) it fades out and is unmounted, so a + hidden phone mock isn't kept alive behind the stage. */} + + {!syncing && ( + )} + {/* Rohan's phone: drops in from the top on screen 2 */} @@ -1601,7 +1672,8 @@ function PhonesStage({ index }: { index: number }) { {lang && (
- + {/* prevIndex still holds the previous screen during this render */} +
)} @@ -1672,7 +1744,7 @@ function PhonesStage({ index }: { index: number }) { messagesOnly liveThread={newMsg ? ISHA_WITH_NEW : restoring ? ISHA_SENDS : NOTHING_YET} liveDelay={0} - liveSpeed={freshDirect ? 0.01 : 0.28} + liveSpeed={freshDirect || instant ? 0.01 : 0.28} contactName="Rohan" contactPhoto="/media/profile-demo/profile-male-1.jpg" ownerName="Isha" @@ -1840,7 +1912,7 @@ function CaptionMain() { */ /** One translation box springing out of the main window, read off screen 5's scroll clock. */ function BurstBox({ b, cx, cy, clock, reduce }: { b: { x: number; y: number; s: number; delay: number; blurred: boolean; pair: (typeof CAPTION_PAIRS)[number]; i: number }; cx: number; cy: number; clock: MotionValue; reduce: boolean }) { - const d = b.delay - 0.6; // same 0.6 s pull-forward as the main window + const d = b.delay + 0.1; // after the window (from PHONE_GONE − 0.1 s) has grown into place const left = useTransform(clock, [d, d + 0.45, d + 0.65], [cx, b.x + (b.x - cx) * 0.04, b.x]); const top = useTransform(clock, [d, d + 0.45, d + 0.65], [cy, b.y + (b.y - cy) * 0.04, b.y]); const scale = useTransform(clock, [d, d + 0.45, d + 0.65], [0.2, b.s * 1.04, b.s]); @@ -1855,17 +1927,23 @@ function BurstBox({ b, cx, cy, clock, reduce }: { b: { x: number; y: number; s: ); } -function TranslationBurst({ layout }: { layout: 'land' | 'port' }) { +function TranslationBurst({ layout, handoff }: { layout: 'land' | 'port'; handoff: boolean }) { const reduce = useReducedMotion() ?? false; - // Screen 5 is scrubbed by scroll: the old second marks (main window 0.85 s, boxes from 1.15 s) - // are read off the screen's scroll clock. - const clock = useSceneClock(SCENE_LANG, SCENE_SECONDS[SCENE_LANG]); - // (pulled ~0.6 s earlier than the old timings: the phone's shrink still plays on arrival, so the - // window must be there as soon as the reader starts scrolling into the screen, never an empty stage) - const mainOpacity = useTransform(clock, [0.2, 0.4], [0, 1]); - const mainScale = useTransform(clock, [0.2, 0.55, 0.75], [0.35, 1.04, 1]); - const mainY = useTransform(clock, [0.2, 0.65], [-50, 0]); - const mainRotate = useTransform(clock, [0.2, 0.65], [8, 0]); + // Arriving from screen 4 the devices gather into the phone, then it drops and shrinks away on a fixed + // ~1 s timer. The window IS that phone becoming a caption window, so it grows on the same timer, right + // as the phone vanishes. The boxes after it are scrubbed by scroll, but held back so they can never + // overtake the phone either. + const [fromPhone] = useState(handoff); + const since = useSinceMount(fromPhone && !reduce); + const scrollClock = useSceneClock(SCENE_LANG, SCENE_SECONDS[SCENE_LANG]); + const clock = useHandoffClock(scrollClock, since); + const windowClock = fromPhone && !reduce ? since : scrollClock; + // the window grows out of the spot where the phone vanished (it dropped 70 px, tilted 10°) + const w0 = PHONE_GONE - 0.1; + const mainOpacity = useTransform(windowClock, [w0, w0 + 0.2], [0, 1]); + const mainScale = useTransform(windowClock, [w0, w0 + 0.35, w0 + 0.55], [0.35, 1.04, 1]); + const mainY = useTransform(windowClock, [w0, w0 + 0.45], [70, 0]); + const mainRotate = useTransform(windowClock, [w0, w0 + 0.45], [10, 0]); const land = layout === 'land'; const W = land ? LAYOUTS.land.w : LAYOUTS.port.w; const H = land ? LAYOUTS.land.h : LAYOUTS.port.h; @@ -1936,11 +2014,13 @@ function TranslationBurst({ layout }: { layout: 'land' | 'port' }) { * The supported-language list, rolling the full height of the screen on the right. It sits in front of * the caption boxes, and turns see-through where it would run behind the headline and buttons. */ -function LanguageRail() { +function LanguageRail({ handoff }: { handoff: boolean }) { const reduce = useReducedMotion() ?? false; - const clock = useSceneClock(SCENE_LANG, SCENE_SECONDS[SCENE_LANG]); - const railOpacity = useTransform(clock, [0.3, 0.9], [0, 1]); - const railX = useTransform(clock, [0.3, 0.9], [40, 0]); + const [fromPhone] = useState(handoff); + const clock = useHandoffClock(useSceneClock(SCENE_LANG, SCENE_SECONDS[SCENE_LANG]), useSinceMount(fromPhone && !reduce)); + // slides in with the boxes, once the phone has gone and the window is in place + const railOpacity = useTransform(clock, [PHONE_GONE + 0.25, PHONE_GONE + 0.85], [0, 1]); + const railX = useTransform(clock, [PHONE_GONE + 0.25, PHONE_GONE + 0.85], [40, 0]); const list = [...CAPTION_LANGS, ...CAPTION_LANGS]; // doubled for a seamless loop return ( m.pick); @@ -2115,7 +2198,8 @@ function SceneMrOneHook() { ))}
- + {/* shown finished (revisit / scrolling up): the ring is already there, no pop-in */} +

Mr OneHook

@@ -2276,7 +2360,8 @@ function MascotPeeks() { function PinkStrokes() { const p = useSceneProgress(SCENE_MR); const { t } = useTimeline(MR_SECONDS); - const [w0, w1] = t(0, 1.8); + // sweeps the whole time the story plays out, finishing just after Mr OneHook's reply lands (2.2 s) + const [w0, w1] = t(0, 2.4); const revealWidth = useTransform(p, [w0, w1], [0, 1600]); // One line: it draws in from off-screen left, sweeps across and leaves past the right edge — then // simply stays there, still. @@ -2328,24 +2413,202 @@ function PinkStrokes() { /** Scene indexes of the screens that drive their own choreography from scroll. */ const SCENE_SEALED = 6; -const SCENE_FREE = 7; -/** Photo, video, voice note, file — each gets stamped with the same lock, scrubbed by scroll. */ +/** + * Screen 7's second wave: once the four memories are sealed, more of the messenger loads in the + * background around them. Every tile is a real capability (see the file header): reactions are stored + * as ciphertext, calls carry the 4-emoji safety code, screen sharing needs the other person to accept, + * disappearing messages expire on the server, deletes fan out to both phones, and read receipts / + * typing / last seen are privacy settings. Those last ones are controls, not encryption, so they get + * a switch badge instead of a lock. + */ +type Feature = { Icon: typeof ImageIcon; title: string; sub: string; badge: 'lock' | 'switch' }; +const SEALED_FEATURES: Feature[] = [ + { Icon: Phone, title: 'Voice calls', sub: 'Encrypted end to end', badge: 'lock' }, + { Icon: SmilePlus, title: 'Reactions', sub: 'Even the emoji is sealed', badge: 'lock' }, + { Icon: Video, title: 'Video calls', sub: 'Face to face, sealed', badge: 'lock' }, + { Icon: Timer, title: 'Disappearing', sub: 'Gone when the timer ends', badge: 'lock' }, + { Icon: Reply, title: 'Replies', sub: 'Quote any message', badge: 'lock' }, + { Icon: ScreenShare, title: 'Screen sharing', sub: 'Only if they accept', badge: 'lock' }, + { Icon: ShieldCheck, title: 'Safety code', sub: '4 emoji verify every call', badge: 'lock' }, + { Icon: Trash2, title: 'Delete for both', sub: 'Gone from both phones', badge: 'switch' }, + { Icon: CheckCheck, title: 'Read receipts', sub: 'On or off, your call', badge: 'switch' }, + { Icon: MessageCircleMore, title: 'Typing & last seen', sub: 'Show it, or don’t', badge: 'switch' }, +]; +/** The four memories finish sealing at 1.8 s; the background starts loading just after. */ +const FEATURES_FROM = 1.95; +const FEATURE_STAGGER = 0.14; +/** Side-column geometry (canvas px, before FitBox scaling). */ +const FEATURE_W = 136; +const FEATURE_GAP = 24; +/** Where the side tiles sit, in load order: inner columns first, alternating sides, top to bottom. */ +const FEATURE_SPOTS: { side: 'left' | 'right'; col: 0 | 1; top: string }[] = [ + { side: 'left', col: 0, top: '14%' }, + { side: 'right', col: 0, top: '14%' }, + { side: 'left', col: 0, top: '50%' }, + { side: 'right', col: 0, top: '50%' }, + { side: 'left', col: 0, top: '86%' }, + { side: 'right', col: 0, top: '86%' }, + { side: 'left', col: 1, top: '30%' }, + { side: 'right', col: 1, top: '30%' }, + { side: 'left', col: 1, top: '70%' }, + { side: 'right', col: 1, top: '70%' }, +]; + +/** + * How many side columns fit beside the 420 px grid: 2 on wide screens, 1 on laptops/tablets, 0 on + * phones (the tiles become a row of chips under the grid). Read from the window width only, so the + * choice never feeds back into FitBox's scaling. + */ +function featureColumns(): 0 | 1 | 2 { + if (typeof window === 'undefined') return 2; + const grid = Math.min(420, window.innerWidth - 48); + const side = (window.innerWidth - grid) / 2 - 40; // keep clear of the story thread on the right + return side >= 2 * (FEATURE_W + FEATURE_GAP) ? 2 : side >= FEATURE_W + FEATURE_GAP ? 1 : 0; +} + +function useFeatureColumns() { + const [cols, setCols] = useState(featureColumns); + useEffect(() => { + const onResize = () => setCols(featureColumns()); + onResize(); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + return cols; +} + +/** Photo, video, voice note, file — each gets stamped with the same lock, scrubbed by scroll. Then + * more of the messenger loads in the background around them. */ function SceneSealed() { const p = useSceneProgress(SCENE_SEALED); - const { t } = useTimeline(1.9); + const { t } = useTimeline(SCENE_SECONDS[SCENE_SEALED]); + const cols = useFeatureColumns(); const items = [ImageIcon, Video, Mic, FileText]; + const spots = FEATURE_SPOTS.filter((s) => s.col < cols); return ( -
- {items.map((Icon, i) => ( - - - - ))} +
+
+ {/* the background wave, beside the grid (behind it in paint order) */} + {spots.map((spot, k) => ( + + + + ))} +
+ {items.map((Icon, i) => ( + + + + ))} +
+
+ {/* phones: no room beside the grid, so the same wave loads as chips under it (space reserved, + so nothing moves when they appear) */} + {cols === 0 && ( +
+ {SEALED_FEATURES.map((f, k) => ( + + + + ))} +
+ )}
); } +/** One background tile's load-in: it fades up, then its badge stamps on (scrubbed by scroll). */ +function useFeatureIn(k: number, p: MotionValue, t: (start: number, end: number) => [number, number]) { + const s = FEATURES_FROM + k * FEATURE_STAGGER; + const [a0, a1] = t(s, s + 0.35); + const [b0, b1] = t(s + 0.2, s + 0.55); + const mid = b0 + (b1 - b0) * 0.65; + return { + opacity: useTransform(p, [a0, a1], [0, 1]), + y: useTransform(p, [a0, a1], [10, 0]), + scale: useTransform(p, [a0, a1], [0.9, 1]), + badgeScale: useTransform(p, [b0, mid, b1], [1.8, 0.92, 1]), + badgeOpacity: useTransform(p, [b0, b0 + (b1 - b0) * 0.4], [0, 1]), + }; +} + +function FeatureBadge({ kind, scale, opacity, small = false }: { kind: Feature['badge']; scale: MotionValue; opacity: MotionValue; small?: boolean }) { + return kind === 'lock' ? ( + + + + ) : ( + // a tiny switch, on: "your choice", not encryption + + + + ); +} + +function FeatureTile({ + f, + k, + p, + t, + spot, +}: { + f: Feature; + k: number; + p: MotionValue; + t: (start: number, end: number) => [number, number]; + spot: (typeof FEATURE_SPOTS)[number]; +}) { + const a = useFeatureIn(k, p, t); + // background, not foreground: the outer column sits a step further back + const opacity = useTransform(a.opacity, (o) => o * (spot.col === 0 ? 0.92 : 0.7)); + const offset = `calc(100% + ${FEATURE_GAP + spot.col * (FEATURE_W + FEATURE_GAP)}px)`; + const { Icon } = f; + return ( + +
+ + + + +
+

{f.title}

+

{f.sub}

+
+ ); +} + +function FeatureChip({ f, k, p, t }: { f: Feature; k: number; p: MotionValue; t: (start: number, end: number) => [number, number] }) { + const a = useFeatureIn(k, p, t); + const { Icon } = f; + return ( + + + {f.title} + + + ); +} + function SealedItem({ Icon, i, @@ -2383,12 +2646,13 @@ function SealedItem({ ); } -/** A heart that fills up — free forever, kept alive by people who care. The fill follows scroll. */ +/** + * A heart that fills up — free forever, kept alive by people who care. It fills the moment it + * arrives: the finale dissolves the heart into the big words within ~1.8 s, so a scroll-driven fill + * was never seen. + */ function SceneFree() { const reduce = useReducedMotion(); - const p = useSceneProgress(SCENE_FREE); - // was: a 3.2 s fill to 30 with a small settle (times 0 / 0.7 / 0.85 / 1) - const fillY = useTransform(p, [0, 0.7, 0.85, 1], [92, 30, 38, 30]); return (
{/* ambient: hearts keep floating up on their own (not part of the story's timeline) */} @@ -2518,7 +2791,7 @@ const SCENES: Scene[] = [ { title: <>Every memory, sealed., plain: 'Every memory, sealed.', - line: 'Photos, videos, voice notes, files.', + line: 'Photos, videos, voice notes, files — and calls, reactions, the lot.', art: () => , }, { @@ -2530,15 +2803,23 @@ const SCENES: Scene[] = [ ]; -/** How long each screen's choreography runs (s). Screens map these timings onto their scroll band. */ -const SCENE_SECONDS = [3.9, 8.9, 6.1, 5.4, 2.6, 3.2, 1.9, 1.8]; -/** Scroll length per screen: proportional to its choreography, within limits so short screens still - * get a comfortable stretch of scroll and the longest doesn't take forever. */ -const STORY_BANDS = makeBands(SCENE_SECONDS.map((s) => Math.min(6, Math.max(2.4, s)))); - -/** A thin bar under the counter showing how far through the current screen the reader has scrolled. */ +/** + * Each screen's choreography as AUTHORED, in seconds: every beat is written against this (e.g. screen + * 2's checkpoints at 2.6 / 4.3 / 6.0 / 7.9 s of 8.9 s). It is the timeline beats are placed on, not + * how long the screen plays — see SCENE_PLAY_SECONDS. + */ +const SCENE_SECONDS = [3.9, 8.9, 6.1, 5.4, 3.0, 3.2, 3.9, 1.8]; +/** Every screen's animation plays for the same time; each screen's authored timeline is scaled to fit, + * so its beats keep their order and spacing (screen 2 runs a little faster, screen 8 slower). */ +const SCREEN_PLAY_SECONDS = 5; +const SCENE_PLAY_SECONDS = SCENE_SECONDS.map(() => SCREEN_PLAY_SECONDS); +/** Every screen owns exactly one eighth of story progress, independent of choreography complexity. */ +const STORY_BANDS = makeEqualBands(SCENES.length); + +/** A thin bar under the counter on the screen's own clock: its animation fills the first ~70%, the rest + * fills while everything stays on screen to look at. */ function SceneProgress({ index }: { index: number }) { - const p = useSceneProgress(index); + const p = useSceneLine(index); return (
(null); - const { scrollYProgress } = useScroll({ target: storyRef, offset: ['start start', 'end end'] }); + const headerH = useHeaderHeight(); + // Progress starts at the initial, below-header stage position—not after the sticky header has + // scrolled past—and finishes exactly where the bottom of the track meets the viewport bottom. + const { scrollYProgress } = useScroll({ target: storyRef, offset: [`start ${headerH}px`, 'end end'] }); const n = SCENES.length; // The page always scrolls natively: nothing intercepts the wheel, trackpad, touch, keyboard or - // scrollbar. The story is scrubbed by scroll instead (see storyTimeline.tsx): a visual progress - // follows the scroll position, rate-limited so even a hard flick plays every screen in order. + // scrollbar. Scroll picks the screen; each screen's animation plays once, for the same time on every screen, the first + // time it arrives going forward. Going back, or returning to a screen that has played, just switches + // to its finished state (see storyTimeline.tsx). // - // A hard flick can't fly past the story either: the closer and footer below it only exist once - // the last screen is on stage (see `storyDone`), so until then the page ends at the bottom of the - // track and the browser stops there by itself. - const visual = useVisualProgress(scrollYProgress, STORY_BANDS, Boolean(reduce)); - const [index, setIndex] = useState(() => bandIndex(STORY_BANDS, scrollYProgress.get())); - useMotionValueEvent(visual, 'change', (p) => { - const i = bandIndex(STORY_BANDS, p); - setIndex((prev) => (prev === i ? prev : i)); + // The footer exists only while the LAST screen is on stage. The stage can trail the scroll + // (it walks screen by screen), so without this the page could scroll on past the track into the + // 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); + // 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 + // footer, and screen 8 is cut off half-shown. + const [endingOpen, setEndingOpen] = useState(false); + const atEnd = useRef(isStoryComplete(scrollYProgress.get())); + const lastReady = useRef(false); + useMotionValueEvent(scrollYProgress, 'change', (p) => { + atEnd.current = isStoryComplete(p); + if (atEnd.current && index === n - 1 && lastReady.current) setEndingOpen(true); }); - const threadHeight = useTransform(visual, (v) => `${Math.min(1, Math.max(0, v)) * 100}%`); - const story = useMemo(() => ({ progress: visual, bands: STORY_BANDS, reduce: Boolean(reduce) }), [visual, reduce]); + useEffect(() => { + lastReady.current = false; + if (index < n - 1) { + setEndingOpen(false); + return; + } + const last = play[n - 1]; + let timer = 0; + const arm = () => { + window.clearTimeout(timer); + timer = window.setTimeout(() => { + lastReady.current = true; + if (atEnd.current) setEndingOpen(true); + }, SHOW_MIN_MS); + }; + const off = last.on('change', (v) => v >= 1 && arm()); + if (last.get() >= 1) arm(); + return () => { + off(); + window.clearTimeout(timer); + }; + }, [index, n, play]); + 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). + useScrollLimit({ + trackRef: storyRef, + headerHeight: headerH, + maxProgress: scrollLimit(STORY_BANDS, index), + enabled: !reduce, + }); + 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) + const prevIndex = useRef(index); + useEffect(() => { + prevIndex.current = index; + }, [index]); + const story = useMemo( + () => ({ scroll: scrollYProgress, play, line, bands: STORY_BANDS, reduce: Boolean(reduce), instant }), + [scrollYProgress, play, line, reduce, instant] + ); - // The closer + footer exist only once the story's last screen is on stage (see the scroll comment). - // (With reduced motion the scene simply matches the scroll position, so nothing needs gating.) - const storyDone = index === n - 1 || Boolean(reduce); const scene = SCENES[index]; - const headerH = useHeaderHeight(); - const ending = useFullScreenEnding(); // 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. + // over the whole stage—large and centred—with the buttons staying in their usual place. const finale = index === n - 1; @@ -2620,8 +2949,8 @@ export function Messenger() { ))} - {/* One tall scroll track; the stage inside stays pinned while the story plays. */} -
+ {/* One stable large-viewport of scroll per screen, plus the sticky stage itself. */} +
{index === 4 && ( - + )} @@ -2835,19 +3164,10 @@ export function Messenger() {
- {/* the heartbeat closer (shared with the Science page) + the footer below it fill exactly one - screen under the header, so the last scroll lands on a clean, full-screen ending */} -
- -
-
+ {/* the footer follows the last screen directly — only once screen 8 is on stage */} +
diff --git a/src/components/messenger/storyTimeline.tsx b/src/components/messenger/storyTimeline.tsx index 6edfc57..9ae792a 100644 --- a/src/components/messenger/storyTimeline.tsx +++ b/src/components/messenger/storyTimeline.tsx @@ -1,33 +1,54 @@ /** - * Scroll-scrubbed story timeline for the Messenger page. + * Story timeline for the Messenger page: scroll picks the screen, time plays it — once. * - * The page always scrolls natively; nothing here intercepts input. Instead: + * The page always scrolls natively; nothing here moves the page or intercepts input. * - * 1. Scroll progress through the story track (0..1) is the TARGET. - * 2. A "visual" progress follows it every frame: almost instantly for normal scrolling (so the - * animation feels scrubbed to the wheel), but never faster than MAX_SCENES_PER_SECOND. A hard - * flick therefore still plays every screen in order, just quickly, instead of skipping them. - * 3. The track is split into bands, one per screen, sized by how much choreography each screen has. - * Inside its band a screen has a short entrance, a play window and a short rest on its end state. - * 4. Screens read their own 0..1 play progress (`useSceneProgress`) and map their old second-based - * choreography onto it (`useTimeline`), so the existing timing is kept, just driven by scroll. + * 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. * - * With reduced motion the visual progress simply equals the scroll progress (no catch-up motion). + * 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 + * finished also start their ambient loops stopped (they are mounted while animations are skipped). + * + * With reduced motion every screen is simply shown finished. */ -import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'; -import { useMotionValue, useMotionValueEvent, useTransform, type MotionValue } from 'motion/react'; +import { createContext, useContext, useEffect, useMemo, useState, type ReactNode, type RefObject } from 'react'; +import { + MotionGlobalConfig, + animate, + motionValue, + useMotionValue, + useMotionValueEvent, + useTransform, + type MotionValue, +} from 'motion/react'; -/** Fraction of each band spent entering, and resting on the finished state. */ -const ENTER = 0.1; -const REST = 0.15; -/** The visual story never moves faster than this many screens per second. */ -const MAX_SCENES_PER_SECOND = 1.4; -/** How quickly the visual story catches up with the scroll position (per second, exponential). */ -const FOLLOW_RATE = 14; +/** 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. */ +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; +/** 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 + * exit-then-enter hand-off). A forward 'play' switch ends it at once. */ +const SHOW_SKIP_MS = 250; +/** Share of the progress line the animation takes; the rest of the line is time to look. */ +export const ANIMATION_SHARE = 0.7; export type Bands = { start: readonly number[]; len: readonly number[] }; -/** Bands proportional to `weights` (e.g. each screen's choreography length), summing to 1. */ +/** Bands proportional to `weights`, summing to 1. */ export function makeBands(weights: readonly number[]): Bands { const total = weights.reduce((a, b) => a + b, 0) || 1; const len = weights.map((w) => w / total); @@ -39,78 +60,439 @@ export function makeBands(weights: readonly number[]): Bands { return { start, len }; } +/** Equal progress bands ensure every screen receives exactly the same scroll exposure. */ +export function makeEqualBands(sceneCount: number): Bands { + if (!Number.isInteger(sceneCount) || sceneCount < 1) { + throw new RangeError('sceneCount must be a positive integer'); + } + return makeBands(Array.from({ length: sceneCount }, () => 1)); +} + +/** + * One stable large-viewport scroll length per scene, plus the sticky stage itself. With a stage of + * `100lvh - headerHeight`, the resulting scrollable distance is exactly `sceneCount * 100lvh`. + */ +export function storyTrackHeight(sceneCount: number, headerHeight: number): string { + if (!Number.isInteger(sceneCount) || sceneCount < 1) { + throw new RangeError('sceneCount must be a positive integer'); + } + return `calc(${(sceneCount + 1) * 100}lvh - ${Math.max(0, Math.round(headerHeight))}px)`; +} + +/** The reader has reached the end of the story track (tiny tolerance for sub-pixel scroll). */ +export function isStoryComplete(progress: number): boolean { + return progress >= 1 - 0.0001; +} + /** Which band a progress value falls in (the last band includes 1). */ export function bandIndex(bands: Bands, p: number) { for (let i = bands.start.length - 1; i >= 0; i -= 1) if (p >= bands.start[i]) return i; return 0; } -/** Visual story progress: follows `target`, rate-limited so a hard flick still plays every screen. */ -export function useVisualProgress(target: MotionValue, bands: Bands, reduce: boolean) { - const visual = useMotionValue(target.get()); +/** + * How a screen arrives: 'play' its animation (first visit, going forward) or 'show' it finished + * (going backward, or it has already played). + */ +export function arrivalMode(prev: number, next: number, played: ReadonlySet): 'play' | 'show' { + return next > 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; + seconds: readonly number[]; + play: readonly { set(v: number): void }[]; + 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. */ + run: (value: { set(v: number): void }, duration: number, onComplete?: () => void) => () => void; + /** Skip every animation for a moment (a 'show' switch), or stop skipping (a 'play' switch). */ + setSkip: (on: boolean) => void; + onChange: (index: number, instant: boolean) => void; + setTimer: (fn: () => void, ms: number) => number; + clearTimer: (id: number) => void; +}; + +/** + * 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: + * - 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. + * - Everything before the screen on stage is in the past of the story, so it is finished too. + */ +export function createStoryPlayer(d: StoryPlayerDeps) { + const { bands, seconds, play, line } = d; + 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 shownAt = 0; + let shownMode: 'play' | 'show' = 'play'; + + const finish = (i: number) => { + play[i].set(1); + line[i].set(1); + }; + /** Leaving a screen mid-animation (only possible going backward): it counts as loaded. */ + const finishPlaying = () => { + if (!playing) return; + playing.stop(); + finish(playing.scene); + playing = null; + }; + const start = (i: number) => { + const duration = seconds[i] ?? 0; + if (!(duration > 0)) return finish(i); + play[i].set(0); + line[i].set(0); + const stopLine = d.run(line[i], duration / ANIMATION_SHARE); + const stopPlay = d.run(play[i], duration, () => { + if (playing?.scene !== i) return; + playing = null; + step(); // the animation is done: carry on toward wherever the reader has scrolled + }); + playing = { + scene: i, + stop: () => { + stopPlay(); + stopLine(); + }, + }; + }; + const commit = (next: number, mode: 'play' | 'show') => { + finishPlaying(); + for (let i = 0; i < next; i += 1) finish(i); + played.add(next); + committed = next; + if (mode === 'play') { + d.setSkip(false); + start(next); + } else { + finish(next); + d.setSkip(true); + } + d.onChange(next, mode === 'show'); + shownAt = d.now(); + 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. */ + 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) { + 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); + commit(next, arrivalMode(committed, next, played)); + }; + + return { + /** The screen the page opens on plays (every visit starts by going forward). */ + begin: () => commit(committed, 'play'), + onScroll: () => step(), + /** Which screen is on stage, and whether one is still playing (for tests and debugging). */ + get index() { + return committed; + }, + get playingScene() { + return playing?.scene ?? null; + }, + dispose: () => { + d.clearTimer(dwell); + dwell = 0; + playing?.stop(); + playing = null; + }, + }; +} + +/** + * Drives the story in the app: which screen is on stage, each screen's play-once playhead, and the + * progress line (see `createStoryPlayer` for the rules). + * + * A 'show' switch turns on Motion's global `skipAnimations` for SHOW_SKIP_MS, so the entrances of the + * screen switched to (and the previous screen's exits) land on their final frame instead of playing. + * The progress line runs on the same clock as the animation: the animation is done at ANIMATION_SHARE + * of it, the rest is time to look. + */ +export function useStoryPlayback( + scroll: MotionValue, + bands: Bands, + seconds: readonly number[], + reduce: boolean, + speed: { screensPerSecond(): number } +) { + const n = bands.start.length; + const play = useMemo(() => Array.from({ length: n }, () => motionValue(0)), [n]); + const line = useMemo(() => Array.from({ length: n }, () => motionValue(0)), [n]); + const [state, setState] = useState(() => ({ index: bandIndex(bands, scroll.get()), instant: false })); + useEffect(() => { if (reduce) { - visual.set(target.get()); - return target.on('change', (v) => visual.set(v)); + play.forEach((v) => v.set(1)); + line.forEach((v) => v.set(1)); + return scroll.on('change', (p) => { + const band = bandIndex(bands, p); + setState((s) => (s.index === band ? s : { index: band, instant: false })); + }); } - let raf = 0; - let last = 0; - const tick = (now: number) => { - const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60; - last = now; - const t = target.get(); - const p = visual.get(); - const diff = t - p; - if (Math.abs(diff) < 0.00005) { - visual.set(t); - raf = 0; - last = 0; - return; - } - const cap = bands.len[bandIndex(bands, p)] * MAX_SCENES_PER_SECOND * dt; - const follow = diff * (1 - Math.exp(-FOLLOW_RATE * dt)); - visual.set(p + Math.max(-cap, Math.min(cap, follow))); - raf = requestAnimationFrame(tick); + let skipTimer = 0; + const player = createStoryPlayer({ + bands, + seconds, + 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 }); + return () => controls.stop(); + }, + setSkip: (on) => { + window.clearTimeout(skipTimer); + MotionGlobalConfig.skipAnimations = on; + if (on) skipTimer = window.setTimeout(() => (MotionGlobalConfig.skipAnimations = false), SHOW_SKIP_MS); + }, + onChange: (index, instant) => setState({ index, instant }), + setTimer: (fn, ms) => window.setTimeout(fn, ms), + clearTimer: (id) => window.clearTimeout(id), + }); + player.begin(); + const off = scroll.on('change', player.onScroll); + return () => { + off(); + player.dispose(); + window.clearTimeout(skipTimer); + MotionGlobalConfig.skipAnimations = false; + }; + }, [scroll, bands, seconds, reduce, play, line, speed]); + + 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). + */ +export function scrollLimit(bands: Bands, index: number): number | null { + const next = index + 1; + if (next >= bands.start.length) return null; + return Math.min(1, bands.start[next] + bands.len[next]) - LIMIT_EPSILON; +} + +/** + * Holds the page at `maxProgress` (see `scrollLimit`). The wheel, a finger drag and the keyboard are + * stopped at the limit before they scroll (no overshoot); anything else (scrollbar drag, momentum, + * End key) is brought back to it. The stage is pinned, so the correction itself is invisible. + */ +export function useScrollLimit({ + trackRef, + headerHeight, + maxProgress, + enabled, +}: { + trackRef: RefObject; + headerHeight: number; + maxProgress: number | null; + enabled: boolean; +}) { + useEffect(() => { + if (!enabled || maxProgress === null) return; + /** scrollY of the limit: progress 0 → track top meets the header, 1 → track bottom meets the viewport bottom */ + const maxY = () => { + const track = trackRef.current; + if (!track) return Number.POSITIVE_INFINITY; + const r = track.getBoundingClientRect(); + const top = window.scrollY + r.top - headerHeight; + return top + maxProgress * (r.height + headerHeight - window.innerHeight); }; - const kick = () => { - if (!raf) raf = requestAnimationFrame(tick); + const atLimit = () => window.scrollY >= maxY() - 1; + const clamp = () => { + const y = maxY(); + if (window.scrollY > y + 1) window.scrollTo({ top: y, behavior: 'instant' }); }; - kick(); - const off = target.on('change', kick); + const onWheel = (e: WheelEvent) => { + if (e.deltaY > 0 && atLimit()) e.preventDefault(); + }; + let touchY = 0; + const onTouchStart = (e: TouchEvent) => { + touchY = e.touches[0]?.clientY ?? 0; + }; + const onTouchMove = (e: TouchEvent) => { + 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(); + }; + 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(); + }; + clamp(); + window.addEventListener('scroll', clamp, { passive: true }); + window.addEventListener('wheel', onWheel, { passive: false }); + window.addEventListener('touchstart', onTouchStart, { passive: true }); + window.addEventListener('touchmove', onTouchMove, { passive: false }); + window.addEventListener('keydown', onKey); return () => { - off(); - cancelAnimationFrame(raf); + window.removeEventListener('scroll', clamp); + window.removeEventListener('wheel', onWheel); + window.removeEventListener('touchstart', onTouchStart); + window.removeEventListener('touchmove', onTouchMove); + window.removeEventListener('keydown', onKey); }; - }, [target, visual, bands, reduce]); - return visual; + }, [trackRef, headerHeight, maxProgress, enabled]); } -type StoryValue = { progress: MotionValue; bands: Bands; reduce: boolean }; +type StoryValue = { + /** Scroll progress through the story track (0..1). */ + scroll: MotionValue; + /** Each screen's play-once playhead (0..1). */ + play: readonly MotionValue[]; + /** Each screen's progress line (0..1): animation, then look time. */ + line: readonly MotionValue[]; + bands: Bands; + reduce: boolean; + /** The current screen was switched to finished (no animation). */ + instant: boolean; +}; const StoryContext = createContext(null); export function StoryTimelineProvider({ value, children }: { value: StoryValue; children: ReactNode }) { return {children}; } +function useStory(): StoryValue { + const ctx = useContext(StoryContext); + if (!ctx) throw new Error('story hooks must be used inside StoryTimelineProvider'); + return ctx; +} + +/** Whether the current screen was switched to without animation (CSS transitions can opt out too). */ +export function useStoryInstant(): boolean { + return useStory().instant; +} + /** - * 0..1 progress through scene `i`'s play window (0 while it enters, 1 while it rests). With reduced - * motion it is always 1: every screen simply shows its finished state. + * 0..1 playhead through scene `i`'s choreography: plays once at the screen's own pace, then stays at 1. + * With reduced motion it is always 1: every screen simply shows its finished state. */ export function useSceneProgress(i: number): MotionValue { - const ctx = useContext(StoryContext); - if (!ctx) throw new Error('useSceneProgress must be used inside StoryTimelineProvider'); - const { progress, bands, reduce } = ctx; - const a = bands.start[i] + bands.len[i] * ENTER; - const b = bands.start[i] + bands.len[i] * (1 - REST); - const local = useTransform(progress, [a, b], [0, 1], { clamp: true }); + const { play, reduce } = useStory(); const done = useMotionValue(1); - return reduce ? done : local; + return reduce ? done : play[i]; } /** - * Converts a screen's old second-based choreography onto its scroll progress. - * `t(start, end)` returns the [from, to] progress range for something that used to run from - * `start` to `end` seconds into a `totalSeconds`-long screen. + * 0..1 progress line for scene `i`, on the screen's own clock: its animation fills the first + * ANIMATION_SHARE, then the rest fills while everything stays on screen to look at. + */ +export function useSceneLine(i: number): MotionValue { + const { line, reduce } = useStory(); + const done = useMotionValue(1); + return reduce ? done : line[i]; +} + +/** + * Converts a screen's second-based choreography onto its playhead. + * `t(start, end)` returns the [from, to] playhead range for something that runs from `start` to `end` + * seconds into a `totalSeconds`-long screen. */ export function useTimeline(totalSeconds: number) { return useMemo( @@ -125,9 +507,8 @@ export function useTimeline(totalSeconds: number) { } /** - * How many of `marks` (sorted progress values) `p` has passed. Re-renders only when that count - * changes, so discrete story beats (a message arriving, a checkpoint lighting up) follow scroll in - * both directions without re-rendering every frame. + * How many of `marks` (sorted playhead values) `p` has passed. Re-renders only when that count + * changes, so discrete story beats (a message arriving, a checkpoint lighting up) cost one render each. */ export function usePassed(p: MotionValue, marks: readonly number[]) { const count = (v: number) => marks.reduce((n, m) => (v >= m ? n + 1 : n), 0); @@ -141,11 +522,7 @@ export function usePassed(p: MotionValue, marks: readonly number[]) { return passed; } -/** - * Scene `i`'s progress expressed in its old choreography seconds (0..totalSeconds), so components - * that were written with `delay: 1.4` style timings can keep those numbers and simply read them off - * the scroll instead of the clock. - */ +/** Scene `i`'s playhead in its choreography seconds (0..totalSeconds) — real seconds since it arrived. */ export function useSceneClock(i: number, totalSeconds: number): MotionValue { const p = useSceneProgress(i); return useTransform(p, (v) => v * totalSeconds); diff --git a/src/components/science/OrbitNav.tsx b/src/components/science/OrbitNav.tsx index 9854c31..1332bff 100644 --- a/src/components/science/OrbitNav.tsx +++ b/src/components/science/OrbitNav.tsx @@ -1,28 +1,90 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { motion, useMotionValueEvent, useReducedMotion, useSpring } from 'motion/react'; -import { UiElement, trackProps } from '../../lib/analytics/analytics'; +import { UiElement, trackProps, trackUi } from '../../lib/analytics/analytics'; import { BRAND, ENGINE_META, type ChapterDef } from './ScienceUi'; /** - * Chapter rail that comes straight OUT of a small dark hole, toward the viewer (no sideways tilt). + * Chapter scroller in the floating bottom bar: the original side "drum", turned horizontal. * - * The chapters sit on a ring (a drum) that rotates toward you as you scroll: the current chapter is - * at the front, centred and full size; chapters above/below rotate back into the hole — - * foreshortened, smaller and fainter. Labels are drawn with a 3D rotateX plus a horizontal stretch, - * so as a chapter rotates to the front its text looks like it is being stretched out of the hole. - * The line itself is a straight ribbon on the hole's axis: thin where it enters the hole, widest at - * the front, which sells the "coming out of the screen" depth. + * 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 STEP = 30; // degrees between chapters on the ring -const R = 132; // ring radius (px) — vertical reach of the projection -const H = R * 2 + 48; // nav height -const CY = H / 2; // the front of the ring (current chapter) -const BOX_W = 40; // breadth of the dark hole -const DOT_X = BOX_W / 2; // the line runs straight down the hole's axis -const WIDE = 10; // ribbon width at the front (px) -const THIN = 1; // ribbon width where it enters the hole (px) -const MAX_DEG = 84; // beyond this a chapter has rotated into the hole +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% @@ -62,6 +124,8 @@ function useChapterPosition(ids: readonly string[]) { 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); @@ -73,149 +137,244 @@ export function OrbitNav({ chapters, onSelect }: { chapters: readonly ChapterDef useMotionValueEvent(spring, 'change', setPos); const [hover, setHover] = useState(false); - const visible = rawPos > -0.6; + 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]; - // Straight ribbon on the hole's axis: its width follows the ring's foreshortening (cos), so it is - // thin where it disappears into the hole and widest at the front. - const SAMPLES = 56; - const right: string[] = []; - const left: string[] = []; + // 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 y = CY - R + (s / SAMPLES) * (2 * R); - const c = Math.sqrt(Math.max(0, 1 - ((y - CY) / R) ** 2)); // cos of the ring angle at this height - const hw = (THIN + (WIDE - THIN) * c) / 2; - right.push(`${(DOT_X + hw).toFixed(2)},${y.toFixed(2)}`); - left.unshift(`${(DOT_X - hw).toFixed(2)},${y.toFixed(2)}`); + 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 ${right.join(' L ')} L ${left.join(' L ')} Z`; + 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 ( setHover(true)} - onMouseLeave={() => setHover(false)} - onFocusCapture={() => setHover(true)} - onBlurCapture={() => setHover(false)} + animate={{ opacity: visible ? 1 : 0, y: visible ? 0 : 24 }} + transition={{ duration: reduce ? 0 : 0.3 }} + aria-hidden={!visible} > - {/* the small dark hole */} ); } diff --git a/src/components/science/Science.tsx b/src/components/science/Science.tsx index 08f690b..a4d4c46 100644 --- a/src/components/science/Science.tsx +++ b/src/components/science/Science.tsx @@ -1,19 +1,19 @@ 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, Send, Smartphone } from 'lucide-react'; +import { ArrowRight, MoreHorizontal, Smartphone } from 'lucide-react'; import { SiteHeader } from '../common/SiteHeader'; import { SiteFooter } from '../common/SiteFooter'; -import { StickyCta } from '../common/StickyCta'; import { Seo } from '../common/Seo'; import { ROUTE_SEO } from '../../config/seo-routes'; -import { Accent, BRAND, Chapter, ENGINE_META, Kicker, Panel, labelClass, pillClass, type ChapterDef } from './ScienceUi'; -import { Constellation, ErasureDemo, PipelineDiagram, SIGNALS } from './ScienceDiagrams'; +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 { 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, SignalKind } from './science-model'; +import type { EngineId } from './science-model'; /** * /science — how OneHook Intelligence works, told as the journey of one signal. @@ -47,7 +47,8 @@ const coupleImage = '/media/young-couple-in-love-on-a-romantic-date-2022-03-31-1 function scrollToId(id: string) { - document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + document.getElementById(id)?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' }); } /* ── Reading progress + chapter rail (the page's continuity thread) ──────────── */ @@ -102,80 +103,71 @@ function Interlude({ image, quote, by }: { image: string; quote: string; by: str /* ── Chapter 02: journey ───────────────────────────────────────────────────── */ function JourneyChapter() { - const [kind, setKind] = useState('swipe'); + const [kind, setKind] = useState('swipe'); const [pulse, setPulse] = useState(0); - const signal = SIGNALS.find((s) => s.kind === kind) ?? SIGNALS[0]; + const caption = kind === 'more' ? MORE_SIGNALS_CAPTION : (SIGNALS.find((s) => s.kind === kind) ?? SIGNALS[0]).caption; - const send = (k: SignalKind) => { + const send = (k: DiagramSignal) => { setKind(k); setPulse((p) => p + 1); }; return ( - Milliseconds later, it is on its way + Milliseconds later, it is on its way } lede={

- OneHook confirms the moment really came from you, checks it against a strict contract, and gives it a unique id so it can never - count twice. Then it reaches the event bus, which keeps your moments in order, and fans out only to the minds that need - it. + Checked, then sent only where it is needed. Nowhere else.

} handoff="So who are these three minds, and what does each one hear?" > -
- {SIGNALS.map(({ kind: k, label, Icon }) => { - const on = kind === k; - return ( - - ); - })} -
- - - -
-

- {signal.caption} -

- + ); + })} + {/* "More": there are more signals than these five; this one reaches every mind. */} +
-
-
- {[ - { k: 'Exactly once, in effect', v: 'Networks retry. Every moment carries one id, so a duplicate is recognised and ignored.' }, - { k: 'In your order', v: 'Your moments are processed in sequence, so a “close app” never overtakes the “open app” before it.' }, - { k: 'Nothing lost quietly', v: 'A moment that keeps failing is set aside for a person to inspect, never silently dropped.' }, - ].map((f) => ( -
-

{f.k}

-

{f.v}

-
- ))} -
+
+ +
+
); } @@ -183,18 +175,24 @@ function JourneyChapter() { /* ── Chapter 03: three minds (tabs) ────────────────────────────────────────── */ const MIND_ORDER: EngineId[] = ['engagement', 'rhythm', 'meaning']; +/** The "Try it" line at the top of the Three minds box, for the selected mind. */ +const MIND_TRY: Record = { + engagement: 'Pick a decision, then set the pause.', + rhythm: 'Pick how someone uses the app.', + meaning: 'Pick a pair and compare them.', +}; const MIND_COPY: Record = { engagement: { headline: 'How much did that moment mean?', - body: 'Engagement turns views, swipes and replies into a number between 0 and 1. It is how OneHook learns who genuinely interests whom, so the people who would light up at your profile meet you first.', + body: 'A view, a swipe, a reply. Each one quietly says how interested you really are.', }, rhythm: { headline: 'How are you moving right now?', - body: 'Rhythm watches pace, not people: how fast you swipe and how long you have been here today. When the pace stops being healthy, it recommends a calmer deck or a break, rather than pushing you to keep going.', + body: 'It watches your pace, never you, and knows when to suggest a breather.', }, meaning: { headline: 'Who are you, in your own words?', - body: 'Meaning reads your bio, interests and prompts and places you in a space of ideas, so two people who describe the same Sunday differently can still find each other.', + body: 'It reads how you describe yourself, so kindred people can find each other.', }, }; @@ -229,72 +227,83 @@ function MindsChapter() { } lede={ -

- Instead of one giant algorithm, OneHook listens with three small, focused minds. Each owns a single question, keeps only what it needs - to answer it, and can be explained in a sentence. -

+

Not one giant algorithm. Three small minds, each with a single question.

} handoff="A single moment is a whisper. Over time, the whispers become a voice." > -
+ {/* One restrained surface: fine border, underline navigation, editorial heading, compact demo. */} +
- {MIND_ORDER.map((id) => { - const on = tab === id; - return ( - - ); - })} -
-
+ aria-hidden="true" + className="pointer-events-none absolute inset-x-0 top-0 h-28 opacity-60 transition-colors duration-500" + style={{ background: `linear-gradient(180deg, ${meta.soft}, transparent)` }} + /> +
+ {MIND_TRY[tab]} +
+
+ {MIND_ORDER.map((id) => { + const on = tab === id; + return ( + + ); + })} +
+
- -
-

- {meta.name} · {meta.role} -

-

{copy.headline}

-

{copy.body}

-
-
- {tab === 'engagement' && } - {tab === 'rhythm' && } - {tab === 'meaning' && } + +
+

{copy.headline}

+

{copy.body}

+
+
+ {tab === 'engagement' && } + {tab === 'rhythm' && } + {tab === 'meaning' && } +
+
-
+
); } @@ -302,37 +311,31 @@ function MindsChapter() { /* ── Chapter 06: promises ──────────────────────────────────────────────────── */ const PROMISES = [ - { k: 'Your words stay yours', v: 'The intelligence layer never receives the text of your messages. Only that one was sent, and how quickly.' }, - { k: 'Kept in India', v: 'Everything the three minds store lives in Google Cloud’s Mumbai region.' }, - { k: 'Built to forget', v: 'Moments expire after 30 days. Scores and intent are rebuilt fresh and expire within a day.' }, - { k: 'No outside AI reads you', v: 'Meaning runs its language model on OneHook’s own servers, not a third-party AI service.' }, - { k: 'Keyless by design', v: 'The clouds trust each other through short-lived, verified identities, not passwords in a file.' }, - { k: 'Explainable, always', v: 'Every rule on this page is a rule in the code. No black box decides who you meet.' }, + { k: 'Your words stay yours', v: 'Your messages are never read to match you.' }, + { k: 'Kept in India', v: 'What the three minds hold stays in India.' }, + { k: 'Built to forget', v: 'Every signal fades. Nothing is kept forever.' }, + { k: 'No outside AI reads you', v: 'Understanding happens on OneHook’s own servers.' }, + { k: 'Keyless by design', v: 'No passwords sitting in files.' }, + { k: 'Explainable, always', v: 'No black box decides who you meet.' }, ]; function PromisesChapter() { const reduce = useReducedMotion(); return ( - Intelligence, with manners + Intelligence, with manners } lede={ -

- Knowing someone well is a privilege, so the system is built around limits. These are the ones that matter most, followed by the - hardest one, demonstrated live: letting you go completely. -

+

Knowing someone is a privilege. So the system is built around limits, including the hardest one: letting you go.

} handoff="Which brings us back to where we started." > -
+
{PROMISES.map((p, i) => ( -

0{i + 1}

-

{p.k}

-

{p.v}

+ {/* Numbered only when the cards sit side by side; stacked on a phone they read as a list. */} +

0{i + 1}

+

{p.k}

+

{p.v}

))}
- -
- The right to be forgotten -

- Delete your account, and every mind lets go -

-
- -
-
); } @@ -401,6 +394,8 @@ export function Finale({ secondary = 'philosophy', fill = false, lite = false, + showTitle = true, + compact = false, }: { body?: string; /** 'philosophy' → Our philosophy (Science page); 'invite' → Start with an invite (messenger). */ @@ -410,6 +405,10 @@ export function Finale({ /** Phones / low-memory devices: keep the photo and heartbeat logo, skip the endless ripples and the * pulsing photo layer. */ lite?: boolean; + /** The "Thousands of quiet signals. One connection." headline (the messenger closer leaves it out). */ + showTitle?: boolean; + /** Shorter closer: smaller heartbeat mark, headline and padding (Science page). */ + compact?: boolean; } = {}) { const navigate = useNavigate(); const reduce = useReducedMotion() ?? false; @@ -441,8 +440,8 @@ export function Finale({ return (
{/* The photo breathes very slightly with each beat, scaling from the logo (transform only). */} @@ -481,16 +480,21 @@ export function Finale({
diff --git a/src/components/science/ScienceLabs.tsx b/src/components/science/ScienceLabs.tsx index 294b916..b483d91 100644 --- a/src/components/science/ScienceLabs.tsx +++ b/src/components/science/ScienceLabs.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'; import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; import { Check, Clock } from 'lucide-react'; import { AlienScanner } from '../mascot'; -import { BRAND, ENGINE_META, Meter, Panel, Range, Toggle, labelClass } from './ScienceUi'; +import { BRAND, Disclosure, ENGINE_META, Meter, Panel, Range, TryIt, labelClass } from './ScienceUi'; import { CURVE_MAX_MIN, CURVE_MIN_MIN, ReplyCurve } from './ScienceDiagrams'; import { INTENT_THRESHOLD, @@ -26,7 +26,7 @@ const M = ENGINE_META.meaning; const lingerImage = '/media/man-with-backpack-walking-on-snow-covered-forest-b-JAJ77DS-1600.jpg'; /** Big centred numeric readout shared by every lab. */ -function Readout({ value, label, dark = false, size = 'text-6xl' }: { value: string; label: string; dark?: boolean; size?: string }) { +function Readout({ value, label, dark = false, size = 'text-5xl' }: { value: string; label: string; dark?: boolean; size?: string }) { return (

{value} @@ -39,27 +39,25 @@ function Readout({ value, label, dark = false, size = 'text-6xl' }: { value: str * ────────────────────────────────────────────────────────────────────────────── */ export function ProfileViewLab() { - const reduce = useReducedMotion(); const [dwell, setDwell] = useState(18); const [photos, setPhotos] = useState(3); - const [bio, setBio] = useState(true); const [prompts, setPrompts] = useState(1); const TOTAL = 6; - const s = profileViewScore({ dwellSeconds: dwell, photosViewed: photos, totalPhotos: TOTAL, bioExpanded: bio, promptsRead: prompts }); + // The bio is always on the profile, so there is no separate "opened the bio" action to score. + const s = profileViewScore({ dwellSeconds: dwell, photosViewed: photos, totalPhotos: TOTAL, bioExpanded: false, promptsRead: prompts }); const parts = [ - { key: 'time', label: 'Time spent', value: s.time, max: 0.35, color: E.deep }, - { key: 'photos', label: 'Photos explored', value: s.photos, max: 0.25, color: E.color }, - { key: 'bio', label: 'Bio opened', value: s.bio, max: 0.2, color: M.deep }, - { key: 'prompts', label: 'Prompts read', value: s.prompts, max: 0.2, color: M.color }, + { key: 'time', label: 'Time spent', value: s.time, color: E.deep }, + { key: 'photos', label: 'Photos explored', value: s.photos, color: E.color }, + { key: 'prompts', label: 'Prompts read', value: s.prompts, color: M.deep }, ]; const verdict = - s.total >= 0.75 ? 'Genuine curiosity' : s.total >= 0.45 ? 'A real look' : s.total >= 0.2 ? 'A passing glance' : 'Barely a blink'; + s.total >= 0.65 ? 'Genuine curiosity' : s.total >= 0.4 ? 'A real look' : s.total >= 0.2 ? 'A passing glance' : 'Barely a blink'; return ( -

-
+
+