diff --git a/frontend/apps/artcraft-website-next/src/components/landing/hero.tsx b/frontend/apps/artcraft-website-next/src/components/landing/hero.tsx
index fde0b2981b..52f5b35dad 100644
--- a/frontend/apps/artcraft-website-next/src/components/landing/hero.tsx
+++ b/frontend/apps/artcraft-website-next/src/components/landing/hero.tsx
@@ -18,7 +18,10 @@ export default function Hero() {
-
+ {/* 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. */}
+
([]);
const topPoolRef = useRef
(null);
const bottomPoolRef = useRef(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({
{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"
/>