From b0cd86e07c847c734cf08404d0ec870fa9ea8b4e Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 25 Jul 2026 11:45:59 +0700 Subject: [PATCH] feat: make the scroll film land, and give the story a last frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GSAP tier was a net negative: entrance triggers were created before the pins and without refreshPriority, so ScrollTrigger measured them before each pin inserted its spacer and every scene's animation completed below the fold. Film mode also collapsed the two-column editorial layout, because `html.film-ready .film-scene{display:flex}` outranks `.section-grid`, and both pins held scroll without playing anything. Engine: - Create pins first, with refreshPriority, so reveals measure against the real document height. - Replace the per-scene scrub with per-beat, non-reversing batches. Fixes tall scenes a single trigger can never time, and stops beats — including the closing CTAs — from sitting at opacity 0 while still in the tab order. - Give both pins real work: the cold open scrubs a camera push-in and dissolves its copy; the closing pin scrubs a decorative vignette only, never content opacity. - Slow the honesty scene deliberately, and restore the longer landing the closing beat is meant to get. - Route in-page links through Lenis so nav travel shares the film's easing. Layout and craft: - Stop clobbering `display`; grid scenes keep their columns in every tier. - Request Fraunces as a weight range — 550 and 600 were both snapping to 650, so h1, h2 and h3 rendered identically. - Collapse the mobile header from three rows to one (254px to 73px) and drive scroll-margin-top from the measured height, so anchor jumps stop landing headings behind the bar. - Un-hide the page if no motion engine claims it within 1200ms, so a slow CDN no longer means a wordless first paint. - Full-bleed header, per-tween will-change, no backdrop blur on phones, cached scroll extent, preloaded opening frame. Story: - Three shapes for three registers in the sagging middle: a numbered package, an unboxed vow, and a climax set quieter than anything around it. - An end card after the appendix, carrying the freshness stamp out of the pinned closing scene and repeating the one apply door at the true bottom. - Dissolve the hero into the page instead of cutting to ivory. Vietnamese copy is moved, not reworded; the end card's single new word is marked for the owner. Verified at 1440x900, 390x844 and 320x640, both themes, reduced motion, CDN blocked and engine blocked. --- README.md | 50 +++++- assets/freshness-stamp.js | 7 + assets/story-chrome.js | 27 ++- assets/story-film.css | 26 ++- assets/story-film.js | 204 ++++++++++++++++++---- assets/story.js | 10 ++ assets/styles.css | 346 ++++++++++++++++++++++++++++++++++---- index.html | 235 +++++++++++++++++--------- 8 files changed, 743 insertions(+), 162 deletions(-) diff --git a/README.md b/README.md index 66024d2..4b748b8 100644 --- a/README.md +++ b/README.md @@ -4,27 +4,59 @@ Warm-witty dating JD and CV site for Tiến Nguyễn Minh. ## Structure -Single-page site: `index.html` at the repo root holds all three parts, -navigable via anchors: +Single-page site: `index.html` at the repo root holds the whole story, told as +scenes and navigable via anchors: -- `#home` — intro hero -- `#jd` — future lover job description -- `#cv` — candidate CV +- `#home` — cold open, the vacant position +- `#applicant` — who the candidate is +- `#honest` — saying it straight +- `#offer` — what you get +- `#shared` — what you both protect +- `#no-test` — bonus points, and the written climax +- `#closing` — the connect beat +- `#dossier` — appendix, the detail table +- `#end` — end card ## Features -- Light theme: white and blue -- Dark theme: black and pink +- Light theme: warm ivory and terracotta-rose +- Dark theme: candlelit plum and rose-gold +- First visit follows the OS colour scheme; an explicit choice is persisted - Compact emoji theme switch - Vietnamese-only copy -- Theme preference persisted in the browser ## Tech - Plain HTML, CSS, and JavaScript — no framework, no build step -- Shared styles in `assets/styles.css`, theme toggle in `assets/theme-switch.js` - GitHub Pages (classic, deploy from branch) +### Motion tiers + +Motion is a three-tier progressive enhancement. Any change must keep all three +working, and no `[data-reveal]` element may ever be left invisible: + +1. **Film** (`html.film-ready`) — GSAP + ScrollTrigger + Lenis from a CDN, in + `assets/story-film.js`. Activates only on ≥768px, with motion allowed, and + only when the CDN libraries actually arrived in time. +2. **Reveal** (`html.js-ready:not(.film-ready)`) — IntersectionObserver fade-and-rise + in `assets/story.js`. This is what phones and reduced-motion visitors get. +3. **Static** — no JS, or a stalled CDN. A watchdog in the `index.html` head + un-hides everything after 1200ms if no engine has claimed the page, so the + first paint is never wordless. + +Two ordering rules in `story-film.js` are load-bearing and commented there: pins +are created before reveals and carry `refreshPriority: 1`, and reveals are +per-beat and non-reversing. + +### Files + +- `assets/styles.css` — tokens, layout, components (both themes) +- `assets/story.css` / `story.js` — tier 2 reveal +- `assets/story-film.css` / `story-film.js` — tier 1 film +- `assets/story-chrome.css` / `story-chrome.js` — progress bar + active nav +- `assets/theme-switch.js` — theme toggle (keep in sync with the head bootstrap) +- `assets/freshness-stamp.js` — "cập nhật [ngày]" from the GitHub commit date + ## Developing No install needed. Open `index.html` directly, or serve the folder: diff --git a/assets/freshness-stamp.js b/assets/freshness-stamp.js index 55a9489..30e2d33 100644 --- a/assets/freshness-stamp.js +++ b/assets/freshness-stamp.js @@ -37,6 +37,13 @@ el.textContent = 'Vị trí vẫn đang mở · cập nhật ' + formatted + '.'; el.hidden = false; + + // The stamp lands after ScrollTrigger has already measured, so every + // trigger below it would otherwise sit one stamp-height out of place. + // (.freshness-stamp also reserves its own height in styles.css, so the + // visible layout shift is zero; this keeps the film's maths honest for + // the cases where the text wraps to a second line.) + window.ScrollTrigger?.refresh(); }) .catch(() => { // Silent by design: keep the stamp hidden rather than show a broken date. diff --git a/assets/story-chrome.js b/assets/story-chrome.js index e2651bb..e2713db 100644 --- a/assets/story-chrome.js +++ b/assets/story-chrome.js @@ -20,10 +20,19 @@ if (fill) { let ticking = false; + // Cached scroll extent. Reading scrollHeight inside the rAF callback + // forces a layout on every scroll frame, and with Lenis writing scroll + // position and pins mutating layout each frame, that recomputes + // continuously. Recompute only when the document can actually change + // height: resize, and after ScrollTrigger re-measures its pins. + let max = 0; + + const measure = () => { + max = docEl.scrollHeight - window.innerHeight; + }; const updateProgress = () => { ticking = false; - const max = docEl.scrollHeight - window.innerHeight; const fraction = max > 0 ? window.scrollY / max : 0; // Clamp to 0..1 (rubber-band scrolling can overshoot on some devices). const clamped = fraction < 0 ? 0 : fraction > 1 ? 1 : fraction; @@ -36,9 +45,19 @@ window.requestAnimationFrame(updateProgress); }; + const remeasure = () => { + measure(); + requestProgress(); + }; + + measure(); updateProgress(); // initial paint (e.g. deep-link / restored scroll position) window.addEventListener('scroll', requestProgress, { passive: true }); - window.addEventListener('resize', requestProgress, { passive: true }); + window.addEventListener('resize', remeasure, { passive: true }); + // Pins add scroll distance; re-measure whenever the film re-measures. + window.ScrollTrigger?.addEventListener('refresh', remeasure); + // Fonts and the hero frame can settle after this script runs. + window.addEventListener('load', remeasure, { passive: true }); } /* ---------- Scroll-linked active nav ---------- */ @@ -49,8 +68,8 @@ }); // 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'] + // nav targets in index.html, in the order they appear in the document. + const sections = ['home', 'applicant', 'offer', 'closing', 'dossier'] .map((id) => document.getElementById(id)) .filter(Boolean); diff --git a/assets/story-film.css b/assets/story-film.css index 0707e76..5310cb1 100644 --- a/assets/story-film.css +++ b/assets/story-film.css @@ -10,9 +10,26 @@ /* 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. */ + content-heavy scenes grow past the viewport instead of clipping. + + Deliberately does NOT set `display`: five scenes carry `.section-grid` + (display:grid, two editorial columns) and this selector's specificity (0,2,1) + would silently beat it (0,1,0), collapsing them to one 1120px column at ~130 + characters per line. Vertical centring is expressed per display type below. */ html.film-ready .film-scene { min-height: 100vh; +} + +/* Grid scenes keep their two columns; centre the rows in the taller stage. */ +html.film-ready .section-grid { + align-content: center; +} + +/* The only non-grid, non-hero scene (#offer) is a single column, so it centres + as a flex column. .home-hero is excluded: it already sets its own + `display:flex; align-items:center` for a row, and flipping it to a column + would silently repurpose that align-items into horizontal alignment. */ +html.film-ready .film-scene:not(.section-grid):not(.home-hero) { display: flex; flex-direction: column; justify-content: center; @@ -21,11 +38,14 @@ html.film-ready .film-scene { /* 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. */ + styles, so this can never trap content. + + No blanket `will-change`: holding a compositor layer for all 31 beats for the + life of the page costs more than it saves. story-film.js sets it per beat when + a reveal starts and clears it on completion. */ html.film-ready [data-scene] [data-reveal] { opacity: 0; transform: translateY(40px); - will-change: opacity, transform; } /* Defensive: the film never activates under reduced motion (story-film.js gates diff --git a/assets/story-film.js b/assets/story-film.js index 4edd22c..ae49a4d 100644 --- a/assets/story-film.js +++ b/assets/story-film.js @@ -1,8 +1,8 @@ // 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. +// Turns each chapter into a scene whose beats rise into view as the reader +// scrolls (GSAP + ScrollTrigger), gliding on Lenis smooth scroll. The opening +// and closing scenes pin and play a scrubbed camera move during the hold. // // Relationship to the older reveal engine (assets/story.js): // * This file runs BEFORE story.js (script order in index.html). @@ -13,6 +13,15 @@ // CDN libraries are missing) this file returns early and the existing // story.js reveal runs unchanged — the guaranteed fallback. // +// Two ordering rules keep the choreography honest and must not be reshuffled: +// 1. Pins are created BEFORE reveals and carry `refreshPriority: 1`. +// ScrollTrigger refreshes in priority-then-creation order; if a reveal is +// measured before the pin above it has inserted its spacer, that reveal is +// early by the full pin distance and finishes below the fold. +// 2. Reveals are per-beat and non-reversing (`once: true`), matching story.js's +// reveal-once contract. A reversing scrub leaves beats — including the two +// closing CTAs — at opacity 0 while still in the tab order. +// // 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. @@ -22,6 +31,17 @@ const root = document.documentElement; const REVEAL = '[data-reveal]'; + // Per-scene entrance timing. Scenes absent from this map use DEFAULT_TIMING. + // `#honest` carries the strongest copy, so it is deliberately the slowest + // cascade on the page — restraint used as emphasis rather than decoration. + const DEFAULT_TIMING = { start: 'top 86%', duration: 0.8, stagger: 0.12 }; + const SCENE_TIMING = { + honest: { start: 'top 82%', duration: 1.15, stagger: 0.24 }, + }; + // `#closing` is excluded from the batch entirely: its beats are choreographed + // on its own pin timeline so the soft landing survives in this tier too. + const PIN_CHOREOGRAPHED = new Set(['closing']); + // 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. @@ -33,19 +53,27 @@ }; // Activation gate. The film is a progressive enhancement on top of the - // static reveal; only take over when all three hold: + // static reveal; only take over when all four hold: // * viewport wide enough that pinning/scrubbing reads well (not phones), // * the reader has not asked to reduce motion, - // * the CDN libraries actually loaded. + // * the CDN libraries actually loaded, + // * the head watchdog has not already given up waiting for us. // Otherwise return and let story.js handle the reveal as it does today. + // + // The watchdog case is the subtle one: a CDN that is slow but ultimately + // succeeds. By the time this runs the watchdog has un-hidden the page and the + // reader is already reading, so activating here would hide all 31 beats again + // in order to animate them back in — a flash-of-disappearing-content on + // exactly the slow connections the watchdog exists to protect. const wideEnough = window.matchMedia('(min-width: 768px)').matches; const motionOK = window.matchMedia('(prefers-reduced-motion: no-preference)').matches; const libsReady = typeof window.gsap !== 'undefined' && typeof window.ScrollTrigger !== 'undefined' && typeof window.Lenis !== 'undefined'; + const watchdogFired = root.dataset.storyWatchdog === 'fired'; - if (!wideEnough || !motionOK || !libsReady) { + if (!wideEnough || !motionOK || !libsReady || watchdogFired) { return; } @@ -56,49 +84,163 @@ // 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'); + // Tells the head watchdog an engine claimed the page, so it stops waiting. + root.dataset.storyEngine = 'film'; // 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 }); + // the eased scroll position rather than raw wheel deltas. Lenis already + // eases the input, so scrub values stay low — stacking a long Lenis + // duration on a long scrub makes the page feel mushy to read. + const lenis = new Lenis({ duration: 0.95, 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. + /* ---------- 1. Pinned scenes (created first — see header note) ---------- */ + + // Cold open: the pin used to be 540px of frozen scroll with nothing + // attached. It now drives a slow camera push-in on the hero frame while + // the copy drifts up and dissolves, so the hold reads as a dissolve into + // scene 2 rather than a stuck page. --hero-scale is consumed by + // .home-hero::before (styles.css); it defaults to 1, so the other two + // tiers are unaffected. + const hero = document.getElementById('home'); + const heroCopy = hero && hero.querySelector('.hero-copy'); + if (hero) { + const heroPush = gsap.timeline({ + scrollTrigger: { + trigger: hero, + start: 'top top', + end: '+=60%', + pin: true, + pinSpacing: true, + scrub: 0.5, + refreshPriority: 1, + }, + }); + heroPush.fromTo(hero, { '--hero-scale': 1 }, { '--hero-scale': 1.07, ease: 'none' }, 0); + if (heroCopy) { + heroPush.to(heroCopy, { y: -48, opacity: 0.12, ease: 'power1.in' }, 0); + } + } + + // Closing beat. The pin's job is deliberately DECORATIVE: it scrubs a warm + // vignette (--closing-settle) so the camera appears to settle during the + // hold. It must never drive content opacity — a scrub is reversible, and + // scrolling back up would leave this scene's two CTAs at opacity 0 while + // they are still in the tab order. + const closing = document.getElementById('closing'); + if (closing) { + const softBeats = closing.querySelectorAll(REVEAL); + gsap.set(softBeats, { opacity: 0, y: 40 }); + + gsap.timeline({ + scrollTrigger: { + trigger: closing, + start: 'top top', + end: '+=60%', + pin: true, + pinSpacing: true, + scrub: 1.2, + refreshPriority: 1, + }, + }).fromTo(closing, { '--closing-settle': 0 }, { '--closing-settle': 1, ease: 'none' }); + + // The content reveal is separate and non-reversing, matching every other + // scene. It keeps the longer, softer landing story.css reserves for + // [data-reveal="soft"], which the old shared scene tween threw away. + ScrollTrigger.create({ + trigger: closing, + start: 'top 80%', + once: true, + onEnter: () => { + gsap.to(softBeats, { + opacity: 1, + y: 0, + ease: 'power2.out', + duration: 1.25, + stagger: 0.28, + onStart: () => + softBeats.forEach((el) => { + el.style.willChange = 'opacity, transform'; + }), + onComplete: () => + softBeats.forEach((el) => { + el.style.willChange = ''; + }), + }); + }, + }); + } + + /* ---------- 2. Per-beat entrances (non-reversing) ---------- */ + + // One batch per scene so each can carry its own tempo, and so a tall scene + // times its lower half correctly — a single scene-level trigger never can. gsap.utils.toArray('[data-scene]').forEach((scene) => { + if (PIN_CHOREOGRAPHED.has(scene.id)) return; + const beats = scene.querySelectorAll(REVEAL); if (!beats.length) return; + const timing = SCENE_TIMING[scene.id] || DEFAULT_TIMING; 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, + + ScrollTrigger.batch(beats, { + start: timing.start, + once: true, + onEnter: (batch) => { + gsap.to(batch, { + opacity: 1, + y: 0, + ease: 'expo.out', + duration: timing.duration, + stagger: timing.stagger, + // Promote only for the life of the tween. A blanket + // will-change on all 31 beats holds compositor layers for + // the whole session and costs more than it saves. + onStart: () => + batch.forEach((el) => { + el.style.willChange = 'opacity, transform'; + }), + onComplete: () => + batch.forEach((el) => { + el.style.willChange = ''; + }), + }); }, }); }); - // 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, + /* ---------- 3. Anchor jumps travel with the film's camera ---------- */ + + // Native `scroll-behavior: smooth` bypasses Lenis: a nav click warps the + // page in ~24 frames with a completely different easing signature from + // wheel scrolling. Route in-page links through Lenis instead, offset by + // the real sticky-header height so headings never land behind it. + // The skip link is deliberately left alone — its native behaviour also + // moves keyboard focus, which lenis.scrollTo does not. + const header = document.querySelector('.site-header'); + document.querySelectorAll('a[href^="#"]:not(.skip-link)').forEach((link) => { + link.addEventListener('click', (event) => { + const hash = link.getAttribute('href'); + if (!hash || hash === '#') return; + + const target = document.querySelector(hash); + if (!target) return; + + event.preventDefault(); + lenis.scrollTo(target, { + offset: header ? -header.offsetHeight : 0, + duration: 1.4, + }); + // Keep the URL shareable without triggering a second native jump. + history.replaceState(null, '', hash); }); }); + /* ---------- 4. Measurement ---------- */ + // 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(); diff --git a/assets/story.js b/assets/story.js index 68df6c3..35603da 100644 --- a/assets/story.js +++ b/assets/story.js @@ -39,9 +39,19 @@ return; } + // The head watchdog gave up waiting for an engine (slow or stalled CDN) and + // already un-hid the page. Re-adding `js-ready` here would hide every beat a + // second time and reveal it again — a visible flash on exactly the slow + // connections the watchdog exists to protect. Leave the page as it is. + if (root.dataset.storyWatchdog === 'fired') { + return; + } + // Defensive: the controller sets html.js-ready pre-paint, but re-add it here // so the hidden state is guaranteed even if that inline setter was skipped. root.classList.add('js-ready'); + // Tells the head watchdog an engine claimed the page, so it stops waiting. + root.dataset.storyEngine = 'reveal'; const revealAll = () => { document diff --git a/assets/styles.css b/assets/styles.css index 11efac3..ead41d3 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -9,11 +9,18 @@ color-scheme: light; /* Light theme — "daylight love-letter": warm ivory paper, blush surfaces, a terracotta-rose accent. Reads sincere and tender, not corporate. */ + /* Measured height of the sticky header, consumed by section[id]'s + scroll-margin-top so anchor jumps never land a heading behind the bar. + Overridden per breakpoint where the header changes shape. */ + --header-h: 5.5rem; --background: #fbf5ee; /* warm ivory */ --surface: #fffaf4; /* softest warm white */ - --surface-muted: #f4e6da; /* blush sand */ + --surface-muted: #f0dccd; /* blush sand — deepened so panels separate from the ground */ --surface-translucent: rgba(255, 250, 244, 0.86); - --panel-bg: rgba(255, 250, 244, 0.82); + /* Warmer and a touch more opaque than the ground: at rgba(255,250,244,.82) on + an ivory page the panels had almost no surface separation and read as pale + stationery rather than candlelit. Text contrast is unchanged. */ + --panel-bg: rgba(255, 248, 240, 0.94); --header-bg: rgba(251, 245, 238, 0.84); --ink: #3a2a28; /* warm espresso — 12:1 on ivory */ --muted: #7a5a54; /* muted rosewood — ~5.9:1 on panels (AA small text) */ @@ -36,11 +43,11 @@ --hero-overlay-start: rgba(60, 25, 30, 0.86); --hero-overlay-mid: rgba(120, 45, 55, 0.56); --hero-overlay-end: rgba(190, 90, 80, 0.1); + /* Legacy aliases still referenced by component rules below. --mist and + --brass were also defined here but had zero usages; removed. */ --paper: var(--surface); - --mist: var(--surface-muted); --sage: var(--primary); --oxblood: var(--primary-strong); - --brass: var(--focus); --blueprint: var(--muted); } @@ -82,6 +89,13 @@ html { scroll-behavior: smooth; } +/* When the film runs, in-page links are routed through Lenis (story-film.js) so + they share the film's camera easing. Native smooth scrolling would race that + and warp the page in ~24 frames with a different easing signature. */ +html.film-ready { + scroll-behavior: auto; +} + body { margin: 0; min-width: 320px; @@ -266,14 +280,13 @@ li + li { margin-inline: auto; } +/* Full-bleed bar. The background, border and blur belong to the header itself; + the 1120px measure is applied to the inner wrapper only, so the bar spans the + viewport like the progress rail above it. */ .site-header { position: sticky; top: 0; z-index: 10; - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; padding-block: 1rem; border-bottom: 1px solid var(--line); background: var(--header-bg); @@ -284,6 +297,13 @@ li + li { box-shadow var(--motion-theme); } +.site-header__inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + .brand-mark, nav { display: flex; @@ -400,20 +420,67 @@ nav a.active { } @media (max-width: 720px) { + /* The header used to stack into three rows (brand / wrapped nav / theme) and + measured 254px — 40% of a 640px viewport, permanently sticky, on the tier + where reading *is* the product. It now stays a single row: the brand + collapses to its seal and the nav scrolls horizontally instead of wrapping. */ .site-header { - align-items: flex-start; - flex-direction: column; + padding-block: 0.6rem; } - .header-actions, - .preference-controls { - width: 100%; - justify-content: space-between; + /* backdrop-filter on a full-width sticky element is the most expensive paint + on the page, and mobile is the tier that can least afford it. The header + background is already near-opaque, so dropping the blur is barely visible. */ + @supports (backdrop-filter: blur(1px)) { + .site-header { + backdrop-filter: none; + } + } + + .site-header__inner { + gap: 0.6rem; + } + + /* Keep the seal (it is the brand) and drop the long wordmark that forced the + wrap. The link keeps its aria-label, so its accessible name is unchanged. */ + .brand-name { + display: none; + } + + .header-actions { + min-width: 0; + flex: 1; + flex-wrap: nowrap; + gap: 0.5rem; } nav { - max-width: 100%; - flex-wrap: wrap; + min-width: 0; + overflow-x: auto; + flex-wrap: nowrap; + gap: 0.25rem; + /* Momentum scrolling without a visible scrollbar eating vertical space. */ + scrollbar-width: none; + -webkit-overflow-scrolling: touch; + } + + nav::-webkit-scrollbar { + display: none; + } + + nav a { + padding: 0.5rem 0.6rem; + font-size: 0.88rem; + white-space: nowrap; + } + + .preference-controls { + flex: none; + } + + /* One row of chrome, so anchor targets clear a much smaller bar. */ + :root { + --header-h: 4rem; } } @@ -427,19 +494,26 @@ nav a.active { font-size: clamp(2.8rem, 8vw, 6.4rem); } -/* Keep anchor targets clear of the sticky header when navigating with #hash links. */ +/* Keep anchor targets clear of the sticky header when navigating with #hash links. + Driven by --header-h so the mobile breakpoint cannot drift out of sync with the + real bar height (it previously reserved 88px against a 254px header, so every + mobile anchor jump landed the heading behind the chrome). */ section[id] { - scroll-margin-top: 5.5rem; + scroll-margin-top: calc(var(--header-h) + 0.5rem); } +/* Demoted to a quiet sub-label. Every scene stacks .chapter-tag + .eyebrow above + its heading, and when both were uppercase, letterspaced and --primary-strong + the eyebrow — being longer — outweighed the chapter tag above it and inverted + the hierarchy. Sentence case, muted, no tracking: the slate leads, the eyebrow + supports, the heading lands. No copy changed. */ .eyebrow { margin: 0 0 1rem; - /* --primary fails WCAG AA on the light background at this size; --primary-strong passes in both themes. */ - color: var(--primary-strong); - font-size: 0.78rem; - font-weight: 700; - letter-spacing: 0.14em; - text-transform: uppercase; + color: var(--muted); + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0; + text-transform: none; } .lead { @@ -529,6 +603,16 @@ section[id] { color var(--motion-theme); } +/* Panels never reset paragraph margins, so a one-line panel carried a full + default

margin top and bottom and measured ~104px tall. */ +.panel > p:first-child { + margin-top: 0; +} + +.panel > p:last-child { + margin-bottom: 0; +} + .panel:hover { transform: translateY(-3px); box-shadow: @@ -546,6 +630,112 @@ section[id] { 0 0 60px -18px var(--glow-primary); } +/* The opposite of .strong. Used once, on "Ghi chú tuyển chọn" — the written + climax. Everything that competes for attention is removed so the most + important block on the page is also the quietest thing on screen. */ +.panel.quiet { + border-left: 0; + border-radius: 18px; + background: none; + padding-block: clamp(1.8rem, 5vw, 3rem); + box-shadow: none; + text-align: center; +} + +.panel.quiet:hover { + transform: none; + box-shadow: none; +} + +.panel.quiet h3 { + color: var(--primary-strong); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.14em; + line-height: 1.35; + text-transform: uppercase; + font-family: 'Be Vietnam Pro', 'Segoe UI', sans-serif; + margin-bottom: 1.1rem; +} + +.panel.quiet p { + max-width: 46ch; + margin-inline: auto; + font-family: 'Fraunces', Georgia, serif; + font-size: clamp(1.15rem, 2.2vw, 1.5rem); + font-optical-sizing: auto; + font-weight: 400; + line-height: 1.6; +} + +/* ---------- Cảnh 4 · "Gói đãi ngộ" benefit list ---------- + A numbered package rather than four title-less floating boxes, so scene 4 has + a shape of its own and the JD conceit reads as structure instead of filler. */ +.benefit-list { + display: grid; + margin: 2rem 0 0; + padding: 0; + gap: 1px; + overflow: hidden; + border-radius: 14px; + list-style: none; + background: var(--line); + box-shadow: 0 18px 40px -30px var(--glow-primary); + counter-reset: benefit; + transition: background-color var(--motion-theme); +} + +.benefit-list li { + display: grid; + grid-template-columns: auto 1fr; + gap: 1rem; + margin: 0; + background: var(--panel-bg); + padding: 1.15rem 1.25rem; + line-height: 1.72; + transition: background-color var(--motion-theme); +} + +.benefit-list li::before { + counter-increment: benefit; + content: counter(benefit, decimal-leading-zero); + color: var(--primary-strong); + font-family: 'Fraunces', Georgia, serif; + font-size: 0.9rem; + font-weight: 700; + letter-spacing: 0.06em; + /* Align the numeral with the first line of body text beside it. */ + line-height: 1.9; +} + +/* ---------- Cảnh 5 · The vow ---------- + Unboxed large serif lines. Inside the standard panel these four pledges read + as an HR policy; set as display type with room around them they read as a vow. */ +.vow-list { + display: grid; + margin: 0; + padding: 0; + gap: clamp(1.5rem, 3.5vw, 2.4rem); + list-style: none; +} + +.vow-list li { + margin: 0; + max-width: 30ch; + border-left: 2px solid var(--line); + padding-left: clamp(1rem, 2.5vw, 1.6rem); + color: var(--ink); + font-family: 'Fraunces', Georgia, serif; + font-optical-sizing: auto; + font-size: clamp(1.25rem, 2.4vw, 1.75rem); + font-weight: 400; + /* >= 1.3 keeps stacked Vietnamese diacritics clear of the line above. */ + line-height: 1.45; + letter-spacing: -0.005em; + text-wrap: pretty; + transition: border-color var(--motion-theme); +} + .meta-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -588,6 +778,24 @@ section[id] { align-items: center; margin-top: -5rem; padding-top: 6rem; + /* Contains the scaled ::before frame during the film's push-in. */ + overflow: hidden; + color: var(--hero-ink); +} + +/* The opening frame lives on its own layer so the film can scale it. + --hero-scale defaults to 1 and is animated to ~1.07 by story-film.js across + the #home pin, turning what used to be 540px of frozen scroll into a slow + camera push-in. Only `transform` animates, so this stays on the compositor — + animating background-size instead would repaint a full-viewport layer every + frame. Mobile, reduced-motion and no-JS never set the variable and simply get + the still frame. */ +.home-hero::before { + content: ''; + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; background-position: center; background-size: cover; /* The image URL resolves relative to this stylesheet inside assets/. */ @@ -599,7 +807,8 @@ section[id] { var(--hero-overlay-end) ), url('dating-application-hero-background.webp'); - color: var(--hero-ink); + transform: scale(var(--hero-scale, 1)); + transform-origin: 50% 55%; } /* Cinematic hold on the opening frame: a soft edge vignette plus a warm candle @@ -612,6 +821,13 @@ section[id] { z-index: 0; pointer-events: none; background: + /* Dusk carry-over (first layer = painted on top). Scene 1 is a dark + cinematic frame and scene 2 is bright ivory, so the film used to cut hard + at the boundary — most jarring in the light theme. Dissolving the bottom + of the hero into the page colour resolves it. Done here rather than on + #applicant because that section is width-constrained by .site-shell, so an + overlay there shows vertical seams at the 1120px edges. */ + linear-gradient(to bottom, transparent 68%, var(--background) 100%), radial-gradient(120% 90% at 20% 100%, var(--hero-overlay-end), transparent 60%), radial-gradient(130% 120% at 50% 40%, transparent 52%, rgba(20, 8, 10, 0.55) 100%); } @@ -636,16 +852,66 @@ section[id] { color: var(--hero-ink); } -/* ---------- JD page ---------- */ - -.jd-hero { - padding-top: clamp(4rem, 10vw, 8rem); +/* ---------- Cảnh 7 · The hold ---------- + During the closing pin the film scrubs --closing-settle 0 → 1, deepening a warm + vignette so the camera reads as settling on the last spoken beat. Purely + decorative: it never touches content opacity, so nothing here can become + invisible while still focusable. Absolutely positioned so it does not become a + grid item on this .section-grid section; defaults to 0 in the other tiers. */ +#closing { + position: relative; + isolation: isolate; } -/* ---------- CV page ---------- */ +#closing::after { + content: ''; + position: absolute; + z-index: -1; + /* Bleeds vertically only. A horizontal bleed widens the scrollable area — and + because it is a pseudo-element it does so invisibly to any DOM inspection, + which cost 42px of horizontal overflow at 320px. */ + inset: -10% 0; + pointer-events: none; + opacity: var(--closing-settle, 0); + background: radial-gradient(70% 50% at 50% 50%, var(--glow-primary), transparent 72%); +} -.cv-hero { - padding-top: clamp(4rem, 10vw, 8rem); +/* ---------- Khung cuối · End card ---------- + The last frame. Short, centred and quiet: it turns the dossier above it into a + credits roll and puts the apply door where the reader actually stops. */ +/* Centres correctly whether this is a grid (static/reduced-motion tiers) or a + flex column — in film mode the .film-scene centring rule outranks `display` + here, so `align-items` is set for the flex case and `justify-items` for grid. */ +.end-card { + display: grid; + justify-items: center; + align-items: center; + gap: 1.1rem; + padding-block: clamp(4rem, 12vw, 8rem); + text-align: center; +} + +.end-card__seal { + width: 3rem; + height: 3rem; + font-size: 1.05rem; + font-weight: 700; + opacity: 0.9; +} + +.end-card__mark { + margin: 0; + color: var(--muted); + font-family: 'Fraunces', Georgia, serif; + font-optical-sizing: auto; + font-size: clamp(1.3rem, 3vw, 1.9rem); + font-weight: 400; + letter-spacing: 0.02em; +} + +.end-card .button-row { + margin-top: 0.5rem; + justify-content: center; } .facts-list { @@ -761,9 +1027,14 @@ section[id] { color: var(--primary); } -/* ---------- Freshness stamp (closing) ---------- - Filled + un-hidden by assets/freshness-stamp.js only on success; the [hidden] - attribute keeps it out of layout until then, so a failed fetch shows nothing. */ +/* ---------- Freshness stamp (end card) ---------- + Filled + un-hidden by assets/freshness-stamp.js only on success, so a failed + fetch shows nothing at all — that silence is the honesty guarantee and is worth + keeping. It does mean the element is display:none until the fetch resolves, so + revealing it still costs one line of shift; two things keep that harmless: + it now lives in the end card instead of inside the pinned #closing, and + freshness-stamp.js calls ScrollTrigger.refresh() afterwards so no trigger below + it is left mis-measured. */ .freshness-stamp { margin: 1.25rem 0 0; color: var(--muted); @@ -772,3 +1043,8 @@ section[id] { letter-spacing: 0.04em; transition: color var(--motion-theme); } + +/* [hidden] must still win over the grid display .end-card gives its children. */ +.freshness-stamp[hidden] { + display: none; +} diff --git a/index.html b/index.html index d25769b..98832e7 100644 --- a/index.html +++ b/index.html @@ -9,11 +9,24 @@ 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." /> + + + @@ -63,6 +76,21 @@ // Consumed by assets/story.css and assets/story-chrome.css. if ('IntersectionObserver' in window) { root.classList.add('js-ready'); + + // Watchdog. `js-ready` hides all 31 beats, and the engine that + // reveals them again sits behind ~40KB of deferred CDN script. + // A hard CDN failure is handled (the scripts never run, and + // story.js is local), but a SLOW-but-successful CDN is not: + // until it resolves, the first paint contains no words at all. + // If no engine has claimed the page in time, un-hide everything + // and let the reader read. Engines set `storyEngine` when they + // take over; story.js also checks `storyWatchdog` so it never + // re-hides content this already revealed. + window.setTimeout(() => { + if (root.dataset.storyEngine) return; + root.dataset.storyWatchdog = 'fired'; + root.classList.remove('js-ready'); + }, 1200); } } catch { document.documentElement.dataset.theme = 'light'; @@ -76,10 +104,13 @@ - - - + If any fails, story-film.js bails and the static reveal (story.js) runs. + All three come from one host so the film costs a single connection + rather than two DNS+TLS handshakes. --> + + + + @@ -94,44 +125,52 @@ -