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. -
-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 @@
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
- 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ả 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. -
-- 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
-- Đâ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. -
- -Quyền lợi
-Ư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ểm cộng
-- 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
-- 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. -
- -Cảnh 2 · Nhân vật chính
Chân dung nhanh
-Cảnh 3 · Thành thật từ đầu
+Nói thẳng
+@@ -315,19 +212,158 @@
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
++ Đâ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. +
+ +Ư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 6 · Không có bài kiểm tra áp lực
+Điểm cộng
++ 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
++ 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
-Chương 5 · Lời kết
-Lời kết
-- 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é. -
-