From 8669ea0195300ff5b92b6c3e354d2e3a8336903f Mon Sep 17 00:00:00 2001 From: Pushp Date: Wed, 28 Oct 2026 09:03:36 +0530 Subject: [PATCH 1/2] feat(science): six minds, one process diagram, tilted orbit disc; landing Intelligence section Science page - Six minds (Engagement, Rhythm, Meaning, Taste, Wish, Tradition), synced with the matching backend; routes, model and tests updated. - Hero: tilted 3D orbit disc that fits between headline and buttons at every aspect ratio, labels around the logo, Get the app / See how it works / Our philosophy. Rings, spin and parallax are compositor-only layers. - Story index: six chapters, three per row, on white. - One sticky interactive process diagram (ScienceProcess) replaces the chapter labs and the chapter drum (OrbitNav, ScienceLabs removed). Fits both axes, side by side on wide or landscape screens, captions centred on the logo, process section on the landing "How it works" ground. Landing - Mr.OneHook section renamed Intelligence (nav, footer, kicker, lead copy). - Its orbit backdrop shows the same tilted disc as the science hero for the same screen (shared ScienceDisc and hero geometry). - The mascot message opens the explainer video; smaller mascot; its sparkle ring now shows on phones too. Header - Science and Messenger share one header layout (fitTitles), so both switch between full and compact at the same width. --- src/components/Landing.tsx | 36 +- src/components/common/SiteFooter.tsx | 2 +- src/components/common/SiteHeader.tsx | 30 +- src/components/mascot/AlienScanner.tsx | 4 +- src/components/science/OrbitNav.tsx | 380 ----- src/components/science/Science.tsx | 580 +------- src/components/science/ScienceDiagrams.tsx | 63 +- src/components/science/ScienceHero.tsx | 863 +++++++---- src/components/science/ScienceLabs.tsx | 797 ----------- .../science/ScienceOrbitBackdrop.tsx | 187 +-- src/components/science/ScienceOverview.tsx | 103 +- src/components/science/ScienceProcess.tsx | 1258 +++++++++++++++++ src/components/science/ScienceUi.tsx | 18 +- src/components/science/science-model.ts | 226 ++- src/config/seo-routes.ts | 2 +- src/index.css | 329 +++-- src/tests/science-model.test.ts | 6 +- 17 files changed, 2545 insertions(+), 2339 deletions(-) delete mode 100644 src/components/science/OrbitNav.tsx delete mode 100644 src/components/science/ScienceLabs.tsx create mode 100644 src/components/science/ScienceProcess.tsx 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/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 ( -
-