// 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 (
<>