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 @@ -