ruler: heading link affordances; free the wordmark from the pool's z-trap

Section heading links get proper interactive states: hovering a word
brightens its letters from the dimmed ink-mix to full ink (eased, per
word), and clicking pulses it toward the accent as the jump takes off —
on top of the existing pointer cursor. The reduced-motion queue links
get an active accent state too.

The wordmark rendering under the top contrast pool was a stacking-
context trap: the hero content container's z-10 capped the masthead's
z-40 against the root-level fixed z-39 pools. The container itself now
carries z-40.

Claude-Session: https://claude.ai/code/session_01YBoaBEt3gynzTwogBFtn4B
This commit is contained in:
Tanish Manku committed 2026-09-10 15:26:10 -07:00
1 parent ea5429ec43
commit 5eebbbf49d
2 files changed
+41 -7

No files matched your search

@@ -18,7 +18,10 @@ export default function Hero() {
<div className="relative flex min-h-[calc(100svh-3rem)] flex-col">
<HeroGalaxy />
<div className="pointer-events-none relative z-10 mx-auto flex w-full max-w-[1280px] flex-1 flex-col items-center justify-center border-x border-line px-6 py-10 md:px-10">
{/* z-40 (not z-10): this container is a stacking context, so the
masthead's own z-40 is capped by it — the hero letters must
outrank the ruler's fixed z-39 contrast pools at root level. */}
<div className="pointer-events-none relative z-40 mx-auto flex w-full max-w-[1280px] flex-1 flex-col items-center justify-center border-x border-line px-6 py-10 md:px-10">
<div
data-reveal-group
className="relative flex w-full max-w-2xl flex-col items-center text-center"
@@ -93,6 +93,17 @@ export default function HeadingFlow({
const wordRefs = useRef<WordRefs[]>([]);
const topPoolRef = useRef<HTMLDivElement>(null);
const bottomPoolRef = useRef<HTMLDivElement>(null);
// Link affordances: which word the pointer is over (its letters brighten
// to full ink), eased per word, and a per-word press pulse deadline.
const hoverWi = useRef(-1);
const hoverK = useMemo(
() => new Float32Array(sections.length),
[sections.length],
);
const pressUntil = useMemo(
() => new Float32Array(sections.length),
[sections.length],
);
const fs = useRef({
lastY: 0,
vel: 0,
@@ -382,6 +393,13 @@ export default function HeadingFlow({
const letterEls = heroDrive ? heroWordmark.els : refs.letters;
const scaleFix = heroDrive ? hp / heroWordmark.fontPx : 1;
const wz = heroDrive ? zoomE * Math.min(1, flipP) : zoomE;
// Link affordances, word-level: a hovered link's letters brighten
// to full ink; a click pulses the word toward the accent as the
// jump takes off.
const hk = (hoverK[wi] +=
((hoverWi.current === wi ? 1 : 0) - hoverK[wi]) *
(1 - Math.exp(-dt / 0.08)));
const pk = clamp01((pressUntil[wi] - performance.now()) / 350);
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
@@ -423,11 +441,17 @@ export default function HeadingFlow({
// Dimming is a solid ink-toward-paper mix, not translucency:
// dimmed headings must still mask the footage passing beneath
// them, and translucent gray over video reads as no contrast.
// Hover lifts the mix to full ink; a press tints toward accent.
el.style.opacity = "1";
el.style.color =
alpha >= 0.995
const aEff = alpha + (1 - alpha) * hk;
const baseCol =
aEff >= 0.995
? "var(--ink-strong)"
: `color-mix(in srgb, var(--ink-strong) ${(clamp01(alpha) * 100).toFixed(1)}%, var(--bg))`;
: `color-mix(in srgb, var(--ink-strong) ${(clamp01(aEff) * 100).toFixed(1)}%, var(--bg))`;
el.style.color =
pk > 0.01
? `color-mix(in srgb, var(--accent-ink) ${(pk * 70).toFixed(0)}%, ${baseCol})`
: baseCol;
if (alpha > maxAlpha) maxAlpha = alpha;
// Per-axis extents (swapped when the glyph is rotated toward
// vertical) so hit boxes stay tight: a shared radius made
@@ -563,7 +587,7 @@ export default function HeadingFlow({
<li key={s.id}>
<a
href={`#${s.id}`}
className="font-display text-ink hover:text-ink-strong"
className="font-display text-ink hover:text-ink-strong active:text-accent-ink"
style={{ fontSize: layout.queuePx + 2 }}
>
{s.label}
@@ -577,9 +601,10 @@ export default function HeadingFlow({
if (!metrics) return null;
const jump = (s: MeasuredSection) => (e: React.MouseEvent) => {
const jump = (s: MeasuredSection, wi: number) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
pressUntil[wi] = performance.now() + 350;
const lenis = lenisRef.current;
const el = document.getElementById(s.id);
const target = s.isHero
@@ -676,7 +701,13 @@ export default function HeadingFlow({
ref={(el) => {
refs.hit = el;
}}
onClick={jump(s)}
onClick={jump(s, wi)}
onPointerEnter={() => {
hoverWi.current = wi;
}}
onPointerLeave={() => {
if (hoverWi.current === wi) hoverWi.current = -1;
}}
aria-label={`Jump to ${s.label}`}
className="pointer-events-auto absolute cursor-pointer"
/>