- {/* The heading spans the full container width; only the supporting line stays narrow. */}
-
+
The complete story
- Six chapters. One transparent system
+ One moment. One continuous story
- Six questions. Pick the one you are most curious about.
+ Start at the beginning, or enter wherever you are curious.
-
- {chapters.map((chapter) => {
- const details = OVERVIEW_DETAILS[chapter.id];
- return (
- -
-
+
+ );
+ })}
+
+
);
diff --git a/src/components/science/ScienceProcess.tsx b/src/components/science/ScienceProcess.tsx
new file mode 100644
index 0000000..741fca7
--- /dev/null
+++ b/src/components/science/ScienceProcess.tsx
@@ -0,0 +1,1258 @@
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+ type CSSProperties,
+ type ReactNode,
+} from 'react';
+import { motion, useInView, useReducedMotion } from 'motion/react';
+import {
+ Activity,
+ ArrowRight,
+ Clock3,
+ Eye,
+ Heart,
+ MessageCircle,
+ ShieldCheck,
+ Sparkles,
+} from 'lucide-react';
+import { useNavigate } from 'react-router-dom';
+import { hideMrOneHook } from '../Chatbot/ChatbotWidget';
+import { UiElement, trackProps } from '../../lib/analytics/analytics';
+import {
+ BRAND,
+ ENGINE_META,
+ ENGINE_ORDER,
+ type ChapterDef,
+ type ScienceChapterId,
+} from './ScienceUi';
+import { ROUTES, type EngineId, type SignalKind } from './science-model';
+
+const EASE = [0.22, 1, 0.36, 1] as const;
+
+const MOMENTS = [
+ {
+ id: 'view',
+ label: 'A lingering look',
+ detail: 'A pause can say more than a fast swipe.',
+ Icon: Eye,
+ },
+ {
+ id: 'swipe',
+ label: 'A considered like',
+ detail: 'Taking your time gives the moment more weight.',
+ Icon: Heart,
+ },
+ {
+ id: 'chat',
+ label: 'A reply',
+ detail: 'That you came back matters. The words stay private.',
+ Icon: MessageCircle,
+ },
+] as const satisfies readonly { id: SignalKind; label: string; detail: string; Icon: typeof Eye }[];
+
+const SIGNAL_CHOICES = [
+ { id: 'view', label: 'View' },
+ { id: 'swipe', label: 'Like' },
+ { id: 'session', label: 'Session' },
+ { id: 'chat', label: 'Reply' },
+ { id: 'profile', label: 'Profile' },
+ { id: 'ask', label: 'Ask' },
+ { id: 'birth', label: 'Birth' },
+] as const satisfies readonly { id: SignalKind; label: string }[];
+
+const MIND_COPY: Record
= {
+ engagement: {
+ question: 'Did this moment matter?',
+ answer: 'Notices the difference between a glance and genuine curiosity.',
+ },
+ rhythm: {
+ question: 'How are you moving?',
+ answer: 'Knows when the pace feels thoughtful, rushed or simply tired.',
+ },
+ meaning: {
+ question: 'Who are you in words?',
+ answer: 'Looks for people who sound at home in the same conversations.',
+ },
+ taste: {
+ question: 'Who keeps catching your eye?',
+ answer: 'Notices the kinds of people you choose, never the ones you pass.',
+ },
+ wish: {
+ question: 'Who did you ask for?',
+ answer: 'Carries what you tell Mr OneHook into the people he finds.',
+ },
+ tradition: {
+ question: 'What do the old traditions say?',
+ answer: 'Adds a gentle nudge from the birth details you choose to share.',
+ },
+};
+
+const PATTERNS = [
+ {
+ id: 'early',
+ label: 'Just arrived',
+ title: 'One moment',
+ body: 'Too soon to assume. OneHook waits.',
+ },
+ {
+ id: 'steady',
+ label: 'Taking time',
+ title: 'A pattern appears',
+ body: 'Thoughtful moments begin to point in the same direction.',
+ },
+ {
+ id: 'ready',
+ label: 'Ready to talk',
+ title: 'The timing feels right',
+ body: 'A good introduction can arrive when you are ready for it.',
+ },
+] as const;
+
+const WISHES = [
+ {
+ id: 'reader',
+ label: 'Calm reader',
+ ask: 'Someone calm who reads and makes real plans.',
+ result: 'A thoughtful reader who likes turning weekends into plans.',
+ },
+ {
+ id: 'outside',
+ label: 'Outdoorsy',
+ ask: 'Someone outdoorsy who wants something real.',
+ result: 'A person who would rather plan a trail than collect matches.',
+ },
+ {
+ id: 'music',
+ label: 'Live music',
+ ask: 'Someone curious who loves live music.',
+ result: 'A curious gig-goer with something to talk about afterwards.',
+ },
+] as const;
+
+const PROMISES = [
+ ['Words stay yours', 'Match chats are never read to match you.'],
+ ['Only what is needed', 'Each moment goes only to the minds that need it.'],
+ ['Built to forget', 'Your trail can disappear with you.'],
+ ['A reason, always', 'Every introduction says who found them and why.'],
+] as const;
+
+interface ProcessState {
+ active: ScienceChapterId;
+ signal: SignalKind;
+ mind: EngineId;
+ pattern: (typeof PATTERNS)[number]['id'];
+ wish: (typeof WISHES)[number]['id'];
+ pulse: number;
+ erasing: boolean;
+}
+
+interface SceneProps extends ProcessState {
+ reduce: boolean;
+ onJump: (chapter: ScienceChapterId) => void;
+ onMind: (mind: EngineId) => void;
+}
+
+/* ── Diagram geometry (viewBox units) ─────────────────────────────────────────
+ * The input lane runs left to right through A moment → Check → OneHook and nothing else sits on
+ * it. Three minds sit above the centre (labels above them) and three below (labels below), so no
+ * route ever crosses a label. The right side is the output lane: the pattern timeline in "Time"
+ * and the introduction card in "Mr OneHook". Every label size below is the largest the geometry
+ * was checked against, so text can grow on small screens without colliding. */
+const VIEW_W = 660;
+const VIEW_H = 470;
+const SCENE_CX = 350;
+const SCENE_CY = 220;
+const RING = 58;
+const MOMENT = { x: 60, y: 220 };
+const CHECK = { x: 127, y: 220 };
+const CARD = { x: 488, y: 150, w: 164, h: 140 };
+const AXIS = { x0: 432, step: 30, count: 7 };
+/** Centre of the lane between Check (its largest ring) and OneHook's ring. */
+const SORT_X = (CHECK.x + 22 + SCENE_CX - RING - 6) / 2;
+const BOUNDARY = { x: 2, y: 4, w: 656, h: 456, r: 120 };
+
+const MIND_POSITIONS: Record = {
+ tradition: { x: 180, y: 80 },
+ engagement: { x: 350, y: 62 },
+ rhythm: { x: 520, y: 80 },
+ wish: { x: 180, y: 362 },
+ meaning: { x: 350, y: 380 },
+ taste: { x: 520, y: 362 },
+};
+const LABEL_ABOVE: ReadonlySet = new Set(['tradition', 'engagement', 'rhythm']);
+
+/**
+ * Sizes in viewBox units for the drawn scale `s` (rendered px per unit). Text aims for a legible
+ * on-screen size but is clamped to the range the layout above was designed for.
+ */
+function sceneMetrics(s: number) {
+ const k = s > 0 ? s : 1;
+ const u = (px: number, min: number, max: number) => Math.min(max, Math.max(min, px / k));
+ return {
+ label: u(10, 10, 20),
+ title: u(11.5, 13, 20),
+ note: u(9, 10, 14),
+ minor: u(8.5, 9, 16),
+ cardHead: u(7.5, 8, 13),
+ cardName: u(10.5, 11, 18),
+ cardNote: u(9, 9, 15),
+ node: u(7, 12, 15),
+ moment: u(15, 26, 32),
+ check: u(10, 18, 22),
+ dot: u(3.5, 3.5, 6),
+ showNotes: k >= 0.6,
+ };
+}
+
+function routePath(engine: EngineId, end: number) {
+ const p = MIND_POSITIONS[engine];
+ const dx = p.x - SCENE_CX;
+ const dy = p.y - SCENE_CY;
+ const length = Math.max(1, Math.hypot(dx, dy));
+ const ux = dx / length;
+ const uy = dy / length;
+ const startX = SCENE_CX + ux * (RING + 4);
+ const startY = SCENE_CY + uy * (RING + 4);
+ const endX = p.x - ux * end;
+ const endY = p.y - uy * end;
+ return `M${startX} ${startY} C ${startX + dx * 0.3} ${startY + dy * 0.12}, ${endX - dx * 0.2} ${endY - dy * 0.18}, ${endX} ${endY}`;
+}
+
+/** Fits the 660×470 drawing plus its caption inside the box on both axes (never wider than
+ * 760px), so the drawing and its caption stay together as one centred group. */
+function useFittedWidth() {
+ const ref = useRef(null);
+ const captionRef = useRef(null);
+ const [width, setWidth] = useState(0);
+ useLayoutEffect(() => {
+ const el = ref.current;
+ const caption = captionRef.current;
+ if (!el || !caption) return;
+ const measure = () => {
+ const { width: w, height: h } = el.getBoundingClientRect();
+ const free = h - caption.getBoundingClientRect().height;
+ const next = Math.floor(Math.max(0, Math.min(w, (free * VIEW_W) / VIEW_H, 760)));
+ setWidth((prev) => (prev === next ? prev : next));
+ };
+ measure();
+ const ro = new ResizeObserver(measure);
+ ro.observe(el);
+ ro.observe(caption);
+ return () => ro.disconnect();
+ }, []);
+ return { ref, captionRef, width };
+}
+
+const uppercase = { letterSpacing: '0.12em' } as const;
+
+/** Stacked layout: the sticky diagram takes the drawing's natural height plus its caption, capped
+ * so the step text below always keeps more than half of the screen. */
+const SCENE_VARS = {
+ '--scene-h': `min(44svh, calc((100vw - 2.5rem) * ${VIEW_H / VIEW_W} + 3rem))`,
+} as CSSProperties;
+
+/** One persistent diagram. Scrolling changes emphasis; it never remounts into another card. */
+function ProcessScene({
+ active,
+ signal,
+ mind,
+ pattern,
+ wish,
+ pulse,
+ erasing,
+ reduce,
+ onJump,
+ onMind,
+}: SceneProps) {
+ const targets = ROUTES[signal];
+ const selectedWish = WISHES.find((w) => w.id === wish) ?? WISHES[0];
+ const patternIndex = PATTERNS.findIndex((p) => p.id === pattern);
+ const sceneOpacity = erasing ? 0.08 : 1;
+ const { ref: boxRef, captionRef, width: drawW } = useFittedWidth();
+ // Looping packets run only while the diagram is on screen.
+ const onScreen = useInView(boxRef);
+ const animate = !reduce && onScreen;
+ const m = sceneMetrics(drawW / VIEW_W);
+ const nodeMax = m.node + 5;
+ const pct = (v: number, of: number) => `${(v / of) * 100}%`;
+ const hit = (units: number) => Math.max(36, Math.round(units * 2 * (drawW / VIEW_W)));
+
+ return (
+
+
+
+
+
+
+ {/* Transparent HTML controls make the drawn nodes directly operable by mouse, touch and
+ keyboard. They are sized from the drawn scale so neighbouring targets never stack. */}
+
onJump('signal')}
+ {...trackProps(UiElement.LANDING_DEMO_INTERACT, { value: 'science_chapter' })}
+ className="absolute -translate-x-1/2 -translate-y-1/2 cursor-pointer rounded-full bg-transparent transition-colors hover:bg-[#A2525E]/[0.06] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#A2525E]/40"
+ style={{
+ left: pct(MOMENT.x, VIEW_W),
+ top: pct(MOMENT.y, VIEW_H),
+ width: hit(m.moment + 2),
+ height: hit(m.moment + 2),
+ }}
+ />
+ onJump('journey')}
+ {...trackProps(UiElement.LANDING_DEMO_INTERACT, { value: 'science_chapter' })}
+ className="absolute -translate-x-1/2 -translate-y-1/2 cursor-pointer rounded-full bg-transparent transition-colors hover:bg-[#1F3A6B]/[0.035] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1F3A6B]/30"
+ style={{
+ left: pct(SCENE_CX, VIEW_W),
+ top: pct(SCENE_CY, VIEW_H),
+ width: hit(RING),
+ height: hit(RING),
+ }}
+ />
+ {ENGINE_ORDER.map((id) => {
+ const p = MIND_POSITIONS[id];
+ return (
+ onMind(id)}
+ {...trackProps(UiElement.LANDING_DEMO_INTERACT, { value: 'science_tab' })}
+ className="absolute -translate-x-1/2 -translate-y-1/2 cursor-pointer rounded-full bg-transparent transition-colors hover:bg-black/[0.025] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/20"
+ style={{
+ left: pct(p.x, VIEW_W),
+ top: pct(p.y, VIEW_H),
+ width: hit(nodeMax + 4),
+ height: hit(nodeMax + 4),
+ }}
+ />
+ );
+ })}
+ {active === 'mr-onehook' && (
+ onJump('mr-onehook')}
+ {...trackProps(UiElement.LANDING_DEMO_INTERACT, { value: 'science_chapter' })}
+ className="absolute rounded-[16px] bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5B3F86]/30"
+ style={{
+ left: pct(CARD.x, VIEW_W),
+ top: pct(CARD.y, VIEW_H),
+ width: pct(CARD.w, VIEW_W),
+ height: pct(CARD.h, VIEW_H),
+ }}
+ />
+ )}
+
+
+
+ {active === 'signal' && 'It begins with one moment · tap any node to explore'}
+ {active === 'journey' && 'Only the right minds listen'}
+ {active === 'minds' && `${ENGINE_META[mind].name} · ${MIND_COPY[mind].question}`}
+ {active === 'time' && PATTERNS[patternIndex].title}
+ {active === 'mr-onehook' && `“${selectedWish.ask}”`}
+ {active === 'promises' && 'You remain in control'}
+
+
+
+ );
+}
+
+function FlatChoices({
+ label,
+ options,
+ value,
+ onChange,
+ color = BRAND.navy,
+ trackValue = 'science_tab',
+}: {
+ label: string;
+ options: readonly { id: T; label: string }[];
+ value: T;
+ onChange: (id: T) => void;
+ color?: string;
+ trackValue?: 'science_tab' | 'science_signal_kind';
+}) {
+ return (
+
+ {options.map((option) => {
+ const on = option.id === value;
+ return (
+ onChange(option.id)}
+ {...trackProps(UiElement.LANDING_DEMO_INTERACT, { value: trackValue })}
+ className="relative min-h-11 pb-3 pt-2 text-left text-[10px] font-semibold uppercase tracking-[0.18em] text-black/40 transition-colors hover:text-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/20"
+ style={{ color: on ? color : undefined }}
+ >
+ {option.label}
+
+
+ );
+ })}
+
+ );
+}
+
+function ProcessStep({
+ chapter,
+ eyebrow,
+ title,
+ body,
+ active,
+ onEnter,
+ children,
+}: {
+ chapter: ChapterDef;
+ eyebrow: string;
+ title: ReactNode;
+ body: string;
+ active: boolean;
+ onEnter: (id: ScienceChapterId) => void;
+ children: ReactNode;
+}) {
+ const ref = useRef(null);
+ // A section is current while it covers the middle of the screen. A visible-area ratio would never
+ // trigger for steps taller than the viewport (short landscape screens, long final step).
+ const inView = useInView(ref, { margin: '-50% 0px -49% 0px' });
+ useEffect(() => {
+ if (inView) onEnter(chapter.id);
+ }, [chapter.id, inView, onEnter]);
+
+ return (
+
+
+
+ {chapter.number}
+
+
+ {eyebrow}
+
+
+
+ {title}
+
+ {body}
+ {children}
+
+
+ );
+}
+
+export function ScienceProcess({ chapters }: { chapters: readonly ChapterDef[] }) {
+ const reduce = useReducedMotion() ?? false;
+ const navigate = useNavigate();
+ const [state, setState] = useState({
+ active: 'signal',
+ signal: 'view',
+ mind: 'engagement',
+ pattern: 'steady',
+ wish: 'reader',
+ pulse: 0,
+ erasing: false,
+ });
+
+ const setActive = useCallback(
+ (active: ScienceChapterId) => setState((s) => (s.active === active ? s : { ...s, active })),
+ []
+ );
+ const jump = useCallback(
+ (active: ScienceChapterId) => {
+ setActive(active);
+ const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ document
+ .getElementById(active)
+ ?.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' });
+ },
+ [setActive]
+ );
+ const focusMind = useCallback((mind: EngineId) => {
+ setState((s) => ({ ...s, active: 'minds', mind }));
+ const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ document
+ .getElementById('minds')
+ ?.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' });
+ }, []);
+ const selectSignal = (signal: SignalKind) =>
+ setState((s) => ({ ...s, signal, pulse: s.pulse + 1 }));
+ const eraseTimer = useRef(null);
+ useEffect(
+ () => () => {
+ if (eraseTimer.current !== null) window.clearTimeout(eraseTimer.current);
+ },
+ []
+ );
+ const erase = () => {
+ setState((s) => ({ ...s, erasing: true }));
+ if (eraseTimer.current !== null) window.clearTimeout(eraseTimer.current);
+ eraseTimer.current = window.setTimeout(
+ () => setState((s) => ({ ...s, erasing: false })),
+ reduce ? 80 : 1750
+ );
+ };
+
+ const activeMoment = MOMENTS.find((m) => m.id === state.signal) ?? MOMENTS[0];
+ const activePattern = PATTERNS.find((p) => p.id === state.pattern) ?? PATTERNS[1];
+ const activeWish = WISHES.find((w) => w.id === state.wish) ?? WISHES[0];
+ const targetNames = useMemo(
+ () => ROUTES[state.signal].map((id) => ENGINE_META[id].name).join(' · '),
+ [state.signal]
+ );
+
+ return (
+ // The process sits on the landing page's "How it works" ground, rising on the same curved edge,
+ // so it reads as a new chapter after the white story index.
+
+
+
+
+
+
+
+ You know the feeling: one profile makes you{' '}
+ pause
+ >
+ }
+ body="Before you decide, your attention has already said something. OneHook notices the moment, never judges the person."
+ active={state.active === 'signal'}
+ onEnter={setActive}
+ >
+
+
+
+
{activeMoment.detail}
+
+
+
+
+ Your moments should never become{' '}
+ noise
+ >
+ }
+ body="Each one is checked, kept in order and heard only by the minds that can use it."
+ active={state.active === 'journey'}
+ onEnter={setActive}
+ >
+
+
+ Heard by {targetNames}
+
+
+
+
+ No giant algorithm gets to define you
+ >
+ }
+ body="Six small minds ask six small questions. Together, they make one more considered introduction."
+ active={state.active === 'minds'}
+ onEnter={setActive}
+ >
+ ({ id, label: ENGINE_META[id].name }))}
+ value={state.mind}
+ onChange={(mind) => setState((s) => ({ ...s, mind }))}
+ color={ENGINE_META[state.mind].deep}
+ />
+ {MIND_COPY[state.mind].question}
+
+ {MIND_COPY[state.mind].answer}
+
+
+
+
+ Readiness has a rhythm
+ >
+ }
+ body="OneHook waits for a pattern before it makes a suggestion, because the right introduction at the wrong time is still wrong."
+ active={state.active === 'time'}
+ onEnter={setActive}
+ >
+ setState((s) => ({ ...s, pattern }))}
+ color={ENGINE_META.rhythm.deep}
+ />
+
+
+
+
{activePattern.title}
+
+ {activePattern.body}
+
+
+
+
+
+
+ Say what matters. See why they fit
+ >
+ }
+ body="Tell Mr OneHook who you hope to meet. He carries the useful part into Discover, leaves sensitive traits out, and explains the introduction."
+ active={state.active === 'mr-onehook'}
+ onEnter={setActive}
+ >
+ setState((s) => ({ ...s, wish }))}
+ color={ENGINE_META.wish.deep}
+ />
+
+
+
+
+ “{activeWish.ask}”
+
+
+ He looks for
+
+
+ {activeWish.result}
+
+
+
+
+
+
+ Intelligence should earn your trust
+ >
+ }
+ body="The boundaries are part of OneHook itself: your words stay yours, every introduction has a reason, and your trail can disappear with you."
+ active={state.active === 'promises'}
+ onEnter={setActive}
+ >
+
+ {PROMISES.map(([title, body]) => (
+ -
+
+
+
+ ))}
+
+
+ Show how forgetting works
+
+
+
+
+ Dating can feel this considered.
+
+
+ Spend less time decoding the app, and more time getting to know one person.
+
+
+
navigate('/#get-app')}
+ {...trackProps(UiElement.LANDING_OPEN_IN_APP)}
+ className="inline-flex min-h-12 items-center gap-3 rounded-full bg-[#0A0B10] px-6 text-[10px] font-semibold uppercase tracking-[0.24em] text-white transition-transform hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1F3A6B]/40 focus-visible:ring-offset-2"
+ >
+ Get OneHook
+
+
navigate('/#philosophy')}
+ {...trackProps(UiElement.LANDING_HOME_LINK)}
+ className="inline-flex min-h-12 items-center rounded-full border border-black/15 px-6 text-[10px] font-semibold uppercase tracking-[0.24em] text-[#0A0B10] transition-[transform,border-color] hover:-translate-y-0.5 hover:border-black/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1F3A6B]/40 focus-visible:ring-offset-2"
+ >
+ Our philosophy
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/science/ScienceUi.tsx b/src/components/science/ScienceUi.tsx
index facb60c..f9b8e71 100644
--- a/src/components/science/ScienceUi.tsx
+++ b/src/components/science/ScienceUi.tsx
@@ -6,7 +6,9 @@ import { UiElement, trackProps } from '../../lib/analytics/analytics';
/** Brand tones sampled from the OneHook love logo (silver heart, navy "1"). */
export const BRAND = {
ink: '#0A0B10',
- paper: '#FAFAF8',
+ paper: '#FFFFFF',
+ /** The landing page's "How it works" ground, used for the process chapter on /science. */
+ mist: '#EEF0F3',
navy: '#1F3A6B',
silver: '#C9CDD3',
logo: '/media/onehook-512.png',
@@ -24,8 +26,17 @@ export const ENGINE_META: Record ENGINE_META[e].soft).join(', ')})`;
+
export type ScienceChapterId = 'signal' | 'journey' | 'minds' | 'time' | 'mr-onehook' | 'promises';
export interface ChapterDef {
@@ -89,9 +100,11 @@ export function Chapter({
data-chapter={chapter.id}
aria-labelledby={`${chapter.id}-title`}
className={`relative scroll-mt-24 px-5 sm:px-6 ${continues ? 'pb-12 pt-2 sm:pb-14 sm:pt-4' : 'py-12 sm:py-14'}`}
- style={{ background: dark ? BRAND.ink : BRAND.paper, color: dark ? '#FFFFFF' : BRAND.ink }}
+ // Light chapters are transparent: the paper comes from the wrapper around them (Science.tsx).
+ style={{ background: dark ? BRAND.ink : 'transparent', color: dark ? '#FFFFFF' : BRAND.ink }}
>
+
{/* Hairline separator: the light chapters are divided by a rule, not by colour blocks. */}
{/* Number on top; the hairlines run either side of the section name below it. */}
@@ -116,6 +129,7 @@ export function Chapter({
{lede}
+
{children}
diff --git a/src/components/science/science-model.ts b/src/components/science/science-model.ts
index dbdc519..a13db1e 100644
--- a/src/components/science/science-model.ts
+++ b/src/components/science/science-model.ts
@@ -9,6 +9,18 @@
* - deckPacing -> behavioral_engine/services/analyzers.py (+ config defaults)
* - ROUTES -> infra/pubsub.tf subscription filters
*
+ * Taste and Wish are the two halves of Mr. OneHook in OneHook Matching (OneHookBackend):
+ *
+ * - inclinationCount / clusterInclinations -> matching service/suggestion/InclinationClustering.java
+ * - inferInclinations / inclinationReasoning -> matching service/suggestion/SuggestionInferenceJob.java
+ * - mixDeck -> matching service/discovery/DiscoveryFeedService.interleave
+ *
+ * Tradition is OneHook Matching's traditional (cosmic) compatibility:
+ *
+ * - TRADITIONAL_SYSTEMS -> matching cosmic/TraditionalComparisonEngine.SYSTEMS
+ * (availability measured by running the real
+ * CosmicChartCalculator on random pairs)
+ *
* The one deliberate exception is `embed`: production uses a sentence-transformers model on the
* server; the page uses a tiny feature-hashing embedding (the shape of the engine's own `hashing`
* provider) so the demo runs entirely in the visitor's browser and nothing typed leaves the page.
@@ -126,17 +138,24 @@ export function deckPacing(input: PacingInput): PacingResult {
// ── Chapter 2 · Journey ───────────────────────────────────────────────────────
-export type EngineId = 'engagement' | 'rhythm' | 'meaning';
-export type SignalKind = 'view' | 'swipe' | 'session' | 'chat' | 'profile' | 'erase';
+export type EngineId = 'engagement' | 'rhythm' | 'meaning' | 'taste' | 'wish' | 'tradition';
+export type SignalKind = 'view' | 'swipe' | 'session' | 'chat' | 'profile' | 'ask' | 'birth' | 'erase';
-/** Which engines' subscriptions accept each event type (mirrors the Pub/Sub filters). */
+/**
+ * Which minds hear each event. Engagement, Rhythm and Meaning mirror the Pub/Sub filters; Taste
+ * (inferred suggestions) learns only from likes, Wish only from what a member asks Mr OneHook in
+ * their own chat with him (never from chats with matches), and Tradition only from birth details,
+ * which are never forwarded to OnehookIntelligence. Erasure reaches every mind.
+ */
export const ROUTES: Record
= {
view: ['engagement'],
- swipe: ['engagement', 'rhythm'],
+ swipe: ['engagement', 'rhythm', 'taste'],
session: ['engagement', 'rhythm'],
chat: ['engagement'],
profile: ['meaning'],
- erase: ['engagement', 'rhythm', 'meaning'],
+ ask: ['wish'],
+ birth: ['tradition'],
+ erase: ['engagement', 'rhythm', 'meaning', 'taste', 'wish', 'tradition'],
};
// ── Chapter 3 · Meaning (illustrative embedding) ──────────────────────────────
@@ -187,3 +206,200 @@ export function composeProfileText(bio: string, interests: readonly string[], pr
for (const p of prompts) if (p.trim()) parts.push(p.trim());
return parts.join('\n');
}
+
+// ── Chapter 3 · Taste (inferred suggestions: who you keep liking) ─────────────
+
+export const TASTE_RECENT_LIKES = 50;
+export const TASTE_MIN_LIKES = 3;
+export const TASTE_MAX_INCLINATIONS = 3;
+export const TASTE_MIN_CLUSTER = 3;
+export const TASTE_FALLBACK_REASON = "Similar to people you've been liking";
+
+/** About ten likes per inclination before OneHook splits them: clamp(likes / 10, 1, max). */
+export function inclinationCount(likes: number, max = TASTE_MAX_INCLINATIONS): number {
+ return Math.max(1, Math.min(max, Math.floor(likes / 10)));
+}
+
+function dot(a: readonly number[], b: readonly number[]): number {
+ let s = 0;
+ for (let i = 0; i < Math.min(a.length, b.length); i += 1) s += a[i] * b[i];
+ return s;
+}
+
+function normalize(v: number[]): number[] {
+ const norm = Math.sqrt(dot(v, v));
+ return norm ? v.map((x) => x / norm) : v;
+}
+
+/**
+ * Spherical k-means over unit vectors (cosine). Deterministic: centroids are seeded farthest-first,
+ * starting from the most recent point (index 0). Returns each non-empty cluster's member indices.
+ */
+export function clusterInclinations(points: readonly (readonly number[])[], k: number): number[][] {
+ if (!points.length) return [];
+ const kk = Math.max(1, Math.min(k, points.length));
+ const centroids: number[][] = [normalize([...points[0]])];
+ while (centroids.length < kk) {
+ let far = -1;
+ let worst = Number.POSITIVE_INFINITY;
+ points.forEach((p, i) => {
+ const bestSim = Math.max(...centroids.map((c) => dot(p, c)));
+ if (bestSim < worst) {
+ worst = bestSim;
+ far = i;
+ }
+ });
+ centroids.push(normalize([...points[far]]));
+ }
+
+ const assign = new Array(points.length).fill(0);
+ for (let iter = 0; iter < 10; iter += 1) {
+ let changed = false;
+ points.forEach((p, i) => {
+ let best = 0;
+ let bestSim = Number.NEGATIVE_INFINITY;
+ centroids.forEach((c, ci) => {
+ const sim = dot(p, c);
+ if (sim > bestSim) {
+ bestSim = sim;
+ best = ci;
+ }
+ });
+ if (iter === 0 || assign[i] !== best) {
+ changed = changed || iter > 0;
+ assign[i] = best;
+ }
+ });
+ centroids.forEach((_, ci) => {
+ const sum = new Array(points[0].length).fill(0);
+ let n = 0;
+ points.forEach((p, i) => {
+ if (assign[i] !== ci) return;
+ p.forEach((x, d) => (sum[d] += x));
+ n += 1;
+ });
+ if (n > 0) centroids[ci] = normalize(sum);
+ });
+ if (iter > 0 && !changed) break;
+ }
+
+ return centroids
+ .map((_, ci) => assign.flatMap((a, i) => (a === ci ? [i] : [])))
+ .filter((members) => members.length > 0);
+}
+
+/** "You've been liking people into hiking and reading", from the interests a cluster shares. */
+export function inclinationReasoning(members: readonly (readonly string[])[]): string {
+ const counts = new Map();
+ for (const interests of members) {
+ for (const i of new Set(interests.map((x) => x.trim().toLowerCase()).filter(Boolean))) {
+ counts.set(i, (counts.get(i) ?? 0) + 1);
+ }
+ }
+ const threshold = Math.max(2, Math.ceil(members.length * 0.4));
+ const names = [...counts.entries()]
+ .filter(([, n]) => n >= threshold)
+ .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
+ .slice(0, 2)
+ .map(([name]) => name);
+ return names.length ? `You've been liking people into ${names.join(' and ')}` : TASTE_FALLBACK_REASON;
+}
+
+export interface LikedProfile {
+ interests: readonly string[];
+ vector: readonly number[];
+}
+
+export interface Inclination {
+ /** Indices into the (most recent first) likes passed in. */
+ members: number[];
+ reasoning: string;
+}
+
+/** Recent likes (most recent first) → inclinations, largest first. Fewer than 3 likes: none yet. */
+export function inferInclinations(likes: readonly LikedProfile[]): Inclination[] {
+ const recent = likes.slice(0, TASTE_RECENT_LIKES);
+ if (recent.length < TASTE_MIN_LIKES) return [];
+ const k = inclinationCount(recent.length);
+ return clusterInclinations(
+ recent.map((l) => l.vector),
+ k
+ )
+ .filter((m) => m.length >= Math.min(TASTE_MIN_CLUSTER, recent.length))
+ .sort((a, b) => b.length - a.length)
+ .map((members) => ({ members, reasoning: inclinationReasoning(members.map((i) => recent[i].interests)) }));
+}
+
+// ── Chapter 5 · One deck, two finders ─────────────────────────────────────────
+
+export type MatchFinder = 'ONEHOOK_INTELLIGENCE' | 'MR_ONEHOOK';
+/** Default share of the "For you" deck each finder fills. */
+export const DECK_SHARES: Record = { ONEHOOK_INTELLIGENCE: 0.7, MR_ONEHOOK: 0.3 };
+
+/**
+ * Weighted interleave: each position goes to the finder furthest below its share; ties go to the
+ * finder listed first (higher priority). A finder that runs dry drops out and the others backfill.
+ */
+export function mixDeck(
+ sources: readonly { finder: MatchFinder; share: number; available: number }[],
+ limit: number
+): MatchFinder[] {
+ const live = sources.filter((s) => s.share > 0);
+ const total = live.reduce((sum, s) => sum + s.share, 0);
+ if (total <= 0) return [];
+ const taken = live.map(() => 0);
+ const out: MatchFinder[] = [];
+ while (out.length < limit) {
+ let next = -1;
+ let bestDeficit = Number.NEGATIVE_INFINITY;
+ const position = out.length + 1;
+ live.forEach((s, i) => {
+ if (taken[i] >= s.available) return;
+ const deficit = position * (s.share / total) - taken[i];
+ if (deficit > bestDeficit + 1e-9) {
+ bestDeficit = deficit;
+ next = i;
+ }
+ });
+ if (next < 0) break;
+ out.push(live[next].finder);
+ taken[next] += 1;
+ }
+ return out;
+}
+
+// ── Chapter 3 · Tradition (traditional compatibility from birth details) ──────
+
+/** What both people have added. Time always comes with a time zone; place adds coordinates. */
+export type BirthDetailLevel = 'none' | 'date' | 'time' | 'place';
+/**
+ * How often the engine can compare a system at a level. Measured by running the production
+ * CosmicChartCalculator + TraditionalComparisonEngine on 400 random pairs per level: always ≥ 99%,
+ * usually 60 to 98%, sometimes 15 to 59%, never 0%. (A placement is only kept when it is the same
+ * across the whole uncertainty window of the birth moment, so a date alone cannot pin the Moon.)
+ */
+export type Availability = 'always' | 'usually' | 'sometimes' | 'never';
+
+export interface TraditionalSystemInfo {
+ id: string;
+ name: string;
+ tradition: string;
+ needs: Record, Availability>;
+}
+
+/** All 13 systems, in the order of OneHook's master comparison table. */
+export const TRADITIONAL_SYSTEMS: readonly TraditionalSystemInfo[] = [
+ { id: 'vedic', name: 'Kundali', tradition: 'Hindu', needs: { date: 'never', time: 'always', place: 'always' } },
+ { id: 'western', name: 'Natal chart', tradition: 'Western', needs: { date: 'always', time: 'always', place: 'always' } },
+ { id: 'bazi', name: 'Bazi', tradition: 'Chinese', needs: { date: 'always', time: 'always', place: 'always' } },
+ { id: 'ziwei', name: 'Zi Wei Dou Shu', tradition: 'Chinese', needs: { date: 'never', time: 'always', place: 'always' } },
+ { id: 'tzolkin', name: 'Tzolk’in', tradition: 'Mayan', needs: { date: 'always', time: 'always', place: 'always' } },
+ { id: 'tibetan', name: 'Elements', tradition: 'Tibetan', needs: { date: 'always', time: 'always', place: 'always' } },
+ { id: 'hebrew', name: 'Mazal', tradition: 'Hebrew', needs: { date: 'usually', time: 'always', place: 'always' } },
+ { id: 'arabic', name: 'Arabic Parts', tradition: 'Islamic', needs: { date: 'never', time: 'never', place: 'always' } },
+ { id: 'buddhist', name: 'Zodiac cycle', tradition: 'Buddhist', needs: { date: 'always', time: 'always', place: 'always' } },
+ { id: 'celtic', name: 'Tree astrology', tradition: 'Celtic', needs: { date: 'always', time: 'always', place: 'always' } },
+ { id: 'egyptian', name: 'Deity zodiac', tradition: 'Egyptian', needs: { date: 'always', time: 'always', place: 'always' } },
+ { id: 'hellenistic', name: 'Hellenistic chart', tradition: 'Greek', needs: { date: 'sometimes', time: 'always', place: 'always' } },
+ { id: 'babylonian', name: 'Decans', tradition: 'Babylonian', needs: { date: 'usually', time: 'always', place: 'always' } },
+];
diff --git a/src/config/seo-routes.ts b/src/config/seo-routes.ts
index b39439e..6bf8605 100644
--- a/src/config/seo-routes.ts
+++ b/src/config/seo-routes.ts
@@ -140,7 +140,7 @@ export const ROUTE_SEO: Record = {
'/science': {
title: 'The Science of One — How OneHook Dating Works',
description:
- 'How OneHook matches: reading intent, rhythm and meaning in real conversations so two people build one genuine connection instead of swiping endlessly.',
+ 'How OneHook turns attention, timing, profile meaning and what you ask for into considered introductions, while your match chats stay private.',
},
'/pricing': {
title: 'OneHook Pricing — Free, Super & Infinity Dating Plans',
diff --git a/src/index.css b/src/index.css
index 82eb9df..0b1bf63 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,47 +1,58 @@
-@import "tailwindcss";
+@import 'tailwindcss';
@custom-variant dark (&:where(.app-theme-dark, .app-theme-dark *));
+/* Side-by-side layout for wide screens and for short landscape screens (tablets, laptops with a
+ docked window), where a stacked sticky diagram would leave no room for the text. */
+@custom-variant split {
+ @media (min-width: 1024px), (min-width: 560px) and (min-aspect-ratio: 5/4) {
+ @slot;
+ }
+}
/* Original theme for all non-app routes (Landing, Login, Careers, Legal, Onboarding) */
:root {
- --color-bg: #FDFDFD;
- --color-text: #1A1A1A;
- --color-border: #EBEBEB;
- --color-accent: #1A1A1A;
- --color-status-hooked: #22C55E;
-
- --color-surface: #FAFAFA;
- --color-surface-card: #FFFFFF;
- --color-surface-hover: #F2F2F2;
+ --color-bg: #fdfdfd;
+ --color-text: #1a1a1a;
+ --color-border: #ebebeb;
+ --color-accent: #1a1a1a;
+ --color-status-hooked: #22c55e;
+
+ --color-surface: #fafafa;
+ --color-surface-card: #ffffff;
+ --color-surface-hover: #f2f2f2;
--color-text-secondary: #737373;
- --color-text-muted: #8E8E8E;
- --color-tab-active: #1A1A1A;
+ --color-text-muted: #8e8e8e;
+ --color-tab-active: #1a1a1a;
--color-tab-inactive: #737373;
- --color-ig-blue: #0095F6;
+ --color-ig-blue: #0095f6;
}
@keyframes oh-flip {
- from { transform: rotateY(0deg); }
- to { transform: rotateY(360deg); }
+ from {
+ transform: rotateY(0deg);
+ }
+ to {
+ transform: rotateY(360deg);
+ }
}
/* App Route Scoped Themes: Light Mode */
.app-theme-light {
- --color-bg: #FFFFFF;
- --color-surface: #FAFAFA;
- --color-surface-card: #FFFFFF;
- --color-surface-hover: #F2F2F2;
+ --color-bg: #ffffff;
+ --color-surface: #fafafa;
+ --color-surface-card: #ffffff;
+ --color-surface-hover: #f2f2f2;
--color-text: #262626;
--color-text-secondary: #737373;
- --color-text-muted: #8E8E8E;
- --color-border: #DBDBDB;
- --color-border-subtle: #EFEFEF;
+ --color-text-muted: #8e8e8e;
+ --color-border: #dbdbdb;
+ --color-border-subtle: #efefef;
--color-accent: #262626;
--color-accent-hover: #000000;
--color-tab-active: #262626;
--color-tab-inactive: #737373;
- --color-status-hooked: #22C55E;
- --color-ig-blue: #0095F6;
+ --color-status-hooked: #22c55e;
+ --color-ig-blue: #0095f6;
color-scheme: light;
}
@@ -50,25 +61,25 @@
--color-bg: #000000;
--color-surface: #121212;
--color-surface-card: #000000;
- --color-surface-hover: #1A1A1A;
- --color-text: #F5F5F5;
- --color-text-secondary: #A8A8A8;
+ --color-surface-hover: #1a1a1a;
+ --color-text: #f5f5f5;
+ --color-text-secondary: #a8a8a8;
--color-text-muted: #737373;
--color-border: #262626;
- --color-border-subtle: #1C1C1C;
- --color-accent: #F5F5F5;
- --color-accent-hover: #FFFFFF;
- --color-tab-active: #F5F5F5;
- --color-tab-inactive: #A8A8A8;
- --color-status-hooked: #22C55E;
- --color-ig-blue: #0095F6;
+ --color-border-subtle: #1c1c1c;
+ --color-accent: #f5f5f5;
+ --color-accent-hover: #ffffff;
+ --color-tab-active: #f5f5f5;
+ --color-tab-inactive: #a8a8a8;
+ --color-status-hooked: #22c55e;
+ --color-ig-blue: #0095f6;
color-scheme: dark;
}
@theme {
- --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
- --font-serif: "Playfair Display", serif;
- --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
+ --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
+ --font-serif: 'Playfair Display', serif;
+ --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
--color-bg: var(--color-bg);
--color-surface: var(--color-surface);
@@ -91,7 +102,7 @@
html {
min-height: 100%;
- background: #FDFDFD;
+ background: #fdfdfd;
color-scheme: light;
scroll-behavior: smooth;
}
@@ -108,8 +119,8 @@ html {
body {
min-height: 100vh;
margin: 0;
- background-color: #FDFDFD;
- color: #1A1A1A;
+ background-color: #fdfdfd;
+ color: #1a1a1a;
@apply font-sans antialiased selection:bg-accent selection:text-white;
}
@@ -154,27 +165,67 @@ body {
* settles. Discrete steps read as "glitch" rather than "fade"; under 350 ms keeps it quick. The
* chroma uses drop-shadow (not text-shadow) because the accent is background-clipped text. */
@keyframes oh-glitch-in {
- 0% { clip-path: inset(var(--g-a1, 42%) 0 var(--g-b1, 38%) 0); transform: translateX(calc(var(--g-x, 0.05em) * -1)); filter: drop-shadow(var(--g-c, 0.05em) 0 rgba(255, 60, 110, 0.7)) drop-shadow(calc(var(--g-c, 0.05em) * -1) 0 rgba(60, 220, 255, 0.7)); }
- 18% { clip-path: inset(0 0 var(--g-a2, 62%) 0); transform: translateX(var(--g-x, 0.05em)); }
- 36% { clip-path: inset(var(--g-b2, 58%) 0 0 0); transform: translateX(calc(var(--g-x, 0.05em) * -0.5)); filter: drop-shadow(calc(var(--g-c, 0.05em) * -0.8) 0 rgba(255, 60, 110, 0.6)) drop-shadow(calc(var(--g-c, 0.05em) * 0.8) 0 rgba(60, 220, 255, 0.6)); }
- 54% { clip-path: inset(var(--g-b1, 18%) 0 var(--g-a1, 30%) 0); transform: translateX(calc(var(--g-x, 0.05em) * 0.35)); }
- 72% { clip-path: inset(-20% -10%); transform: translateX(calc(var(--g-x, 0.05em) * -0.15)); filter: drop-shadow(calc(var(--g-c, 0.05em) * 0.4) 0 rgba(255, 60, 110, 0.4)) drop-shadow(calc(var(--g-c, 0.05em) * -0.4) 0 rgba(60, 220, 255, 0.4)); }
- 100% { clip-path: none; transform: none; filter: none; }
+ 0% {
+ clip-path: inset(var(--g-a1, 42%) 0 var(--g-b1, 38%) 0);
+ transform: translateX(calc(var(--g-x, 0.05em) * -1));
+ filter: drop-shadow(var(--g-c, 0.05em) 0 rgba(255, 60, 110, 0.7))
+ drop-shadow(calc(var(--g-c, 0.05em) * -1) 0 rgba(60, 220, 255, 0.7));
+ }
+ 18% {
+ clip-path: inset(0 0 var(--g-a2, 62%) 0);
+ transform: translateX(var(--g-x, 0.05em));
+ }
+ 36% {
+ clip-path: inset(var(--g-b2, 58%) 0 0 0);
+ transform: translateX(calc(var(--g-x, 0.05em) * -0.5));
+ filter: drop-shadow(calc(var(--g-c, 0.05em) * -0.8) 0 rgba(255, 60, 110, 0.6))
+ drop-shadow(calc(var(--g-c, 0.05em) * 0.8) 0 rgba(60, 220, 255, 0.6));
+ }
+ 54% {
+ clip-path: inset(var(--g-b1, 18%) 0 var(--g-a1, 30%) 0);
+ transform: translateX(calc(var(--g-x, 0.05em) * 0.35));
+ }
+ 72% {
+ clip-path: inset(-20% -10%);
+ transform: translateX(calc(var(--g-x, 0.05em) * -0.15));
+ filter: drop-shadow(calc(var(--g-c, 0.05em) * 0.4) 0 rgba(255, 60, 110, 0.4))
+ drop-shadow(calc(var(--g-c, 0.05em) * -0.4) 0 rgba(60, 220, 255, 0.4));
+ }
+ 100% {
+ clip-path: none;
+ transform: none;
+ filter: none;
+ }
}
/* The plain word snaps back with a single short jitter so the hand-off feels like one event. */
@keyframes oh-glitch-out {
- 0% { clip-path: inset(0 0 55% 0); transform: translateX(0.04em); }
- 40% { clip-path: inset(50% 0 0 0); transform: translateX(-0.03em); }
- 100% { clip-path: none; transform: none; }
+ 0% {
+ clip-path: inset(0 0 55% 0);
+ transform: translateX(0.04em);
+ }
+ 40% {
+ clip-path: inset(50% 0 0 0);
+ transform: translateX(-0.03em);
+ }
+ 100% {
+ clip-path: none;
+ transform: none;
+ }
}
-.oh-glitch-in { animation: oh-glitch-in 340ms steps(1, end) both; }
-.oh-glitch-out { animation: oh-glitch-out 200ms steps(1, end) both; }
+.oh-glitch-in {
+ animation: oh-glitch-in 340ms steps(1, end) both;
+}
+.oh-glitch-out {
+ animation: oh-glitch-out 200ms steps(1, end) both;
+}
@media (prefers-reduced-motion: reduce) {
.oh-glitch-in,
- .oh-glitch-out { animation: none; }
+ .oh-glitch-out {
+ animation: none;
+ }
}
/* ── Heartbeat (Science page finale) ─────────────────────────────────────────
@@ -188,34 +239,84 @@ body {
@keyframes oh-heartbeat {
/* lub: ~130 ms swell, ~110 ms release */
- 0% { transform: scale(1); animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
- 14% { transform: scale(1.045); animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
- 26% { transform: scale(0.998); animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
+ 0% {
+ transform: scale(1);
+ animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
+ }
+ 14% {
+ transform: scale(1.045);
+ animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
+ }
+ 26% {
+ transform: scale(0.998);
+ animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
+ }
/* dub: softer, then a long relaxed rest */
- 37% { transform: scale(1.022); animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
- 56%, 100% { transform: scale(1); }
+ 37% {
+ transform: scale(1.022);
+ animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
+ }
+ 56%,
+ 100% {
+ transform: scale(1);
+ }
}
@keyframes oh-heartglow {
- 0% { opacity: 0.18; animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
- 14% { opacity: 0.62; animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
- 26% { opacity: 0.3; animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
- 37% { opacity: 0.45; animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
- 70%, 100% { opacity: 0.18; }
+ 0% {
+ opacity: 0.18;
+ animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
+ }
+ 14% {
+ opacity: 0.62;
+ animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
+ }
+ 26% {
+ opacity: 0.3;
+ animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
+ }
+ 37% {
+ opacity: 0.45;
+ animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
+ }
+ 70%,
+ 100% {
+ opacity: 0.18;
+ }
}
@keyframes oh-heart-ripple {
- 0% { transform: scale(0.42); opacity: 0; }
- 2.25% { opacity: 0.55; }
- 100% { transform: scale(5.2); opacity: 0; }
+ 0% {
+ transform: scale(0.42);
+ opacity: 0;
+ }
+ 2.25% {
+ opacity: 0.55;
+ }
+ 100% {
+ transform: scale(5.2);
+ opacity: 0;
+ }
}
/* The photo breathes with the beat, very slightly. */
@keyframes oh-photo-pulse {
- 0% { transform: scale(1); animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
- 14% { transform: scale(1.006); animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
- 37% { transform: scale(1.003); animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
- 70%, 100% { transform: scale(1); }
+ 0% {
+ transform: scale(1);
+ animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
+ }
+ 14% {
+ transform: scale(1.006);
+ animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
+ }
+ 37% {
+ transform: scale(1.003);
+ animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
+ }
+ 70%,
+ 100% {
+ transform: scale(1);
+ }
}
.oh-heartbeat,
@@ -226,9 +327,18 @@ body {
animation-iteration-count: infinite;
will-change: transform, opacity;
}
-.oh-heartbeat { animation-name: oh-heartbeat; transform-box: fill-box; transform-origin: center; }
-.oh-heartglow { animation-name: oh-heartglow; }
-.oh-photo-pulse { animation-name: oh-photo-pulse; transform-origin: var(--oh-origin, 50% 30%); }
+.oh-heartbeat {
+ animation-name: oh-heartbeat;
+ transform-box: fill-box;
+ transform-origin: center;
+}
+.oh-heartglow {
+ animation-name: oh-heartglow;
+}
+.oh-photo-pulse {
+ animation-name: oh-photo-pulse;
+ transform-origin: var(--oh-origin, 50% 30%);
+}
.oh-heart-ripple {
animation-name: oh-heart-ripple;
animation-duration: calc(var(--oh-beat) * 4);
@@ -248,42 +358,71 @@ body {
.oh-photo-pulse {
animation: none;
}
- .oh-heart-ripple { opacity: 0; }
+ .oh-heart-ripple {
+ opacity: 0;
+ }
}
/* Polished-chrome capsule around the header's Dating Science / Private Messenger links. */
.oh-chrome-pill {
border-color: rgba(154, 161, 171, 0.65);
- background: linear-gradient(180deg, #FFFFFF 0%, #EEF0F3 38%, #D4D8DE 52%, #E9ECEF 70%, #FFFFFF 100%);
+ background: linear-gradient(
+ 180deg,
+ #ffffff 0%,
+ #eef0f3 38%,
+ #d4d8de 52%,
+ #e9ecef 70%,
+ #ffffff 100%
+ );
box-shadow:
- inset 0 1px 0 #FFFFFF,
+ inset 0 1px 0 #ffffff,
inset 0 -1px 0 rgba(10, 11, 16, 0.08),
0 1px 2px rgba(10, 11, 16, 0.08),
0 6px 16px -8px rgba(31, 58, 107, 0.35);
}
.oh-chrome-shine {
- background: linear-gradient(100deg, transparent 0%, rgba(255, 255, 255, 0.95) 50%, transparent 100%);
+ background: linear-gradient(
+ 100deg,
+ transparent 0%,
+ rgba(255, 255, 255, 0.95) 50%,
+ transparent 100%
+ );
transform: skewX(-20deg);
animation: oh-chrome-sweep 4.8s ease-in-out infinite;
}
/* Each half fills its side edge to edge; the capsule's rounded border clips it, so a hovered half
is rounded on the outside and straight at the divider. Hover = a brighter, deeper chrome band. */
.oh-chrome-seg {
- transition: background 200ms ease, color 200ms ease;
+ transition:
+ background 200ms ease,
+ color 200ms ease;
}
.oh-chrome-seg:hover,
.oh-chrome-seg:focus-visible {
/* edgeless navy glow rising from the bottom of the half — no hard lines, follows the capsule */
- background: radial-gradient(85% 120% at 50% 110%, rgba(31, 58, 107, 0.18) 0%, rgba(31, 58, 107, 0.06) 55%, transparent 80%);
- color: #1F3A6B;
+ background: radial-gradient(
+ 85% 120% at 50% 110%,
+ rgba(31, 58, 107, 0.18) 0%,
+ rgba(31, 58, 107, 0.06) 55%,
+ transparent 80%
+ );
+ color: #1f3a6b;
outline: none;
}
@keyframes oh-chrome-sweep {
- 0%, 62% { left: -50%; }
- 100% { left: 130%; }
+ 0%,
+ 62% {
+ left: -50%;
+ }
+ 100% {
+ left: 130%;
+ }
}
@media (prefers-reduced-motion: reduce) {
- .oh-chrome-shine { animation: none; display: none; }
+ .oh-chrome-shine {
+ animation: none;
+ display: none;
+ }
}
/* Messenger lighter mode (phones / low-memory devices): frosted glass and blur halos are the costliest
@@ -299,7 +438,7 @@ body {
background: radial-gradient(closest-side, rgba(10, 11, 16, 0.22), transparent);
}
.oh-lite .blur-3xl,
-.oh-lite [class*="blur-[1.5px]"] {
+.oh-lite [class*='blur-[1.5px]'] {
filter: none;
}
/* the faint white halo around the mock phones is invisible on the light page anyway */
@@ -312,3 +451,27 @@ body {
filter: none !important; /* beats the element's inline blur */
opacity: 0.25;
}
+
+/* Science hero: compositor-only motion for the tilted orbit disc (ScienceHero.tsx). */
+@keyframes oh-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+.oh-spin {
+ animation: oh-spin 120s linear infinite;
+ will-change: transform;
+}
+@keyframes oh-ping {
+ from {
+ transform: scale(1);
+ opacity: 0.5;
+ }
+ to {
+ transform: scale(1.4);
+ opacity: 0;
+ }
+}
+.oh-ping {
+ animation: oh-ping 2.8s ease-out infinite;
+}
diff --git a/src/tests/messenger-story-timeline.test.ts b/src/tests/messenger-story-timeline.test.ts
index b73dd1c..9acb703 100644
--- a/src/tests/messenger-story-timeline.test.ts
+++ b/src/tests/messenger-story-timeline.test.ts
@@ -4,10 +4,10 @@ import {
bandIndex,
createStoryPlayer,
MIN_SHOW_MS,
- SHOW_MAX_MS,
- SHOW_MIN_MS,
- showDwellMs,
+ isRepeatedAttempt,
isStoryComplete,
+ NUDGE_AFTER_MS,
+ NUDGE_RESET_MS,
makeEqualBands,
scrollLimit,
storyTrackHeight,
@@ -22,7 +22,6 @@ const mid = (i: number) => (i + 0.5) / 8;
/** A player on fake time: runs complete when the clock passes their duration. */
function harness(startAt = 0) {
let p = startAt;
- let speed = 0; // screens per second
const value = () => ({ v: 0, set(x: number) { this.v = x; } });
const play = Array.from({ length: 8 }, value);
const line = Array.from({ length: 8 }, value);
@@ -34,7 +33,6 @@ function harness(startAt = 0) {
play,
line,
progress: () => p,
- speed: () => speed,
now: () => Date.now(),
run: (target, duration, onComplete) => {
const id = setTimeout(() => {
@@ -55,15 +53,11 @@ function harness(startAt = 0) {
line,
changes,
skips,
- /** The reader scrolls to progress `to`, currently at `screensPerSecond`. */
- scrollTo(to: number, screensPerSecond = speed) {
+ /** The reader scrolls to progress `to`. */
+ scrollTo(to: number) {
p = to;
- speed = screensPerSecond;
player.onScroll();
},
- setSpeed(screensPerSecond: number) {
- speed = screensPerSecond;
- },
};
}
@@ -105,24 +99,40 @@ describe('Messenger story timeline', () => {
expect(h.changes.at(-1)).toEqual({ index: 2, instant: false });
});
- it('switches straight back without animating when scrolling up, and never replays', () => {
+ it('scrolling back up leaves the stage on the furthest screen, finished, and never replays it', () => {
const h = harness(mid(2));
h.player.begin();
vi.advanceTimersByTime(6100);
h.scrollTo(mid(3));
expect(h.changes.at(-1)).toEqual({ index: 3, instant: false });
- // up, mid-animation (after its 2 s): screen 4 counts as loaded, screen 3 shows finished
- vi.advanceTimersByTime(MIN_SHOW_MS);
- h.scrollTo(mid(2));
- expect(h.changes.at(-1)).toEqual({ index: 2, instant: true });
+ vi.advanceTimersByTime(300); // mid-way through screen 4's first play
+ const before = h.changes.length;
+ // up: the page shows the loaded screens; the stage stays on screen 4 and finishes it at once
+ h.scrollTo(mid(1));
+ expect(h.player.index).toBe(3);
expect(h.play[3].v).toBe(1);
expect(h.player.playingScene).toBeNull();
- expect(h.skips.at(-1)).toBe(true);
- // down again: screen 4 has loaded once already, so it is simply shown (after screen 3's 2 s)
+ // back down to it: nothing replays, nothing switches
h.scrollTo(mid(3));
- vi.advanceTimersByTime(MIN_SHOW_MS);
- expect(h.changes.at(-1)).toEqual({ index: 3, instant: true });
- expect(h.player.playingScene).toBeNull();
+ expect(h.player.index).toBe(3);
+ expect(h.changes.length).toBe(before);
+ });
+
+ it('after scrolling back, the next new screen still gets the first-play rules', () => {
+ const h = harness(mid(2));
+ h.player.begin();
+ vi.advanceTimersByTime(6100);
+ h.scrollTo(mid(3));
+ vi.advanceTimersByTime(300);
+ h.scrollTo(mid(0));
+ h.scrollTo(mid(4)); // screen 4 has been on stage only 0.3 s: it keeps its minimum time
+ expect(h.player.index).toBe(3);
+ vi.advanceTimersByTime(MIN_SHOW_MS - 300 - 1);
+ expect(h.player.index).toBe(3);
+ vi.advanceTimersByTime(1);
+ expect(h.player.index).toBe(4);
+ expect(h.changes.at(-1)).toEqual({ index: 4, instant: false }); // a first play, animated
+ expect(h.player.playingScene).toBe(4);
});
it('never skips a screen: a fast scroll from the top plays every screen in order, each in full', () => {
@@ -147,63 +157,14 @@ describe('Messenger story timeline', () => {
expect(h.changes.every((c) => !c.instant)).toBe(true); // all first visits: all played
});
- it('once loaded, how long each screen stays follows the scroll speed, never under the minimum', () => {
- expect(showDwellMs(0)).toBe(SHOW_MAX_MS); // not moving: the full time
- expect(showDwellMs(0.25)).toBe(SHOW_MAX_MS);
- expect(showDwellMs(1)).toBe(1000); // a screen a second: a second each
- expect(showDwellMs(-1)).toBe(1000); // up is the same as down
- expect(showDwellMs(10)).toBe(SHOW_MIN_MS); // however fast: never below the minimum
- });
-
- it('a fast scroll through loaded screens steps one by one, each for the minimum', () => {
- const h = harness(mid(0));
- h.player.begin();
- h.scrollTo(mid(5));
- vi.advanceTimersByTime(60_000); // screens 1–6 have all played
- expect(h.player.index).toBe(5);
- h.changes.length = 0;
-
- h.scrollTo(mid(1), 6); // fast, all the way up
- expect(h.player.index).toBe(4); // one step, at once
- for (const expected of [3, 2, 1]) {
- vi.advanceTimersByTime(SHOW_MIN_MS - 1);
- expect(h.player.index).toBe(expected + 1);
- vi.advanceTimersByTime(1);
- expect(h.player.index).toBe(expected);
- }
- expect(h.changes.map((c) => c.index)).toEqual([4, 3, 2, 1]);
- expect(h.changes.every((c) => c.instant)).toBe(true); // shown finished, never replayed
- });
-
- it('a slower scroll gives each loaded screen longer, and speeding up mid-wait moves on sooner', () => {
- const h = harness(mid(0));
- h.player.begin();
- h.scrollTo(mid(5));
- vi.advanceTimersByTime(60_000);
- h.scrollTo(mid(2), 0.8); // gently up: 1 / 0.8 = 1.25 s a screen
- expect(h.player.index).toBe(4);
- vi.advanceTimersByTime(1249);
- expect(h.player.index).toBe(4);
- vi.advanceTimersByTime(1);
- expect(h.player.index).toBe(3);
- // 300 ms into screen 4, the reader speeds up: it only needs the minimum now
- vi.advanceTimersByTime(300);
- h.scrollTo(mid(2), 8);
- vi.advanceTimersByTime(SHOW_MIN_MS - 300 - 1);
- expect(h.player.index).toBe(3);
- vi.advanceTimersByTime(1);
- expect(h.player.index).toBe(2);
- });
-
- it('first plays are not shortened by scroll speed', () => {
+ it('first plays are never shortened by scrolling on', () => {
const h = harness(mid(6));
h.player.begin();
- h.scrollTo(mid(7), 10); // screen 8 has never played: screen 7 plays its full 3.9 s first
+ h.scrollTo(mid(7)); // screen 8 has never played: screen 7 plays its full 3.9 s first
vi.advanceTimersByTime(3899);
expect(h.player.index).toBe(6);
vi.advanceTimersByTime(1);
expect(h.player.index).toBe(7);
- expect(MIN_SHOW_MS).toBeGreaterThan(SHOW_MIN_MS);
});
it('runs the progress line on the same clock: animation done at 70%, the rest is look time', () => {
@@ -228,6 +189,14 @@ describe('Messenger story timeline', () => {
expect(scrollLimit(bands, 7)).toBeNull();
});
+ it('only counts repeated, sustained scroll attempts against the hold as "trying repeatedly"', () => {
+ expect(isRepeatedAttempt([])).toBe(false);
+ expect(isRepeatedAttempt([0, 16, 32, 48])).toBe(false); // one quick flick
+ expect(isRepeatedAttempt([0, 200, 400, NUDGE_AFTER_MS])).toBe(true); // kept trying
+ // a pause longer than the reset starts the count again
+ expect(isRepeatedAttempt([0, NUDGE_AFTER_MS, NUDGE_AFTER_MS + NUDGE_RESET_MS + 1])).toBe(false);
+ });
+
it('stops cleanly', () => {
const h = harness(mid(1));
h.player.begin();
diff --git a/src/tests/science-model.test.ts b/src/tests/science-model.test.ts
index 06c68d6..2dd086c 100644
--- a/src/tests/science-model.test.ts
+++ b/src/tests/science-model.test.ts
@@ -90,11 +90,13 @@ describe('deckPacing (behavioral analyzers + defaults)', () => {
describe('ROUTES (Pub/Sub subscription filters)', () => {
it('matches the per-engine event filters, with erasure reaching every engine', () => {
expect(ROUTES.view).toEqual(['engagement']);
- expect(ROUTES.swipe).toEqual(['engagement', 'rhythm']);
+ expect(ROUTES.swipe).toEqual(['engagement', 'rhythm', 'taste']);
expect(ROUTES.session).toEqual(['engagement', 'rhythm']);
expect(ROUTES.chat).toEqual(['engagement']);
expect(ROUTES.profile).toEqual(['meaning']);
- expect(ROUTES.erase).toEqual(['engagement', 'rhythm', 'meaning']);
+ expect(ROUTES.ask).toEqual(['wish']);
+ expect(ROUTES.birth).toEqual(['tradition']);
+ expect(ROUTES.erase).toEqual(['engagement', 'rhythm', 'meaning', 'taste', 'wish', 'tradition']);
});
});