mirror of
https://github.com/tiennm99/dating.git
synced 2026-08-30 22:23:59 +00:00
feat: make the scroll film land, and give the story a last frame
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.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
+23
-4
@@ -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);
|
||||
|
||||
|
||||
+23
-3
@@ -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
|
||||
|
||||
+173
-31
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
+311
-35
@@ -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 <p> 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;
|
||||
}
|
||||
|
||||
+155
-80
@@ -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."
|
||||
/>
|
||||
<link rel="icon" href="assets/favicon.svg" />
|
||||
<!-- The hero photo is the film's opening frame but is only discoverable after
|
||||
styles.css parses; preloading it pulls the first paint forward. -->
|
||||
<link
|
||||
rel="preload"
|
||||
as="image"
|
||||
href="assets/dating-application-hero-background.webp"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<!-- Fraunces is requested as a continuous weight RANGE (400..750), not as three
|
||||
fixed instances. With only 500/650/750 available, CSS weight matching
|
||||
snapped both h2 (550) and h3 (600) to 650 — h1, h2 and h3 all rendered at
|
||||
the same weight and the type hierarchy the stylesheet describes did not
|
||||
exist. Be Vietnam Pro 500 was requested but never used; dropped. -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;500;600;700&family=Fraunces:opsz,wght@9..144,500;9..144,650;9..144,750&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;600;700&family=Fraunces:opsz,wght@9..144,400..750&display=swap"
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Dating JD & CV" />
|
||||
@@ -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 @@
|
||||
<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>
|
||||
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. -->
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js" defer></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js" defer></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.25/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>
|
||||
@@ -94,44 +125,52 @@
|
||||
|
||||
<a class="skip-link" href="#content">Bỏ qua đến nội dung</a>
|
||||
|
||||
<header class="site-shell site-header">
|
||||
<a
|
||||
class="brand-mark"
|
||||
href="#home"
|
||||
aria-label="Về đầu trang mô tả và hồ sơ hẹn hò của Tiến Nguyễn Minh"
|
||||
>
|
||||
<span class="brand-seal">TN</span>
|
||||
<span>Mô tả & hồ sơ hẹn hò</span>
|
||||
</a>
|
||||
<!-- Full-bleed bar with an inner shell: .site-shell used to be on the header
|
||||
itself, so the background, border and blur were clipped to 1120px and the
|
||||
bar floated over the hero with the photo showing on both sides. -->
|
||||
<header class="site-header">
|
||||
<div class="site-shell site-header__inner">
|
||||
<a
|
||||
class="brand-mark"
|
||||
href="#home"
|
||||
aria-label="Về đầu trang mô tả và hồ sơ hẹn hò của Tiến Nguyễn Minh"
|
||||
>
|
||||
<span class="brand-seal">TN</span>
|
||||
<span class="brand-name">Mô tả & hồ sơ hẹn hò</span>
|
||||
</a>
|
||||
|
||||
<div class="header-actions">
|
||||
<nav aria-label="Điều hướng chính">
|
||||
<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="header-actions">
|
||||
<!-- "Kết nối" (#closing) is the page's only apply door; it previously
|
||||
had no link in the persistent chrome. -->
|
||||
<nav aria-label="Điều hướng chính">
|
||||
<a href="#home">Mở đầu</a>
|
||||
<a href="#applicant">Con người</a>
|
||||
<a href="#offer">Lời mời</a>
|
||||
<a href="#closing">Kết nối</a>
|
||||
<a href="#dossier">Chi tiết</a>
|
||||
</nav>
|
||||
|
||||
<div class="preference-controls">
|
||||
<div class="segmented-control icon-segmented" role="group" aria-label="Giao diện">
|
||||
<button
|
||||
type="button"
|
||||
data-theme-option="light"
|
||||
aria-label="Chuyển sang giao diện sáng"
|
||||
aria-pressed="false"
|
||||
title="Chuyển sang giao diện sáng"
|
||||
>
|
||||
<span class="emoji-icon" aria-hidden="true">☀️</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-theme-option="dark"
|
||||
aria-label="Chuyển sang giao diện tối"
|
||||
aria-pressed="false"
|
||||
title="Chuyển sang giao diện tối"
|
||||
>
|
||||
<span class="emoji-icon" aria-hidden="true">🌙</span>
|
||||
</button>
|
||||
<div class="preference-controls">
|
||||
<div class="segmented-control icon-segmented" role="group" aria-label="Giao diện">
|
||||
<button
|
||||
type="button"
|
||||
data-theme-option="light"
|
||||
aria-label="Chuyển sang giao diện sáng"
|
||||
aria-pressed="false"
|
||||
title="Chuyển sang giao diện sáng"
|
||||
>
|
||||
<span class="emoji-icon" aria-hidden="true">☀️</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-theme-option="dark"
|
||||
aria-label="Chuyển sang giao diện tối"
|
||||
aria-pressed="false"
|
||||
title="Chuyển sang giao diện tối"
|
||||
>
|
||||
<span class="emoji-icon" aria-hidden="true">🌙</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -308,23 +347,22 @@
|
||||
<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>
|
||||
<!-- Numbered "gói đãi ngộ" rather than four title-less floating boxes:
|
||||
the JD conceit lands harder as a structured package, and it gives
|
||||
scene 4 a shape distinct from scenes 5 and 6. Copy unchanged. -->
|
||||
<ol class="benefit-list">
|
||||
<li data-reveal>
|
||||
Ư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.
|
||||
</li>
|
||||
<li data-reveal>Sự thành thật đều đặn, giao tiếp rõ ràng, và báo lỗi không đổ lỗi.</li>
|
||||
<li data-reveal>
|
||||
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.
|
||||
</li>
|
||||
<li data-reveal>
|
||||
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.
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- ============ Cảnh 5 · Điều cả hai cùng giữ (trách nhiệm) ============ -->
|
||||
@@ -340,21 +378,19 @@
|
||||
<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>
|
||||
<!-- Unboxed on purpose. As four large serif lines, each arriving as its
|
||||
own beat, these read as a vow; inside the same rose-bordered
|
||||
rectangle as every other scene they read as an HR policy. Copy
|
||||
unchanged — only the container and the beat granularity differ. -->
|
||||
<ul class="vow-list">
|
||||
<li data-reveal>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 data-reveal>
|
||||
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 data-reveal>Mang theo sự tò mò, lòng tử tế, và thiện chí sửa chữa sau mâu thuẫn.</li>
|
||||
<li data-reveal>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>
|
||||
</section>
|
||||
|
||||
<!-- ============ Cảnh 6 · Không có bài kiểm tra áp lực (điểm cộng + cao trào) ============ -->
|
||||
@@ -382,10 +418,25 @@
|
||||
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>
|
||||
<!-- Zodiac emoji carry meaning here, so they are labelled in Vietnamese;
|
||||
unlabelled, a screen reader announces eight English emoji names
|
||||
in the middle of a Vietnamese sentence. -->
|
||||
<li>
|
||||
Về tử vi: tuổi <span role="img" aria-label="Mão">🐈</span> này hợp các tuổi
|
||||
<span role="img" aria-label="Thân">🐒</span>
|
||||
<span role="img" aria-label="Dần">🐅</span>
|
||||
<span role="img" aria-label="Tỵ">🐍</span>
|
||||
<span role="img" aria-label="Ngọ">🐎</span>
|
||||
<span role="img" aria-label="Hợi">🐖</span> hoặc
|
||||
<span role="img" aria-label="Thìn">🐉</span>
|
||||
<span role="img" aria-label="Tuất">🐕</span> — nhưng hợp sóng vẫn hơn hợp tuổi.
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article class="panel strong" data-reveal>
|
||||
<!-- The written climax of the page. Deliberately the quietest block in
|
||||
the layout — no accent border, no halo, centred and given room —
|
||||
so the calmest thing on screen is also the most important. -->
|
||||
<article class="panel quiet" 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
|
||||
@@ -407,7 +458,9 @@
|
||||
>
|
||||
<div data-reveal="soft">
|
||||
<p class="chapter-tag">Cảnh 7 · Lời kết</p>
|
||||
<p class="eyebrow">Lời kết</p>
|
||||
<!-- The .eyebrow here read "Lời kết", repeating the chapter tag directly
|
||||
above it verbatim. Removed rather than demoted; every other scene
|
||||
keeps its eyebrow. -->
|
||||
<h2 id="closing-title">Kết nối để tìm hiểu thêm nhé.</h2>
|
||||
</div>
|
||||
<article class="panel strong" data-reveal="soft">
|
||||
@@ -433,9 +486,6 @@
|
||||
>Theo dõi Instagram</a
|
||||
>
|
||||
</div>
|
||||
<!-- Dấu "còn mở tính đến [ngày]" — assets/freshness-stamp.js tự điền theo ngày
|
||||
commit mới nhất trên GitHub và bỏ ẩn khi thành công; nếu lỗi/offline thì để ẩn. -->
|
||||
<p class="freshness-stamp" data-freshness hidden></p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@@ -523,10 +573,35 @@
|
||||
</div>
|
||||
<div>
|
||||
<dt>Góc riêng tư</dt>
|
||||
<dd>Hơi… tà răm. Fetish là chân 🌚</dd>
|
||||
<dd>Hơi… tà răm. Fetish là chân <span role="img" aria-label="mặt trăng quay lưng">🌚</span></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<!-- ============ Khung cuối · End card ============
|
||||
The dossier is the longest block on the page and used to be the last
|
||||
thing the reader saw, so the film ended on a reference table. This is
|
||||
the last frame: it turns the dossier into a credits roll, gives the
|
||||
freshness stamp a home outside the pinned #closing (where it shifted
|
||||
layout after measurement), and repeats the apply door at the true
|
||||
bottom, where the reader actually finishes. -->
|
||||
<section class="site-shell page-section end-card film-scene" id="end" data-scene data-reveal-group>
|
||||
<span class="brand-seal end-card__seal" aria-hidden="true">TN</span>
|
||||
<!-- OWNER: một chữ duy nhất do máy thêm vào — đổi hoặc xoá tuỳ ý anh. -->
|
||||
<p class="end-card__mark" data-reveal="soft">Hết.</p>
|
||||
<div class="button-row" data-reveal="soft">
|
||||
<a
|
||||
class="button"
|
||||
href="https://www.facebook.com/YOUR_HANDLE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Nhắn qua Facebook</a
|
||||
>
|
||||
</div>
|
||||
<!-- Dấu "còn mở tính đến [ngày]" — assets/freshness-stamp.js tự điền theo ngày
|
||||
commit mới nhất trên GitHub và bỏ ẩn khi thành công; nếu lỗi/offline thì để ẩn. -->
|
||||
<p class="freshness-stamp" data-freshness hidden></p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user