Files
tiennm99 ff6dc3b39e feat: the room gets later as she reads
One continuous transformation across all nine scenes, so the page reads as a
single evening instead of nine separate slides. A fixed backdrop layer travels
from early evening toward past midnight as she scrolls: the corners go to plum,
the lamp keeps its warmth, and dust turns over in the beam.

It starts from the hour on her own device, so opening it at 1am begins deep in
the night and a friend shown it the next afternoon sees a different room.
Nothing leaves the device and there is no backend — it reads her clock.

Deliberate boundaries:
- Atmosphere only. The palette tokens are never interpolated, so an explicit
  light or dark choice always wins; both themes simply travel within themselves.
- The scrubbed value is written to one element, not to :root. On :root it would
  invalidate everything inheriting it on every frame.
- Dust is a masked, tiled dot pattern on one composited layer rather than N
  particle elements — many things moving, one transform.
- Defaults to a lit room in CSS, so the no-JS tier gets a deliberate composition
  rather than an unlit one, and reduced motion freezes the evening at about 22:00
  with the air still.

Verified at 1440x900 and 390x844, both themes, and reduced motion: the value
travels with scroll, the theme choice survives, no horizontal overflow, no hidden
beats, no console output.

This is the light system the remaining objects will be lit by; it is groundwork
as much as it is a scene.
2026-07-25 14:32:47 +07:00

1101 lines
30 KiB
CSS

/* Fonts are loaded via <link> tags in each page head so the fetch is not serialized behind this stylesheet. */
/* ---------- Base tokens & elements ---------- */
:root {
font-family: 'Be Vietnam Pro', 'Segoe UI', sans-serif;
color: var(--ink);
background: var(--background);
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: #f0dccd; /* blush sand — deepened so panels separate from the ground */
--surface-translucent: rgba(255, 250, 244, 0.86);
/* 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) */
--primary: #c14d68; /* terracotta-rose — 4.65:1 with white (buttons, bold) */
/* --primary fails AA for small text; --primary-strong is the accessible
variant used everywhere small type takes the accent colour. */
--primary-strong: #9e3350; /* deep rose — ~6.3:1 on ivory (AA small text) */
--on-primary: #fff8f4;
--focus: #b23a5b;
--line: rgba(158, 51, 80, 0.2);
--glow-primary: rgba(193, 77, 104, 0.16);
--shadow: 0 24px 70px rgba(76, 40, 40, 0.14);
--motion-fast: 160ms ease;
--motion-theme: 260ms ease;
/* Hero sits on a dark photo overlay in both themes — warm dusk/candlelight. */
--hero-ink: #fff8f4;
--hero-muted: rgba(255, 248, 244, 0.86);
--hero-accent: #f2c2a8; /* soft rose-gold */
--hero-border: rgba(255, 248, 244, 0.72);
--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);
--sage: var(--primary);
--oxblood: var(--primary-strong);
--blueprint: var(--muted);
}
:root[data-theme='dark'] {
color-scheme: dark;
/* Dark theme — "candlelit dusk": deep plum-wine ground, warm cream ink, a
rose-gold/amber glow. Intimate and soft rather than the old harsh
black + hot-pink. */
--background: #17090f; /* deep wine-plum */
--surface: #241019; /* wine */
--surface-muted: #2e1622; /* lifted plum */
--surface-translucent: rgba(36, 16, 25, 0.86);
--panel-bg: rgba(40, 18, 28, 0.88);
--header-bg: rgba(23, 9, 15, 0.82);
--ink: #fbeae0; /* warm candlelit cream */
--muted: #d9a9b8; /* soft rose-mauve — ~8.7:1 on panels */
--primary: #f2a488; /* candlelit rose-gold — ~9:1 with --on-primary (buttons) */
/* In dark mode the accessible small-text variant is LIGHTER than --primary. */
--primary-strong: #f7bda6; /* ~11.5:1 on the plum ground (AA small text) */
--on-primary: #2a0e14; /* deep wine ink on the warm accent */
--focus: #f9c4b0;
--line: rgba(242, 164, 136, 0.26);
--glow-primary: rgba(242, 164, 136, 0.2);
--shadow: 0 24px 70px rgba(0, 0, 0, 0.5);
--hero-ink: #fbeae0;
--hero-muted: rgba(251, 234, 224, 0.85);
--hero-accent: #f7c4a8;
--hero-border: rgba(251, 234, 224, 0.72);
--hero-overlay-start: rgba(15, 6, 10, 0.92);
--hero-overlay-mid: rgba(66, 22, 38, 0.72);
--hero-overlay-end: rgba(242, 164, 136, 0.12);
}
* {
box-sizing: border-box;
}
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;
/* Diagonal ivory→blush wash. The candlelight glows that "stay put" as the story
scrolls live on the fixed, GPU-promoted body::after bloom — NOT here — so this
layer needs no background-attachment: fixed (that forces a full-viewport
repaint every scroll frame and is the main cause of scroll jank). */
background:
linear-gradient(150deg, var(--background) 0%, var(--surface) 52%, var(--surface-muted) 100%);
color: var(--ink);
transition:
background-color var(--motion-theme),
color var(--motion-theme);
}
/* Paper grain + gentle vignette. A fixed, non-interactive overlay that sits just
above content for a filmic, hand-made warmth. Deliberately NO blend mode: a
mix-blend-mode on a fixed, full-viewport layer forces the browser to re-blend
against the scrolling content every frame (scroll jank). Instead we keep it a
plain, cheaply-composited layer at low opacity so it reads as faint film grain
without touching body-text contrast. The SVG noise is inline — no network fetch. */
body::before {
content: '';
position: fixed;
inset: 0;
z-index: 2;
pointer-events: none;
opacity: 0.1;
background-image:
radial-gradient(circle at 50% 45%, transparent 58%, rgba(40, 20, 20, 0.14) 100%),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.45'/%3E%3C/svg%3E");
background-size:
100% 100%,
140px 140px;
}
/* Slow-drifting candlelight bloom, painted BEHIND content (negative z-index sits
above the body wash but below <main>), so translucent panels glow warmly from
within. Compositor-only transform drift; disabled under reduced motion. */
body::after {
content: '';
position: fixed;
inset: -20% -10%;
z-index: -1;
pointer-events: none;
/* Radial glows are already soft to transparent, so no blur filter is needed —
dropping it removes an expensive full-layer rasterization on every animation
frame while looking identical. Only transform (drift) is animated, so the
layer stays composited and cheap. */
background:
radial-gradient(circle at 30% 30%, var(--glow-primary), transparent 45%),
radial-gradient(circle at 72% 68%, var(--glow-primary), transparent 48%);
will-change: transform;
animation: candlelight-drift 26s ease-in-out infinite alternate;
}
@keyframes candlelight-drift {
from {
transform: translate3d(-2%, -1.5%, 0) scale(1.02);
}
to {
transform: translate3d(2.5%, 2%, 0) scale(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
body::after {
animation: none;
}
}
/* ---------- Đêm dần xuống · the night falls ----------
One fixed layer that makes the room get later as she scrolls. `--night` runs
0 (early evening) → 1 (past midnight) and is written by story-film.js onto
this element only — deliberately NOT onto :root, so a per-frame scrub
invalidates one subtree instead of every element on the page.
It is atmosphere, not theming. The palette tokens are never interpolated: the
visitor's explicit light/dark choice always wins, and both themes simply
travel — light from dusk toward evening, dark from evening toward midnight.
The 0.35 default is the whole point of the fallback story: with no JS, or
under reduced motion, the room is simply already lit at a plausible hour
rather than sitting at "unlit". */
.nightfall {
--night: 0.35;
position: fixed;
inset: 0;
/* Above the page wash and the candle bloom (-1), below the grain (2) and all
chrome. Transparent through the middle, so body text is never dimmed. */
z-index: 1;
pointer-events: none;
background:
radial-gradient(
125% 105% at 50% 42%,
transparent 46%,
rgba(18, 7, 12, calc(0.5 * var(--night))) 100%
),
radial-gradient(
70% 55% at 22% 88%,
rgba(255, 190, 140, calc(0.1 * var(--night))),
transparent 62%
);
}
/* Dust turning over in the lamp beam — the cheapest possible way to have many
small things moving at once. A tiled dot pattern rather than N elements: one
composited layer, one transform, no per-particle cost. Masked to the beam so
it reads as lit air instead of as noise over the page. */
.nightfall__dust {
position: absolute;
inset: -25%;
display: block;
opacity: calc(0.55 * var(--night));
background-image:
radial-gradient(circle at 20% 30%, rgba(255, 226, 200, 0.9) 0 1px, transparent 1.6px),
radial-gradient(circle at 68% 12%, rgba(255, 226, 200, 0.7) 0 1.2px, transparent 1.8px),
radial-gradient(circle at 42% 76%, rgba(255, 226, 200, 0.8) 0 1px, transparent 1.5px);
background-size:
180px 180px,
260px 260px,
220px 220px;
/* Confine the motes to the lamp's throw. */
mask-image: radial-gradient(58% 48% at 24% 84%, #000 12%, transparent 72%);
-webkit-mask-image: radial-gradient(58% 48% at 24% 84%, #000 12%, transparent 72%);
animation: dust-drift 42s linear infinite alternate;
will-change: transform;
}
@keyframes dust-drift {
from {
transform: translate3d(-2.5%, 1.5%, 0);
}
to {
transform: translate3d(2.5%, -2%, 0);
}
}
@media (prefers-reduced-motion: reduce) {
/* Freeze the evening at roughly 22:00 and still the air. A lit room, no
travel — nothing readable is lost. */
.nightfall {
--night: 0.45;
}
.nightfall__dust {
animation: none;
}
}
a {
color: inherit;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.24em;
transition:
background-color var(--motion-fast),
border-color var(--motion-fast),
color var(--motion-fast);
}
button {
transition:
background-color var(--motion-fast),
border-color var(--motion-fast),
box-shadow var(--motion-fast),
color var(--motion-fast),
opacity var(--motion-fast),
transform var(--motion-fast);
}
a:focus-visible,
button:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 4px;
}
h1,
h2,
h3 {
font-family: 'Fraunces', Georgia, serif;
/* Fraunces is a variable optical-size serif; let it thicken slightly as it
grows so display headings feel warm and considered, not thin. */
font-optical-sizing: auto;
/* Tender editorial tightening. Kept >= 1.12 (not sub-1): stacked Vietnamese
diacritics on wrapped headings — e.g. "Nghiêm túc" — clip below ~1.1. */
line-height: 1.14;
letter-spacing: -0.01em;
text-wrap: balance;
}
h1 {
max-width: 12ch;
margin: 0;
font-size: clamp(3.4rem, 11vw, 8.6rem);
font-weight: 650;
letter-spacing: -0.02em;
}
h2 {
margin: 0 0 1rem;
font-size: clamp(2rem, 5vw, 4.6rem);
font-weight: 550;
}
h3 {
margin: 0 0 0.6rem;
font-size: clamp(1.35rem, 3vw, 2.1rem);
font-weight: 600;
}
p {
line-height: 1.72;
text-wrap: pretty;
}
ul {
margin: 0;
padding-left: 1.25rem;
}
li + li {
margin-top: 0.65rem;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 1ms !important;
scroll-behavior: auto !important;
transition-duration: 1ms !important;
}
}
@media (max-width: 720px) {
h1 {
font-size: clamp(3rem, 18vw, 5rem);
}
}
/* ---------- Layout: header, nav, theme switch ---------- */
.skip-link {
position: fixed;
left: 1rem;
top: 1rem;
z-index: 20;
transform: translateY(-140%);
background: var(--primary);
color: var(--on-primary);
padding: 0.75rem 1rem;
border-radius: 8px;
}
.skip-link:focus {
transform: translateY(0);
}
.site-shell {
width: min(1120px, calc(100% - 32px));
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;
padding-block: 1rem;
border-bottom: 1px solid var(--line);
background: var(--header-bg);
backdrop-filter: blur(18px);
transition:
background-color var(--motion-theme),
border-color var(--motion-theme),
box-shadow var(--motion-theme);
}
.site-header__inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.brand-mark,
nav {
display: flex;
align-items: center;
gap: 0.8rem;
}
.brand-mark {
font-weight: 700;
line-height: 1.1;
text-decoration: none;
}
.brand-seal {
display: grid;
width: 2.25rem;
height: 2.25rem;
place-items: center;
border: 1px solid var(--line);
border-radius: 50%;
background: var(--paper);
color: var(--primary);
font-family: 'Fraunces', Georgia, serif;
transition:
background-color var(--motion-theme),
border-color var(--motion-theme),
color var(--motion-theme);
}
.header-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 1rem;
flex-wrap: wrap;
}
nav a {
min-height: 44px;
display: inline-flex;
align-items: center;
padding: 0.55rem 0.72rem;
border: 1px solid transparent;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
text-decoration: none;
}
nav a:hover {
background: var(--surface-muted);
border-color: var(--line);
}
nav a.active {
background: var(--primary);
color: var(--on-primary);
}
.preference-controls {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
flex-wrap: wrap;
}
.segmented-control {
display: flex;
gap: 0.2rem;
padding: 0.2rem;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface-translucent);
box-shadow: 0 1px 0 var(--line);
transition:
background-color var(--motion-theme),
border-color var(--motion-theme),
box-shadow var(--motion-theme);
}
.segmented-control button {
width: 44px;
height: 44px;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--ink);
cursor: pointer;
font: inherit;
font-size: 1.08rem;
font-weight: 700;
line-height: 1;
touch-action: manipulation;
}
.segmented-control button:hover {
background: var(--surface-muted);
}
.segmented-control button:active {
transform: scale(0.96);
}
.segmented-control button.active {
background: var(--primary);
color: var(--on-primary);
box-shadow: 0 10px 24px var(--glow-primary);
}
.emoji-icon {
font-family:
'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', 'Twemoji Mozilla', sans-serif;
}
@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 {
padding-block: 0.6rem;
}
/* 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 {
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;
}
}
/* ---------- Shared content blocks ---------- */
/* Big section titles for the JD and CV blocks on the single-page layout:
smaller than the home h1 but clearly above regular h2 headings. */
.page-title {
max-width: 12ch;
margin: 0;
font-size: clamp(2.8rem, 8vw, 6.4rem);
}
/* 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: 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;
color: var(--muted);
font-size: 0.85rem;
font-weight: 600;
letter-spacing: 0;
text-transform: none;
}
.lead {
max-width: 40rem;
font-size: clamp(1.1rem, 2vw, 1.32rem);
line-height: 1.75;
color: var(--muted);
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
margin-top: 2rem;
}
.button {
min-height: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.85rem 1.35rem;
border: 1px solid var(--primary);
border-radius: 999px; /* soft pill — warmer, more intimate than a hard rect */
background: var(--primary);
color: var(--on-primary);
font-weight: 700;
letter-spacing: 0.01em;
text-decoration: none;
box-shadow: 0 10px 26px var(--glow-primary);
transition:
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 220ms ease,
background 180ms ease;
}
.button:hover {
transform: translateY(-2px);
border-color: var(--primary-strong);
background: var(--primary-strong);
color: var(--on-primary);
box-shadow: 0 16px 34px var(--glow-primary);
}
.button.secondary {
background: transparent;
color: var(--primary-strong);
box-shadow: none;
}
.button.secondary:hover {
background: var(--glow-primary);
color: var(--primary-strong);
}
.page-section {
padding-block: clamp(3rem, 8vw, 6.5rem);
}
.section-grid {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.4fr);
gap: clamp(2rem, 7vw, 6rem);
align-items: start;
}
.panel-list {
display: grid;
gap: 1rem;
}
.panel {
position: relative;
border-left: 3px solid var(--sage);
border-radius: 14px;
background:
linear-gradient(155deg, var(--surface-translucent), var(--panel-bg));
padding: clamp(1.2rem, 3vw, 1.7rem);
box-shadow:
0 1px 0 var(--line),
0 18px 40px -28px var(--glow-primary);
transition:
transform var(--motion-fast),
background-color var(--motion-theme),
border-color var(--motion-theme),
box-shadow var(--motion-theme),
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:
0 1px 0 var(--line),
0 26px 52px -26px var(--glow-primary);
}
.panel.strong {
border-left-width: 4px;
border-color: var(--oxblood);
/* Warm halo marks the emotional / high-signal panels (closing beat, the
"things we both protect" pledge). */
box-shadow:
var(--shadow),
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 · What he can offer ----------
Four unhurried lines. This was a numbered, hairline-ruled table, which read as
a compensation schedule; scene 4 still needs a shape distinct from the vow
(scene 5) and the quiet climax (scene 6), so it keeps body-size type and a
small rose mark instead of borrowing either of theirs. */
.gift-list {
display: grid;
margin: 2.25rem 0 0;
padding: 0;
gap: 1.35rem;
list-style: none;
max-width: 44rem;
}
.gift-list li {
position: relative;
margin: 0;
padding-left: 1.75rem;
line-height: 1.72;
}
/* A small open circle rather than a bullet or a numeral: present enough to mark
the line, quiet enough not to tally it. */
.gift-list li::before {
content: '';
position: absolute;
left: 0;
top: 0.62em;
width: 0.5rem;
height: 0.5rem;
border: 1.5px solid var(--primary);
border-radius: 50%;
transition: border-color var(--motion-theme);
}
/* ---------- 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 removed with the job-spec table it styled (Loại hình / Địa điểm /
Ngày bắt đầu) — that block only made sense while the page was a job posting. */
@media (max-width: 720px) {
.section-grid {
grid-template-columns: 1fr;
}
}
/* ---------- Home page ---------- */
.home-hero {
position: relative;
min-height: min(760px, 82dvh);
display: flex;
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/. */
background-image:
linear-gradient(
90deg,
var(--hero-overlay-start),
var(--hero-overlay-mid),
var(--hero-overlay-end)
),
url('dating-application-hero-background.webp');
transform: scale(var(--hero-scale, 1));
transform-origin: 50% 55%;
}
/* Cinematic hold on the opening frame: a soft edge vignette plus a warm candle
bloom rising from the lower-left, so the cold open reads as dusk light rather
than a flat photo. Decorative + non-interactive; text sits above via z-index. */
.home-hero::after {
content: '';
position: absolute;
inset: 0;
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%);
}
.hero-copy {
position: relative;
z-index: 1;
padding-block: clamp(5rem, 12vw, 8rem);
}
.hero-copy .lead {
margin-top: 1.5rem;
color: var(--hero-muted);
}
.hero-copy .eyebrow {
color: var(--hero-accent);
}
.hero-copy .button.secondary {
border-color: var(--hero-border);
color: var(--hero-ink);
}
/* ---------- 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;
}
#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%);
}
/* ---------- 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 {
display: grid;
gap: 1px;
overflow: hidden;
border-radius: 14px;
background: var(--line);
box-shadow: 0 18px 40px -30px var(--glow-primary);
transition: background-color var(--motion-theme);
}
.facts-list div {
display: grid;
grid-template-columns: minmax(9rem, 0.7fr) 1fr;
gap: 1rem;
background: var(--panel-bg);
padding: 1rem;
transition: background-color var(--motion-theme);
}
.facts-list dt {
color: var(--blueprint);
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
}
.facts-list dd {
margin: 0;
font-weight: 700;
}
@media (max-width: 620px) {
.facts-list div {
grid-template-columns: 1fr;
}
}
/* ---------- Portrait slot (Cảnh 2) ----------
Ships with a themed placeholder; the owner swaps in a real <img class="portrait__img">
(commented in index.html). Fixed aspect-ratio reserves space so adding the photo
later causes no layout shift (CLS). */
.portrait {
margin: 1.75rem 0 0;
max-width: 16rem;
aspect-ratio: 4 / 5;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--surface-muted);
box-shadow: var(--shadow);
transition:
background-color var(--motion-theme),
border-color var(--motion-theme),
box-shadow var(--motion-theme);
}
.portrait__img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
/* Warm, quiet placeholder: a soft-glow ground + a muted silhouette + caption. */
.portrait__placeholder {
display: flex;
width: 100%;
height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.9rem;
padding: 1rem;
color: var(--muted);
text-align: center;
background:
radial-gradient(circle at 50% 38%, var(--glow-primary), transparent 70%),
linear-gradient(160deg, var(--surface) 0%, var(--surface-muted) 100%);
}
.portrait__placeholder svg {
width: 44%;
max-width: 5.5rem;
fill: none;
stroke: currentColor;
stroke-width: 3.5;
stroke-linecap: round;
opacity: 0.55;
}
.portrait__placeholder span {
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.02em;
}
/* ---------- Inline "verify the claim" link (e.g. real playlist) ---------- */
.panel-link {
margin: 0.85rem 0 0;
font-weight: 700;
}
.panel-link a {
color: var(--primary-strong); /* AA-safe small-text accent in both themes */
text-decoration-thickness: 0.08em;
text-underline-offset: 0.22em;
}
.panel-link a:hover {
color: var(--primary);
}
/* ---------- 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);
font-size: 0.8rem;
font-weight: 600;
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;
}