// App v5 — same architecture as v3/v4, new section order: // Hero → Invitation → Pillars → Card Draw → Story (bio) → Why the Raven → Bridge → Gatherings → Contact → Footer const { useEffect: useEffectA5, useState: useStateA5, useCallback: useCallbackA5 } = React; const THEME_KEY_V5 = 'up_v5_theme'; function AppV5() { // Default twilight; load from localStorage if user has changed it const [theme, setTheme] = useStateA5(() => { try { const stored = localStorage.getItem(THEME_KEY_V5); return stored === 'parchment' ? 'parchment' : 'twilight'; } catch { return 'twilight'; } }); useEffectA5(() => { document.body.dataset.theme = theme; try { localStorage.setItem(THEME_KEY_V5, theme); } catch {} }, [theme]); const toggleTheme = useCallbackA5(() => { setTheme(t => t === 'twilight' ? 'parchment' : 'twilight'); }, []); // Fade-in observer useEffectA5(() => { document.documentElement.setAttribute('data-fadein-ready', '1'); const els = document.querySelectorAll('.fade-in'); els.forEach(el => { const r = el.getBoundingClientRect(); if (r.top < window.innerHeight && r.bottom > 0) { el.classList.add('visible'); } }); const io = new IntersectionObserver((entries) => { entries.forEach(e => { if (e.isIntersecting) { e.target.classList.add('visible'); io.unobserve(e.target); } }); }, { threshold: 0, rootMargin: '0px 0px -5% 0px' }); els.forEach(el => { if (!el.classList.contains('visible')) io.observe(el); }); const safety = setTimeout(() => { document.querySelectorAll('.fade-in:not(.visible)').forEach(el => el.classList.add('visible')); }, 3000); return () => { io.disconnect(); clearTimeout(safety); }; }, []); // Smooth scroll on in-page anchor clicks useEffectA5(() => { const onClick = (e) => { const a = e.target.closest('a[href^="#"]'); if (!a) return; const id = a.getAttribute('href').slice(1); if (!id) return; const target = document.getElementById(id); if (!target) return; e.preventDefault(); const top = target.getBoundingClientRect().top + window.scrollY - 80; window.scrollTo({ top, behavior: 'smooth' }); try { history.replaceState(null, '', '#' + id); } catch {} }; document.addEventListener('click', onClick); return () => document.removeEventListener('click', onClick); }, []); // =========================================================================== // Perching raven (v7) — sprite-driven state machine // --------------------------------------------------------------------------- // Six animation states, each backed by real artwork frames: // PERCHED — bird is sitting on a section title (idle) // PERCHED_IDLE — bird occasionally turns its head (headturn sheet) // TAKEOFF — perched → airborne (takeoff sheet, 4 frames, plays once) // CRUISING — flying along a path (interleaved flap + downflap, loops) // BRAKING — approaching perch, slowing down (brake sheet 0-1) // LANDING — final descent to perch (brake sheet 2-3) // // Position is still rAF-driven with smoothing (kept from v6 — that part // worked well). What changed: // - The wing animation is no longer a CSS scale-Y squash — it's real // hand-drawn frames swapped on .raven-frame.src // - Direction-aware: when flying leftward, the .raven-flip layer // applies scaleX(-1) (mirroring the right-facing frames) // - Headturn idle: when perched and stationary >IDLE_HEADTURN_MS, plays // one head-turn cycle (front → over-shoulder → back to front) // =========================================================================== useEffectA5(() => { const raven = document.getElementById('perchingRaven'); if (!raven) return; // Respect reduced motion — hide the raven entirely for those users const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (prefersReducedMotion) { raven.style.display = 'none'; return; } const tilt = raven.querySelector('.raven-tilt'); const bob = raven.querySelector('.raven-bob'); const flip = raven.querySelector('.raven-flip'); const frameImg = raven.querySelector('.raven-frame'); // ---- Tunables -------------------------------------------------------- const RAVEN_W = 140; // matches CSS const EDGE_PAD = 16; const PERCH_OFFSET_X = 18; // perch slightly to the right of title const PERCH_OFFSET_Y = -78; // perch above the title baseline const POS_SMOOTH = 0.075; // position lerp factor (per frame at ~60fps) const TILT_SMOOTH = 0.10; // banking lerp const MAX_TILT_DEG = 18; // bird banks up to this much const TILT_PER_VX = 0.50; // deg per px/frame of horizontal velocity const TILT_PER_VY = 0.08; // a bit of nose-up/down with vertical speed const IDLE_MS = 600; // ms of no-scroll before we lock onto perch const LAND_DIST = 12; // px from perch target to count as landed const BRAKE_DIST = 80; // px from perch target to enter BRAKING state const CRUISE_SPEED = 1.2; // px/frame above which we're "actively flying" const GLIDE_SPEED = 0.6; // px/frame below which we glide (slow flap) const DRIFT_AMP_X = 14; // sideways drift amplitude (px) while cruising const DRIFT_AMP_Y = 6; // vertical drift amplitude (px) const BOB_AMP = 5; // body bob amplitude (px) during flight // Sprite-cycle timing — calm pacing for a contemplative site const FLAP_FRAME_MS = 90; // 90ms × 8 frames = 720ms full flap cycle const TAKEOFF_FRAME_MS = 110; // 4 frames × 110ms = 440ms takeoff const BRAKE_FRAME_MS = 120; // 4 frames × 120ms = 480ms brake/land const GLIDE_FRAME_MS = 180; // slower frame cycle when gliding const HEADTURN_FRAME_MS = 280; // slow, deliberate head turn const IDLE_HEADTURN_MIN = 4500; // min ms between head turns const IDLE_HEADTURN_MAX = 9000; // max ms between head turns // ---- Frame sets ------------------------------------------------------ // All frames are right-facing in source. The .raven-flip layer mirrors // them when the bird is moving left. // Eight-frame flap cycle interleaves "flap" (upper-arc) with "downflap" // (power downstroke) for a complete wing-beat arc. const FRAMES = { // Upper-arc + downstroke woven together for a real beat cycle. // Order picked to read as: wings high → mid → power-down → low → recovery → up. flap: [ 'assets/raven-frames/flap-1.png', // wings high (top of upstroke) 'assets/raven-frames/flap-2.png', // wings mid-high (transitioning down) 'assets/raven-frames/downflap-0.png', // wings sweeping down 'assets/raven-frames/downflap-1.png', // wings mid-down (power) 'assets/raven-frames/downflap-2.png', // wings fully extended down (bottom) 'assets/raven-frames/downflap-3.png', // wings back+down (end of power stroke) 'assets/raven-frames/flap-0.png', // wings cupped (recovery, coming back up) 'assets/raven-frames/flap-3.png', // wings up-back (returning to top) ], // Takeoff: perched → crouch → wings up → wings climbing takeoff: [ 'assets/raven-frames/takeoff-0.png', 'assets/raven-frames/takeoff-1.png', 'assets/raven-frames/takeoff-2.png', 'assets/raven-frames/takeoff-3.png', ], // Brake + land: wings up → cupped forward → folding → perched brake: [ 'assets/raven-frames/brake-0.png', 'assets/raven-frames/brake-1.png', 'assets/raven-frames/brake-2.png', 'assets/raven-frames/brake-3.png', ], // Idle perched — uses brake-3 (clean perched silhouette) perched: ['assets/raven-frames/brake-3.png'], // Head turn: front → 3/4 → profile → over-shoulder headturn: [ 'assets/raven-frames/headturn-0.png', 'assets/raven-frames/headturn-1.png', 'assets/raven-frames/headturn-2.png', 'assets/raven-frames/headturn-3.png', ], }; // Preload all frames so the first sprite swap doesn't flash an empty img const preloaded = []; Object.values(FRAMES).flat().forEach(src => { const i = new Image(); i.src = src; preloaded.push(i); }); // ---- States ---------------------------------------------------------- // Finite state machine for the raven's behavior. const STATE = { OFFSCREEN: 'offscreen', TAKEOFF: 'takeoff', CRUISING: 'cruising', BRAKING: 'braking', LANDING: 'landing', PERCHED: 'perched', HEADTURN: 'headturn', }; let state = STATE.OFFSCREEN; let stateStart = 0; // ms timestamp when current state began let frameIdx = 0; // index into current state's frame set let lastFrameSwap = 0; // ms timestamp of last frame change // Helper: switch state, reset counters function setState(newState, now) { state = newState; stateStart = now; frameIdx = 0; lastFrameSwap = now; } // Helper: advance frame index based on current state's timing. // Returns one of: // 'wait' — not enough time has passed; nothing changed // 'advanced' — frame index moved to a new value within the set // 'looped' — wrapped from end back to start (loop sets only) // 'finished' — non-loop sets: just left the final frame in place, // signalling the one-shot animation is complete. // Caller should transition to the next state. function advanceFrame(now, set, intervalMs, loop) { if (now - lastFrameSwap < intervalMs) return 'wait'; lastFrameSwap = now; // If we're already at the last frame of a non-looping set, this is // the moment to declare "finished" — without resetting the timer // further. Clamp frameIdx, return 'finished'. if (!loop && frameIdx >= set.length - 1) { frameIdx = set.length - 1; return 'finished'; } frameIdx++; if (frameIdx >= set.length) { if (loop) { frameIdx = 0; return 'looped'; } frameIdx = set.length - 1; } return 'advanced'; } // Helper: paint the current frame to the img element let lastSrc = ''; function paintFrame(src) { if (src !== lastSrc) { frameImg.src = src; lastSrc = src; } } // ---- Position state -------------------------------------------------- // current rendered position let x = -200, y = -200; // target position the raven wants to be at let tx = -200, ty = -200; // last frame position for velocity let lastX = x, lastY = y; // smoothed velocity let vx = 0, vy = 0; // heavily-smoothed horizontal velocity used for direction decisions // (raw vx oscillates with drift sine waves; we don't want the bird to // flip back and forth from drift, only from real motion) let vxSmooth = 0; // current tilt (deg) and smoothed target let tilt_cur = 0; // facing direction: 1 = right, -1 = left. Smoothed via flipBlend. // Start facing left because the bird seeds offscreen-right and flies // leftward into view (negative vx). Without this, the first frames would // show her facing right while moving left — backwards. let facing = -1; let flipBlend = -1; let visible = false; let landedTarget = null; // element we currently perch on let lastScrollAt = 0; let lastScrollY = window.scrollY; let nextHeadturnAt = 0; // when to play next idle headturn let rafId = 0; let t0 = performance.now(); // ---- Helpers --------------------------------------------------------- const clamp = (v, a, b) => Math.max(a, Math.min(b, v)); // Find the section title nearest a given viewport-Y anchor (default // 35% down the viewport). // // Returns { el, x, y } — ALWAYS returns a valid perch: // - If a title is in (or near) the viewport, perch beside it. // - Otherwise, fall back to a "virtual perch" at the right edge of the // viewport at ~35% down — so the bird gracefully glides to a hover // instead of cruising forever in the gap between distant sections. const findNearestTitle = (anchorY) => { const titles = Array.from(document.querySelectorAll('[data-section-title]')); const anchor = anchorY ?? window.innerHeight * 0.35; // Pass 1: prefer titles inside (or near) the viewport. let best = null; let bestDist = Infinity; titles.forEach(t => { const r = t.getBoundingClientRect(); if (r.bottom < -300 || r.top > window.innerHeight + 200) return; const center = r.top + r.height / 2; const dist = Math.abs(center - anchor); if (dist < bestDist) { bestDist = dist; best = t; } }); if (best) { const r = best.getBoundingClientRect(); const perchX = clamp( r.right + PERCH_OFFSET_X, EDGE_PAD, window.innerWidth - RAVEN_W - EDGE_PAD ); const perchY = clamp(r.top + PERCH_OFFSET_Y, 56, window.innerHeight - RAVEN_W); return { el: best, x: perchX, y: perchY }; } // Pass 2: no title in viewport — perch at a virtual "hover" point. // This keeps the bird from flapping forever when the user has stopped // between distant section titles (e.g. mid-bio). const virtualX = clamp( window.innerWidth - RAVEN_W - EDGE_PAD * 2, EDGE_PAD, window.innerWidth - RAVEN_W - EDGE_PAD ); const virtualY = clamp(anchor - RAVEN_W * 0.4, 80, window.innerHeight - RAVEN_W); return { el: null, x: virtualX, y: virtualY }; }; // Compute a flight target while scrolling — keeps the raven roughly at // the same vertical position in the viewport so it reads as "flying // alongside" the content rather than chasing a far-away element. const computeFlightTarget = () => { const anchorY = clamp(window.innerHeight * 0.32, 120, window.innerHeight - 200); // Sit toward the right edge of the viewport while flying const flyX = window.innerWidth - RAVEN_W - EDGE_PAD * 2; // Lean into wind: scroll down (positive dy) → raven a touch left const dy = window.scrollY - lastScrollY; const lean = clamp(dy * 1.2, -80, 80); return { x: flyX - lean, y: anchorY }; }; // Add organic drift (two-frequency sine perpendicular to motion). // The drift uses asymmetric Y amplitude (more vertical wave than // horizontal) on purpose: large horizontal drift would cause the bird // to flip direction back and forth on her own. Keep X drift gentle. const addDrift = (target, tMs) => { const tSec = tMs / 1000; const dx = Math.sin(tSec * 0.7) * (DRIFT_AMP_X * 0.5) + Math.sin(tSec * 1.9 + 1.3) * (DRIFT_AMP_X * 0.2); const dy = Math.cos(tSec * 0.9 + 0.6) * DRIFT_AMP_Y + Math.sin(tSec * 2.3) * (DRIFT_AMP_Y * 0.5); return { x: target.x + dx, y: target.y + dy }; }; // ---- Main loop ------------------------------------------------------- const tick = (now) => { const tMs = now - t0; const idleFor = now - lastScrollAt; // ----- Decide position target ----- // While user is scrolling: chase a "flight anchor" (right-ish of viewport, // ~30% down). After idle, lock onto nearest section title. let perch = null; let wantPerch = false; if (idleFor > IDLE_MS) { perch = findNearestTitle(); if (perch) { tx = perch.x; ty = perch.y; landedTarget = perch.el; wantPerch = true; } } if (!wantPerch) { const flight = computeFlightTarget(); const drifted = addDrift(flight, tMs); tx = drifted.x; ty = drifted.y; landedTarget = null; } // ----- Integrate position with smoothing ----- x += (tx - x) * POS_SMOOTH; y += (ty - y) * POS_SMOOTH; vx = x - lastX; vy = y - lastY; lastX = x; lastY = y; const speed = Math.hypot(vx, vy); const distToTarget = Math.hypot(tx - x, ty - y); // ----- Direction (facing) ----- // The bird should flip orientation ONLY when it's actually traveling in // a sustained direction — not when drift sine waves wiggle vx around 0, // and not when she's perching or perched. Three guards: // 1. Smooth vx with a slow low-pass so brief sign flips don't count // 2. Threshold + hysteresis: require >1.2 px/frame to flip; require // >1.6 px/frame in the OPPOSITE direction to flip BACK // 3. Don't change facing at all when not actively cruising vxSmooth += (vx - vxSmooth) * 0.12; const isActivelyFlying = (state === STATE.CRUISING || state === STATE.TAKEOFF); if (isActivelyFlying) { const FLIP_THRESHOLD = 1.2; const FLIP_HYSTERESIS = 1.6; // need this much to reverse current direction if (facing === 1 && vxSmooth < -FLIP_HYSTERESIS) { facing = -1; } else if (facing === -1 && vxSmooth > FLIP_HYSTERESIS) { facing = 1; } else if (Math.abs(vxSmooth) > FLIP_THRESHOLD && Math.sign(vxSmooth) !== facing) { // First-time or after a long pause — easier to commit to a direction facing = vxSmooth > 0 ? 1 : -1; } } // Smoothly blend the flip so direction changes don't snap (the actual // mirror is binary, but we ease the scaleX so the wing motion doesn't // cut mid-frame). flipBlend += (facing - flipBlend) * 0.16; const flipX = flipBlend < 0 ? -1 : 1; // Subtle x-axis compression at the moment of crossing zero — reads as // "turning toward camera" rather than instant flip. const flipScale = 0.25 + 0.75 * Math.abs(flipBlend); if (flip) flip.style.transform = `scaleX(${(flipX * flipScale).toFixed(3)})`; // ----- Visibility ----- if (!visible) { visible = true; raven.classList.add('visible'); } // ----- State machine ----- // Goal: drive `state` based on (a) where we are vs perch target, // (b) how fast we're moving, (c) how long we've been still. // // Transitions: // OFFSCREEN → CRUISING (after entry) // CRUISING → BRAKING when wantPerch && dist < BRAKE_DIST // BRAKING → LANDING when dist < LAND_DIST * 2 // LANDING → PERCHED when landing animation finishes // PERCHED → HEADTURN after a random idle interval // HEADTURN → PERCHED when head turn finishes // PERCHED → TAKEOFF when user scrolls (wantPerch false) // TAKEOFF → CRUISING when takeoff animation finishes const inFlight = (state === STATE.CRUISING || state === STATE.TAKEOFF || state === STATE.BRAKING || state === STATE.LANDING); switch (state) { case STATE.OFFSCREEN: { // Once we have a real position target, start cruising. if (visible && distToTarget < window.innerWidth * 0.7) { setState(STATE.CRUISING, now); } break; } case STATE.CRUISING: { // Cycle the flap loop const slow = speed < GLIDE_SPEED; const interval = slow ? GLIDE_FRAME_MS : FLAP_FRAME_MS; advanceFrame(now, FRAMES.flap, interval, true); paintFrame(FRAMES.flap[frameIdx]); // Approaching a perch target → BRAKING if (wantPerch && distToTarget < BRAKE_DIST) { setState(STATE.BRAKING, now); } break; } case STATE.BRAKING: { // Use brake-0 and brake-1 (wings up cupped, then forward braking). // We just want to advance to and hold brake-1 while approaching. const brakingFrames = [FRAMES.brake[0], FRAMES.brake[1]]; advanceFrame(now, brakingFrames, BRAKE_FRAME_MS, false); paintFrame(brakingFrames[frameIdx]); // No longer wants to perch (user resumed scroll) → back to cruising if (!wantPerch) { setState(STATE.CRUISING, now); break; } // Close enough → LANDING if (distToTarget < LAND_DIST * 2) { setState(STATE.LANDING, now); } break; } case STATE.LANDING: { // brake-2 (wings folding) → brake-3 (perched). Plays once. const landingFrames = [FRAMES.brake[2], FRAMES.brake[3]]; const r = advanceFrame(now, landingFrames, BRAKE_FRAME_MS + 20, false); paintFrame(landingFrames[frameIdx]); if (r === 'finished') { setState(STATE.PERCHED, now); // schedule first headturn nextHeadturnAt = now + IDLE_HEADTURN_MIN + Math.random() * (IDLE_HEADTURN_MAX - IDLE_HEADTURN_MIN); } // User resumed scrolling mid-landing → bail to takeoff if (!wantPerch) { setState(STATE.TAKEOFF, now); } break; } case STATE.PERCHED: { paintFrame(FRAMES.perched[0]); // User scrolls → take off if (!wantPerch) { setState(STATE.TAKEOFF, now); break; } // Time for a head turn? if (now >= nextHeadturnAt) { setState(STATE.HEADTURN, now); } break; } case STATE.HEADTURN: { // Play headturn forward then back: front → 3/4 → profile → // over-shoulder → profile → 3/4 → front. Built as an indirect // sequence over FRAMES.headturn so we can reuse advanceFrame. // pingpong indices: 0,1,2,3,2,1,0 (length 7) const seq = [0, 1, 2, 3, 2, 1, 0]; // Build a "virtual" frame set by indexing through seq; we treat // it as a non-looping sequence of length 7. const r = advanceFrame(now, seq, HEADTURN_FRAME_MS, false); paintFrame(FRAMES.headturn[seq[frameIdx]]); if (r === 'finished') { setState(STATE.PERCHED, now); nextHeadturnAt = now + IDLE_HEADTURN_MIN + Math.random() * (IDLE_HEADTURN_MAX - IDLE_HEADTURN_MIN); break; } // User scrolls → cancel headturn, take off if (!wantPerch) { setState(STATE.TAKEOFF, now); } break; } case STATE.TAKEOFF: { // Plays once. When advanceFrame returns 'finished', we've held // the final takeoff frame long enough → transition to CRUISING. const r = advanceFrame(now, FRAMES.takeoff, TAKEOFF_FRAME_MS, false); paintFrame(FRAMES.takeoff[frameIdx]); if (r === 'finished') { setState(STATE.CRUISING, now); } break; } } // ----- Tilt (banking) ----- // No tilt while perched/heading. Bank while cruising/braking proportional // to velocity. Easing always smooths. const tiltTarget = inFlight ? clamp(vx * TILT_PER_VX + vy * TILT_PER_VY, -MAX_TILT_DEG, MAX_TILT_DEG) : 0; tilt_cur += (tiltTarget - tilt_cur) * TILT_SMOOTH; // ----- Bob (body wave) — only during flight ----- const bobAmp = inFlight ? BOB_AMP : 0; const bobY = Math.sin(tMs / 240) * bobAmp; // ----- Apply transforms ----- raven.style.transform = `translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, 0)`; if (tilt) tilt.style.transform = `rotate(${tilt_cur.toFixed(2)}deg)`; if (bob) bob.style.setProperty('--bob', `${bobY.toFixed(2)}px`); rafId = requestAnimationFrame(tick); }; // ---- Scroll listener: just records "scroll happened" + delta --------- const onScroll = () => { lastScrollAt = performance.now(); // record scroll delta for "lean into wind" effect // (lastScrollY is read inside computeFlightTarget; update AFTER use) // We update it on next animation frame so the velocity has a frame to read requestAnimationFrame(() => { lastScrollY = window.scrollY; }); }; const onResize = () => { // re-clamp target on resize lastScrollAt = performance.now(); }; // Seed: start raven somewhere offscreen-right so its entrance is a fly-in x = window.innerWidth + 60; y = window.innerHeight * 0.20; lastX = x; lastY = y; raven.style.transform = `translate3d(${x}px, ${y}px, 0)`; // Begin lastScrollAt = performance.now() - IDLE_MS - 100; // start by seeking a perch window.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener('resize', onResize); rafId = requestAnimationFrame(tick); return () => { cancelAnimationFrame(rafId); window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onResize); }; }, []); return ( <>
); } const rootEl = document.getElementById('root'); const root = ReactDOM.createRoot(rootEl); root.render();