From 53e7c14d34b837de571b61fb2dd1994db30cb58f Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 25 Jul 2026 13:11:09 +0700 Subject: [PATCH] feat: give each scene its own motion, and let her pick the photograph up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every one of the 35 beats used the same fade-and-rise, so the page moved constantly and expressed nothing. Motion is now per-scene and physical: this is a letter on a desk, so beats behave like paper on a surface. - Per-scene entrance recipes replace one global timing. A narrow vocabulary — drop, slide, land fractionally off-square, rack focus — with no scene using more than two. #honest and #shared are now the stillest scenes on the page and #home the most unhurried, so tempo carries meaning instead of decorating it. - Scene 2 set-piece: the portrait starts lying on the desk and hinges up on its own bottom edge as she scrolls, through 68 degrees rather than 180. It never passes the plane, so there is no back face and no two-sided markup — the mechanical limit is what makes it an object obeying gravity instead of a card transition. Scrubbed, so she raises it with her thumb, and it locks upright on leave rather than lying back down. The film now runs on phones. It required 768px, which meant the device this link is most often opened on got none of the choreography. Phone variants ship with it: shorter pins, a numeric scrub so momentum tracks the thumb instead of stepping, the focus pull swapped from filter to a compositor-only scale, and no horizontal travel where the gutter is 16px. Fixes a real defect found while specifying this: GSAP leaves a residual inline `transform: translate(0px, 0px)` on every beat, and an inline transform outranks any stylesheet rule, so `.panel:hover` had been silently dead for the whole session. Beats now hand themselves back to CSS on completion. Setup failures also stop being silent. The catch still reveals all content so a visitor loses nothing, but it now reports — that silence had been hiding a broken timeline during this very change. Verified at 1440x900 and 390x844, both themes, plus reduced motion: portrait lifts and stays up across a full down-and-back-up pass, zero hidden beats, zero invisible-but-focusable controls, zero horizontal overflow, no console output. Page is ~24% longer on a phone, the cost of keeping the pins there. --- assets/story-film.css | 23 ++++- assets/story-film.js | 200 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 192 insertions(+), 31 deletions(-) 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); } })();