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.
This commit is contained in:
2026-07-25 14:32:47 +07:00
parent 046151403a
commit ff6dc3b39e
3 changed files with 144 additions and 4 deletions
+56 -4
View File
@@ -233,7 +233,59 @@
});
}
/* ---------- 2. Scene 2 set-piece: the photograph is picked up ---------- */
/* ---------- 2. Đêm dần xuống · the night falls ---------- */
// The single continuous transformation across all nine scenes, and the
// reason the page reads as one evening rather than nine slides. The room
// gets later as she scrolls, starting from the hour on HER OWN device — so
// if she opens this at 1am it is already deep night, and a friend she shows
// it to tomorrow afternoon sees a different room. Nothing leaves the device
// and there is no backend; it is just her clock.
//
// Deliberately drives ONE custom property on ONE element. Writing a scrubbed
// value onto :root would invalidate every element that inherits it, every
// frame; scoping it to .nightfall keeps the recalculation to one subtree.
//
// It must never touch the palette tokens — she may have explicitly chosen
// light or dark, and overriding that would be a bug, not a feature.
const nightfall = document.querySelector('.nightfall');
if (nightfall) {
// Where the evening starts, by local hour. Daytime still opens softly
// lit rather than at zero, because the room has a lamp in it either way.
const hour = new Date().getHours();
const startNight =
hour >= 1 && hour < 5
? 0.72 // small hours
: hour < 16
? 0.12 // daytime
: hour < 19
? 0.22 // late afternoon
: hour < 21
? 0.34 // early evening
: hour < 23
? 0.48 // night
: 0.62; // near midnight
// Always leave headroom so the arc is felt rather than saturating early.
const endNight = Math.min(0.96, startNight + 0.46);
gsap.fromTo(
nightfall,
{ '--night': startNight },
{
'--night': endNight,
ease: 'none',
scrollTrigger: {
trigger: document.body,
start: 'top top',
end: 'bottom bottom',
scrub: SCRUB,
},
},
);
}
/* ---------- 3. Scene 2 set-piece: the photograph is picked up ---------- */
// The one place on this page where a "turn" is physically motivated. The
// hero frame establishes a desk with a letter on it; this is a photograph
@@ -287,7 +339,7 @@
});
}
/* ---------- 3. Per-beat entrances (non-reversing) ---------- */
/* ---------- 4. 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.
@@ -352,7 +404,7 @@
});
});
/* ---------- 4. Anchor jumps travel with the film's camera ---------- */
/* ---------- 5. 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
@@ -379,7 +431,7 @@
});
});
/* ---------- 5. Measurement ---------- */
/* ---------- 6. Measurement ---------- */
// Pins change total scroll height; recompute all triggers once now and on
// full load (fonts/images can shift layout after this script runs).
+79
View File
@@ -167,6 +167,85 @@ body::after {
}
}
/* ---------- Đê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;
+9
View File
@@ -121,6 +121,15 @@
<script src="assets/freshness-stamp.js" defer></script>
</head>
<body>
<!-- The room gets later as she reads. One fixed backdrop layer whose --night
value travels from her own local hour toward midnight across the whole
page, so the nine scenes read as one evening rather than nine slides.
Purely atmospheric and aria-hidden: it never touches the theme tokens
(her explicit light/dark choice always wins) and never gates content.
Defaults to a lit room in CSS, so the no-JS and reduced-motion tiers get
a deliberate composition rather than an unlit one. -->
<div class="nightfall" aria-hidden="true"><span class="nightfall__dust"></span></div>
<div class="scroll-progress" aria-hidden="true"><span class="scroll-progress__fill"></span></div>
<a class="skip-link" href="#content">Bỏ qua đến nội dung</a>