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.
@
This commit is contained in:
2026-07-24 23:34:28 +07:00
parent 6e996f4d66
commit e2d09fd3de
9 changed files with 612 additions and 176 deletions
+3 -2
View File
@@ -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);
+38
View File
@@ -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;
}
}
+128
View File
@@ -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();
}
})();
+8 -6
View File
@@ -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;
+8
View File
@@ -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.
+190 -168
View File
@@ -6,7 +6,7 @@
<title>Tiến Nguyễn Minh | Mô tả &amp; hồ sơ hẹn hò</title>
<meta
name="description"
content="Mô tả công việc cho vị trí người yêu tương lai và hồ sơ hẹn hò dí dỏm của Tiến Nguyễn Minh — tất cả trong một trang."
content="Mô tả công việc cho vị trí người yêu tương lai và hồ sơ hẹn hò dí dỏm của Tiến Nguyễn Minh — kể như một thước phim ngắn."
/>
<link rel="icon" href="assets/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -21,7 +21,7 @@
<meta property="og:title" content="Tiến Nguyễn Minh | Mô tả &amp; hồ sơ hẹn hò" />
<meta
property="og:description"
content="Mô tả công việc cho vị trí người yêu tương lai và hồ sơ hẹn hò dí dỏm của Tiến Nguyễn Minh — tất cả trong một trang."
content="Mô tả công việc cho vị trí người yêu tương lai và hồ sơ hẹn hò dí dỏm của Tiến Nguyễn Minh — kể như một thước phim ngắn."
/>
<meta
property="og:image"
@@ -63,7 +63,16 @@
<link rel="stylesheet" href="assets/styles.css" />
<link rel="stylesheet" href="assets/story.css" />
<link rel="stylesheet" href="assets/story-chrome.css" />
<link rel="stylesheet" href="assets/story-film.css" />
<script src="assets/theme-switch.js" defer></script>
<!-- Film engine libraries (CDN, versioned). Load before story-film.js.
If any fails, story-film.js bails and the static reveal (story.js) runs. -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/lenis@1.1.13/dist/lenis.min.js" defer></script>
<!-- story-film.js decides whether the film activates; must run before story.js
so story.js can defer to it. story.js is the reduced-motion / mobile / no-CDN fallback. -->
<script src="assets/story-film.js" defer></script>
<script src="assets/story.js" defer></script>
<script src="assets/story-chrome.js" defer></script>
</head>
@@ -84,9 +93,10 @@
<div class="header-actions">
<nav aria-label="Điều hướng chính">
<a href="#home">Trang chủ</a>
<a href="#jd">Mô tả công việc</a>
<a href="#cv">Hồ sơ cá nhân</a>
<a href="#home">Mở đầu</a>
<a href="#applicant">Con người</a>
<a href="#offer">Lời mời</a>
<a href="#dossier">Chi tiết</a>
</nav>
<div class="preference-controls">
@@ -115,166 +125,36 @@
</header>
<main id="content">
<!-- ============ Trang chủ ============ -->
<section class="home-hero" id="home">
<!-- ============ Cảnh 1 · Cold open: vị trí bỏ trống ============ -->
<section class="home-hero film-scene" id="home" data-scene data-scene-pin>
<div class="site-shell hero-copy" data-reveal-group>
<p class="chapter-tag" data-reveal>Chương 1 · Lời mời</p>
<p class="chapter-tag" data-reveal>Cảnh 1 · Vị trí bỏ trống</p>
<p class="eyebrow" data-reveal>Vị trí mở / bạn đồng hành dài hạn</p>
<h1 data-reveal>Tiến Nguyễn Minh</h1>
<p class="lead" data-reveal>
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.
</p>
<div class="button-row" data-reveal>
<a class="button" href="#jd">Đọc mô tả công việc</a>
<a class="button secondary" href="#cv">Xem hồ sơ cá nhân</a>
<a class="button" href="#applicant">Gặp ứng viên</a>
<a class="button secondary" href="#offer">Đọc lời mời</a>
</div>
</div>
</section>
<section class="site-shell page-section section-grid" aria-labelledby="packet-title" data-reveal-group>
<div data-reveal>
<p class="chapter-tag">Chương 2 · Bộ hồ sơ</p>
<p class="eyebrow">Bộ hồ sơ ứng tuyển</p>
<h2 id="packet-title">Một trò đùa nghiêm túc, được quản lý nhẹ nhàng.</h2>
</div>
<div class="panel-list">
<article class="panel strong" data-reveal>
<h3>Dành cho người yêu tương lai</h3>
<p>
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.
</p>
</article>
<article class="panel" data-reveal>
<h3>Dành cho hội đồng tuyển chọn</h3>
<p>
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.
</p>
</article>
</div>
</section>
<!-- ============ Mô tả công việc ============ -->
<section class="site-shell page-section jd-hero" id="jd" aria-labelledby="jd-title" data-reveal-group>
<p class="chapter-tag" data-reveal>Chương 3 · Mô tả công việc</p>
<p class="eyebrow" data-reveal>Mô tả công việc</p>
<h2 class="page-title" id="jd-title" data-reveal>Người yêu tương lai</h2>
<p class="lead" data-reveal>
Đâ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.
</p>
<div class="meta-grid" role="group" aria-label="Tóm tắt vị trí" data-reveal>
<div><span>Loại hình</span>Toàn thời gian bằng trái tim, lịch linh hoạt</div>
<div><span>Địa điểm</span>Chủ yếu ở Trái Đất, đôi khi tại Sài Gòn</div>
<div><span>Ngày bắt đầu</span>Khi niềm tin qua vòng xem xét</div>
</div>
</section>
<section class="site-shell page-section section-grid" aria-labelledby="benefits-title" data-reveal-group>
<div data-reveal>
<p class="eyebrow">Quyền lợi</p>
<h2 id="benefits-title">Bạn sẽ nhận được.</h2>
</div>
<div class="panel-list">
<article class="panel" data-reveal>
<p>Ư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.</p>
</article>
<article class="panel" data-reveal>
<p>Sự thành thật đều đặn, giao tiếp rõ ràng, và báo lỗi không đổ lỗi.</p>
</article>
<article class="panel" data-reveal>
<p>
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.
</p>
</article>
<article class="panel" data-reveal>
<p>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.</p>
</article>
</div>
</section>
<!-- ============ Cảnh 2 · Nhân vật chính ============ -->
<section
class="site-shell page-section section-grid"
aria-labelledby="responsibilities-title"
class="site-shell page-section section-grid film-scene"
id="applicant"
aria-labelledby="applicant-title"
data-scene
data-reveal-group
>
<div data-reveal>
<p class="eyebrow">Trách nhiệm</p>
<h2 id="responsibilities-title">Điều cả hai cùng bảo vệ.</h2>
</div>
<div class="panel-list">
<article class="panel strong" data-reveal>
<ul>
<li>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.</li>
<li>
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.
</li>
<li>Mang theo sự tò mò, lòng tử tế, và thiện chí sửa chữa sau mâu thuẫn.</li>
<li>
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.
</li>
</ul>
</article>
</div>
</section>
<section class="site-shell page-section section-grid" aria-labelledby="process-title" data-reveal-group>
<div data-reveal>
<p class="eyebrow">Điểm cộng</p>
<h2 id="process-title">Điểm cộng thì vui, không có cũng chẳng sao.</h2>
</div>
<div class="panel-list">
<article class="panel" data-reveal>
<ul>
<li>
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ả.
</li>
<li>
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ý.
</li>
<li>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 ý.</li>
</ul>
</article>
<article class="panel" data-reveal>
<h3>Ghi chú tuyển chọn</h3>
<p>
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.
</p>
</article>
</div>
</section>
<!-- ============ Hồ sơ cá nhân ============ -->
<section class="site-shell page-section cv-hero" id="cv" aria-labelledby="cv-title" data-reveal-group>
<p class="chapter-tag" data-reveal>Chương 4 · Hồ sơ ứng viên</p>
<p class="eyebrow" data-reveal>Hồ sơ ứng viên</p>
<h2 class="page-title" id="cv-title" data-reveal>Tiến Nguyễn Minh</h2>
<p class="lead" data-reveal>
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.
</p>
<div class="meta-grid" role="group" aria-label="Tóm tắt ứng viên" data-reveal>
<div><span>Tuổi</span>26 — sinh năm 1999, tuổi con 🐈</div>
<div><span>Tìm kiếm</span>Mối quan hệ lâu dài</div>
<div><span>Khu vực hẹn hò</span>TPHCM hoặc Long An cũ</div>
</div>
</section>
<section class="site-shell page-section section-grid" aria-labelledby="strengths-title" data-reveal-group>
<div data-reveal>
<p class="chapter-tag">Cảnh 2 · Nhân vật chính</p>
<p class="eyebrow">Chân dung nhanh</p>
<h2 id="strengths-title">Có gì trong hồ sơ này?</h2>
<h2 id="applicant-title">Ứng viên là ai?</h2>
</div>
<div class="panel-list">
<article class="panel" data-reveal>
@@ -299,6 +179,23 @@
có bài lót sẵn, kể cả những tâm trạng chưa đặt tên.
</p>
</article>
</div>
</section>
<!-- ============ Cảnh 3 · Thành thật từ đầu ============ -->
<section
class="site-shell page-section section-grid film-scene"
id="honest"
aria-labelledby="honest-title"
data-scene
data-reveal-group
>
<div data-reveal>
<p class="chapter-tag">Cảnh 3 · Thành thật từ đầu</p>
<p class="eyebrow">Nói thẳng</p>
<h2 id="honest-title">Nói thẳng, để đỡ mất công đoán.</h2>
</div>
<div class="panel-list">
<article class="panel" data-reveal>
<h3>Sống đơn giản</h3>
<p>
@@ -315,19 +212,158 @@
</p>
</article>
<article class="panel" data-reveal>
<h3>Thành thật từ đầu</h3>
<h3>Thành thật đến cùng</h3>
<p>
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.
</p>
</article>
</div>
</section>
<section class="site-shell page-section section-grid" aria-labelledby="facts-title" data-reveal-group>
<!-- ============ Cảnh 4 · Lời mời làm việc (quyền lợi) ============ -->
<section
class="site-shell page-section film-scene"
id="offer"
aria-labelledby="offer-title"
data-scene
data-reveal-group
>
<p class="chapter-tag" data-reveal>Cảnh 4 · Lời mời làm việc</p>
<p class="eyebrow" data-reveal>Người yêu tương lai · Quyền lợi</p>
<h2 class="page-title" id="offer-title" data-reveal>Bạn sẽ nhận được.</h2>
<p class="lead" data-reveal>
Đâ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.
</p>
<div class="meta-grid" role="group" aria-label="Tóm tắt vị trí" data-reveal>
<div><span>Loại hình</span>Toàn thời gian bằng trái tim, lịch linh hoạt</div>
<div><span>Địa điểm</span>Chủ yếu ở Trái Đất, đôi khi tại Sài Gòn</div>
<div><span>Ngày bắt đầu</span>Khi niềm tin qua vòng xem xét</div>
</div>
<div class="panel-list">
<article class="panel" data-reveal>
<p>Ư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.</p>
</article>
<article class="panel" data-reveal>
<p>Sự thành thật đều đặn, giao tiếp rõ ràng, và báo lỗi không đổ lỗi.</p>
</article>
<article class="panel" data-reveal>
<p>
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.
</p>
</article>
<article class="panel" data-reveal>
<p>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.</p>
</article>
</div>
</section>
<!-- ============ Cảnh 5 · Điều cả hai cùng giữ (trách nhiệm) ============ -->
<section
class="site-shell page-section section-grid film-scene"
id="shared"
aria-labelledby="shared-title"
data-scene
data-reveal-group
>
<div data-reveal>
<p class="chapter-tag">Cảnh 5 · Điều cả hai cùng giữ</p>
<p class="eyebrow">Trách nhiệm</p>
<h2 id="shared-title">Điều cả hai cùng bảo vệ.</h2>
</div>
<div class="panel-list">
<article class="panel strong" data-reveal>
<ul>
<li>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.</li>
<li>
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.
</li>
<li>Mang theo sự tò mò, lòng tử tế, và thiện chí sửa chữa sau mâu thuẫn.</li>
<li>
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.
</li>
</ul>
</article>
</div>
</section>
<!-- ============ Cảnh 6 · Không có bài kiểm tra áp lực (điểm cộng + cao trào) ============ -->
<section
class="site-shell page-section section-grid film-scene"
id="no-test"
aria-labelledby="no-test-title"
data-scene
data-reveal-group
>
<div data-reveal>
<p class="chapter-tag">Cảnh 6 · Không có bài kiểm tra áp lực</p>
<p class="eyebrow">Điểm cộng</p>
<h2 id="no-test-title">Điểm cộng thì vui, không có cũng chẳng sao.</h2>
</div>
<div class="panel-list">
<article class="panel" data-reveal>
<ul>
<li>
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ả.
</li>
<li>
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ý.
</li>
<li>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 ý.</li>
<li>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.</li>
</ul>
</article>
<article class="panel strong" data-reveal>
<h3>Ghi chú tuyển chọn</h3>
<p>
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.
</p>
</article>
</div>
</section>
<!-- ============ Cảnh 7 · Lời kết ============ -->
<section
class="site-shell page-section section-grid film-scene"
id="closing"
aria-labelledby="closing-title"
data-scene
data-scene-pin
data-reveal-group
>
<div data-reveal="soft">
<p class="chapter-tag">Cảnh 7 · Lời kết</p>
<p class="eyebrow">Lời kết</p>
<h2 id="closing-title">Kết nối để tìm hiểu thêm nhé.</h2>
</div>
<article class="panel strong" data-reveal="soft">
<p>
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é.
</p>
</article>
</section>
<!-- ============ Phụ lục · Hồ sơ chi tiết (credits roll) ============ -->
<section
class="site-shell page-section section-grid film-scene"
id="dossier"
aria-labelledby="dossier-title"
data-scene
data-reveal-group
>
<div data-reveal>
<p class="chapter-tag">Phụ lục · Hồ sơ chi tiết</p>
<p class="eyebrow">Thông tin nhanh</p>
<h2 id="facts-title">Để đỡ mất công đoán.</h2>
<h2 id="dossier-title">Để đỡ mất công đoán.</h2>
</div>
<dl class="facts-list" data-reveal>
<div>
@@ -404,20 +440,6 @@
</div>
</dl>
</section>
<section class="site-shell page-section section-grid" aria-labelledby="closing-title" data-reveal-group>
<div data-reveal="soft">
<p class="chapter-tag">Chương 5 · Lời kết</p>
<p class="eyebrow">Lời kết</p>
<h2 id="closing-title">Kết nối để tìm hiểu thêm nhé.</h2>
</div>
<article class="panel strong" data-reveal="soft">
<p>
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é.
</p>
</article>
</section>
</main>
</body>
</html>
@@ -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 `<script>` (lines ~39-62)
- Chrome to reconcile later: `assets/story-chrome.js`
## Requirements
- Load GSAP core, ScrollTrigger, Lenis via CDN with `defer` (mirroring
`theme-switch.js`), pinned to explicit versions.
- Create `assets/story-film.js` that:
- Bails out (leaving content fully visible) if GSAP/ScrollTrigger/Lenis
absent, or `prefers-reduced-motion: reduce`, or `IntersectionObserver`
unsupported.
- When active: adds a distinct root flag (e.g. `html.film-ready`) so
`story-film.css` owns the hidden/pinned states — never `story.css`.
- Bridges Lenis → ScrollTrigger: drive `lenis.raf` from GSAP ticker, call
`ScrollTrigger.update` on Lenis scroll, `lenis.on('scroll', ...)`.
- Create `assets/story-film.css` owning film-only layout, all scoped under
`html.film-ready`, with a `prefers-reduced-motion` neutralizer.
## Files
- Create: `assets/story-film.js`, `assets/story-film.css`
- Modify: `index.html` (CDN `<script>` tags, new `<link>`/`<script>` for the two new assets)
## Implementation steps
1. Add CDN tags for GSAP, ScrollTrigger, Lenis (versioned) + new local assets.
2. Implement the guard/bail-out and the Lenis⇄GSAP ticker bridge in
`story-film.js`; set `html.film-ready` only on success.
3. Add `story-film.css` skeleton (scene container, pin wrapper) scoped under
`html.film-ready`; reduced-motion media query neutralizes it.
4. Ensure `story.js` reveal and `story-film.js` never both animate the same
node (coordination finalized in Phase 2).
## Validation
- JS off / CDN blocked (DevTools request block): page = today's document.
- `prefers-reduced-motion: reduce`: `html.film-ready` NOT set; static content.
- Motion allowed, modern browser: `html.film-ready` set, Lenis scrolling smooth,
no console errors. No scenes animate yet — just smooth scroll + flag.
## Risks / rollback
- Lenis/ScrollTrigger version mismatch → pin known-compatible versions.
- Rollback: remove new tags + two new files.
@@ -0,0 +1,84 @@
# Phase 2 — Scene conversion (the story arc)
Goal: rearrange the EXISTING information into a short-film narrative arc, then
bind each scene to a pinned/scrubbed GSAP timeline. Nothing invented, nothing
dropped — only reordered and reframed so the page opens on the human and builds
to sincerity.
## Why rearrange
Current order: Invitation → Packet(meta) → full JD → full CV → Close. That
front-loads the corporate conceit and saves the person for the end. A film opens
on a protagonist + a want, earns interest, then makes the pitch and turns
emotional. New order leads with the human ache, then presents "the offer,"
then lands sincerity.
## New arc (7 scenes)
Vietnamese keeps the existing witty tone; source = current index.html content.
### Scene 1 — Cold open: "Vị trí bỏ trống đã lâu"
- Beat: a single spotlit line — a position has stayed open too long.
- Hook, funny + a little lonely.
- Source: hero eyebrow "Vị trí mở / bạn đồng hành dài hạn" + CV lead
("lười đi một mình", "nghiêm túc hơn cả cách mình cày event Genshin").
### Scene 2 — Nhân vật chính (the applicant reveal)
- Beat: quick, funny self-portrait — the honest introvert dev.
- Source: name, 26 / 1999 / tuổi 🐈, Quận 7, "công nhân đánh máy tại Xí nghiệp
gêm Vê Nờ Gờ", wibu/isekai, Genshin/TFT/T1-Keria, eclectic playlist.
(from CV "Chân dung nhanh" panels — condensed into character texture.)
### Scene 3 — Thành thật từ đầu (vulnerability beat)
- Beat: flaws + truths on the table — the move that makes it real AND funny.
- Source: past 3-yr relationship, healed & ready; frugal; no smoking / occasional
drink; Phật giáo but not ordained; and the disarming punchlines — 162cm
"không cộng dép", không thích thú cưng, fetish là chân 🌚.
### Scene 4 — Lời mời làm việc (the pitch / benefits)
- Beat: reframe JD benefits as promises to the reader.
- Source: JD "Quyền lợi" (support on hard days, honest blameless comms, someone
who debugs prod and overthinks dinner, long-term growth roadmap).
### Scene 5 — Điều cả hai cùng giữ (responsibilities, shared)
- Beat: the "we both protect this" reciprocity beat.
- Source: JD "Trách nhiệm" (trust via small repeated actions, respect for time /
family / friends, curiosity + repair after conflict, serious-and-silly).
### Scene 6 — Không có bài kiểm tra áp lực (criteria + climax)
- Beat: joke drops away, sincerity peaks — the emotional turn.
- Source: JD "Điểm cộng" + "Ghi chú tuyển chọn" (no pressure test, no hidden
puzzle; strong applicant = kind when tired, honest when tangled); zodiac
compatibility gag (tuổi 🐒🐅🐍🐎🐖 / 🐉🐕) as a light exit-laugh.
### Scene 7 — Lời kết (resolution / closing card)
- Beat: warm landing + call to connect.
- Source: current Chương 5 closing copy. Optional confetti payoff (open Q).
## Full-facts handling
The `facts-list` (`<dl>`: height, weight, hometown, languages, education, etc.)
is reference data, not a dramatic beat. Keep it as a **"credits roll" / dossier
appendix after Scene 7** — collapsible or a quieter final panel — so the film
stays lean but the completeness is preserved.
## Implementation steps
1. Re-sequence sections in `index.html` to the 7-scene order (semantic reorder;
keep all copy as real DOM text).
2. Wrap each scene in a pin container; author one GSAP timeline per scene with
scrubbed beats (headline in, body/panels stagger, exit).
3. Update chapter tags (Chương 1..7 or rename to scene titles).
4. Keep `data-reveal` markup as the reduced-motion / no-JS fallback path.
## Validation
- Reading order (JS off) tells the same story top-to-bottom and is complete.
- Each scene pins and scrubs; no orphaned/empty beats.
- Facts dossier reachable and readable; nothing from the original is lost.
## Risks / rollback
- Reordering may disturb in-page anchors (#jd, #cv) used by nav → update nav +
active-nav id list in `story-chrome.js` (Phase 3).
- Rollback: revert index.html section order; scenes are additive wrappers.
+101
View File
@@ -0,0 +1,101 @@
# Plan: Whole-page scroll-driven "short film" story
**Status:** Implemented — verified in headless Chrome (film activates, no console errors, fallback intact)
**Created:** 2026-07-24
**Branch:** main
**Slug:** scroll-film-story
## Implementation notes (decisions made during build)
- **Pinning limited to title cards.** Full-viewport pinning of content-heavy
scenes risks clipping/jank when content exceeds the viewport, so only the two
short scenes (`#home`, `#closing`, tagged `data-scene-pin`) pin. Every scene
still gets a scrubbed entrance timeline. Ask if you want more scenes pinned.
- **Dropped the "Bộ hồ sơ" packet-explainer** (old Chương 2). It explained the
JD/CV joke rather than being a fact about the person; the new arc makes the
conceit implicit. No personal facts lost — the full dossier retains all 18.
- **Mobile / reduced-motion:** film gates OFF below 768px or under reduced
motion; the existing `story.js` reveal runs there unchanged (no dynamic
resize upgrade without reload — accepted).
- Orphaned `.jd-hero` / `.cv-hero` CSS rules left in styles.css (harmless dead
rules; classes no longer used).
## Goal
Convert the existing witty JD/CV one-pager into a **scroll-driven cinematic
narrative**: each chapter becomes a scene that pins and its beats scrub to
scroll position, like frames of a short film. Reader controls pacing by
scrolling. Whole page participates (all 5 chapters), not just a hero.
## Decisions (locked with user)
- Playback: **scroll-driven** (reader scrolls to advance; scenes pin + scrub).
- Scope: **whole page** as a film (all chapters converted).
- Delivery: **CDN, no build step** (matches GitHub Pages / no-bundler setup).
## Stack
| Concern | Library | Why |
|--------------------|-----------------------------|--------------------------------------------------|
| Sequencing + pin | GSAP core + ScrollTrigger | Free (2025), CDN, battle-tested scrub/pin engine |
| Cinematic scroll | Lenis | Momentum glide; feeds scroll value to ScrollTrigger |
| Theme (unchanged) | assets/theme-switch.js | Keep as-is |
## Non-negotiable safety contract
Mirrors the existing `story.js` / `story.css` philosophy:
1. **Fail-open.** Content fully visible by default. GSAP only hides-then-reveals
*after* it loads and confirms support. CDN/JS failure ⇒ current readable
document, never a blank film.
2. **`prefers-reduced-motion`.** No pin/scrub. Fall back to the existing static
reveals (or plain final-position content). No horizontal band jank.
3. **Accessibility/SEO preserved.** All copy stays real DOM text (no canvas),
heading order and reading order unchanged.
4. **Chrome reconciled.** Pinning changes total scroll height — the
scroll-progress bar (`story-chrome.js`) and active-nav IO must stay correct
after pins are created (ScrollTrigger.refresh + progress recompute).
## Phases
| # | Phase | File | Depends on |
|---|-------|------|-----------|
| 1 | Foundation: load stack, Lenis⇄ScrollTrigger bridge, fail-open + reduced-motion gate | phase-01-foundation.md | — |
| 2 | Scene conversion: 5 chapters → pinned/scrubbed timelines | phase-02-scenes.md | 1 |
| 3 | Chrome reconciliation + verification (progress bar, nav, a11y, perf) | phase-03-chrome-verify.md | 2 |
## Acceptance criteria
- With JS disabled or CDN blocked: page renders as today (readable, no gaps).
- With `prefers-reduced-motion: reduce`: no pinning; content in final position.
- With motion allowed: each chapter pins and its beats scrub smoothly to scroll.
- Scroll-progress bar reaches 100% exactly at page end (accounts for pin height).
- Active-nav still highlights the correct section while scrubbing.
- No console errors; no layout shift (CLS) on load; keyboard/reader order intact.
## Files touched
- **Modify:** `index.html` (add CDN + new asset tags; minimal scene hooks/attrs)
- **New:** `assets/story-film.js` (GSAP/Lenis orchestration)
- **New:** `assets/story-film.css` (pin/scene layout + reduced-motion fallback)
- **Modify:** `assets/story.js` (disable IO reveal for filmed sections when film is active; keep as reduced-motion/no-support fallback)
- **Modify:** `assets/story-chrome.js` (recompute progress after ScrollTrigger pins; refresh on layout change)
## Risks & rollback
- **Pin height math** breaks progress bar / nav → mitigate with
`ScrollTrigger.refresh()` after setup and on resize; progress reads live
scrollHeight so it self-corrects.
- **Double animation** (story.js reveal + GSAP) → gate story.js off for filmed
sections when the film engine initializes.
- **Mobile jank** from pinning → allow disabling pins under a width/motion
threshold, degrade to static reveals.
- **Rollback:** all changes are additive files + reversible edits; `git revert`
or delete the two new assets and their `<script>`/`<link>` tags.
## Open questions
1. Pin on mobile (<~768px) too, or degrade to the current reveal there? (Pinning
on small touch screens is the most common source of jank.)
2. Keep the confetti payoff idea at Chương 5, or pure film with no gags?
3. Pin GSAP/Lenis to specific CDN versions (reproducible) vs. latest?