diff --git a/assets/story-film.css b/assets/story-film.css index 5310cb1..5266df0 100644 --- a/assets/story-film.css +++ b/assets/story-film.css @@ -40,10 +40,18 @@ html.film-ready .film-scene:not(.section-grid):not(.home-hero) { first paint and the script running. failOpen() overrides both with inline styles, so this can never trap content. - No blanket `will-change`: holding a compositor layer for all 31 beats for the + The `:not(.beat-in)` guard is load-bearing. On completion story-film.js + clears its inline styles and adds `.beat-in`, handing the element back to the + stylesheet — without the guard, clearing the inline transform would let this + rule re-hide the beat. The reason for the handoff at all: GSAP leaves a + residual inline `transform: translate(0px, 0px)`, and an inline transform + outranks any stylesheet rule, which silently disabled `.panel:hover`'s lift + for the whole session. + + No blanket `will-change`: holding a compositor layer for every beat for the life of the page costs more than it saves. story-film.js sets it per beat when a reveal starts and clears it on completion. */ -html.film-ready [data-scene] [data-reveal] { +html.film-ready [data-scene] [data-reveal]:not(.beat-in) { opacity: 0; transform: translateY(40px); } @@ -51,8 +59,17 @@ html.film-ready [data-scene] [data-reveal] { /* Defensive: the film never activates under reduced motion (story-film.js gates on it), but if that ever changes, keep content visible rather than hidden. */ @media (prefers-reduced-motion: reduce) { - html.film-ready [data-scene] [data-reveal] { + html.film-ready [data-scene] [data-reveal]:not(.beat-in) { opacity: 1; transform: none; } } + +/* Beats in #applicant slide in from the side, which puts them briefly outside + the 16px gutter. Clipping the x axis only contains that without creating a + scroll container, so ScrollTrigger's pinning is unaffected (this computes to + `clip visible`, and both pins keep identical start/end across a refresh). + Film-scoped: the other two tiers have no horizontal travel to contain. */ +html.film-ready main { + overflow: clip visible; +} diff --git a/assets/story-film.js b/assets/story-film.js index ae49a4d..feebc2d 100644 --- a/assets/story-film.js +++ b/assets/story-film.js @@ -31,13 +31,64 @@ const root = document.documentElement; const REVEAL = '[data-reveal]'; - // Per-scene entrance timing. Scenes absent from this map use DEFAULT_TIMING. - // `#honest` carries the strongest copy, so it is deliberately the slowest - // cascade on the page — restraint used as emphasis rather than decoration. - const DEFAULT_TIMING = { start: 'top 86%', duration: 0.8, stagger: 0.12 }; - const SCENE_TIMING = { - honest: { start: 'top 82%', duration: 1.15, stagger: 0.24 }, + // Per-scene entrance recipes. `from` values are the pre-hide state; the tween + // lands all of them at rest. Scenes absent from this map use DEFAULT_ENTRANCE. + // + // The vocabulary is deliberately narrow and physical — this is a letter on a + // desk, so beats behave like paper on a surface rather than like UI: + // y the sheet drops the last few millimetres onto the desk + // x the sheet is slid in from the side + // rotation it lands very slightly off-square (never more than 0.6deg) + // blur the camera finds focus (desktop only — see the isPhone block) + // scale the camera settles, used where blur is too expensive + // No scene combines more than two. Every scene reads differently from its + // neighbours, which is what kills the "35 beats all doing one fade" monotony. + const DEFAULT_ENTRANCE = { + start: 'top 86%', + y: 40, + duration: 0.8, + stagger: 0.12, + ease: 'expo.out', }; + const ENTRANCE = { + // Cold open: the copy is set down on the frame, barely off-square. + home: { start: 'top 92%', y: 30, rotation: -0.5, duration: 0.95, stagger: 0.14, ease: 'expo.out' }, + // Notes slid onto the desk beside the photograph. + applicant: { start: 'top 84%', y: 26, x: 18, rotation: -0.4, duration: 0.9, stagger: 0.15, ease: 'expo.out' }, + // The strongest copy on the page: almost no travel, the camera racks + // focus onto each admission instead. Slowest cascade — restraint as + // emphasis rather than decoration. + honest: { start: 'top 82%', y: 14, blur: 5, duration: 1.45, stagger: 0.3, ease: 'power2.out' }, + offer: { start: 'top 86%', y: 34, duration: 0.85, stagger: 0.14, ease: 'expo.out' }, + // The vow: the stillest scene on the page. Pure dissolve, zero travel. + shared: { start: 'top 84%', y: 0, duration: 1.3, stagger: 0.4, ease: 'power2.out' }, + // The climax. Nothing moves quickly here; the page has already stopped. + 'no-test': { start: 'top 84%', y: 18, duration: 1.35, stagger: 0.42, ease: 'power2.out' }, + dossier: { start: 'top 88%', y: 34, duration: 0.8, stagger: 0.1, ease: 'expo.out' }, + // Last frame. Longest landing on the page. + end: { start: 'top 82%', y: 24, duration: 1.4, stagger: 0.3, ease: 'power2.out' }, + }; + + // Touch scroll delivers events in bursts during momentum, so a numeric scrub + // (which interpolates between them) is the difference between "tracks the + // thumb" and "catches up in jumps". Pins also cost real page length on a + // phone, so they are shortened there. + const isPhone = window.matchMedia('(max-width: 767px)').matches; + const isTouch = window.matchMedia('(hover: none)').matches; + const SCRUB = isTouch ? 0.6 : 0.5; + const PIN_END = isPhone ? '+=45%' : '+=60%'; + + if (isPhone) { + // Animating `filter` re-rasterizes the layer every frame — the one + // technique here likely to cost frames on a mid-range Android. The focus + // pull becomes a compositor-only scale settle instead. + delete ENTRANCE.honest.blur; + ENTRANCE.honest.scale = 1.015; + ENTRANCE.honest.duration = 1.2; + // The shell has only a 16px gutter at 390px, so a slide-in has nowhere + // to come from. + delete ENTRANCE.applicant.x; + } // `#closing` is excluded from the batch entirely: its beats are choreographed // on its own pin timeline so the soft landing survives in this tier too. const PIN_CHOREOGRAPHED = new Set(['closing']); @@ -53,19 +104,22 @@ }; // Activation gate. The film is a progressive enhancement on top of the - // static reveal; only take over when all four hold: - // * viewport wide enough that pinning/scrubbing reads well (not phones), + // static reveal; only take over when all three hold: // * the reader has not asked to reduce motion, // * the CDN libraries actually loaded, // * the head watchdog has not already given up waiting for us. // Otherwise return and let story.js handle the reveal as it does today. // + // There is deliberately NO viewport gate any more. The film used to require + // >=768px, which meant the phone — where this link is most likely opened — + // got the plain fade-and-rise and none of the choreography. The recipes below + // carry explicit phone variants instead of the whole layer switching off. + // // The watchdog case is the subtle one: a CDN that is slow but ultimately // succeeds. By the time this runs the watchdog has un-hidden the page and the - // reader is already reading, so activating here would hide all 31 beats again + // reader is already reading, so activating here would hide every beat again // in order to animate them back in — a flash-of-disappearing-content on // exactly the slow connections the watchdog exists to protect. - const wideEnough = window.matchMedia('(min-width: 768px)').matches; const motionOK = window.matchMedia('(prefers-reduced-motion: no-preference)').matches; const libsReady = typeof window.gsap !== 'undefined' && @@ -73,7 +127,7 @@ typeof window.Lenis !== 'undefined'; const watchdogFired = root.dataset.storyWatchdog === 'fired'; - if (!wideEnough || !motionOK || !libsReady || watchdogFired) { + if (!motionOK || !libsReady || watchdogFired) { return; } @@ -111,10 +165,10 @@ scrollTrigger: { trigger: hero, start: 'top top', - end: '+=60%', + end: PIN_END, pin: true, pinSpacing: true, - scrub: 0.5, + scrub: SCRUB, refreshPriority: 1, }, }); @@ -138,7 +192,7 @@ scrollTrigger: { trigger: closing, start: 'top top', - end: '+=60%', + end: PIN_END, pin: true, pinSpacing: true, scrub: 1.2, @@ -164,16 +218,76 @@ softBeats.forEach((el) => { el.style.willChange = 'opacity, transform'; }), + // Same handoff back to CSS as the batch loop — these beats + // skip that loop, so without it the closing panel keeps a + // residual inline transform and loses its hover lift. onComplete: () => softBeats.forEach((el) => { + el.classList.add('beat-in'); el.style.willChange = ''; + el.style.opacity = ''; + el.style.transform = ''; }), }); }, }); } - /* ---------- 2. Per-beat entrances (non-reversing) ---------- */ + /* ---------- 2. Scene 2 set-piece: the photograph is picked up ---------- */ + + // The one place on this page where a "turn" is physically motivated. The + // hero frame establishes a desk with a letter on it; this is a photograph + // lying on that same desk, and she lifts it to look at his face. + // + // It hinges on its OWN BOTTOM EDGE through 68 degrees — not 180. It never + // passes the plane, so there is no back face, no backface-visibility and + // no two-sided markup. That mechanical limit is exactly what makes it read + // as an object obeying gravity rather than as a card-flip transition. + // + // The scrub is deliberate: she controls the rise with her thumb, which is + // the difference between watching an animation and handling an object. It + // is safe against the non-reversing invariant because it drives TRANSFORM + // ONLY — the portrait is foreshortened but never invisible, contains + // nothing focusable, and its accessible name is unaffected by transforms. + const portrait = document.querySelector('#applicant .portrait'); + if (portrait) { + gsap.set(portrait, { + transformOrigin: '50% 100%', + transformPerspective: 900, + rotationX: 68, + scale: 0.94, + y: 8, + force3D: true, + }); + + gsap.to(portrait, { + rotationX: 0, + scale: 1, + y: 0, + ease: 'none', + scrollTrigger: { + trigger: '#applicant', + start: 'top 80%', + end: 'top 28%', + scrub: SCRUB, + invalidateOnRefresh: true, + onToggle: (self) => { + portrait.style.willChange = self.isActive ? 'transform' : ''; + }, + // Once it is up it stays up: picked up once, mirroring the + // reveal-once contract every other beat honours. kill(false) + // leaves the inline transform as-is, so the resting state is + // set explicitly first. + onLeave: (self) => { + gsap.set(portrait, { rotationX: 0, scale: 1, y: 0 }); + portrait.style.willChange = ''; + self.kill(false); + }, + }, + }); + } + + /* ---------- 3. Per-beat entrances (non-reversing) ---------- */ // One batch per scene so each can carry its own tempo, and so a tall scene // times its lower half correctly — a single scene-level trigger never can. @@ -183,36 +297,62 @@ const beats = scene.querySelectorAll(REVEAL); if (!beats.length) return; - const timing = SCENE_TIMING[scene.id] || DEFAULT_TIMING; - gsap.set(beats, { opacity: 0, y: 40 }); + const recipe = ENTRANCE[scene.id] || DEFAULT_ENTRANCE; + + const from = { opacity: 0, y: recipe.y ?? 40 }; + if (recipe.x) from.x = recipe.x; + if (recipe.rotation) from.rotation = recipe.rotation; + if (recipe.scale) from.scale = recipe.scale; + if (recipe.blur) from.filter = 'blur(' + recipe.blur + 'px)'; + gsap.set(beats, from); + + const to = { + opacity: 1, + y: 0, + ease: recipe.ease, + duration: recipe.duration, + stagger: recipe.stagger, + }; + if (recipe.x) to.x = 0; + if (recipe.rotation) to.rotation = 0; + if (recipe.scale) to.scale = 1; + if (recipe.blur) to.filter = 'blur(0px)'; ScrollTrigger.batch(beats, { - start: timing.start, + start: recipe.start, once: true, onEnter: (batch) => { gsap.to(batch, { - opacity: 1, - y: 0, - ease: 'expo.out', - duration: timing.duration, - stagger: timing.stagger, + ...to, // Promote only for the life of the tween. A blanket - // will-change on all 31 beats holds compositor layers for + // will-change on every beat holds compositor layers for // the whole session and costs more than it saves. onStart: () => batch.forEach((el) => { - el.style.willChange = 'opacity, transform'; + el.style.willChange = recipe.blur + ? 'opacity, transform, filter' + : 'opacity, transform'; }), + // Hand the element back to CSS. GSAP otherwise leaves a + // residual inline `transform: translate(0px, 0px)`, and an + // inline transform outranks any stylesheet rule — which + // silently killed `.panel:hover`'s lift in this tier. The + // `.beat-in` class stops the pre-hide rule from matching, + // so clearing the inline styles cannot re-hide the beat. onComplete: () => batch.forEach((el) => { + el.classList.add('beat-in'); el.style.willChange = ''; + el.style.opacity = ''; + el.style.transform = ''; + el.style.filter = ''; }), }); }, }); }); - /* ---------- 3. Anchor jumps travel with the film's camera ---------- */ + /* ---------- 4. Anchor jumps travel with the film's camera ---------- */ // Native `scroll-behavior: smooth` bypasses Lenis: a nav click warps the // page in ~24 frames with a completely different easing signature from @@ -239,7 +379,7 @@ }); }); - /* ---------- 4. Measurement ---------- */ + /* ---------- 5. Measurement ---------- */ // Pins change total scroll height; recompute all triggers once now and on // full load (fonts/images can shift layout after this script runs). @@ -263,8 +403,12 @@ }, { passive: true }, ); - } catch { + } catch (error) { // Any failure: reveal everything so content is never trapped hidden. + // The page stays perfectly readable, which is exactly why this used to be + // silent — and exactly why a broken timeline could sit here unnoticed. + // Report it: a visitor loses nothing, and a real failure stops hiding. failOpen(); + console.error('[story-film] disabled after setup error:', error); } })();