Merge branch 'main' of git.fenja.ai:aru/fenja-project-bifrost

This commit is contained in:
Arlind Ukshini 2026-04-23 16:41:59 +02:00
commit 072896b126
2 changed files with 134 additions and 55 deletions

74
CHANGES 3.md Normal file
View file

@ -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)` |

View file

@ -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