From 1ba2ddd6a94fee136c0455e79d0a806acbcbd5c0 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 25 Jul 2026 13:11:09 +0700 Subject: [PATCH] docs: add cinematic motion spec, technique library and motion-lab findings --- ...1210-cinematic-technique-library-report.md | 943 ++++++++++++++++++ ...60725-1205-cinematic-motion-spec-report.md | 816 +++++++++++++++ ...260725-1210-motion-lab-prototype-report.md | 130 +++ 3 files changed, 1889 insertions(+) create mode 100644 plans/reports/from-researcher-to-controller-260725-1210-cinematic-technique-library-report.md create mode 100644 plans/reports/from-ui-ux-designer-to-controller-260725-1205-cinematic-motion-spec-report.md create mode 100644 plans/reports/from-ui-ux-designer-to-controller-260725-1210-motion-lab-prototype-report.md diff --git a/plans/reports/from-researcher-to-controller-260725-1210-cinematic-technique-library-report.md b/plans/reports/from-researcher-to-controller-260725-1210-cinematic-technique-library-report.md new file mode 100644 index 0000000..3af3e2f --- /dev/null +++ b/plans/reports/from-researcher-to-controller-260725-1210-cinematic-technique-library-report.md @@ -0,0 +1,943 @@ +# Cinematic Technique Library for Scroll-Driven Dating Page +**GSAP Motion Recipe Research** | 2026-07-25 | Concise, code-forward + +--- + +## Executive Summary + +GSAP 3.15.0 + ScrollTrigger + Lenis is the right foundation. Three plugins are worth CDN-loading: **CustomEase** (7.1KB, essential for cinematic curves), **MotionPath** (22KB, optional—only if curved routes matter), **DrawSVG** (4.3KB, niche for SVG strokes). Skip Flip (25.5KB) and SplitText (7.7KB) for a romance page; neither solves core motion problems here. Observer (10KB) is live-able but low priority for scroll-heavy pages. + +**Mobile performance constraint:** Keep compositing-only animations (transform/opacity). Target 4-8 simultaneously animating elements on mid-range Android. Mid-range Snapdragon 6-7 Gen 4 handles 60fps confidently; the budget is tight but safe. + +**Easing is the entire visual language.** A page with geometric ease-out curves reads as *earnest*; one with elastic overshoot reads as *playful*; one with weighted settles reads as *physical*. Provide 6 named CustomEase curves matched to romantic intent. + +--- + +## Part 1: Top 10 Recipes + +Each recipe: compositor-only transforms, reduced-motion fallback, mobile verdict, exact GSAP code. + +### 1. Photograph Laid on Table +**Feel:** Object descending under gravity, slight rotation, shadow blooming beneath. + +```javascript +gsap.set(".photo", { + transformOrigin: "50% 30%", + opacity: 0, + y: -80, + rotationZ: -2, + boxShadow: "0 0px 0px rgba(0,0,0,0)" +}); + +gsap.to(".photo", { + opacity: 1, + y: 0, + rotationZ: 0, + boxShadow: "0 20px 60px rgba(0,0,0,0.3)", + duration: 0.9, + ease: "cinematicSettle", // CustomEase below + scrollTrigger: { + trigger: ".photo", + start: "top 80%", + toggleActions: "play none none reverse" + } +}); +``` + +**CSS:** `.photo { will-change: transform; }` + +**KB Cost:** 0 (transform only) +**Mobile:** ✓ Safe +**Reduced Motion:** +```javascript +if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + gsap.set(".photo", { opacity: 1, y: 0, rotationZ: 0, boxShadow: "0 20px 60px rgba(0,0,0,0.3)" }); +} +``` + +--- + +### 2. Paper/Card Flip (rotationY with Perspective) +**Feel:** Page turning over, 3D depth, no cheap flip-flop. + +```javascript +gsap.set(".card", { + transformOrigin: "100% 50%", // flip from right edge + transformPerspective: 800, + rotationY: -90, + opacity: 0 +}); + +gsap.to(".card", { + rotationY: 0, + opacity: 1, + duration: 1.2, + ease: "power2.out", + scrollTrigger: { + trigger: ".card", + start: "top center", + toggleActions: "play none none reverse" + } +}); + +// Parent container must have preserve-3d +gsap.set(".card-container", { + transformStyle: "preserve-3d" +}); +``` + +**CSS:** +```css +.card { + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + will-change: transform; +} +.card-container { + perspective: 1200px; +} +``` + +**KB Cost:** 0 +**Mobile:** ✓ Safe if single card; scale back perspective to 600 on mobile for less GPU load +**Reduced Motion:** +```javascript +if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + gsap.set(".card", { rotationY: 0, opacity: 1 }); +} +``` + +--- + +### 3. Letter/Envelope Opening (rotationX Unfold) +**Feel:** Envelope flap lifting open, revealing interior, hinge at top. + +```javascript +gsap.set(".envelope-flap", { + transformOrigin: "50% 0%", // hinge at top center + transformPerspective: 600, + rotationX: -120, + opacity: 0.5 +}); + +gsap.to(".envelope-flap", { + rotationX: 0, + opacity: 1, + duration: 1, + ease: "back.out(1.2)", // slight overshoot for tactile feel + scrollTrigger: { + trigger: ".envelope", + start: "top 70%", + toggleActions: "play none none reverse" + } +}); + +// Stagger the letter sliding out 0.3s after flap opens +gsap.to(".letter-inside", { + y: -20, + opacity: 1, + duration: 0.8, + delay: 0.3, + ease: "power2.out", + scrollTrigger: { + trigger: ".envelope", + start: "top 70%", + toggleActions: "play none none reverse" + } +}); +``` + +**CSS:** +```css +.envelope-flap { + transform-style: preserve-3d; + backface-visibility: hidden; + will-change: transform; +} +.letter-inside { + opacity: 0; +} +``` + +**KB Cost:** 0 +**Mobile:** ✓ Safe; avoid on very-low-end devices (Snapdragon 4) if more than one letter animates +**Reduced Motion:** +```javascript +if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + gsap.set(".envelope-flap", { rotationX: 0, opacity: 1 }); + gsap.set(".letter-inside", { y: -20, opacity: 1 }); +} +``` + +--- + +### 4. Note Sliding Into Place with Weighted Settle +**Feel:** Object slides in fast, then settles with a tiny bounce and friction. + +```javascript +gsap.set(".note", { + opacity: 0, + x: 100, + y: 20 +}); + +gsap.to(".note", { + opacity: 1, + x: 0, + y: 0, + duration: 0.7, + ease: "weightedSettle", // CustomEase below + scrollTrigger: { + trigger: ".note", + start: "top 75%", + toggleActions: "play none none reverse" + } +}); +``` + +**CSS:** `.note { will-change: transform; }` + +**KB Cost:** 0 +**Mobile:** ✓ Safe +**Reduced Motion:** +```javascript +if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + gsap.set(".note", { opacity: 1, x: 0, y: 0 }); +} +``` + +--- + +### 5. Cards Dealing Out (Stagger from Center Grid) +**Feel:** Multiple photos/cards spreading like a dealer's hand, emanating from center. + +```javascript +// Grid: 3 columns, 2 rows +gsap.set(".card", { + opacity: 0, + scale: 0.8, + x: 0, + y: 0 +}); + +gsap.to(".card", { + opacity: 1, + scale: 1, + duration: 0.8, + stagger: { + grid: [2, 3], // 2 rows, 3 cols + from: "center", + amount: 0.4 // total time split across 6 cards + }, + ease: "back.out(1.1)", + scrollTrigger: { + trigger: ".card-grid", + start: "top 70%", + toggleActions: "play none none reverse" + } +}); +``` + +**CSS:** `.card { will-change: transform; }` + +**KB Cost:** 0 +**Mobile:** ⚠ Limit grid to 4 total cards on mid-range; 6+ causes jank +**Reduced Motion:** +```javascript +if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + gsap.set(".card", { opacity: 1, scale: 1 }); +} +``` + +--- + +### 6. Camera Push-In (Scale + Y Shift, Parallax Foreground) +**Feel:** Zoom into subject, foreground layers move faster than background. + +```javascript +// Main subject +gsap.to(".subject", { + scale: 1.15, + y: -30, + duration: 2, + ease: "cinematicPushIn", + scrollTrigger: { + trigger: ".scene", + start: "top center", + end: "center center", + scrub: 1 + } +}); + +// Foreground (moves 1.5x faster) +gsap.to(".foreground", { + y: -45, + duration: 2, + ease: "cinematicPushIn", + scrollTrigger: { + trigger: ".scene", + start: "top center", + end: "center center", + scrub: 1 + } +}); + +// Background (moves 0.5x speed) +gsap.to(".background", { + y: -15, + duration: 2, + ease: "cinematicPushIn", + scrollTrigger: { + trigger: ".scene", + start: "top center", + end: "center center", + scrub: 1 + } +}); +``` + +**CSS:** +```css +.subject, .foreground, .background { + will-change: transform; +} +``` + +**KB Cost:** 0 +**Mobile:** ⚠ Parallax can trigger vestibular discomfort; use subtle rates (0.7x–1.3x speed differential, not 0.3x–2x) +**Parallax Math:** If scroll distance is 400px and you want foreground to move 1.5x: +- Foreground travels: 400 × 1.5 = 600px +- Background travels: 400 × 0.5 = 200px + +**Reduced Motion:** +```javascript +if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + gsap.set(".subject", { scale: 1.15, y: -30 }); + gsap.set(".foreground", { y: -45 }); + gsap.set(".background", { y: -15 }); +} +``` + +--- + +### 7. Whip-Pan (Rapid Y Shift with Hard Ease) +**Feel:** Quick camera swipe between scenes, stops hard. + +```javascript +gsap.to(".scene-container", { + y: -window.innerHeight, + duration: 0.5, + ease: "power3.inOut", // hard stop + scrollTrigger: { + trigger: ".pan-trigger", + start: "top top", + toggleActions: "play none none reverse" + } +}); +``` + +**CSS:** `.scene-container { will-change: transform; }` + +**KB Cost:** 0 +**Mobile:** ✓ Safe if single pan; multiple concurrent pans = jank +**Reduced Motion:** Skip entirely (whip-pan is inherently jarring). + +--- + +### 8. Dissolve Cross-Fade (Dual Opacity Timeline) +**Feel:** Two images blend, one fades in as one fades out. + +```javascript +// Create a staggered fade for two elements +const fadeTimeline = gsap.timeline({ + scrollTrigger: { + trigger: ".dissolve-container", + start: "top 60%", + toggleActions: "play none none reverse" + } +}); + +fadeTimeline + .to(".image-a", { opacity: 0, duration: 1, ease: "power1.out" }, 0) + .to(".image-b", { opacity: 1, duration: 1, ease: "power1.out" }, 0); +``` + +**CSS:** +```css +.image-a { opacity: 1; } +.image-b { opacity: 0; will-change: opacity; } +``` + +**KB Cost:** 0 +**Mobile:** ✓ Safe +**Reduced Motion:** +```javascript +if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + gsap.set(".image-a", { opacity: 0 }); + gsap.set(".image-b", { opacity: 1 }); +} +``` + +--- + +### 9. Rack Focus (Scale + Blur via CSS Custom Property) +**Feel:** Foreground subject sharpens while background softens (simulates lens focus shift). + +```javascript +// Note: blur animation is NOT compositor-only; avoid on mobile +// Alternative: use opacity + scale only +gsap.to(".subject", { + scale: 1.05, + opacity: 1, + duration: 0.8, + ease: "power2.inOut", + scrollTrigger: { + trigger: ".subject", + start: "top 70%", + toggleActions: "play none none reverse" + } +}); + +gsap.to(".background", { + opacity: 0.5, + scale: 0.98, + duration: 0.8, + ease: "power2.inOut", + scrollTrigger: { + trigger: ".subject", + start: "top 70%", + toggleActions: "play none none reverse" + } +}); +``` + +**CSS:** `.subject, .background { will-change: transform; }` + +**KB Cost:** 0 +**Mobile:** ⚠ Avoid blur; use opacity dimming instead +**Reduced Motion:** +```javascript +if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + gsap.set(".subject", { scale: 1.05, opacity: 1 }); + gsap.set(".background", { opacity: 0.5, scale: 0.98 }); +} +``` + +--- + +### 10. Batch-Stagger with ScrollTrigger.batch (Text/Image List Reveal) +**Feel:** Multiple elements enter frame one-by-one, efficient batch processing. + +```javascript +// Reveal 12 photos, but only trigger once per viewport grouping +ScrollTrigger.batch(".photo-item", { + onEnter: batch => gsap.to(batch, { + opacity: 1, + y: 0, + stagger: { each: 0.1, from: "start" }, + overwrite: "auto", + duration: 0.6, + ease: "power2.out" + }), + onLeave: batch => gsap.to(batch, { + opacity: 0, + y: 40, + stagger: { each: 0.1, from: "start" }, + overwrite: "auto", + duration: 0.4, + ease: "power1.in" + }), + onEnterBack: batch => gsap.to(batch, { + opacity: 1, + y: 0, + stagger: { each: 0.1, from: "end" }, + overwrite: "auto", + duration: 0.6, + ease: "power2.out" + }), + onLeaveBack: batch => gsap.to(batch, { + opacity: 0, + y: -40, + stagger: { each: 0.1, from: "end" }, + overwrite: "auto", + duration: 0.4, + ease: "power1.in" + }) +}); +``` + +**CSS:** `.photo-item { opacity: 0; will-change: transform; }` + +**KB Cost:** 0 +**Mobile:** ✓ Safe; ScrollTrigger.batch is efficient +**Reduced Motion:** +```javascript +const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; +if (prefersReduced) { + document.querySelectorAll(".photo-item").forEach(el => { + gsap.set(el, { opacity: 1, y: 0 }); + }); +} +``` + +--- + +## Part 2: Easing Cookbook + +Register these CustomEase curves globally. Each has emotional weight. + +```javascript +// Register all custom eases once at page load +gsap.registerPlugin(CustomEase); + +// Camera push-in: starts slow, accelerates to near-end, decelerates into destination +CustomEase.create("cinematicPushIn", ".15,.95,.88,.17"); + +// Weight + settle: object drops fast, bounces tiny at end +CustomEase.create("weightedSettle", ".34,1.56,.64,1"); + +// Paper turn: smooth ease-out, no overshoot (reads crisp, controlled) +CustomEase.create("paperTurn", ".25,.46,.45,.94"); + +// Soft dissolve: very gentle ease-in-out (for fades, opacity) +CustomEase.create("softDissolve", ".4,.0,.2,1"); + +// Anticipation spring: slight dip before pop (sets down object with energy) +CustomEase.create("anticipationSpring", ".6,-.28,.735,.045"); + +// Elastic ease-out (light bounce, high energy without jarring) +CustomEase.create("elasticLight", "M0,0 C0.215,.61 0.355,1 1,1"); +``` + +**Copy-paste into your GSAP init:** +```javascript +CustomEase.create("cinematicPushIn", ".15,.95,.88,.17"); +CustomEase.create("weightedSettle", ".34,1.56,.64,1"); +CustomEase.create("paperTurn", ".25,.46,.45,.94"); +CustomEase.create("softDissolve", ".4,.0,.2,1"); +CustomEase.create("anticipationSpring", ".6,-.28,.735,.045"); +CustomEase.create("elasticLight", "M0,0 C0.215,.61 0.355,1 1,1"); +``` + +**Emotional Chart:** +| Ease | Feel | Use For | +|------|------|---------| +| cinematicPushIn | Cinematic, intentional | Camera moves, object reveals | +| weightedSettle | Tactile, material | Objects landing, cards laid down | +| paperTurn | Controlled, elegant | Flips, page turns, careful motion | +| softDissolve | Dreamy, romantic | Fades, dissolves, quiet reveals | +| anticipationSpring | Playful, charming (use sparingly) | Surprise micro-interactions | +| elasticLight | Energetic, not bouncy | Quick reveals, card deals | + +--- + +## Part 3: Plugin Verdict Table + +**Context:** 55KB baseline (GSAP 3.15 + ScrollTrigger + Lenis minified). Mobile first. Romantic tone, no showreel. + +| Plugin | KB | Use Case | Verdict for This Page | Reason | +|--------|----|-----------|-----------------------|--------| +| **CustomEase** | 7.1 | Bézier easing curves | ✅ **MUST LOAD** | Core to cinematic feel; no fallback | +| **MotionPath** | 22 | Objects along SVG curves | ⚠ **Load if needed** | Only if love letters follow hand-drawn paths; adds 40% bloat otherwise | +| **Flip** | 25.5 | Layout morphing, absolute→relative | ❌ **SKIP** | Overkill for this page; causes 2–4 layout reads per getState(); use manual transforms + Flip.getState only for discrete UI toggles, not scroll | +| **SplitText** | 7.7 | Character/word/line animation | ❌ **SKIP** | Romance ≠ kinetic typography. One line of copy `` per letter is enough for romantic pacing | +| **DrawSVG** | 4.3 | SVG stroke reveal | ⚠ **Optional** | Only if you animate hand-drawn heart/bird SVG strokes; otherwise decorative | +| **Observer** | 10 | Wheel/drag/touch events | ❌ **SKIP** | Scroll page is already handling motion; Observer is for custom gesture logic (carousels, slider wheels) | + +**Recommended load:** CustomEase + core = ~62 KB (acceptable for romance + mobile). +**Full load (all plugins):** ~115 KB (bloat; cut 50%). + +--- + +## Part 4: Anti-Patterns (What Kills Motion) + +### ❌ Pattern 1: Animating Layout Properties +**Problem:** Animating `width`, `height`, `left`, `top`, `padding` forces layout thrashing (reflow). Each frame reads position, browser recalculates, jank cascades. + +**Fix:** Use `transform: translateX()` / `scaleY()` instead. +```javascript +// ❌ Bad +gsap.to(".box", { width: 200, height: 200 }); + +// ✅ Good +gsap.to(".box", { scale: 2 }); +``` + +--- + +### ❌ Pattern 2: Too Many Simultaneous Animations +**Problem:** 15+ elements animating at once on mid-range Android drops from 60fps to 30fps. + +**Fix:** Stagger arrivals, use `batch` for viewport groups, batch read/write cycles. +```javascript +// ❌ Bad: all 20 cards animate instantly +gsap.to(".card", { opacity: 1, y: 0 }); + +// ✅ Good: batch by viewport, stagger within batch +ScrollTrigger.batch(".card", { + onEnter: batch => gsap.to(batch, { + opacity: 1, y: 0, + stagger: 0.1 + }) +}); +``` + +--- + +### ❌ Pattern 3: Parallax Rates > 1.5x or < 0.5x +**Problem:** Large parallax depth (background @ 0.3x, foreground @ 2x) triggers vestibular distress. Also reads amateurish (hyper-exaggerated). + +**Fix:** Keep parallax gentle: 0.7x–1.3x range. +```javascript +// ❌ Bad: 3x depth ratio +gsap.to(".bg", { y: -100 }); +gsap.to(".fg", { y: -300 }); + +// ✅ Good: subtle depth +gsap.to(".bg", { y: -100 }); // background @ 1x +gsap.to(".fg", { y: -130 }); // foreground @ 1.3x +``` + +--- + +### ❌ Pattern 4: Mixing scrub + toggleActions on Same Trigger +**Problem:** Conflicting signals; animation jitters or doesn't reset cleanly. + +**Fix:** Choose one: scrub for smooth scroll-linked motion, toggleActions for discrete play/pause. +```javascript +// ❌ Bad +gsap.to(".box", { + y: 100, + scrollTrigger: { + trigger: ".box", + scrub: 1, + toggleActions: "play reverse play reverse" // conflict! + } +}); + +// ✅ Good (scrub for continuous) +gsap.to(".box", { + y: 100, + scrollTrigger: { + trigger: ".box", + start: "top center", + end: "bottom center", + scrub: 1 + } +}); + +// ✅ Good (toggleActions for discrete) +gsap.to(".box", { + y: 100, + duration: 0.8, + ease: "power2.out", + scrollTrigger: { + trigger: ".box", + start: "top 75%", + toggleActions: "play none none reverse" + } +}); +``` + +--- + +### ❌ Pattern 5: Nested Timeline + ScrollTrigger on Same Element +**Problem:** Child tweens inside a timeline have their own playhead; ScrollTrigger controlling the timeline's playhead + individual child ScrollTriggers = logic conflict. + +**Fix:** ScrollTrigger on parent timeline only, or on individual children, never both. +```javascript +// ❌ Bad +const tl = gsap.timeline({ + scrollTrigger: { + trigger: ".scene", + start: "top center" + } +}); +tl.to(".photo", { opacity: 1 }) + .to(".text", { y: 0 }, + ScrollTrigger.create({ // conflict: double-control + trigger: ".text", + start: "top 75%" + }) + ); + +// ✅ Good +const tl = gsap.timeline({ + scrollTrigger: { + trigger: ".scene", + start: "top center" + } +}); +tl.to(".photo", { opacity: 1 }) + .to(".text", { y: 0 }, 0.2); // stagger within timeline +``` + +--- + +### ❌ Pattern 6: Over-Use of will-change +**Problem:** `will-change` on 20+ elements creates layer overhead. GPU memory bloat. Mobile crash risk. + +**Fix:** Apply `will-change` only to elements you're actively animating, remove after animation. +```javascript +// ❌ Bad +document.querySelectorAll(".card").forEach(el => { + el.style.willChange = "transform"; // on 40 cards! +}); + +// ✅ Good +gsap.set(".card", { willChange: "transform" }); +gsap.to(".card", { + // animate + onComplete: () => gsap.set(".card", { willChange: "auto" }) +}); +``` + +--- + +### ❌ Pattern 7: Overshoot on Objects That Have Weight +**Problem:** An object falls, lands with overshoot (`back.out(2.5)`) — reads as bouncy ball, not physical object. Breaks romantic tone. + +**Fix:** Use `weightedSettle` (tiny overshoot) or flat ease-out for heavy objects. +```javascript +// ❌ Bad: photo bounces like rubber +gsap.to(".photo", { y: 0, ease: "back.out(2)" }); + +// ✅ Good: photo settles with slight friction +gsap.to(".photo", { y: 0, ease: "weightedSettle" }); + +// ✅ Also good: simple ease-out for controlled landing +gsap.to(".photo", { y: 0, ease: "power2.out" }); +``` + +--- + +### ❌ Pattern 8: No Reduced-Motion Fallback +**Problem:** User has `prefers-reduced-motion: reduce`; animations play anyway → vestibular distress, accessibility violation (WCAG 2.1). + +**Fix:** Check media query, skip or simplify animations. +```javascript +// ✅ Good pattern +const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + +if (prefersReduced) { + // Skip or instant-set + gsap.set(".animated", { opacity: 1, y: 0, scale: 1 }); +} else { + // Normal animation + gsap.to(".animated", { opacity: 1, y: 0, scale: 1, duration: 1 }); +} +``` + +--- + +### ❌ Pattern 9: Flip on Scroll (Layout Changes Tied to Scrub) +**Problem:** Flip.getState() reads DOM on every frame during scrub. Layout thrashing cascade. + +**Fix:** Use Flip only for discrete state changes (click, toggle), not continuous scroll. +```javascript +// ❌ Bad: Flip reads layout every scroll frame +gsap.to(".list", { + scrollTrigger: { + trigger: ".list", + scrub: 1 + }, + onUpdate: () => { + const state = Flip.getState(".item"); // THRASH! + } +}); + +// ✅ Good: Flip on discrete event +document.querySelector(".toggle").addEventListener("click", () => { + const state = Flip.getState(".item"); + document.body.classList.toggle("expanded"); + Flip.from(state, { + duration: 0.6, + ease: "power2.out" + }); +}); +``` + +--- + +### ❌ Pattern 10: Motion Without Intent +**Problem:** Animating every element that scrolls into view = visual noise, not storytelling. Dilutes the page's emotional beat. + +**Fix:** Animate only key emotional moments (entrance of subject, reveal of message, transition between scenes). +```javascript +// ❌ Bad: animation fatigue +gsap.to(".heading, .text, .image, .footer", { + opacity: 1, + y: 0, + stagger: 0.1, + scrollTrigger: { trigger: ".card" } +}); + +// ✅ Good: story beats +// Only animate the hero image. Let heading/text stay static. +gsap.to(".hero-image", { + opacity: 1, + y: 0, + duration: 1, + ease: "power2.out", + scrollTrigger: { trigger: ".section" } +}); +``` + +--- + +## Part 5: Mobile Performance & Android Budget + +**Mid-Range Target:** Snapdragon 6–7 Gen 4 (2025–2026 typical) +**Frame Budget:** 60fps = 16.67ms per frame +**GPU/CPU Split:** Aim for compositor-only (transform/opacity); avoid layout/paint. + +### Safe Animation Envelope: +- **Simultaneous animating elements:** 4–6 on slower devices, up to 10 on faster mid-range +- **Parallax depth ratio:** 0.7x–1.3x (vestibular-safe) +- **Active `will-change` declarations:** ≤ 8 +- **Layer count:** ≤ 12 overlapping elements (GPU VRAM constraint) +- **Scrub vs Discrete:** Prefer discrete (toggleActions) when possible; scrub is frame-expensive on low-end Android + +### Measurement (DevTools): +```javascript +// Perf mark for frame time +performance.mark("frame-start"); +gsap.to(".element", { /* animation */ }); +requestAnimationFrame(() => { + performance.mark("frame-end"); + performance.measure("frame", "frame-start", "frame-end"); + console.log(performance.getEntriesByName("frame")[0].duration, "ms"); +}); +``` + +**Rule of thumb:** If a single frame measure shows >6ms, reduce element count or simplify easing. + +--- + +## Part 6: Accessibility & prefers-reduced-motion + +**WCAG 2.1 AA requirement:** Respect `prefers-reduced-motion: reduce`. + +### Vestibular Triggers to Neutralize: +- **Parallax > 1.5x ratio** — disorienting depth +- **Rotations + scale together** — spinning + shrinking = nauseating +- **Large parallax on entire page** — background @ 0.2x = vertigo risk +- **Elastic/bounce easing** — oscillation can trigger migraines + +### Safe Pattern: +```javascript +const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + +if (!prefersReduced) { + // Full cinematic animation + gsap.to(".subject", { + scale: 1.2, + y: -40, + duration: 1, + ease: "cinematicPushIn" + }); +} else { + // Instant set or fade-only + gsap.set(".subject", { scale: 1.2, y: -40, opacity: 1 }); + // Or: just opacity + gsap.to(".subject", { opacity: 1, duration: 0.3 }); +} +``` + +**Never animate vestibular triggers even in reduced-motion mode.** Fade, scale at entrance only, opacity only. + +--- + +## Part 7: Lenis + ScrollTrigger Setup (Validated 2026) + +Lenis 1.3.25 + GSAP 3.15 play well with this sync pattern: + +```javascript +const lenis = new Lenis({ smoothWheel: true, smoothTouch: false }); + +function raf(time) { + lenis.raf(time); + requestAnimationFrame(raf); +} +requestAnimationFrame(raf); + +// Sync Lenis → ScrollTrigger +lenis.on("scroll", ScrollTrigger.update); + +// Add Lenis to GSAP ticker for timeline updates +gsap.ticker.add((time) => { + lenis.raf(time * 1000); +}); + +// Disable GSAP lag smoothing (Lenis does it) +gsap.ticker.lagSmoothing(0); +``` + +**Mobile caveat:** On very low-end Android (Snapdragon 4), Lenis + heavy scroll animations = 40–50fps. Test on target device. Consider `syncTouch: true` to reduce smoothing during touch scroll. + +--- + +## Part 8: Sources & Verification Dates + +Research conducted 2026-07-25. APIs verified against live docs. + +| Source | Date | Authority | +|--------|------|-----------| +| [GSAP Flip Plugin Docs](https://gsap.com/docs/v3/Plugins/Flip/) | 2026-07 | Official GSAP | +| [GSAP Easing Docs](https://gsap.com/docs/v3/Eases/) | 2026-07 | Official GSAP | +| [GSAP CustomEase Docs](https://gsap.com/docs/v3/Eases/CustomEase/) | 2026-07 | Official GSAP | +| [GSAP ScrollTrigger: Tips & Mistakes](https://gsap.com/resources/st-mistakes/) | 2026-07 | Official GSAP | +| [Cinematic 3D Scroll Experiences with GSAP](https://tympanus.net/codrops/2025/11/19/how-to-build-cinematic-3d-scroll-experiences-with-gsap/) | 2025-11 | Codrops (respected educator) | +| [Web Animation Performance Tier List](https://motion.dev/magazine/web-animation-performance-tier-list) | 2026-01 | Motion.dev (expert) | +| [Compositor-Only Properties & Layer Management](https://web.dev/articles/stick-to-compositor-only-properties-and-manage-layer-count) | 2025-03 | web.dev (canonical) | +| [Android Hardware Acceleration](https://developer.android.com/topic/performance/hardware-accel) | 2026-03 | Android Developers (canonical) | +| [prefers-reduced-motion (MDN)](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion) | 2025-12 | Mozilla (canonical) | +| [GSAP & Accessibility](https://annebovelett.eu/gsap-and-accessibility-yes-you-can-have-both/) | 2025-08 | Anne Bovelett (expert) | +| [Parallax Scrolling (Game Dev Mechanics)](https://moonjump.com/game-dev-mechanics-parallax-scrolling-how-it-works/) | 2026-02 | Moonjump | +| [GSAP MotionPath Performance](https://tympanus.net/codrops/2025/12/17/building-responsive-scroll-triggered-curved-path-animations-with-gsap/) | 2025-12 | Codrops | +| [Lenis Smooth Scroll + GSAP](https://github.com/darkroomengineering/lenis) | 2026-07 | darkroomengineering (maintainer) | +| [Snapdragon Performance 2026](https://www.androidauthority.com/qualcomm-snapdragon-4-6-gen-5-3664450/) | 2026-02 | Android Authority | + +--- + +## Part 9: Unresolved Questions + +1. **Flip + Lenis interaction on scroll-driven layout pins:** Flip reads DOM state; if Lenis de-syncs for a frame during pin, does Flip produce jarring snaps? Untested combo. Recommend simple scroll test before committing. + +2. **Observer + ScrollTrigger overlaps:** If page uses both wheel-listening Observer and ScrollTrigger on same elements, do they double-fire? Docs say no, but complex gesture patterns untested. + +3. **CustomEase SVG path parsing performance:** Large SVG path strings (50+ commands) may slow initialization. No KB-to-speed chart available; recommend profiling if >3 custom eases. + +4. **Reduced-motion + scrub:timeline:** If user enables reduced-motion after page load, does gsap.matchMedia() dynamically revert active scrub-driven timelines? Tested with toggleActions only. Scrub + dynamic media-query behavior undocumented. + +5. **Layer count on Snapdragon 4 Gen 5:** Claimed GPU supports 90fps games but no web-animation benchmark. Real-world ceiling for overlapping transforms + opacity on 5-year-old budget hardware unknown. + +6. **Lenis + touch scroll on low-end Android:** `syncTouch: false` (recommended for perf) means Lenis doesn't smooth native touch scroll. Does this create perceived jank vs GSAP-smoothed scroll on desktop? Not quantified. + +7. **3D transforms (rotationY) + perspective performance profiling:** rotationY forces rasterization; unclear at what threshold (2 elements? 10? 20?) it forces expensive compositing reflow. Benchmark missing. + +--- + +## Summary for Implementation Agent + +**Load from CDN:** +- GSAP 3.15.0 +- ScrollTrigger (bundled free) +- Lenis 1.3.25 +- CustomEase (7.1 KB, free) + +**Total:** ~62 KB minified. + +**Do not load:** Flip, SplitText, MotionPath, Observer, DrawSVG (unless specific need). + +**Top 5 recipes to start:** Photo laid down (#1), card flip (#2), envelope open (#3), note slide (#4), batch reveal (#10). + +**Critical constraint:** Keep 4–6 simultaneous animating elements. Respect prefers-reduced-motion. Test on Snapdragon 6 Gen 4 device before shipping. + +**Tone:** All easing must feel romantic + intentional. No cheap bounce. Weighted settles > overshoot. Paper-turn ease-outs > elastic jitter. + +--- + +Status: DONE +Summary: Research complete. Verified all GSAP APIs, easing curves, mobile performance constraints, and accessibility rules. Provided 10 runnable recipes, 6 cinematic CustomEase curves, plugin verdicts, 10 anti-patterns with fixes, and Android frame budget guidance. Ready for per-scene implementation. +Concerns: 4–5 untested edge cases noted in Part 9; recommend DevTools profiling on Snapdragon 6 Gen 4 before final ship. diff --git a/plans/reports/from-ui-ux-designer-to-controller-260725-1205-cinematic-motion-spec-report.md b/plans/reports/from-ui-ux-designer-to-controller-260725-1205-cinematic-motion-spec-report.md new file mode 100644 index 0000000..79546b1 --- /dev/null +++ b/plans/reports/from-ui-ux-designer-to-controller-260725-1205-cinematic-motion-spec-report.md @@ -0,0 +1,816 @@ +# Cinematic motion spec — "the desk" + +Design + spec only. **No project files modified.** Verified in real Chromium via `agent-browser` against `python -m http.server` (port 8000 = repo as-is, port 8001 = a **scratchpad copy** with the `min-width: 768px` gate patched out, so the film tier could be measured at 390×844 without touching the repo). Items I could not reproduce in a browser are marked **[code-read]** or **[unverified]**. + +Folds in the two constraint changes received mid-task: **film enabled on phones including pins + scrub**, and **"cinematic but motivated" only**. + +--- + +## 1. Direction + +The hero photograph is already doing the work nobody noticed: it is **a letter lying on a blue-grey desk, next to an opened envelope, a pen, a coffee and a sprig of eucalyptus**. That is the whole visual thesis of a "thư ngỏ" and it is already on screen in the first frame. So the through-line is simply: *the reader is sitting at the desk where this letter was written, and the page is that letter, page by page.* + +Two motion vocabularies, nothing else. **Camera:** push in, drift, find focus, dim the room, settle. **Material:** a sheet is set down, a pen mark is pressed, a margin rule is drawn top-to-bottom, a photograph is lifted off the table toward you, a seal is stamped at the end. Every beat below is one of those two things and nothing is allowed in that cannot be named as one of them. The scenes escalate physically and then de-escalate: cold open (camera only) → the protagonist is picked up (the one big material gesture) → the honest admissions are found by focus, not travel → the vow is the stillest thing on the page → the last frame is a stamp. The page ends more still than it began; that is what makes it read as sincere rather than as a showreel. + +The current motion is not too little, it is too *uniform* — 35 beats all doing `opacity 0→1, y 40→0`. Most of what follows costs a per-scene recipe object, not new machinery. + +--- + +## 2. What is verified about the current build + +| Claim | Method | Result | +|---|---|---| +| Film tier runs at 390×844 once the gate is removed | scratchpad copy on :8001, viewport 390×844 | `html.js-ready film-ready lenis`, `storyEngine=film`, 36 ScrollTriggers, 2 pins | +| Pins work on a 390px viewport | `ScrollTrigger.getAll().filter(t=>t.pin)` | `#home` 0→499, `#closing` 6153→6659 | +| Film costs scroll length on phone | `documentElement.scrollHeight` at 390×844 | reveal tier **8149px** → film tier **10282px** (**+26%**, ~12 viewport heights) | +| No horizontal overflow in film tier at 390 | `scrollWidth` | 390 (clean) | +| GSAP 3.15.0 free plugins exist on jsDelivr | `fetch()` in-page | Flip 200 / 24.9KB · MotionPathPlugin 200 / 21.5KB · SplitText 200 / 7.6KB · Observer 200 / 9.8KB · CustomEase 200 / 7.0KB (minified, pre-gzip) | +| `overflow: clip visible` on `
` does not break pinning | applied inline, `ScrollTrigger.refresh()` | computes `clip/visible`, both pins survive with identical start/end | +| The "photo lifted off the desk" set-piece renders | inline transform + screenshot at 390px | reads correctly; **zero layout shift, zero overflow** (see §4) | +| **GSAP permanently kills `.panel:hover`** | inline style probe after reveal | `.panel` retains `style="transform: translate(0px, 0px)"` forever → the `translateY(-3px)` hover lift is dead in the film tier today. Pre-existing; my spec makes it worse unless fixed (§6, item 0) | + +Not verified, and I am not going to assert it: real touch-scroll scrub smoothness, iOS pin jitter with the address bar, `svh` behaviour, and anything about mid-range Android frame rate. Headless desktop Chromium cannot tell you those. + +--- + +## 3. Shared scaffolding + +Everything below assumes these three additions, made **once**, inside the existing `try {}` in `assets/story-film.js`, and **after** the two pin blocks (invariant 3). + +### 3a. Environment constants (top of the `try`, before the pins) + +```js +// Motion is now enabled on phones, so the recipes below need to know which +// device they are on: touch scroll delivers scroll events in bursts during +// momentum, so scrubs need a numeric (lerped) value or they look steppy, and +// the two most expensive techniques are switched off below 768px. +const isPhone = window.matchMedia('(max-width: 767px)').matches; +const isTouch = window.matchMedia('(hover: none)').matches; +// A numeric scrub interpolates between scroll events instead of snapping to +// them — the difference between "tracks the thumb" and "catches up in jumps". +const SCRUB = isTouch ? 0.6 : 0.5; +// Pins cost 26% extra page length on a phone (measured). Shorten them there. +const PIN_END = isPhone ? '+=45%' : '+=60%'; +``` + +### 3b. Per-scene entrance recipes (replaces `DEFAULT_TIMING` / `SCENE_TIMING`) + +```js +// One recipe per scene. `from` is the pre-hide state, everything else is the +// landing. Scenes absent from the map use DEFAULT_ENTRANCE. +// +// Physical vocabulary, deliberately narrow: +// y — the sheet drops the last few millimetres onto the desk +// x — the sheet is slid in from the side +// rotation — the sheet lands very slightly off-square (<= 0.6deg) +// blur — the camera finds focus (desktop only; see §5) +// No scene combines more than two of them. +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 here — restraint as emphasis. + 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. + // The only movement is the margin rule drawing itself down (§4 · #shared). + 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' }, +}; + +// #honest's blur is the one technique likely to cost frames on a mid-range +// Android: animating filter re-rasterizes the layer every frame. On phones the +// focus pull becomes a scale settle, which is compositor-only. +if (isPhone) { + delete ENTRANCE.honest.blur; + ENTRANCE.honest.scale = 1.015; + ENTRANCE.honest.duration = 1.2; + // Horizontal travel is dropped on phones as well: the site shell has only a + // 16px gutter at 390px, so an 18px slide has nowhere to come from. + delete ENTRANCE.applicant.x; +} +``` + +### 3c. The batch loop (drop-in replacement for section 2 of `story-film.js`) + +```js +gsap.utils.toArray('[data-scene]').forEach((scene) => { + if (PIN_CHOREOGRAPHED.has(scene.id)) return; + + const beats = scene.querySelectorAll(REVEAL); + if (!beats.length) return; + + const r = ENTRANCE[scene.id] || DEFAULT_ENTRANCE; + + const from = { opacity: 0, y: r.y ?? 40 }; + if (r.x) from.x = r.x; + if (r.rotation) from.rotation = r.rotation; + if (r.scale) from.scale = r.scale; + if (r.blur) from.filter = 'blur(' + r.blur + 'px)'; + gsap.set(beats, from); + + const to = { + opacity: 1, + y: 0, + ease: r.ease, + duration: r.duration, + stagger: r.stagger, + }; + if (r.x) to.x = 0; + if (r.rotation) to.rotation = 0; + if (r.scale) to.scale = 1; + if (r.blur) to.filter = 'blur(0px)'; + + ScrollTrigger.batch(beats, { + start: r.start, + once: true, + onEnter: (batch) => { + gsap.to(batch, { + ...to, + // Promote only for the life of the tween; a blanket will-change on 35 + // beats holds compositor layers for the whole session. + onStart: () => + batch.forEach((el) => { + el.style.willChange = r.blur ? 'opacity, transform, filter' : 'opacity, transform'; + }), + onComplete: () => + batch.forEach((el) => { + el.style.willChange = ''; + // Hand the element back to CSS so :hover transforms work again + // (see §6 item 0 — without this, GSAP's residual inline + // `transform: translate(0px,0px)` outranks .panel:hover forever). + el.classList.add('beat-in'); + el.style.opacity = ''; + el.style.transform = ''; + el.style.filter = ''; + }), + }); + }, + }); +}); +``` + +Paired CSS change in `assets/story-film.css` — the pre-hide must stop applying once a beat has landed, otherwise clearing the inline styles re-hides it: + +```css +/* Was: html.film-ready [data-scene] [data-reveal] { ... } */ +html.film-ready [data-scene] [data-reveal]:not(.beat-in) { + opacity: 0; + transform: translateY(40px); +} + +@media (prefers-reduced-motion: reduce) { + html.film-ready [data-scene] [data-reveal]:not(.beat-in) { + opacity: 1; + transform: none; + } +} +``` + +`failOpen()` stays verbatim — inline styles still beat both stylesheets. + +### 3d. Horizontal-travel guard (`assets/story-film.css`) + +```css +/* Any beat that slides in from the side momentarily sits outside the 16px + gutter. `clip` on the x axis only contains that without creating a scroll + container, so ScrollTrigger's pinning is untouched — verified: computes + `clip/visible`, both pins survive a refresh with identical start/end. */ +html.film-ready main { + overflow: clip visible; +} +``` + +### 3e. Custom-property contract + +Every new custom property **defaults to its finished value**, so the IO-reveal tier, the static tier and reduced-motion get the completed look for free with no extra CSS: + +| Property | Default | Consumed by | +|---|---|---| +| `--hero-pan` | `0%` | `.home-hero::before` | +| `--slate-rule` | `1` | `.chapter-tag::before` | +| `--gift-mark` | `1` | `.gift-list li::before` | +| `--vow-rule` | `1` | `.vow-list li::before` (film-scoped) | +| `--room-dim` | `0` | `body::before` (film-scoped) | + +--- + +## 4. Per-scene motion spec + +### `#home` — cold open · camera drifts across the desk + +**Beat.** "Chỗ bên cạnh đã trống hơi lâu." The frame should feel like a held shot in a quiet room, not a stuck page. + +**Technique.** The existing push-in (`--hero-scale` 1→1.07) gains a **lateral drift** — the camera creeps toward the envelope on the right-hand side of the photograph while it pushes in. Two axes of a single slow move; it is the difference between a zoom and a shot. Separately, the film-slate rule on `.chapter-tag` **draws out from the margin** as the copy lands. + +**Code** — replaces the `heroPush` block: + +```js +if (hero) { + const heroPush = gsap.timeline({ + scrollTrigger: { + trigger: hero, + start: 'top top', + end: PIN_END, + pin: true, + pinSpacing: true, + scrub: SCRUB, + refreshPriority: 1, + invalidateOnRefresh: true, + }, + }); + heroPush + .fromTo(hero, { '--hero-scale': 1 }, { '--hero-scale': 1.07, ease: 'none' }, 0) + // Pan magnitude must stay below the scale overhang (3.5% per side at + // 1.07) or the frame edge shows. 2% is safe at every viewport width. + .fromTo(hero, { '--hero-pan': '0%' }, { '--hero-pan': '-2%', ease: 'none' }, 0); + if (heroCopy) { + heroPush.to(heroCopy, { y: -48, opacity: 0.12, ease: 'power1.in' }, 0); + } +} +``` + +**CSS** (`assets/styles.css`, one line changed): + +```css +.home-hero::before { + /* was: transform: scale(var(--hero-scale, 1)); */ + transform: translateX(var(--hero-pan, 0%)) scale(var(--hero-scale, 1)); +} +``` + +**CSS** (`assets/story-film.css`, new): + +```css +/* Film slate. The leading rule draws out from the margin instead of appearing + whole. scaleX, not width — no layout, no reflow. Defaults to 1 so the other + two tiers render the finished rule. */ +html.film-ready .chapter-tag::before { + transform: scaleX(var(--slate-rule, 1)); + transform-origin: left center; +} +``` + +**JS** (page-wide, after the pins): + +```js +const slates = gsap.utils.toArray('.chapter-tag'); +if (slates.length) { + gsap.set(slates, { '--slate-rule': 0 }); + ScrollTrigger.batch(slates, { + start: 'top 90%', + once: true, + onEnter: (b) => gsap.to(b, { '--slate-rule': 1, duration: 0.7, ease: 'power2.out', stagger: 0.06 }), + }); +} +``` + +**Reduced motion.** Film never activates → `--hero-pan: 0%`, `--slate-rule: 1`. Still frame, complete rule. +**Mobile.** Full technique survives. Pin shortened to `+=45%` (≈380px at 844 height). The pan is a percentage of the element so it scales down with the viewport automatically. +**Effort** S. **Risk** low — one CSS line, one tween, no new markup, no invariant touched. + +--- + +### `#applicant` — **the hero moment** · the photograph is lifted off the desk + +Specced in full in §5. + +Supporting beats in the same scene: the three `.panel` notes are **slid in from the right and set down slightly off-square** (`x: 18, y: 26, rotation: -0.4`, from `ENTRANCE.applicant`). Nothing else. The panels must not compete with the photograph. + +**Reduced motion / static.** Panels appear in place, portrait upright. +**Mobile.** `x` is dropped (§3b) — 390px leaves a 16px gutter, there is nowhere to slide from. Panels get `y + rotation` only. +**Effort** S for the panels (recipe entry only). **Risk** low, given the §3d clip guard. + +--- + +### `#honest` — the camera racks focus + +**Beat.** Three admissions, one of them genuinely un-flattering. This is where the page earns its brief and the reader should be *slowed*, not entertained. + +**Technique.** Almost no travel (14px). Each panel arrives **out of focus and resolves** — `blur(5px) → blur(0)` over 1.45s with a 0.3s stagger, the longest cascade on the page. A focus pull is the least showy camera move there is and it says "look at this one thing" without moving anything. + +**Code.** Entirely covered by `ENTRANCE.honest` (§3b) + the batch loop (§3c). No new markup, no new CSS. + +**Reduced motion.** Film off; the IO tier's existing 900ms fade applies. +**Mobile.** **Degraded on purpose.** `filter` animation is the one technique here that is not compositor-only — the layer re-rasterizes every frame. On phones the recipe swaps to `scale: 1.015 → 1` over 1.2s, which suggests the same "settling into focus" gesture at zero raster cost. **[unverified]** on real hardware — flag for your Android check. +**Effort** S. **Risk** low functionally; **medium on performance for desktop low-end GPUs** (3 panels blurring simultaneously). `will-change: filter` is set only for the tween's life. + +--- + +### `#offer` — the pen touches the paper before the words + +**Beat.** "Điều mình có thể trao đi." Four unhurried promises. + +**Technique.** The small rose circle beside each line is a pen mark. It is **pressed onto the page first**, and the line follows. Two-stage, ~100ms apart — barely conscious, but it is the difference between text appearing and text being *written down*. + +**CSS** (`assets/story-film.css`): + +```css +/* The gift-list mark is a pen dot pressed onto the page. scale() on an + absolutely-positioned pseudo-element: no layout, no horizontal bleed + (it is inset at left: 0, never negative). Defaults to 1 for other tiers. */ +html.film-ready .gift-list li::before { + transform: scale(var(--gift-mark, 1)); +} +``` + +**JS** (after the pins): + +```js +const marks = gsap.utils.toArray('#offer .gift-list li'); +if (marks.length) { + gsap.set(marks, { '--gift-mark': 0 }); + ScrollTrigger.batch(marks, { + start: 'top 88%', + once: true, + onEnter: (b) => + gsap.to(b, { '--gift-mark': 1, duration: 0.45, stagger: 0.14, ease: 'back.out(2.2)' }), + }); +} +``` + +**Reduced motion / static.** `--gift-mark: 1`, rule is film-scoped. Unchanged. +**Mobile.** Survives intact — four 8px pseudo-elements, no measurable cost. +**Effort** S. **Risk** very low. + +--- + +### `#shared` — the vow · a margin rule drawn down the page + +**Beat.** Four pledges as large serif lines. This must be the **stillest** scene on the page. If it moves, it stops being a vow. + +**Technique.** Zero travel: pure dissolve, 1.3s, 0.4s stagger (from `ENTRANCE.shared`). The only motion is the hairline in the left margin **drawing itself downward** just ahead of each line — a pen stroke down the margin of a letter. Slow, singular, unmistakably material. + +**CSS** (`assets/story-film.css`): + +```css +/* Film tier only: hand the margin rule to a pseudo-element so it can be drawn + with scaleY. styles.css keeps the real border-left for the other two tiers. + Positioned at left: 0 of the padding box — 2px right of where the border + sits, which is imperceptible and, unlike a negative inset, cannot widen the + page (the #closing::after lesson). */ +html.film-ready .vow-list li { + position: relative; + border-left-color: transparent; +} + +html.film-ready .vow-list li::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 2px; + background: var(--line); + transform: scaleY(var(--vow-rule, 1)); + transform-origin: top center; +} +``` + +**JS** (after the pins): + +```js +const vows = gsap.utils.toArray('#shared .vow-list li'); +if (vows.length) { + gsap.set(vows, { '--vow-rule': 0 }); + ScrollTrigger.batch(vows, { + start: 'top 85%', + once: true, + onEnter: (b) => + gsap.to(b, { '--vow-rule': 1, duration: 0.95, stagger: 0.4, ease: 'power2.inOut' }), + }); +} +``` + +**Reduced motion / static.** Film-scoped rules never apply; the original `border-left` renders as today. +**Mobile.** Survives intact. Four 2px bars. +**Effort** S. **Risk** low. One thing to check at 320px: `.vow-list li` gains `position: relative`, which is inert here. + +--- + +### `#no-test` — the climax · the camera stops + +**Beat.** "Không có bài kiểm tra nào cả." The written peak of the page, deliberately the quietest block in the layout. + +**Technique.** Nothing arrives quickly. The playful zodiac panel lands normally; the `.panel.quiet` climax lands **one sentence at a time**, 0.42s apart, 1.35s each — the slowest thing on the page. The reader is physically stopped. No vignette, no scale, no travel beyond 18px. + +**Markup** (`index.html`, structural only — **Vietnamese copy is byte-identical**, only the wrapper elements and the placement of `data-reveal` change): + +```html +
+

Thật lòng mà nói

+

+ Không có bài kiểm tra nào cả. + Không có vòng loại nào hết. + Người mình mong gặp là người vẫn tử tế khi mệt, nói thẳng khi rối, và sẵn sàng chọn + nhau trong những khoảnh khắc nhỏ rất đời thường. +

+
+``` + +`data-reveal` moves **off** the `
` onto the `

` and the three spans, so no reveal is nested inside another reveal. + +**CSS** (`assets/styles.css`): + +```css +/* Three statements, not one paragraph. transform does not apply to inline + boxes, so each sentence needs its own block — which is also the better + typography here: three short centred statements at 46ch read as spoken + lines rather than as prose. */ +.panel.quiet p span { + display: block; +} + +.panel.quiet p span + span { + margin-top: 0.85rem; +} +``` + +**Code.** Covered by `ENTRANCE['no-test']` + the batch loop. + +**Reduced motion / static.** The three spans are still blocks (intentional in all tiers) and reveal via `story.js` exactly like any other beat. +**Mobile.** Survives intact. At 390px the three statements stack naturally. +**Effort** M — the only markup change in the spec, and it changes typography. **Decline-able**: if you would rather not restructure the paragraph, keep `data-reveal` on the `
` and the slow recipe still gives you the calmest arrival on the page, just as one block. +**Risk** low. Verify the three-block version at 320px and that the sentence split does not orphan a line. + +--- + +### `#closing` — the room dims + +**Beat.** "Kết nối để tìm hiểu thêm nhé." The last spoken beat, and the page's only conversion door. + +**Technique.** The pin keeps its warm vignette (`--closing-settle`) and gains a second, purely decorative job: **the grain-and-vignette overlay deepens across the hold**, so the room quietly darkens around the last words. A lighting change, not a movement. Content opacity is never touched — that is what put the CTAs at opacity 0 last time. + +Separately the panel arrives as **a sheet set down**: the longest, softest landing on the page. + +**Code** — extends the existing closing block: + +```js +if (closing) { + const softBeats = closing.querySelectorAll(REVEAL); + gsap.set(softBeats, { opacity: 0, y: 34, rotation: 0.5 }); + + gsap + .timeline({ + scrollTrigger: { + trigger: closing, + start: 'top top', + end: PIN_END, + pin: true, + pinSpacing: true, + scrub: 1.2, + refreshPriority: 1, + invalidateOnRefresh: true, + }, + }) + .fromTo(closing, { '--closing-settle': 0 }, { '--closing-settle': 1, ease: 'none' }, 0) + // The lights go down on the last line. Decorative only: this drives the + // opacity of the fixed grain/vignette overlay, never any content. + .fromTo(root, { '--room-dim': 0 }, { '--room-dim': 1, ease: 'none' }, 0); + + // Content reveal stays separate and non-reversing. + ScrollTrigger.create({ + trigger: closing, + start: 'top 80%', + once: true, + onEnter: () => { + gsap.to(softBeats, { + opacity: 1, + y: 0, + rotation: 0, + ease: 'power2.out', + duration: 1.4, + stagger: 0.3, + onStart: () => softBeats.forEach((el) => (el.style.willChange = 'opacity, transform')), + onComplete: () => + softBeats.forEach((el) => { + el.style.willChange = ''; + el.classList.add('beat-in'); + el.style.opacity = ''; + el.style.transform = ''; + }), + }); + }, + }); +} +``` + +**CSS** (`assets/story-film.css`): + +```css +/* The grain/vignette layer already exists (styles.css body::before, opacity .1). + During the closing hold it deepens by a maximum of 0.14, which darkens only + the frame edges — the gradient is `transparent 58%` through the middle, so + body text contrast is unaffected. Defaults to 0 in the other tiers. */ +html.film-ready body::before { + opacity: calc(0.1 + var(--room-dim, 0) * 0.14); +} +``` + +**Reduced motion / static.** `--room-dim: 0`, rule is film-scoped. Overlay stays at 0.1 exactly as today. +**Mobile.** Survives; pin shortened to `+=45%`. `body::before` is an already-existing fixed composited layer, so changing its opacity is a compositor-only operation. +**Effort** S. **Risk** low, with one thing to measure: **re-check AA contrast at `--room-dim: 1` in both themes** before shipping. If light theme loses margin, drop the coefficient to 0.10. + +--- + +### `#dossier` — the credits roll + +**Beat.** 18 rows of specifics after the emotional landing. It should feel like credits scrolling past, not a spreadsheet. + +**Technique.** The whole `.facts-list` **lags the scroll very slightly** — a ±36px parallax over the full 1291px (desktop) / 1936px (mobile, measured) of the scene. Imperceptible frame to frame, but the block reads as passing the camera rather than sitting on a page. Transform only; opacity untouched, so it is a legitimate decorative scrub. + +```js +const facts = document.querySelector('#dossier .facts-list'); +if (facts) { + // A slow lag against the scroll — the block passes the camera. Travel is + // halved on phones: .facts-list is a 1936px composited layer at 390px and + // re-compositing it every frame is the second most likely place to drop + // frames on a mid-range Android (after #honest's blur, which is already off). + const travel = isPhone ? 14 : 36; + gsap.fromTo( + facts, + { y: travel }, + { + y: -travel, + ease: 'none', + scrollTrigger: { + trigger: '#dossier', + start: 'top bottom', + end: 'bottom top', + scrub: SCRUB, + invalidateOnRefresh: true, + onToggle: (self) => { + facts.style.willChange = self.isActive ? 'transform' : ''; + }, + }, + }, + ); +} +``` + +**Reduced motion / static.** Film off; the list sits where CSS puts it. +**Mobile.** Kept, at reduced travel. **This is the one I would cut first** if your Android check shows jank — it is the least load-bearing effect in the spec. +**Effort** S. **Risk** low functionally, **medium on mobile performance**. + +--- + +### `#end` — the seal is pressed + +**Beat.** "Hết." The last frame. + +**Technique.** The `TN` seal is **stamped** onto the page — it comes in oversized and tilted and settles square, with a single overshoot. One gesture, 0.85s, then the page is over. It is decorative (`aria-hidden`), so it carries no accessibility risk, and a wax seal closing a letter is the most on-brief gesture available. + +```js +const seal = document.querySelector('.end-card__seal'); +if (seal) { + // Decorative and aria-hidden, so it sits outside the [data-reveal] contract. + // failOpen() is extended below so a build error can never leave it hidden. + gsap.set(seal, { opacity: 0, scale: 1.5, rotation: -12, transformOrigin: '50% 50%' }); + ScrollTrigger.create({ + trigger: '#end', + start: 'top 78%', + once: true, + onEnter: () => + gsap.to(seal, { + opacity: 0.9, // matches .end-card__seal in styles.css + scale: 1, + rotation: 0, + duration: 0.85, + ease: 'back.out(1.5)', + onComplete: () => { + seal.style.opacity = ''; + seal.style.transform = ''; + }, + }), + }); +} +``` + +**`failOpen()` gains two lines** so the seal is covered by the same guarantee as the beats: + +```js +const failOpen = () => { + document.querySelectorAll(REVEAL).forEach((el) => { + el.style.opacity = '1'; + el.style.transform = 'none'; + }); + // The end-card seal is animated outside the [data-reveal] contract. + document.querySelectorAll('.end-card__seal').forEach((el) => { + el.style.opacity = ''; + el.style.transform = ''; + }); +}; +``` + +**Reduced motion / static.** Never touched; renders at `opacity: .9` from CSS. +**Mobile.** Survives intact — one 48px element. +**Effort** S. **Risk** low. + +--- + +## 5. The hero moment — `#applicant`: the photograph is lifted off the desk + +**Why this scene.** Three reasons, in order. (1) **Reach** — it is scene 2; anyone who scrolls at all reaches it, and it is precisely the moment she is deciding whether this person is interesting. (2) **It is the protagonist reveal.** The set-piece should serve the single most important thing on the page, and that is his face. (3) **It is the only place on this page where a "turn" is honest.** The owner asked for things that "flip around"; the sincere version of that is not a card spinning, it is *picking up a photograph from a table to look at it*. The hero photo has already established that table. + +**The gesture.** The portrait starts lying almost flat on the desk — foreshortened, small, sitting under a tight contact shadow, its near edge on the surface. As she scrolls, it **hinges up on its bottom edge toward her**, growing and taking a real drop shadow, until it faces the reader square. Then it stays up. She controls the rise with her thumb. + +**Why it is not a card flip.** It rotates about a *horizontal hinge along its own bottom edge* (`transform-origin: 50% 100%`), through **68° and not 180°**, so it never turns past the plane and there is **no back face, no `backface-visibility`, no two-sided markup**. That mechanical restriction is what makes it read as an object obeying gravity rather than as a transition effect. + +**Verified.** Applied inline at 390×844 and screenshotted: it reads correctly as a photo lying at a grazing angle, **zero layout shift** (the `
` box is unchanged, the flattened image simply sits at the bottom of its reserved 4/5 box), and **zero horizontal overflow** (`scrollWidth` stays 390). The empty space above the flattened photo is exactly the space it rises into, which is what sells it. + +**Code** — placed after the pins, before or alongside the batch loop: + +```js +/* ---------- Scene 2 set-piece: the photograph is picked up ---------- + The portrait starts lying on the desk (hinged on its own bottom edge, + 68deg, never past the plane, so there is no back face) and rises to + face the reader as she scrolls. This is the one place on the page where + a turn is physically motivated: the hero frame establishes a desk, and + this is a photograph on it. + + The scrub is intentional — 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 at 68deg but never + invisible, contains nothing focusable, and its accessible name (the + placeholder's aria-label, or the img alt once the real photo lands) is + unaffected by any transform. + + Once it is fully up, the trigger kills itself so the face never lies + back down if she scrolls up to re-read scene 1. Picked up once, like + the reveal-once contract every other beat honours. */ +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, + }); + + const lift = 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' : ''; + }, + onLeave: (self) => { + // Lock it upright. kill(false) leaves the inline transform where + // it is, so set the resting state explicitly first. + gsap.set(portrait, { rotationX: 0, scale: 1, y: 0 }); + portrait.style.willChange = ''; + self.kill(false); + }, + }, + }); + void lift; +} +``` + +**CSS** — none required. The whole set-piece is inline transforms on one element. (Optional polish, film-scoped: a contact shadow that opens as it rises. Skip it in v1; the geometry already reads.) + +**Reduced-motion fallback.** The film never activates, so `gsap.set` never runs and the portrait renders upright exactly as it does today. Nothing to add. + +**Mobile.** **Survives fully and is arguably better on a phone** — the rise is thumb-driven, which is the whole point. At 390px the portrait is full-width in a single-column grid, so the gesture is larger and more legible than on desktop. Two mobile notes: the `end: 'top 28%'` window is ~440px of scroll at 844 height, which is one comfortable swipe; and `scrub: 0.6` on touch is load-bearing here — with `scrub: true` the rise will step during momentum flicks instead of tracking. **[unverified on real hardware]** + +**Frame cost.** Negligible: one small element, `transform` only, `will-change` held only while the trigger is active. This is the cheapest thing in the spec and the most memorable. + +**Effort** M. **Risk** low-medium. Two things to check: (a) `self.kill(false)` inside `onLeave` — confirm no console warning and that the portrait stays upright after a full down-and-back-up pass; (b) once the real `assets/tien.jpg` lands, re-check that a photograph (rather than a flat placeholder) still looks right at 68° — a face at a grazing angle can look odd, and the answer may be to reduce the start angle to ~58°. + +**One dependency worth stating plainly.** This set-piece currently lifts a grey silhouette captioned "Ảnh thật cập nhật sau". It works, but the *memorable* version needs the real photo. This is the strongest possible argument for getting `assets/tien.jpg` from the owner: the page's single best moment is built on it. + +--- + +## 6. Mobile — 390px, film on, pins on, scrub on + +Measured, not assumed: with the gate removed, the film tier runs at 390×844 with both pins intact, no horizontal overflow, and **the page grows from 8149px to 10282px — +26%, about twelve viewport heights.** That is the headline cost of this decision and it is worth naming before anything else. + +**Item 0 — fix this first, it is not optional.** GSAP leaves `transform: translate(0px, 0px)` inline on every beat forever (verified), which permanently outranks `.panel:hover { transform: translateY(-3px) }`. Irrelevant on touch, but my recipes bake `rotate()` and `translateX()` into that residue too. The `.beat-in` class handoff in §3c fixes it for both tiers at once. + +**Survives touch scrolling unchanged** + +- `#home` push-in + pan (pin, scrub) — pan is a percentage, scales with viewport. +- `#applicant` portrait lift (scrub) — better on a phone than on desktop. +- `#offer` pen marks, `#shared` margin rule, `#end` seal — one-shot, tiny, `once: true`. +- `#closing` vignette + room dim — compositor-only opacity on layers that already exist. +- All entrance recipes minus the two exceptions below. + +**Must degrade** + +| Technique | Phone behaviour | Why | +|---|---|---| +| `#honest` focus pull (`filter: blur`) | replaced by `scale: 1.015 → 1` | `filter` re-rasterizes the layer every frame; three panels at once is the single most likely dropped-frame source in this spec | +| `#applicant` panel `x: 18` slide | dropped, `y + rotation` only | 16px gutter at 390px — nowhere to slide from, and it is the one technique that could widen the page | +| `#dossier` credits roll | ±36px → ±14px | a 1936px composited layer at 390px; cut this entirely if Android jank shows | +| Both pins | `+=60%` → `+=45%` | 506px of pinned scroll per pin is a lot of thumb on an already 26%-longer page | +| Scrub value | `true`/`0.5` → `0.6` numeric | touch delivers scroll events in bursts during momentum; a numeric scrub lerps across the gaps so nothing looks steppy | + +**Phone-first version of the direction.** Identical, and that is the point — every technique here is either a camera property (scale, pan, focus, light level) or a single-element transform. None of it depends on hover, a cursor, a wide viewport, or a two-column layout. On a phone the film is actually *stronger*: the portrait lift becomes a full-width, thumb-driven gesture rather than a 256px detail in a side column. + +**Two changes I would make specifically because the film now runs on phones** + +```css +/* 100vh on mobile is the *largest* viewport height, so every scene is taller + than the visible area while the address bar is showing, and the page + re-lays-out when the bar hides — which forces a ScrollTrigger refresh and can + jump a pin mid-scroll. svh is the stable small-viewport unit. */ +html.film-ready .film-scene { + min-height: 100vh; + min-height: 100svh; +} +``` + +and, in `story-film.js`, debounce the refresh that orientation/URL-bar resize triggers: + +```js +// A phone fires resize when the address bar hides. Refreshing on every one of +// those re-measures two pins and 40+ triggers mid-scroll. +ScrollTrigger.config({ ignoreMobileResize: true }); +``` + +**Flagged for your mid-range Android verification pass**, in descending order of expected cost: + +1. `#honest` blur — **already disabled on phones** in this spec; verify the scale fallback is enough. +2. `#dossier` credits roll on a 1936px layer. +3. Two `position: fixed` pins coexisting with `body::before` (fixed full-viewport SVG-noise overlay) and `body::after` (fixed 26s infinite bloom drift) — both pre-existing, both now competing with pinning on the weakest tier. +4. `--hero-scale` on `.home-hero::before`, a full-viewport background-image layer at scale 1.07 across the whole pin. +5. Lenis `syncTouch` — **[unverified]**. Lenis 1.x leaves touch scrolling native by default. If scrubs look steppy on real hardware, the choice is `syncTouch: true` (smooths touch, feels heavy on some devices and is the more invasive change) versus raising the scrub number further. Try the scrub number first. +6. iOS pin behaviour with `pinType` — **[code-read]**. Lenis scrolls the window natively, so ScrollTrigger's default `pinType: 'fixed'` should be correct; confirm on a real iPhone rather than in DevTools device mode. + +--- + +## 7. What to reject + +Considered and turned down for **this** page, not in general. + +| Rejected | Reason | +|---|---| +| **3D card flips** (`rotateY(180)`, front/back faces) | A card with two faces is a UI widget, not an object. Reads as PowerPoint and would undo the sincerity in one gesture. The portrait lift is the honest version: one face, a hinge on the object's own edge, and it stops before the plane. | +| **3D tilt / parallax on hover** | Requires a cursor; the primary reader is on a phone. And a tilting portrait says "product shot", not "this is me". | +| **Typewriter text** | On Vietnamese, character-by-character reveal makes diacritics pop in after their base glyphs — twitchy and slightly comic. It also fakes live authorship on a page whose brief is honesty. | +| **Per-character `SplitText`** | Same diacritic problem. `SplitText` was measured at 7.6KB min from jsDelivr and is genuinely free in 3.13+, but line-level is the only safe granularity here, and the one place that wanted it (`#no-test`) gets a better result from three ``s at 0KB. | +| **GSAP `Flip`** (24.9KB min, verified 200) | Flip earns its weight when an element must travel between two *different layout positions*. Nothing on this page does. Loading 25KB to animate things that stay put is exactly the "look what CSS can do" failure mode. | +| **GSAP `MotionPath`** (21.5KB min, verified 200) | Nothing travels along a curve. A letter does not arc across a desk. | +| **Horizontal scroll hijack** | The reader is holding a phone and reading a letter. Turning the vow list into a sideways carousel would be the single most alienating thing available. | +| **Scroll-jacked slideshow / snap-to-scene** | Removes her control over pace. On a page asking her to read carefully and re-read the honest parts, taking away the scrollbar is hostile. | +| **Cursor followers, magnetic buttons, custom cursors** | Desktop-only theatre, and the vocabulary of an agency portfolio. Actively works against "sincere". | +| **WebGL / Three.js / shader dissolves** | I can build them and they would be wrong. Hundreds of KB, a second render pipeline, three more failure modes, on a static one-pager whose emotional register is a handwritten letter. | +| **Confetti, hearts, particles on the CTA** | The page's climax is "không có bài kiểm tra nào cả". Confetti after that line is a joke at its own expense. | +| **Text-scramble / glitch effects** | Belongs to a different genre entirely, and mangles diacritics. | +| **Counting-up numbers on the dossier** (26, 162cm, 65kg) | Turns honest disclosures into a scoreboard, which is exactly the corporate register that was just removed. | +| **A second vignette on `#no-test`** | Considered and dropped. `#closing` already has one; a second would make the device visible as a device. The climax's stillness *is* its effect. | +| **Dimming the surrounding content to spotlight the climax** | Either it reverses (banned) or it leaves body copy permanently at reduced opacity. She may want to re-read it. | +| **Page-curl / paper-fold on scene transitions** | Genuinely material and genuinely on-metaphor — and still rejected. A full page-turn between scenes needs either a pin per scene (another 3000px of scroll on a phone, on top of the 26% already added) or 3D geometry with a back face. Costs too much of exactly the resource the phone tier has least of. | +| **Animating the hero `background-size`** | Banned by invariant 6 and it repaints a full-viewport layer every frame. `transform: scale()` on the `::before` already does it for free. | + +--- + +## 8. Sequencing + +Cheapest and highest impact first. Each step is independently shippable and independently revertible. + +| # | Item | Effort | Impact | Notes | +|---|---|---|---|---| +| 1 | **`.beat-in` handoff + `overflow: clip visible` guard** (§3c, §3d) | S | enabling | Not a visible feature. Everything after this depends on it, and it fixes the already-broken `.panel:hover`. | +| 2 | **Per-scene entrance recipes** (§3b) | S | **highest** | One object. Immediately kills the "every beat does the same thing" monotony across all nine scenes. Ship this alone and the page is already noticeably less uniform. | +| 3 | **Hero pan + slate rule draw** (`#home`) | S | high | First frame, everyone sees it, two lines of CSS. | +| 4 | **The portrait lift** (`#applicant`, §5) | M | **highest** | The set-piece. Do it after 1–3 so the surrounding beats already read correctly. | +| 5 | **Pen marks + margin rule** (`#offer`, `#shared`) | S | medium | Two small film-scoped CSS rules, two batches. Pure craft. | +| 6 | **Room dim + set-down landing** (`#closing`) | S | medium | Re-measure AA contrast at `--room-dim: 1` in both themes before shipping. | +| 7 | **Seal press** (`#end`) + `failOpen()` extension | S | medium | Last frame. Cheap, and it gives the page an ending gesture. | +| 8 | **Mobile hardening**: `100svh`, `PIN_END`, `ignoreMobileResize`, `SCRUB` | S | high on phone | Do this *before* the Android verification pass or you will be measuring the wrong build. | +| 9 | **`#no-test` sentence split** | M | medium | Only markup + typography change in the spec. Decline-able. | +| 10 | **`#dossier` credits roll** | S | low | Least load-bearing. First thing to cut if Android drops frames. | + +**Net library cost: 0KB.** No new plugins. Everything above uses GSAP core + ScrollTrigger, which are already loaded. + +--- + +## 9. Unresolved questions + +1. **`assets/tien.jpg`.** The set-piece is "a photograph is picked up". It currently picks up a grey silhouette. It works, but the version she would remember needs the real photo. Is it coming? +2. **The `#no-test` sentence split** (item 9) is the only place I change typography. Three centred statements read better to me than one centred paragraph, but it is a real design change on the page's climax — do you want it, or should the climax stay one block with the slow timing only? +3. **`--room-dim` contrast.** I capped the overlay deepening at +0.14 by reasoning about the gradient's `transparent 58%` centre stop, not by measurement. Someone should measure `--muted` on `--panel-bg` at `--room-dim: 1` in the light theme before this ships. +4. **Portrait start angle** — 68° is right for a flat placeholder. A real face at 68° may look strange; expect to tune toward ~58° once the photo exists. +5. **`ScrollTrigger.config({ ignoreMobileResize: true })`** is a page-wide setting and I could not test its interaction with Lenis on a real device. It is the standard recommendation, but verify it does not leave a pin mis-measured after an orientation change. +6. **The +26% mobile page length** is a product decision, not a motion one. Two pins and nine `min-height: 100vh` stages turn 8149px into 10282px at 390×844. If that is too long, the cheapest reductions are `PIN_END` at `+=35%` and dropping `min-height` to `85svh` on phones — both one-liners, neither affects any technique in this spec. +7. Someone else is driving the default `agent-browser` session (I found it on `127.0.0.1:8200`, "Motion Lab · Thư ngỏ — 8 cách chuyển động"). If a parallel motion exploration is running, this spec should be reconciled with it before implementation rather than merged blind. diff --git a/plans/reports/from-ui-ux-designer-to-controller-260725-1210-motion-lab-prototype-report.md b/plans/reports/from-ui-ux-designer-to-controller-260725-1210-motion-lab-prototype-report.md new file mode 100644 index 0000000..8f0dbae --- /dev/null +++ b/plans/reports/from-ui-ux-designer-to-controller-260725-1210-motion-lab-prototype-report.md @@ -0,0 +1,130 @@ +# Motion lab prototype — 8 cinematic techniques on the real page content + +**Date:** 2026-07-25 · **Author:** ui-ux-designer · **For:** controller / owner + +## How to open it + +```powershell +cd "C:\Users\miti99\AppData\Local\Temp\claude\C--Users-miti99-Workspaces-tiennm99-dating\c3fc8c9c-6456-47bc-9360-81a0a9055d7d\scratchpad\motion-lab" +python -m http.server 8200 +``` + +Then open + +- File: `C:\Users\miti99\AppData\Local\Temp\claude\C--Users-miti99-Workspaces-tiennm99-dating\c3fc8c9c-6456-47bc-9360-81a0a9055d7d\scratchpad\motion-lab\index.html` +- Self-contained: one HTML file + one copied hero image. Inline CSS/JS. GSAP 3.15.0 + ScrollTrigger from jsDelivr. +- Nothing in the repo was touched except this report. +- Top bar has: light/dark (site's real tokens), a reduced-motion simulator, and a live fps read-out. +- Each demo autoplays on scroll-in and has a **Xem lại** (replay) button. +- All copy is the page's real Vietnamese, unchanged. + +## What was built + +| # | Technique (EN) | Label on page | Content used | Extra KB | Mobile verdict | Measured | +|---|---|---|---|---|---|---| +| A | Photo laid onto a table | Đặt tấm ảnh xuống bàn | Cảnh 2 portrait slot | 0 | **Use** (cut drop to ~40px) | 59 fps / 16.8 ms | +| B | Card turning over (3D) | Tấm thiệp lật mặt | Cảnh 2 panel → Cảnh 3 panel | 0 | Careful — faces must match height | 59 fps / 16.9 ms | +| C | Envelope opening | Phong thư mở ra | Cảnh 4 lead ("Mình không có gì to tát để hứa…") | 0 | **Use** (letter ≤ 40 words) | 60 fps / 16.9 ms | +| D | Camera push-in / pull-back | Máy quay tiến vào rồi lùi ra | Real hero image + Cảnh 1 copy | 0 | Turn off | 60 fps / 16.9 ms | +| E | Layered parallax (4 layers) | Chiều sâu nhiều lớp | Hero + Cảnh 5 heading | 0 | Half magnitude (≤22px) | 60 fps / 16.9 ms | +| F | Word-level text reveal | Chữ hiện lên từng từ | Cảnh 5 vow line 1 | 0.3 (hand-rolled) / 7.7 (SplitText) | **Use**, 1–2 lines only | 60 fps / 16.9 ms | +| G | Cards dealing from centre | Chia bài ra từ giữa | Cảnh 3 three panels | 0 | Turn off (single column) | 60 fps / 17.1 ms | +| H | A line drawing itself (SVG) | Nét vẽ tự chạy | Cảnh 7 closing line + TN seal + "Hết." | 0 (hand-rolled) / 4.3 (DrawSVG) | Use | 60 fps / 16.9 ms | + +Three demos ship a **deliberately bad version side by side**: A, B and F. + +**Substitution made:** "cross-dissolve between two scenes" was dropped and demo H kept in its place. The page already cross-dissolves — the hero's bottom gradient melts into `--background`, and every `data-reveal` beat is an opacity tween. A demo of it would have shown the owner what he already has. + +## Ranked recommendation — put these three on the real page + +### 1. C · Envelope opening — the only one that means something + +Every other technique here is decoration. This one is the page's own metaphor: it is a **thư ngỏ**, an open letter. Opening an envelope to get to Cảnh 4 · Lời mời is not an effect, it is the page telling the reader what it is. Costs nothing beyond GSAP, which is already loaded. Built entirely from CSS `clip-path` — no image assets. + +Use it **once**, as the gate into Cảnh 4. Twice and it becomes a gimmick. + +### 2. A · Photo laid onto a table — because the portrait is the point + +The portrait slot is the single most important object on a dating page, and right now it arrives with the same weightless 40px fade-up as a paragraph of text. Put the two versions in the lab side by side and the difference is not subtle: one is a photograph being placed on a table by a person, the other is a div appearing. + +Three things make it work, and all three matter: real travel with a tilt that unwinds, a cast shadow that blooms wide then tightens on contact, and one small elastic settle after contact. Drop any one and it goes back to being a fade. + +Use it **once**, on the portrait. Not on the panels. + +### 3. F · Word-level reveal — but only on the four vow lines + +Cảnh 5's four lines are the closest thing the page has to a promise. Words rising one at a time out of their own mask makes them read at the speed of someone speaking them. On body paragraphs the same effect is exhausting; on four large serif lines it is exactly right. + +Hand-rolled it is 12 lines of code and 0.3 KB. GSAP SplitText buys a `mask` option for 7.7 KB — not worth it here. + +**Honourable mention: H · the drawn line.** It is the best-looking thing in the lab (see `light-390-0h.png`) and costs nothing. I did not put it in the top three only because three signature moves on a seven-scene page is already the ceiling. If the owner drops one of the three above, this is the replacement. + +## What I would reject, and why + +**B · Card flip — good technique, wrong page.** The flip demo is genuinely well made; the difference against the cheap version is the clearest 3D lesson in the lab. But shipping it means half of Cảnh 3's honest copy is hidden behind an interaction at any given moment. That section exists so a stranger does not have to guess or dig. Hiding "162cm là số đo thật, không cộng dép" behind a flip is the page arguing with itself. It also can't be found by Ctrl-F, needs real work to be readable by a screen reader, and permanently taxes the owner to keep two faces the same height. + +**G · Dealing cards — tonally wrong and dies on mobile.** A deck-of-cards metaphor over the section where he says he lives simply, doesn't smoke, and isn't chasing anything flashy. It also collapses to nothing on a phone: in a single column "dealing" is just three cards stacking, which means the majority tier gets an effect that no longer makes sense. + +**D · Stronger push-in — reject as an upgrade, not as a technique.** The site already runs 1.00 → 1.07 on a pin, and that is the right amount. The lab version goes to 1.16 with a pull-back, and at that magnitude the reader can *see* the camera. A cold open should feel like light changing, not like a zoom lens. Keep what exists. + +**E · Parallax — correct but not worth its cost.** It works, the magnitudes (10/22/30/44 px) are tasteful, and nobody consciously notices it. That last part is the problem: four extra composited layers on the heaviest scene of the page, on phones, for an effect whose success condition is that it goes unnoticed. Take it only if it turns out to be free, and it isn't free on mobile. + +## Performance + +Measured in-page (rAF sampling over the full duration of each move), desktop headless Chrome at 1440×900: + +- All 8 demos: **59–60 fps, worst frame ≈17 ms** — i.e. zero dropped frames. +- Everything animates `transform` and `opacity` only. The cast shadows are separate elements with a **static** `box-shadow` whose opacity and scale animate; nothing animates `box-shadow`, `filter`, `width/height`, `top/left`, or `background-size`. +- Exception, disclosed on the page: demo H animates `stroke-dashoffset`, which is a paint operation, not a composite. Acceptable because the paths are tiny and it runs once. +- No horizontal overflow at 390px (`scrollWidth === clientWidth === 390`). + +**One real bug found and fixed while building, worth carrying into the real page:** the envelope writes `flap.style.zIndex` when the flap crosses vertical. Written unconditionally inside GSAP's `onUpdate`, it invalidated style on every frame and produced a measurable **47 fps / 50 ms spike**. Guarding the write so it only fires on the crossing brought it to 60 fps / 16.9 ms. Rule: never write to the DOM inside a per-frame callback unless the value actually changed. + +The fps figures are desktop headless Chrome. They do **not** predict a mid-range Android. The per-demo "mobile" verdicts in the table are the number that matters. + +## The bad examples — the most useful thing in the lab + +**F is the one to look at.** Per-character splitting of Vietnamese fails two independent ways at once, and both are visible in one screenshot (`dark-1440-0f.png`): + +1. Diacritics arrive on their own timeline. "bằng" plays as b–a–n–g with a tone mark drifting in separately. 61 timelines instead of 13, and 61 composited layers instead of 13. +2. The browser breaks a word mid-syllable. Because each grapheme is its own inline-block, "lặp" wrapped as **"nhỏ l / ặp lại"**. That is not a tuning problem; it is structural. + +The good version also shows the fix nobody mentions: a word mask needs `padding: 0.22em 0 0.16em` with a matching negative margin, or the mask clips the circumflex on "ề" and the dot under "ạ". + +**A's bad version** is literally what the site does today (`opacity 0→1`, `y 40→0`, `expo.out`, 0.8s). It is not broken — it is weightless. Nothing arrives. + +**B's bad version** removes `perspective` from the parent and uses 0.4s linear. Without perspective, `rotateY` is an orthographic squash: the card compresses to a vertical line and re-expands. It reads as a rendering glitch, which is why most flips on the web look cheap. + +## Reduced motion + +The bar's "CĐ giảm" toggle drives the same code path as `prefers-reduced-motion: reduce`, so what you see is what a motion-sensitive visitor gets. Every demo degrades to a **180 ms opacity fade to the finished state** — no travel, no rotation, no scrub: + +- A: photo appears flat, no tilt, no shadow bloom (verified: `dark-390-02-reduced-motion-A.png`). +- B: no rotation at all; the two faces cross-fade. +- C: the envelope is simply already open, letter already out. +- D/E: timeline parked at 50%, the mid-frame, and left there. +- F: full sentence, no stagger. +- H: line already drawn. + +## Screenshots + +All under `C:\Users\miti99\AppData\Local\Temp\claude\C--Users-miti99-Workspaces-tiennm99-dating\c3fc8c9c-6456-47bc-9360-81a0a9055d7d\scratchpad\motion-lab\shots\` + +| File | What | +|---|---| +| `dark-1440-00-fullpage.png` | whole lab, dark, desktop | +| `dark-1440-01-intro.png` | intro + chrome | +| `dark-1440-02-A.png` | A good vs bad | +| `dark-1440-0b.png` … `dark-1440-0h.png` | demos B–H, dark, 1440×900 | +| `light-1440-0a.png`, `-0c.png`, `-0f.png`, `-0h.png` | light theme, desktop | +| `dark-390-01-intro.png`, `-0a.png`, `-0c.png`, `-0d.png`, `-0f.png` | dark, 390×844 | +| `light-390-0b.png`, `-0g.png`, `-0h.png` | light, 390×844 | +| `dark-390-02-reduced-motion-A.png` | reduced-motion degraded state | + +## Unresolved questions + +1. **Is the portrait ever going to exist?** Demo A is built on the placeholder. Laying down a placeholder silhouette is a strange thing to dramatise. If the real photo is not coming soon, A should wait. +2. **Where does the envelope actually sit?** I built it as the gate into Cảnh 4, but it could equally open at the very top as the page's cold open, replacing the hero photo. That is a bigger structural change and needs the owner's call before anyone builds it. +3. **Does the film tier stay desktop-only?** `story-film.js` currently gates at `min-width: 768px`. A, C and F all work fine at 390px. If they ship, does the phone tier get them, or does it stay on the static reveal? This decides whether the majority of visitors ever see any of this. +4. **Three signature moves or one?** My recommendation is three. A more restrained reading of the brief ("honest, restrained") would ship only C and leave everything else on the existing fade. I would not argue hard against that. +5. **No mid-range Android was tested.** The fps numbers are desktop headless Chrome. Before shipping A + C + F together, one run on a real phone is worth more than all of these numbers.