diff --git a/CHANGES 3.md b/CHANGES 3.md new file mode 100644 index 0000000..6a82698 --- /dev/null +++ b/CHANGES 3.md @@ -0,0 +1,74 @@ +# Site update v4 — sticky scroll fix + hero shrink + move-up + +Two issues from v3, two targeted fixes. Everything lands in `protected/`. + +## Files to replace + +| File | What changed | +|---|---| +| `protected/index.html` | Hero fonts reduced ~13% (title, lede, eyebrow, hero-foot margin); hero block pulled further up | +| `protected/bifrost.js` | Sticky-scroll rewritten to use `wheelMultiplier` attenuation; treasure-map stops added as individual sticky targets | +| `protected/timeline.js` | **No change** from v3 — copy only if you want to re-ship it alongside the others | + +## The fix + +### Why v3's sticky scroll didn't work + +v3 multiplied Lenis's `.velocity` property once per scroll event. Lenis overwrites that value from the next wheel input before it ever gets consumed by the scroll loop — so the damping had no perceptible effect. No amount of tuning the `DAMP_FACTOR` would have helped. + +### The new approach + +Instead of reacting to the scroll after the fact, we attenuate wheel input **at the source**. Lenis reads `options.wheelMultiplier` every time a wheel event fires. When the viewport center passes a sticky target, we drop it from `1.0` to `0.35` — a 65% reduction. The user's wheel still works, but each flick of the wheel now moves the page 35% as far as before. When the zone is exited, the multiplier snaps back to 1.0. + +This produces a tactile, immediate resistance. Power users can still scroll fast (more wheel strokes); casual users get a natural "hold" on each scene. + +### Treasure-map stops + +v3's sticky targets were scenes only — the whole `bifrost-meaning` section (the treasure map) was one target. Since it's ~300vh tall, "center of section" was only briefly aligned with viewport center, and users flew past the individual stops. + +v4 adds each `.map-stop` element as its own sticky target. The intro card, Community, Advisory Council, and Pilot Projects — each dwells in the viewport as the user reaches it. + +### Hero fonts + move up + +`#page-overview #hero` now has overview-specific sizing that runs ~13% smaller than the base hero tokens: + +| Element | Before | After | +|---|---|---| +| `.hero-title` | `var(--step-hero)` → `clamp(2.4rem, 6.2vw, 5.4rem)` | `clamp(2.1rem, 5.4vw, 4.7rem)` | +| `.hero-lede` | `var(--step-lg)` → `clamp(1.35rem, 3vw, 2.2rem)` | `clamp(1.15rem, 2.6vw, 1.9rem)` | +| `.eyebrow` | `var(--step-sm)` → `clamp(0.85rem, 1vw, 0.95rem)` | `0.74rem` | +| `.hero-wrap` padding-top | `clamp(3rem, 8vh, 6rem)` | `clamp(1.75rem, 4.5vh, 3rem)` | +| `.eyebrow` margin-bottom | `clamp(1.4rem, 4vh, 2.4rem)` | `clamp(0.9rem, 2.5vh, 1.6rem)` | +| `.hero-lede` margin-top | `clamp(1.5rem, 4vh, 2.5rem)` | `clamp(1rem, 2.5vh, 1.6rem)` | +| `.hero-foot` margin-top | `clamp(2rem, 5vh, 3.5rem)` | `clamp(1.25rem, 3.5vh, 2.25rem)` | + +Line-heights tightened to match (`1.02` → `1.0` on the title). Together these keep the hero block compact and clear of the dot-nav + S2 cards below. + +These overrides are scoped to `#page-overview #hero` only — the timeline and other pages are unaffected. + +## Spot-check after deploy + +- [ ] Overview → scroll through hero. Text sits clearly in upper third of viewport. Visible space below "Supported by" + arrow. +- [ ] Hero block does NOT overlap the S2 cards below it on scroll; cards drop in cleanly. +- [ ] Scroll into S5 treasure map. Each stop (Community, Advisory Council, Pilot Projects) **slows the scroll** as it passes the viewport center — you feel a real hold, not just a brief damping. +- [ ] Scrolling fast still works — the wheel is slower, not locked. Determined scrolling passes through. +- [ ] S2 (stack) scrubs at normal speed (not damped — pinned scene is exempt). +- [ ] Hero, Words, Bifrost, Join scenes each also feel sticky as they hit center. + +## Tuning knobs + +If the stickiness is too strong or too weak after deploy: + +| What | In `bifrost.js` | Change | +|---|---|---| +| Less sticky (weaker hold) | `STICKY_WHEEL_MULT = 0.35` | → `0.55` or `0.65` | +| More sticky (stronger hold) | `STICKY_WHEEL_MULT = 0.35` | → `0.20` or `0.15` | +| Wider trigger zone | `STICKY_ZONE_VH = 0.18` | → `0.25` | +| Narrower trigger zone | `STICKY_ZONE_VH = 0.18` | → `0.12` | + +If the hero text is still too large or still bleeds into the icons below: + +| What | In `index.html` | Change | +|---|---|---| +| Smaller hero title | `clamp(2.1rem, 5.4vw, 4.7rem)` | e.g. `clamp(1.9rem, 4.8vw, 4.2rem)` | +| Pull up more | `padding-top: clamp(1.75rem, 4.5vh, 3rem)` | e.g. `clamp(1rem, 3vh, 2rem)` | diff --git a/protected/bifrost.js b/protected/bifrost.js index 384bc87..ad45c59 100644 --- a/protected/bifrost.js +++ b/protected/bifrost.js @@ -291,73 +291,78 @@ // Initial paint updateTopographyParallax(); - // ─── Sticky-scroll damping ─────────────────────────────────── + // ─── Sticky-scroll (wheel-multiplier attenuation) ──────────── // - // When a scene's vertical center is close to the viewport center, - // damp Lenis's velocity briefly — scenes "catch" the eye instead - // of flying past at full scroll speed. Applied only to non-pinned - // scenes (S1, S3, S4, S5, S6). S2 is already pinned + scrubbed - // by GSAP ScrollTrigger; adding damping on top would make its - // card-fall feel like a drag. + // Previous implementation multiplied Lenis's `.velocity` per scroll + // event — ineffective, because Lenis overwrites velocity from the + // next wheel event before that damped value is consumed. // - // Implementation: on each scroll tick, if the active scene is in - // the "damp zone" (±20% of viewport height from viewport center), - // scale Lenis's next frame velocity by a damping factor (0.82). - // When outside the zone, pass through unchanged. Keeps power-users - // happy — they can still scroll fast, just feel a soft hold at - // scene boundaries. - const STICKY_SCENES = new Set([ - 'hero', 'words-scene', 'bifrost', 'bifrost-meaning', 'bifrost-join', - ]); // note: 'stack-scene' deliberately excluded (already pinned) - const DAMP_ZONE_VH = 0.20; // ±20% of viewport height from center - const DAMP_FACTOR = 0.82; // multiply velocity while in zone - const DAMP_MAX_MS = 300; // cap: never damp for longer than this per scene-crossing + // New approach: directly reduce Lenis's `wheelMultiplier` when the + // viewport center is near a sticky target. The user's wheel input + // is attenuated at the source — their next scroll produces less + // delta, so the scene noticeably holds. When the zone is exited, + // the multiplier is restored. + // + // Targets are: + // - Non-pinned scenes (hero, words-scene, bifrost, bifrost-join) + // - The treasure map (bifrost-meaning) AND each of its three + // stops individually — previously the whole 300vh section was + // one target, so users flew through the individual stops. + // + // stack-scene (S2) is deliberately excluded — it's GSAP-pinned and + // scrubbed; damping on top makes its card-fall feel like a drag. + const BASE_WHEEL_MULT = 1.0; + const BASE_TOUCH_MULT = 1.5; + const STICKY_WHEEL_MULT = 0.35; // 65% reduction while in a sticky zone + const STICKY_TOUCH_MULT = 0.55; + const STICKY_ZONE_VH = 0.18; // ±18% of viewport height from its center - let dampedUntil = 0; // timestamp (ms); damping active while > now - let lastDampedScene = null; // id of the scene we last entered - - function updateStickyDamping() { - const now = performance.now(); - const midY = window.innerHeight * 0.5; - const zone = window.innerHeight * DAMP_ZONE_VH; - let inZone = false, zoneScene = null; - for (const id of STICKY_SCENES) { + // Collect sticky targets. IDs of scenes + DOM refs for the three + // individual treasure-map stops. Cached once — the list doesn't + // change after init. + function collectStickyTargets() { + const targets = []; + const sceneIds = ['hero', 'words-scene', 'bifrost', 'bifrost-join']; + sceneIds.forEach(id => { const el = document.getElementById(id); - if (!el) continue; + if (el) targets.push(el); + }); + // Treasure map intro + three stops each get their own sticky zone. + const mapSection = document.getElementById('bifrost-meaning'); + if (mapSection) { + // Each .map-stop is a separate card along the path — sticky them + // individually so the user dwells on each one. + mapSection.querySelectorAll('.map-stop').forEach(el => targets.push(el)); + } + return targets; + } + const stickyTargets = collectStickyTargets(); + + let wheelMultState = BASE_WHEEL_MULT; + function updateStickyDamping() { + const midY = window.innerHeight * 0.5; + const zone = window.innerHeight * STICKY_ZONE_VH; + let inZone = false; + for (const el of stickyTargets) { const r = el.getBoundingClientRect(); - // Use the scene's vertical midpoint as the "attractor." - const sceneMid = (r.top + r.bottom) * 0.5; - if (Math.abs(sceneMid - midY) < zone) { + const elMid = (r.top + r.bottom) * 0.5; + if (Math.abs(elMid - midY) < zone) { inZone = true; - zoneScene = id; break; } } - if (inZone) { - // New scene entered the zone → arm a fresh damping window. - // Reusing the same scene (still in zone, already damped) doesn't - // extend — DAMP_MAX_MS is a hard cap per entry to avoid an - // indefinite hold. - if (zoneScene !== lastDampedScene) { - lastDampedScene = zoneScene; - dampedUntil = now + DAMP_MAX_MS; - } - if (now < dampedUntil && lenis.velocity) { - // Lenis exposes .velocity (a number). Multiplying in place - // dampens the next-frame step. Small, contained effect — no - // chance of interfering with ScrollTrigger. - lenis.velocity *= DAMP_FACTOR; - } - } else { - // Left the zone — reset so the NEXT time we enter a scene, - // we get a fresh damping window. - if (lastDampedScene !== null) { - lastDampedScene = null; - dampedUntil = 0; - } + const nextMult = inZone ? STICKY_WHEEL_MULT : BASE_WHEEL_MULT; + if (nextMult !== wheelMultState) { + wheelMultState = nextMult; + // Lenis reads options.wheelMultiplier on every wheel event — + // re-assigning takes effect immediately. + lenis.options.wheelMultiplier = nextMult; + lenis.options.touchMultiplier = inZone ? STICKY_TOUCH_MULT : BASE_TOUCH_MULT; } } lenis.on('scroll', updateStickyDamping); + // Initial paint (we likely start at hero which is itself a target). + requestAnimationFrame(updateStickyDamping); // ─── Site-2 scene animations ───────────────────────────────── // (transplanted verbatim; all ScrollTriggers below automatically