From e2d09fd3de0d0ff06b94fd46c46a9472a9d8025d Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 24 Jul 2026 23:34:28 +0700 Subject: [PATCH] @ feat: turn dating packet into scroll-driven short film Rearrange the JD/CV content into a 7-scene narrative arc that opens on the person, builds through the offer, and closes on sincerity, with the full facts kept as a dossier appendix. Layer a GSAP + ScrollTrigger + Lenis film engine that scrubs each scene into view and pins the opening and closing title cards. Keep the progressive-enhancement contract: the film only activates on wide viewports with motion allowed and the CDN present; otherwise the existing static reveal runs. Scope the reveal styles so the two engines never animate the same content, and fail open so content is never trapped hidden. @ --- assets/story-chrome.js | 5 +- assets/story-film.css | 38 ++ assets/story-film.js | 128 +++++++ assets/story.css | 14 +- assets/story.js | 8 + index.html | 358 ++++++++++-------- .../phase-01-foundation.md | 52 +++ .../phase-02-scenes.md | 84 ++++ plans/260724-2229-scroll-film-story/plan.md | 101 +++++ 9 files changed, 612 insertions(+), 176 deletions(-) create mode 100644 assets/story-film.css create mode 100644 assets/story-film.js create mode 100644 plans/260724-2229-scroll-film-story/phase-01-foundation.md create mode 100644 plans/260724-2229-scroll-film-story/phase-02-scenes.md create mode 100644 plans/260724-2229-scroll-film-story/plan.md diff --git a/assets/story-chrome.js b/assets/story-chrome.js index f3455fd..e2651bb 100644 --- a/assets/story-chrome.js +++ b/assets/story-chrome.js @@ -48,8 +48,9 @@ if (id) navLinks.set(id, link); }); - // DOM order matters: used to pick the topmost in-view section. - const sections = ['home', 'jd', 'cv'] + // DOM order matters: used to pick the topmost in-view section. Mirrors the + // nav targets in index.html (the four scenes that have a nav link). + const sections = ['home', 'applicant', 'offer', 'dossier'] .map((id) => document.getElementById(id)) .filter(Boolean); diff --git a/assets/story-film.css b/assets/story-film.css new file mode 100644 index 0000000..0707e76 --- /dev/null +++ b/assets/story-film.css @@ -0,0 +1,38 @@ +/* story-film.css — layout + pre-hide for the scroll-driven "short film" layer. + Everything here is scoped under `html.film-ready`, a class added by + assets/story-film.js ONLY when the film activates (wide viewport, motion + allowed, CDN libraries present). With JS off, on phones, or under + reduced-motion this class is never set, so none of these rules apply and the + page renders exactly as the static reveal (story.css) presents it. + + This file owns FILM layout + starting opacity only. Colors, spacing, and type + come from styles.css and are never redefined here. */ + +/* Each scene becomes a full-height stage so beats have room to scrub in and + pinned title cards hold cleanly. min-height (not fixed height) lets + content-heavy scenes grow past the viewport instead of clipping. */ +html.film-ready .film-scene { + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; +} + +/* Pre-hide beats so there is no flash before GSAP takes over. story-film.js + sets the same values via gsap.set(); this rule only covers the gap between + first paint and the script running. failOpen() overrides both with inline + styles, so this can never trap content. */ +html.film-ready [data-scene] [data-reveal] { + opacity: 0; + transform: translateY(40px); + will-change: opacity, transform; +} + +/* 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] { + opacity: 1; + transform: none; + } +} diff --git a/assets/story-film.js b/assets/story-film.js new file mode 100644 index 0000000..4edd22c --- /dev/null +++ b/assets/story-film.js @@ -0,0 +1,128 @@ +// story-film.js — scroll-driven "short film" choreography. +// +// Turns each chapter into a scene whose beats scrub into view as the reader +// scrolls (GSAP + ScrollTrigger), gliding on Lenis smooth scroll. Title-card +// scenes ([data-scene-pin]) pin briefly for a dramatic hold. +// +// Relationship to the older reveal engine (assets/story.js): +// * This file runs BEFORE story.js (script order in index.html). +// * When the film activates it adds `html.film-ready`; story.js then bails and +// story.css stops hiding [data-reveal] (its rules are scoped +// `html.js-ready:not(.film-ready)`). GSAP owns the motion instead. +// * When the film does NOT activate (narrow screen, reduced motion, or the +// CDN libraries are missing) this file returns early and the existing +// story.js reveal runs unchanged — the guaranteed fallback. +// +// Fail-open: any error during setup calls failOpen(), which force-shows every +// beat with inline styles that win over both stylesheets, so content is never +// trapped hidden. Loaded with `defer`, mirroring the other enhancement scripts. +(() => { + 'use strict'; + + const root = document.documentElement; + const REVEAL = '[data-reveal]'; + + // Force every beat visible regardless of stylesheet state. Inline opacity/ + // transform beat both story.css and story-film.css (neither uses !important + // on these properties), so this is a hard guarantee against trapped content. + const failOpen = () => { + document.querySelectorAll(REVEAL).forEach((el) => { + el.style.opacity = '1'; + el.style.transform = 'none'; + }); + }; + + // Activation gate. The film is a progressive enhancement on top of the + // static reveal; only take over when all three hold: + // * viewport wide enough that pinning/scrubbing reads well (not phones), + // * the reader has not asked to reduce motion, + // * the CDN libraries actually loaded. + // Otherwise return and let story.js handle the reveal as it does today. + const wideEnough = window.matchMedia('(min-width: 768px)').matches; + const motionOK = window.matchMedia('(prefers-reduced-motion: no-preference)').matches; + const libsReady = + typeof window.gsap !== 'undefined' && + typeof window.ScrollTrigger !== 'undefined' && + typeof window.Lenis !== 'undefined'; + + if (!wideEnough || !motionOK || !libsReady) { + return; + } + + try { + const { gsap, ScrollTrigger, Lenis } = window; + gsap.registerPlugin(ScrollTrigger); + + // Marks the page as "the film is running": disables story.css reveal and + // activates the story-film.css pre-hide + scene layout. + root.classList.add('film-ready'); + + // Smooth momentum scroll, bridged into ScrollTrigger so scrubbing tracks + // the eased scroll position rather than raw wheel deltas. + const lenis = new Lenis({ duration: 1.1, smoothWheel: true }); + lenis.on('scroll', ScrollTrigger.update); + gsap.ticker.add((time) => lenis.raf(time * 1000)); + gsap.ticker.lagSmoothing(0); + + // One scrubbed entrance timeline per scene: beats rise + fade in as the + // scene crosses the viewport. Reversible — scrolling back re-hides them, + // which reads as the camera moving off the scene. + gsap.utils.toArray('[data-scene]').forEach((scene) => { + const beats = scene.querySelectorAll(REVEAL); + if (!beats.length) return; + + gsap.set(beats, { opacity: 0, y: 40 }); + gsap.to(beats, { + opacity: 1, + y: 0, + ease: 'power2.out', + stagger: 0.12, + scrollTrigger: { + trigger: scene, + start: 'top 78%', + end: 'top 32%', + scrub: 1, + }, + }); + }); + + // Title-card scenes pin for a short hold before releasing to the next + // scene. Only used on short scenes (open + close) so pinned content never + // exceeds the viewport. + gsap.utils.toArray('[data-scene-pin]').forEach((scene) => { + ScrollTrigger.create({ + trigger: scene, + start: 'top top', + end: '+=60%', + pin: true, + pinSpacing: true, + }); + }); + + // Pins change total scroll height; recompute all triggers once now and on + // full load (fonts/images can shift layout after this script runs). + ScrollTrigger.refresh(); + window.addEventListener('load', () => ScrollTrigger.refresh(), { passive: true }); + + // Safety net (mirrors story.js): if the opening scene's beats are still + // hidden shortly after load while in view, something went wrong building + // the timelines — force everything open. + window.addEventListener( + 'load', + () => { + window.setTimeout(() => { + const firstBeat = document.querySelector('[data-scene] ' + REVEAL); + if (!firstBeat) return; + const hidden = parseFloat(getComputedStyle(firstBeat).opacity) === 0; + const rect = firstBeat.getBoundingClientRect(); + const inView = rect.top < window.innerHeight && rect.bottom > 0; + if (hidden && inView) failOpen(); + }, 400); + }, + { passive: true }, + ); + } catch { + // Any failure: reveal everything so content is never trapped hidden. + failOpen(); + } +})(); diff --git a/assets/story.css b/assets/story.css index dd01ad5..61ad516 100644 --- a/assets/story.css +++ b/assets/story.css @@ -19,8 +19,10 @@ } /* Hidden starting state. Only applied once JS can guarantee a later reveal, so - content is never trapped invisible for visitors without the enhancement. */ -html.js-ready [data-reveal] { + content is never trapped invisible for visitors without the enhancement. The + `:not(.film-ready)` guard hands motion to the GSAP film layer (story-film.js) + when it activates, so beats are never hidden by two engines at once. */ +html.js-ready:not(.film-ready) [data-reveal] { opacity: 0; /* translateY only — no height/display change, so nothing reflows (zero CLS). */ transform: translateY(var(--reveal-distance)); @@ -32,14 +34,14 @@ html.js-ready [data-reveal] { /* Closing "Lời kết" variant: same gentle rise, longer duration to let the warm final beat settle rather than snap in. */ -html.js-ready [data-reveal='soft'] { +html.js-ready:not(.film-ready) [data-reveal='soft'] { transition: opacity var(--reveal-duration-soft) var(--reveal-ease), transform var(--reveal-duration-soft) var(--reveal-ease); } /* Revealed state added by story.js when the element (or its group) enters view. */ -html.js-ready [data-reveal].is-visible { +html.js-ready:not(.film-ready) [data-reveal].is-visible { opacity: 1; transform: none; } @@ -49,8 +51,8 @@ html.js-ready [data-reveal].is-visible { explicitly (not relying on the global duration override in styles.css) so [data-reveal] can never render invisible for these visitors. */ @media (prefers-reduced-motion: reduce) { - html.js-ready [data-reveal], - html.js-ready [data-reveal='soft'] { + html.js-ready:not(.film-ready) [data-reveal], + html.js-ready:not(.film-ready) [data-reveal='soft'] { opacity: 1; transform: none; transition: none; diff --git a/assets/story.js b/assets/story.js index ca30add..68df6c3 100644 --- a/assets/story.js +++ b/assets/story.js @@ -24,6 +24,14 @@ const root = document.documentElement; + // The scroll-driven film (story-film.js, which runs before this script) owns + // the motion when active — it sets `html.film-ready`. Bail so the two engines + // never animate the same beats. story.css also disables its reveal under + // `.film-ready`, so leaving early here changes nothing for those visitors. + if (root.classList.contains('film-ready')) { + return; + } + // No IntersectionObserver → skip the whole enhancement. Because story.css // hides content only under `html.js-ready` (never added here in that case), // everything stays visible. diff --git a/index.html b/index.html index 98b3336..7e5137e 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ Tiến Nguyễn Minh | Mô tả & hồ sơ hẹn hò @@ -21,7 +21,7 @@ + + + + + + + @@ -84,9 +93,10 @@
@@ -115,166 +125,36 @@
- -
+ +
-

Chương 1 · Lời mời

+

Cảnh 1 · Vị trí bỏ trống

Vị trí mở / bạn đồng hành dài hạn

Tiến Nguyễn Minh

- Một bộ hồ sơ nhỏ gửi đến một người yêu tương lai: vừa là mô tả công việc, vừa là hồ sơ - cá nhân, vừa là lời mời nghiêm túc để cùng xây điều tử tế. + Một vị trí đã bỏ trống hơi lâu. Một anh dev hay ở nhà, cũng muốn đi chơi đấy — nhưng + lười đi một mình. Đang tìm một người cùng đi đường dài, nghiêm túc hơn cả cách mình + cày event Genshin.

-
-
-

Chương 2 · Bộ hồ sơ

-

Bộ hồ sơ ứng tuyển

-

Một trò đùa nghiêm túc, được quản lý nhẹ nhàng.

-
-
-
-

Dành cho người yêu tương lai

-

- Mô tả công việc nói rõ quyền lợi của vị trí này: sự ổn định, chú ý, hài hước, chăm - sóc cảm xúc, và một người xem việc quan tâm là công việc hằng ngày chứ không phải màn - ra mắt. -

-
-
-

Dành cho hội đồng tuyển chọn

-

- Hồ sơ cá nhân tóm tắt ứng viên: kỹ sư phần mềm, người nghĩ bằng hệ thống, kiên nhẫn - gỡ lỗi, và một con người vẫn đều đặn chỉnh sửa thói quen chưa tốt. -

-
-
-
- - -
-

Chương 3 · Mô tả công việc

-

Mô tả công việc

-

Người yêu tương lai

-

- Đây là một vị trí dài hạn, làm việc trực tiếp với con người. Gói đãi ngộ chủ yếu gồm sự - chú ý, lòng chung thủy, những bữa ăn chung, chuyện cười riêng, và một người biết thử lại - sau sai lầm. -

-
-
Loại hìnhToàn thời gian bằng trái tim, lịch linh hoạt
-
Địa điểmChủ yếu ở Trái Đất, đôi khi tại Sài Gòn
-
Ngày bắt đầuKhi niềm tin qua vòng xem xét
-
-
- -
-
-

Quyền lợi

-

Bạn sẽ nhận được.

-
-
-
-

Ưu tiên hỗ trợ trong những ngày khó, ý tưởng lạ, và các buổi tối yên tĩnh.

-
-
-

Sự thành thật đều đặn, giao tiếp rõ ràng, và báo lỗi không đổ lỗi.

-
-
-

- Một người có thể gỡ lỗi hệ thống đang vận hành và cũng nghĩ hơi nhiều chuyện ăn gì - tối nay. -

-
-
-

Lộ trình phát triển dài hạn: khỏe hơn, đi nhiều hơn, và có nhiều câu chuyện hơn.

-
-
-
- +
-

Trách nhiệm

-

Điều cả hai cùng bảo vệ.

-
-
-
-
    -
  • Xây niềm tin bằng lời nói thẳng thắn và những hành động nhỏ lặp lại.
  • -
  • - Tôn trọng thời gian riêng, tham vọng nghề nghiệp, gia đình, bạn bè, và các nghi - thức cá nhân. -
  • -
  • Mang theo sự tò mò, lòng tử tế, và thiện chí sửa chữa sau mâu thuẫn.
  • -
  • - Cùng duy trì một đời sống nơi cả hai được nghiêm túc và ngớ ngẩn đúng lúc. -
  • -
-
-
-
- -
-
-

Điểm cộng

-

Điểm cộng thì vui, không có cũng chẳng sao.

-
-
-
-
    -
  • - Thích đồ ăn, đi bộ, trò chơi, phim, hoặc học những thứ lạ không vì hạn chót nào - cả. -
  • -
  • - Có thể cười với một bản nhại công sở mà không biến tình yêu thành OKR xét theo - quý. -
  • -
  • Tin rằng tình cảm chạy tốt hơn nhờ kiên nhẫn, không phải trò đoán ý.
  • -
-
-
-

Ghi chú tuyển chọn

-

- Không có bài kiểm tra áp lực. Không có vòng giải đố ẩn. Ứng viên mạnh 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. -

-
-
-
- - -
-

Chương 4 · Hồ sơ ứng viên

-

Hồ sơ ứng viên

-

Tiến Nguyễn Minh

-

- Một anh dev hơi cù lần, thiếu kĩ năng sống. Hay ở nhà, nghiện máy tính — cũng muốn đi - chơi đấy, nhưng lười đi một mình. Đang tìm một mối quan hệ lâu dài, nghiêm túc hơn cả - cách mình cày event Genshin. -

-
-
Tuổi26 — sinh năm 1999, tuổi con 🐈
-
Tìm kiếmMối quan hệ lâu dài
-
Khu vực hẹn hòTPHCM hoặc Long An cũ
-
-
- -
-
+

Cảnh 2 · Nhân vật chính

Chân dung nhanh

-

Có gì trong hồ sơ này?

+

Ứng viên là ai?

@@ -299,6 +179,23 @@ có bài lót sẵn, kể cả những tâm trạng chưa đặt tên.

+
+
+ + +
+
+

Cảnh 3 · Thành thật từ đầu

+

Nói thẳng

+

Nói thẳng, để đỡ mất công đoán.

+
+

Sống đơn giản

@@ -315,19 +212,158 @@

-

Thành thật từ đầu

+

Thành thật đến cùng

Rất thích quan điểm của Phật giáo nhưng chưa/không quy y. Sống hơi tâm linh, và - cũng hơi… tà răm — fetish là chân 🌚. + cũng hơi… tà răm — fetish là chân 🌚. 162cm là số đo thật, không cộng dép.

-
+ +
+

Cảnh 4 · Lời mời làm việc

+

Người yêu tương lai · Quyền lợi

+

Bạn sẽ nhận được.

+

+ Đây là một vị trí dài hạn, làm việc trực tiếp với con người. Gói đãi ngộ chủ yếu gồm sự + chú ý, lòng chung thủy, những bữa ăn chung, chuyện cười riêng, và một người biết thử lại + sau sai lầm. +

+
+
Loại hìnhToàn thời gian bằng trái tim, lịch linh hoạt
+
Địa điểmChủ yếu ở Trái Đất, đôi khi tại Sài Gòn
+
Ngày bắt đầuKhi niềm tin qua vòng xem xét
+
+
+
+

Ưu tiên hỗ trợ trong những ngày khó, ý tưởng lạ, và các buổi tối yên tĩnh.

+
+
+

Sự thành thật đều đặn, giao tiếp rõ ràng, và báo lỗi không đổ lỗi.

+
+
+

+ Một người có thể gỡ lỗi hệ thống đang vận hành và cũng nghĩ hơi nhiều chuyện ăn gì + tối nay. +

+
+
+

Lộ trình phát triển dài hạn: khỏe hơn, đi nhiều hơn, và có nhiều câu chuyện hơn.

+
+
+
+ + +
+

Cảnh 5 · Điều cả hai cùng giữ

+

Trách nhiệm

+

Điều cả hai cùng bảo vệ.

+
+
+
+
    +
  • Xây niềm tin bằng lời nói thẳng thắn và những hành động nhỏ lặp lại.
  • +
  • + Tôn trọng thời gian riêng, tham vọng nghề nghiệp, gia đình, bạn bè, và các nghi + thức cá nhân. +
  • +
  • Mang theo sự tò mò, lòng tử tế, và thiện chí sửa chữa sau mâu thuẫn.
  • +
  • + Cùng duy trì một đời sống nơi cả hai được nghiêm túc và ngớ ngẩn đúng lúc. +
  • +
+
+
+
+ + +
+
+

Cảnh 6 · Không có bài kiểm tra áp lực

+

Điểm cộng

+

Điểm cộng thì vui, không có cũng chẳng sao.

+
+
+
+
    +
  • + Thích đồ ăn, đi bộ, trò chơi, phim, hoặc học những thứ lạ không vì hạn chót nào + cả. +
  • +
  • + Có thể cười với một bản nhại công sở mà không biến tình yêu thành OKR xét theo + quý. +
  • +
  • Tin rằng tình cảm chạy tốt hơn nhờ kiên nhẫn, không phải trò đoán ý.
  • +
  • Về tử vi: tuổi 🐈 này hợp các tuổi 🐒 🐅 🐍 🐎 🐖 hoặc 🐉 🐕 — nhưng hợp sóng vẫn hơn hợp tuổi.
  • +
+
+
+

Ghi chú tuyển chọn

+

+ Không có bài kiểm tra áp lực. Không có vòng giải đố ẩn. Ứng viên mạnh 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. +

+
+
+
+ + +
+
+

Cảnh 7 · Lời kết

+

Lời kết

+

Kết nối để tìm hiểu thêm nhé.

+
+
+

+ Cảm ơn vì đã đến. Thước phim chỉ kể được một phần thôi — nếu thấy có chút hợp sóng, cứ + kết nối để mình tìm hiểu nhau thêm nhé. +

+
+
+ + +
+
+

Phụ lục · Hồ sơ chi tiết

Thông tin nhanh

-

Để đỡ mất công đoán.

+

Để đỡ mất công đoán.

@@ -404,20 +440,6 @@
- -
-
-

Chương 5 · Lời kết

-

Lời kết

-

Kết nối để tìm hiểu thêm nhé.

-
-
-

- Cảm ơn vì đã đến. Hồ sơ chỉ kể được một phần thôi — nếu thấy có chút hợp sóng, cứ kết - nối để mình tìm hiểu nhau thêm nhé. -

-
-
diff --git a/plans/260724-2229-scroll-film-story/phase-01-foundation.md b/plans/260724-2229-scroll-film-story/phase-01-foundation.md new file mode 100644 index 0000000..a2fe5b6 --- /dev/null +++ b/plans/260724-2229-scroll-film-story/phase-01-foundation.md @@ -0,0 +1,52 @@ +# Phase 1 — Foundation + +Goal: load the film stack and establish the fail-open, reduced-motion-safe +engine boundary. No scenes yet — just the scaffolding that guarantees safety. + +## Context links + +- Current reveal engine: `assets/story.js`, `assets/story.css` +- Pre-paint bootstrap + `js-ready` gate: `index.html` inline `