Idea ruler (#1939)

Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
This commit is contained in:
BFlatandTanish Manku authored and GitHub committed 2026-09-10 23:12:08 +07:00
1 parent 60bcfffc8d
commit 5fa95ccb06
20 files changed
+2359 -84

No files matched your search

@@ -0,0 +1,59 @@
# Design Ideology
The working philosophy behind the ArtCraft marketing site
(`artcraft-website-next`). This captures direction and standards, not a
finished visual spec — the aesthetic itself is still under experiment.
## What we are building
A product website with a sales/marketing angle. The bar: it should read as
a professionally developed, premium site with a design language that
belongs to ArtCraft — not a template, not AI-slop defaults, and not a
studio/agency showpiece. Creative technology (Three.js, GSAP, smooth
scroll) is welcome and encouraged, but subordinated to selling the
product: used where it deepens the product story, never as decoration for
its own sake, and never overdone.
## The premium principle
Premium is felt immediately, and it is built from accumulated small
details — not from one big idea. Small details make a big difference.
- **One or two elements are never enough.** A single hero gimmick floating
in unoccupied space reads amateur and unfinished — the "blank/empty
negative space" failure. Compositions need layered density: many quiet,
coordinated details rather than one loud one.
- **Negative space must be structured, not merely empty.** Rails, rules,
ticks, annotations, and measurements make emptiness intentional.
- **Everything is choreographed.** Nothing just appears; elements arrive
with easing and stagger. Motion that maps to scroll is scrub-linked and
reversible.
- **Finish every surface.** Raw flat color, unlit 3D, and default
materials read as demos. Grain, lighting, shadow, vignette, and
micro-contrast are the difference between blocked-out and finished.
- **One voice for micro-detail.** Annotations, counters, tick marks, and
labels share a single vocabulary (mono uppercase HUD labels, hairline
rules, crosshair ticks) so density never becomes noise.
## Current experimental language (subject to change)
Brutalist/instrument direction. Nothing here is final; treat every item as
a hypothesis under test, replaceable by a better experiment:
- Two themes, one structure: dark "viewport" (near-black terminal), light
"blueprint" (warm paper). Light-dominant positioning, deliberately apart
from dark competitors.
- Hard edges, zero border radius, hairline rules, ticked 1280px rails.
- Type: Archivo display (stretched), Instrument Serif italic for one
contrast word per headline, Inter body, Geist Mono for HUD micro-labels.
- One blue accent, used sparingly. Color discipline over rainbow.
## Working practices
- **Experiments over debates.** Contested aesthetics get built behind the
dev tuner and judged on the page, not argued in the abstract.
- **Every tweakable constant is registered with the tuner** (see
TUNER.md). A missing slider costs a whole build round-trip.
- **Progressive enhancement is non-negotiable.** Reduced motion, coarse
pointers, no-JS crawlers, and WebGL failure all receive a complete,
legible page.
+16 -4
View File
@@ -37,11 +37,22 @@ iteration/build round-trip.
import { defineTunables } from "@/lib/tuner";
export const glowTuner = defineTunables("glow", "Glow", {
radius: { label: "Radius px", min: 0, max: 400, step: 5, default: 120 },
strength: { label: "Strength", min: 0, max: 2, step: 0.05, default: 0.8 },
radius: {
label: "Radius px", min: 0, max: 400, step: 5, default: 120,
info: "Blur radius of the glow behind the hero card.",
},
strength: {
label: "Strength", min: 0, max: 2, step: 0.05, default: 0.8,
info: "Peak intensity of the glow (0 = off).",
},
});
```
**Every tunable must carry an `info` one-liner.** It renders as the row's
`i`-mark hover in the panel — a slider nobody can decipher ("Ghost alpha"?)
costs more than the sentence explaining it. Write what the knob visibly
does, not what the variable is named.
Then read values where they're used:
- **Per-frame consumers** (`useFrame`, rAF loops): call `glowTuner.read()`
@@ -52,8 +63,9 @@ Then read values where they're used:
`hero-wall.tsx`).
The panel picks up new groups automatically — no panel changes needed.
Registered groups: Wall layout, Wall motion, Wall look (all for the hero
render wall; see `src/components/landing/hero-tunables.ts`).
Registered groups: Wall layout, Wall motion, Wall look (hero render wall;
`src/components/landing/hero-tunables.ts`) and Ruler layout, Ruler motion,
Ruler look (scroll ruler; `src/components/ruler/ruler-tunables.ts`).
## Shipping tuned values
@@ -2,12 +2,18 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
// Same-origin proxy for large media served from the live site. The hero
// rail draws these as WebGL video textures, which need CORS-clean
// sources — getartcraft.com serves its videos without
// Access-Control-Allow-Origin, so we route them through our own origin.
// Same-origin proxies for large media served from other origins. The hero
// wall draws its clips as WebGL video textures, which need CORS-clean
// sources, and neither CDN sends Access-Control-Allow-Origin, so those
// requests route through our own origin.
async rewrites() {
return [
// Showcase clips for the hero wall (see cdnMediaUrl in lib/links.ts).
{
source: "/cdn-media/:path*",
destination: "https://frontend-cdn.fakeyou.com/:path*",
},
// Feature footage and other assets still hosted on the live site.
{
source: "/ext-media/:path*",
destination: "https://getartcraft.com/:path*",
@@ -195,6 +195,47 @@ body {
height: 1px;
}
/* Scroll ruler. JS stamps data-ruler on <html> when the instrument mounts
(capable device only), so no-JS visitors and small/coarse-pointer devices
keep their native scrollbar untouched. "on" = full instrument (native
scrollbar hidden); "static" = reduced-motion rail (scrollbar stays).
Either way the rail's gutter is reserved on main/footer — the nav stays
full-bleed and simply overlaps the rail's top. */
html[data-ruler="on"] {
scrollbar-width: none;
}
html[data-ruler="on"]::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
html[data-ruler][data-ruler-side="right"] main,
html[data-ruler][data-ruler-side="right"] footer {
padding-right: var(--ruler-rail, 48px);
}
html[data-ruler][data-ruler-side="left"] main,
html[data-ruler][data-ruler-side="left"] footer {
padding-left: var(--ruler-rail, 48px);
}
/* Rolling odometer for the ruler's percent readout. Digit columns translate
by whole digits; the reduced-motion blanket below disables the roll. */
.ruler-digits {
display: inline-flex;
height: 1em;
overflow: hidden;
line-height: 1;
}
.ruler-digit-col {
display: flex;
flex-direction: column;
transition: transform 280ms cubic-bezier(0.2, 0.9, 0.2, 1);
}
.ruler-digit-col > span {
display: block;
height: 1em;
}
/* Mono capability marquee. Duplicated track scrolls -50%; the reduced-motion
* blanket below freezes it in place. */
.marquee {
@@ -9,6 +9,7 @@ import {
import SiteNav from "@/components/site-nav";
import SiteFooter from "@/components/site-footer";
import MotionProvider from "@/components/motion-provider";
import ScrollRuler from "@/components/ruler/scroll-ruler";
import TunerPanel from "@/components/dev/tuner-panel";
import "./globals.css";
@@ -121,8 +122,9 @@ export default function RootLayout({
/>
<MotionProvider>
<SiteNav />
<main>{children}</main>
<main id="main">{children}</main>
<SiteFooter />
<ScrollRuler />
</MotionProvider>
<TunerPanel />
</body>
@@ -197,17 +197,37 @@ function Row({
const value = useTunerStore((s) => s.values[storeKey] ?? def.default);
const setValue = useTunerStore((s) => s.setValue);
const modified = value !== def.default;
const [showInfo, setShowInfo] = useState(false);
return (
<label className="flex flex-col gap-1">
<span className="flex items-center justify-between">
<span className="relative flex items-center justify-between">
<span
className={`font-mono text-[10px] tracking-[0.08em] uppercase ${
className={`flex items-center gap-1 font-mono text-[10px] tracking-[0.08em] uppercase ${
modified ? "text-accent-ink" : "text-muted"
}`}
>
{def.label}
{def.info && (
<span
aria-label={def.info}
onMouseEnter={() => setShowInfo(true)}
onMouseLeave={() => setShowInfo(false)}
// A click on anything inside <label> would focus the number
// input — the mark is informational, so swallow it.
onClick={(e) => e.preventDefault()}
onPointerDown={(e) => e.preventDefault()}
className="inline-flex h-3 w-3 cursor-help items-center justify-center border border-line text-[8px] leading-none text-faint normal-case"
>
i
</span>
)}
</span>
{def.info && showInfo && (
<span className="pointer-events-none absolute inset-x-0 top-full z-10 mt-1 border border-line-strong bg-bg-raised p-2 font-mono text-[10px] leading-snug tracking-normal normal-case text-ink shadow-[0_4px_16px_rgba(0,0,0,0.3)]">
{def.info}
</span>
)}
<input
type="number"
value={round(value)}
@@ -4,7 +4,7 @@ import { SectionShell } from "./section-shell";
export default function FinalCta() {
return (
<SectionShell>
<SectionShell id="start">
<div className="flex flex-col items-center px-6 py-20 text-center md:py-28">
<p className="hud-label text-faint">Free to start · No subscription</p>
<h2
@@ -9,25 +9,42 @@ export const wallLayoutTuner = defineTunables("wallLayout", "Wall layout", {
rowHeight: {
label: "Near row × vh",
min: 0.14,
max: 0.42,
max: 0.55,
step: 0.01,
default: 0.31,
default: 0.33,
info: "Panel height of the near row, as a fraction of the viewport height.",
},
rowScale: {
label: "Row shrink",
min: 0.5,
max: 1,
step: 0.01,
default: 0.78,
default: 0.73,
info: "How much each deeper row's panels shrink relative to the row in front.",
},
gap: {
label: "Gap px",
min: 8,
max: 80,
step: 1,
default: 25,
info: "Horizontal gap between panels within a row.",
},
zStep: {
label: "Row z step px",
min: 0,
max: 400,
step: 5,
default: 170,
info: "Depth distance between successive rows of the wall.",
},
gap: { label: "Gap px", min: 8, max: 80, step: 1, default: 22 },
zStep: { label: "Row z step px", min: 0, max: 400, step: 5, default: 170 },
yNear: {
label: "Near row y × vh",
min: -0.5,
max: 0.5,
step: 0.01,
default: -0.17,
info: "Vertical position of the near row, in viewport heights from center.",
},
yMid: {
label: "Mid row y × vh",
@@ -35,19 +52,42 @@ export const wallLayoutTuner = defineTunables("wallLayout", "Wall layout", {
max: 0.5,
step: 0.01,
default: 0.17,
info: "Vertical position of the mid row, in viewport heights from center.",
},
});
export const wallMotionTuner = defineTunables("wallMotion", "Wall motion", {
yawDeg: { label: "Yaw °", min: -35, max: 35, step: 0.5, default: -11 },
pitchDeg: { label: "Pitch °", min: -12, max: 12, step: 0.25, default: 2.5 },
speed: { label: "Drift px/s", min: 0, max: 240, step: 2, default: 42 },
yawDeg: {
label: "Yaw °",
min: -35,
max: 35,
step: 0.5,
default: -11,
info: "Resting sideways rotation of the whole wall.",
},
pitchDeg: {
label: "Pitch °",
min: -12,
max: 12,
step: 0.25,
default: 2.5,
info: "Resting up/down rotation of the whole wall.",
},
speed: {
label: "Drift px/s",
min: 0,
max: 240,
step: 2,
default: 42,
info: "Unattended sideways drift speed of the near row.",
},
speedStep: {
label: "Row parallax",
min: 0.3,
max: 1,
step: 0.02,
default: 0.62,
info: "Depth parallax: each deeper row moves this fraction of the row in front.",
},
dragMax: {
label: "Max throw px/s",
@@ -55,27 +95,65 @@ export const wallMotionTuner = defineTunables("wallMotion", "Wall motion", {
max: 6000,
step: 100,
default: 2400,
info: "Speed cap on the momentum a drag can throw the wall with.",
},
introBurst: {
label: "Intro burst ×",
min: 1,
max: 40,
step: 1,
default: 16,
info: "Multiple of the drift speed the wall sweeps in with on load before easing down.",
},
introBurst: { label: "Intro burst ×", min: 1, max: 40, step: 1, default: 16 },
parallax: {
label: "Pointer tilt rad",
min: 0,
max: 0.09,
step: 0.002,
default: 0.024,
info: "How far the pointer position tilts the whole wall.",
},
});
export const wallLookTuner = defineTunables("wallLook", "Wall look", {
dimNear: { label: "Near row dim", min: 0, max: 1, step: 0.02, default: 0.86 },
dimMid: { label: "Mid row dim", min: 0, max: 1, step: 0.02, default: 0.7 },
washLight: { label: "Light wash", min: 0, max: 1, step: 0.02, default: 0.15 },
edgeFade: { label: "Edge fade", min: 0, max: 1, step: 0.02, default: 0.65 },
dimNear: {
label: "Near row dim",
min: 0,
max: 1,
step: 0.02,
default: 0.86,
info: "Brightness of the near row's footage (1 = full strength).",
},
dimMid: {
label: "Mid row dim",
min: 0,
max: 1,
step: 0.02,
default: 0.7,
info: "Brightness of the mid row's footage — deeper rows recede into the background.",
},
washLight: {
label: "Light wash",
min: 0,
max: 1,
step: 0.02,
default: 0.15,
info: "Light theme only: how much deeper rows wash toward the paper via opacity.",
},
edgeFade: {
label: "Edge fade",
min: 0,
max: 1,
step: 0.02,
default: 0.65,
info: "How strongly panels vignette out toward the viewport's left/right edges.",
},
frameAlpha: {
label: "Frame alpha",
min: 0,
max: 1,
step: 0.02,
default: 0.9,
default: 0.18,
info: "Opacity of the hairline frames around each panel.",
},
});
@@ -44,6 +44,29 @@ export function createWallDrag(): WallDrag {
const ROW_COUNT = 2;
const FOV = 30; // must match FittedCamera in hero-wordmark.tsx
// Decode budget. A wall of 1080p clips all decoding at once is what makes
// the hero feel heavy, so only the clips whose panels are near the viewport
// keep a decoder; the rest pause on their last frame (already invisible
// behind the edge vignette). Playback decisions run on their own slow tick,
// since play/pause churn at 60 Hz would cost more than it saves, and a clip
// holds its slot briefly after leaving so one hovering at the edge does not
// stutter. Panels beyond CULL_EDGE half-widths are fully off screen: a panel
// is clear of the edge once its center passes ~1.25.
const CULL_EDGE = 1.4;
const CULL_TICK_S = 0.25;
const CULL_HOLD_S = 1.2;
const MAX_DECODING = 12;
// Starting a decoder is the expensive moment (demux, buffer, first keyframe),
// so only a couple are started per tick. Scrolling back to the hero would
// otherwise restart a dozen at once and hitch the frame it lands on; instead
// the wall refills over about a second, behind the panels' own fade-in.
const RESUME_PER_TICK = 3;
// Grace period before a scrolled-away wall gives up its decoders. The frame
// loop is already stopped by then, so a few seconds of idle decoding is
// cheaper than the restart a quick scroll down and back would otherwise pay.
const PARK_DELAY_MS = 2500;
const FAR_EDGE = 1e6; // stand-in distance for a clip with no panel this frame
type WallPanel = {
clip: number;
w: number;
@@ -64,10 +87,14 @@ export default function HeroWall({
pointer,
drag,
colors,
onScreen,
}: {
pointer: React.RefObject<{ x: number; y: number; active: boolean }>;
drag: React.RefObject<WallDrag>;
colors: ThemeColors;
// False once the hero has scrolled away or the tab went to the background.
// The frame loop is stopped then, so playback is parked from an effect.
onScreen: boolean;
}) {
const size = useThree((s) => s.size);
const rigRef = useRef<THREE.Group>(null);
@@ -77,6 +104,7 @@ export default function HeroWall({
speed: 0,
wasDragging: false,
started: false,
cullTimer: 0,
});
const tilt = useRef({ x: 0, y: 0 });
@@ -95,7 +123,11 @@ export default function HeroWall({
v.loop = true;
v.playsInline = true;
v.crossOrigin = "anonymous";
v.preload = "auto";
v.disableRemotePlayback = true;
// Metadata only: fetching every clip up front stalls the whole page
// on load. The decode budget below calls play() as panels approach,
// which pulls each clip down roughly in the order it is needed.
v.preload = "metadata";
return v;
}),
[],
@@ -133,7 +165,6 @@ export default function HeroWall({
v.src = SEEDANCE_SHOWCASE[i].src;
v.load();
}
v.play().catch(() => {});
});
return () => {
textures.forEach((t) => t.dispose());
@@ -145,6 +176,18 @@ export default function HeroWall({
};
}, [videos, textures]);
// Scrolled away or backgrounded: the frame loop is stopped, so nothing
// would ever pause these. Park them all after a grace period; the budget
// re-grants slots on the first frame after the hero comes back.
useEffect(() => {
if (onScreen) return;
const timer = setTimeout(
() => videos.forEach((v) => v.pause()),
PARK_DELAY_MS,
);
return () => clearTimeout(timer);
}, [onScreen, videos]);
// Layout tunables change the wall's structure — debounce a rebuild.
const [layoutVersion, setLayoutVersion] = useState(0);
useEffect(() => {
@@ -170,7 +213,7 @@ export default function HeroWall({
// pool, so no clip ever appears in more than one row.
const layout = useMemo(() => {
const t = wallLayoutTuner.read();
const nearH = Math.min(320, Math.max(110, size.height * t.rowHeight));
const nearH = Math.min(460, Math.max(110, size.height * t.rowHeight));
const yFracs = [t.yNear, t.yMid];
const clipCount = SEEDANCE_SHOWCASE.length;
const perRow = Math.floor(clipCount / ROW_COUNT);
@@ -267,6 +310,19 @@ export default function HeroWall({
[layout],
);
// Per-clip decode bookkeeping, reused every frame so the budget allocates
// nothing: closest panel edge, seconds since the clip last held a slot,
// and a scratch permutation the budget sorts in place.
const clipEdge = useMemo(
() => new Float32Array(SEEDANCE_SHOWCASE.length).fill(FAR_EDGE),
[],
);
const clipIdle = useMemo(
() => new Float32Array(SEEDANCE_SHOWCASE.length),
[],
);
const clipOrder = useMemo(() => SEEDANCE_SHOWCASE.map((_, i) => i), []);
useFrame((st3, delta) => {
const dt = Math.min(delta, 0.05);
const st = state.current;
@@ -320,6 +376,7 @@ export default function HeroWall({
const cosY = Math.cos(yaw);
const sinY = Math.sin(yaw);
const dims = [lk.dimNear, lk.dimMid];
clipEdge.fill(FAR_EDGE);
for (let i = 0; i < layout.panels.length; i++) {
const g = groupRefs.current[i];
@@ -342,6 +399,7 @@ export default function HeroWall({
const projX = wx * (dist / (dist - wz));
const edge = Math.abs(projX) / (size.width / 2);
const vig = 1 - lk.edgeFade * smoothstep(0.72, 1.3, edge);
if (edge < clipEdge[c.clip]) clipEdge[c.clip] = edge;
const ready = videos[c.clip].readyState >= 2 ? 1 : 0;
videoAlpha[i] += (ready - videoAlpha[i]) * (1 - Math.exp(-3 * dt));
@@ -352,8 +410,11 @@ export default function HeroWall({
const dim = dims[c.row];
const mat = materials[i];
if (dark) {
mat.color.setScalar(dim * vig * va);
mat.opacity = intro;
// Fade the unloaded panel out with alpha, not toward black: clips
// now load on demand, and an opaque black rectangle reads as a hole
// in the wall against the dark background.
mat.color.setScalar(dim * vig);
mat.opacity = intro * va;
} else {
// Light theme recedes via opacity toward the paper — but only for
// depth: the near row keeps nearly full ink (sqrt softens its dim)
@@ -364,6 +425,12 @@ export default function HeroWall({
}
frameMaterials[i].opacity = lk.frameAlpha * vig * intro;
}
st.cullTimer -= dt;
if (st.cullTimer <= 0) {
st.cullTimer = CULL_TICK_S;
applyDecodeBudget(videos, clipEdge, clipIdle, clipOrder, CULL_TICK_S);
}
});
return (
@@ -386,3 +453,35 @@ export default function HeroWall({
</group>
);
}
// Hand the scarce decoders to the clips nearest the viewport and pause the
// rest. Clips are ranked by their closest panel, so when the cap bites it is
// always the outermost panels that freeze: the ones the edge vignette has
// already dissolved. `elapsed` is the time since the previous call, not the
// frame delta: this runs on the cull tick, not every frame.
function applyDecodeBudget(
videos: HTMLVideoElement[],
clipEdge: Float32Array,
clipIdle: Float32Array,
clipOrder: number[],
elapsed: number,
) {
clipOrder.sort((a, b) => clipEdge[a] - clipEdge[b]);
let started = 0;
for (let rank = 0; rank < clipOrder.length; rank++) {
const clip = clipOrder[rank];
const v = videos[clip];
if (rank < MAX_DECODING && clipEdge[clip] < CULL_EDGE) {
clipIdle[clip] = 0;
// Nearest clips are first in the ranking, so the stagger always
// spends its budget on the panels closest to the middle of frame.
if (v.paused && started < RESUME_PER_TICK) {
started++;
v.play().catch(() => {});
}
continue;
}
clipIdle[clip] += elapsed;
if (clipIdle[clip] >= CULL_HOLD_S && !v.paused) v.pause();
}
}
@@ -4,8 +4,13 @@ import { Component, useEffect, useRef, useState, type ReactNode } from "react";
import { Canvas, useThree } from "@react-three/fiber";
import * as THREE from "three";
import { watchThemeColors, type ThemeColors } from "@/lib/theme-colors";
import { heroWordmark } from "@/components/ruler/ruler-shared";
import HeroWall, { createWallDrag } from "./hero-wall";
const WORDMARK_TEXT = "ARTCRAFT";
const WORDMARK_FONT =
"var(--font-archivo-black), var(--font-archivo), system-ui, sans-serif";
type PointerState = {
x: number;
y: number;
@@ -26,6 +31,7 @@ export default function HeroWordmark() {
const containerRef = useRef<HTMLDivElement>(null);
const pointerRef = useRef<PointerState>({ x: 0, y: 0, active: false });
const wallDragRef = useRef(createWallDrag());
const [onScreen, setOnScreen] = useState(true);
// Gate: motion allowed and the tab actually foregrounded (a canvas born in
// a hidden tab can come up blank).
@@ -102,6 +108,33 @@ export default function HeroWordmark() {
};
}, []);
// The hero sits at the top of a long page. Once it scrolls away there is
// nothing to look at, so stop the render loop and let the wall park its
// video decoders. Same when the tab goes to the background, which the
// observer alone would not catch.
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let intersecting = true;
const sync = () => setOnScreen(intersecting && !document.hidden);
const observer = new IntersectionObserver(
([entry]) => {
intersecting = entry.isIntersecting;
sync();
},
// Generous margin: the wall refills its decoders a few at a time, so
// it needs a head start to be at full speed by the time a scroll back
// up actually brings it into view.
{ rootMargin: "600px 0px" },
);
observer.observe(container);
document.addEventListener("visibilitychange", sync);
return () => {
observer.disconnect();
document.removeEventListener("visibilitychange", sync);
};
}, []);
const active = ready && colors !== null;
return (
@@ -114,7 +147,11 @@ export default function HeroWordmark() {
<Canvas
aria-hidden
tabIndex={-1}
dpr={[1, 2]}
// Video panels gain nothing from a 2x framebuffer; the hairline
// frames are the only detail that wants the extra samples, and
// 1.5x plus MSAA keeps them clean for a lot less fill.
dpr={[1, 1.5]}
frameloop={onScreen ? "always" : "never"}
gl={{
antialias: true,
alpha: true,
@@ -123,45 +160,24 @@ export default function HeroWordmark() {
style={{ position: "absolute", inset: 0, pointerEvents: "none" }}
>
<FittedCamera />
<HeroWall pointer={pointerRef} drag={wallDragRef} colors={colors} />
<HeroWall
pointer={pointerRef}
drag={wallDragRef}
colors={colors}
onScreen={onScreen}
/>
</Canvas>
</CanvasBoundary>
)}
{/* The wordmark, justified flush to the hero width (the big-type
poster treatment): SVG text with textLength stretches the word
edge-to-edge at any viewport, so the height rides the width via
the viewBox aspect. Still real selectable/crawlable text. The
soft drop-shadow is a legibility halo lifting the type off the
busy footage, not decoration (vw units so it scales with the
word). */}
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center px-4 text-ink-strong md:px-6">
<svg
className="w-[62%]"
viewBox="0 0 720 74"
style={{
overflow: "visible",
filter:
"drop-shadow(0 0 0.3vw color-mix(in srgb, var(--bg) 60%, transparent)) drop-shadow(0 0.2vw 1.2vw color-mix(in srgb, var(--bg) 45%, transparent))",
}}
>
<text
x="360"
y="72"
textAnchor="middle"
textLength="780"
lengthAdjust="spacingAndGlyphs"
fill="currentColor"
style={{
fontFamily:
"var(--font-archivo-black), var(--font-archivo), system-ui, sans-serif",
fontSize: "100px",
}}
>
ARTCRAFT
</text>
</svg>
</div>
poster treatment) — per-letter spans instead of SVG text, because
these very spans ARE the ruler's hero heading: the scroll ruler's
HeadingFlow drives their transforms so the resting title peels
tail-first onto the rail as the visitor scrolls out of the
landing. Crawlable via role="img" + aria-label; z-40 so letters
in transit ride above later sections (below the z-50 nav). */}
<MorphWordmark />
{active && (
<div
@@ -176,6 +192,129 @@ export default function HeroWordmark() {
);
}
// The justified wordmark letters. Font size is derived so the word's
// natural advance run spans the 62% column exactly (the spacingAndGlyphs
// stretch of the old SVG, minus the SVG). After layout, each letter's
// natural center and advance are measured and published to the shared
// heroWordmark channel for the ruler to drive; transforms are reset before
// measuring so a mid-morph resize re-baselines cleanly.
function MorphWordmark() {
const boxRef = useRef<HTMLDivElement>(null);
const letterRefs = useRef<(HTMLSpanElement | null)[]>([]);
const [fontPx, setFontPx] = useState(0);
useEffect(() => {
const box = boxRef.current;
if (!box) return;
let cancelled = false;
const compute = () => {
if (cancelled) return;
const probe = document.createElement("span");
probe.style.cssText =
"position:absolute;left:-9999px;top:0;visibility:hidden;white-space:pre;font-size:100px;line-height:1;";
probe.style.fontFamily = WORDMARK_FONT;
probe.textContent = WORDMARK_TEXT;
document.body.appendChild(probe);
const w100 = probe.getBoundingClientRect().width;
probe.remove();
const target = box.clientWidth;
if (w100 > 0 && target > 0) {
setFontPx(Math.round((target / w100) * 1000) / 10);
}
};
const ready = document.fonts?.ready;
if (ready) ready.then(compute).catch(compute);
else compute();
const ro = new ResizeObserver(compute);
ro.observe(box);
return () => {
cancelled = true;
ro.disconnect();
};
}, []);
// Publish letter geometry once the real size is applied.
useEffect(() => {
if (!fontPx) return;
const els = letterRefs.current.filter(
(el): el is HTMLSpanElement => !!el,
);
if (els.length !== WORDMARK_TEXT.length) return;
const measure = () => {
for (const el of els) el.style.transform = "";
const rects = els.map((el) => el.getBoundingClientRect());
heroWordmark.els = els;
heroWordmark.baseX = rects.map((r) => r.left + r.width / 2);
heroWordmark.baseDocY = rects.map(
(r) => r.top + r.height / 2 + window.scrollY,
);
const adv = rects.map((r) => r.width / fontPx);
const cum: number[] = [];
let total = 0;
for (const a of adv) {
cum.push(total);
total += a;
}
heroWordmark.metrics = { adv, cum, total };
heroWordmark.fontPx = fontPx;
heroWordmark.ready = true;
};
measure();
window.addEventListener("resize", measure);
return () => {
window.removeEventListener("resize", measure);
heroWordmark.ready = false;
heroWordmark.els = [];
};
}, [fontPx]);
return (
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center px-4 md:px-6">
<div ref={boxRef} className="w-[62%]">
<div
role="img"
aria-label={WORDMARK_TEXT}
className="whitespace-pre text-ink-strong"
style={{
fontFamily: WORDMARK_FONT,
fontSize: fontPx || "10.5vw",
lineHeight: 1,
}}
>
{WORDMARK_TEXT.split("").map((ch, i) => (
<span
key={i}
aria-hidden
ref={(el) => {
letterRefs.current[i] = el;
}}
className="inline-block"
style={{
willChange: "transform, opacity",
// Three stacked halos in the page background color: a tight
// contact edge, a mid falloff, and a wide pool that sinks
// the busiest footage behind the letters. Sized in vw so the
// spread tracks the type as the wordmark scales.
textShadow: [
"0 0 0.6vw color-mix(in srgb, var(--bg) 92%, transparent)",
"0 0.25vw 2.4vw color-mix(in srgb, var(--bg) 78%, transparent)",
"0 0.4vw 5vw color-mix(in srgb, var(--bg) 55%, transparent)",
].join(", "),
}}
>
{ch}
</span>
))}
</div>
</div>
</div>
);
}
// Perspective camera fitted so 1 world unit == 1 CSS pixel on the z=0 plane:
// wall layout and pointer coordinates all agree. FOV must match hero-wall's
// vignette projection.
@@ -8,7 +8,7 @@ import HeroViewport from "./hero-viewport";
export default function Hero() {
return (
<section className="relative">
<section id="hero" className="relative">
{/* Full-bleed hero — crisp display-type wordmark over the render
wall: rows of Seedance takes forming one perspective wall that
drifts behind the word and answers the cursor. */}
@@ -4,6 +4,7 @@ import { useEffect } from "react";
import Lenis from "lenis";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { lenisRef } from "@/lib/lenis-ref";
gsap.registerPlugin(ScrollTrigger);
@@ -26,6 +27,7 @@ export default function MotionProvider({
lerp: 0.12,
anchors: true,
});
lenisRef.current = lenis;
lenis.on("scroll", ScrollTrigger.update);
const tick = (time: number) => lenis.raf(time * 1000);
@@ -34,6 +36,7 @@ export default function MotionProvider({
return () => {
gsap.ticker.remove(tick);
lenisRef.current = null;
lenis.destroy();
};
}, []);
@@ -0,0 +1,626 @@
"use client";
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import gsap from "gsap";
import { lenisRef } from "@/lib/lenis-ref";
import {
NAV_H,
clamp01,
easeInOutCubic,
easeOutExpo,
heroWordmark,
railOccupancy,
rulerMap,
rulerZoom,
type MeasuredSection,
type RulerMode,
type RulerSide,
} from "./ruler-shared";
import {
rulerLayoutTuner,
rulerLookTuner,
rulerMotionTuner,
} from "./ruler-tunables";
// The ruler's heading lifecycle. Each section's heading is a word with
// three homes, all in the ruler gutter:
//
// queued — compact horizontal link stacked at the bottom (the navbar:
// where you're going),
// riding — vertical (reading upward) on the tick rail, 1:1 with the
// document, annotating its section as it scrolls,
// stacked — horizontal at the top (where you've been): passed sections
// pin under the nav as a compact pile mirroring the bottom
// queue, and the current section's heading sits just below the
// pile at full size. When the next word flips in, the current
// one demotes up into the pile — the two edges read as one
// symmetric sectional navbar.
//
// Transitions between homes are scrub-bound letter-by-letter curves. Every
// word's pose is a pure function of scroll position, so everything is
// reversible; when scroll rests with a top flip half-done, a damped snap
// resolves it to the nearest side.
//
// The hero is special: it IS the current section from load, so its whole
// lifecycle is one flip — the wordmark's own letter spans (published via
// the shared heroWordmark channel) fly STRAIGHT from the resting title to
// the top current-heading slot as the wordmark is about to duck under the
// nav (title-condenses-into-header), no queue home and no rail ride. From
// the stack onward it behaves like any section (demotes when FEATURES
// flips in). The hero letters keep their Archivo Black wordmark face
// through the whole lifecycle — brand identity, and font families can't
// interpolate.
// A letter's pose on screen. x/y are the letter center in viewport px.
type Pose = {
x: number;
y: number;
rot: number;
scale: number;
alpha: number;
};
// Per-label advance metrics, normalized to a 1px font so any size is a
// multiply. cum[i] is the advance before letter i; total is the word width.
type WordMetrics = {
adv: number[];
cum: number[];
total: number;
};
type WordRefs = {
letters: (HTMLSpanElement | null)[];
hit: HTMLAnchorElement | null;
};
export default function HeadingFlow({
mode,
side,
sections,
geom,
layoutVersion,
}: {
mode: RulerMode;
side: RulerSide;
sections: MeasuredSection[];
geom: { docH: number; vh: number; vw: number };
layoutVersion: number;
}) {
const [metrics, setMetrics] = useState<Record<string, WordMetrics> | null>(
null,
);
const wordRefs = useRef<WordRefs[]>([]);
const fs = useRef({
lastY: 0,
vel: 0,
snapSince: null as number | null,
snapping: false,
});
const layout = useMemo(
() => rulerLayoutTuner.read(),
// eslint-disable-next-line react-hooks/exhaustive-deps
[layoutVersion],
);
const labelsKey = sections.map((s) => s.label).join("|");
// Letter advances need the real display face — measure after fonts load.
useEffect(() => {
if (mode !== "full" || !sections.length) return;
let cancelled = false;
const run = () => {
if (cancelled) return;
setMetrics(measureLabels(sections.map((s) => s.label)));
};
const ready = document.fonts?.ready;
if (ready) ready.then(run).catch(run);
else run();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, labelsKey]);
// Frame loop: pose every letter from the current scroll position.
useEffect(() => {
if (mode !== "full" || !metrics || !sections.length || !geom.docH) return;
const tick = (_time: number, deltaMs: number) => {
const st = fs.current;
const lay = rulerLayoutTuner.read();
const mt = rulerMotionTuner.read();
const lk = rulerLookTuner.read();
const { docH, vh, vw } = geom;
const dt = Math.min(deltaMs / 1000, 0.1) || 0.016;
const scrollY = window.scrollY;
const maxScroll = Math.max(1, docH - vh);
const progress = clamp01(scrollY / maxScroll);
const map = rulerMap(vh, lay.edgePad);
const drift = map.drift(progress);
const raw = (scrollY - st.lastY) / dt;
st.lastY = scrollY;
st.vel += (raw - st.vel) * (1 - Math.exp(-6 * dt));
const T = (lay.thresholdPct / 100) * vh;
const yTopLine = NAV_H + lay.topPad;
const yQueueLine = vh - lay.queuePad;
const hp = lay.headingPx;
const rs = lay.ridingPx / hp;
const qs = lay.queuePx / hp;
const inwardSign = side === "right" ? -1 : 1;
const railCenterX =
side === "right" ? vw - lay.railW / 2 : lay.railW / 2;
// Zoomed map blend: every word converges on a horizontal label at
// its section's true percent position while the rail compresses.
const zoomE = easeInOutCubic(rulerZoom.p);
// Phase pass: flip/detach progress per word. Anchors increase with
// index, so stacked words are always a prefix and at most one word is
// mid-flip at a time.
const phases = sections.map((s) => {
const m =
s.isHero && heroWordmark.ready
? heroWordmark.metrics
: metrics[s.label];
const rideLen = (m?.total ?? 0) * hp * rs;
const v = s.anchor - scrollY + drift;
let flipP: number;
let detachP = 0;
// Some flips run in scroll space instead of anchor space; snap
// resolves those against these bounds.
let scrollFlip: { start: number; end: number } | null = null;
if (s.isHero) {
// The hero IS the current section from load — its heading goes
// STRAIGHT to the top slot (no queue, no ride): the whole
// lifecycle is one flip, driven by the wordmark about to duck
// under the nav (title-condenses-into-header), begun heroLead px
// early and spanning heroZone px of scroll.
detachP = 1;
const wordTop = heroWordmark.ready
? Math.min(...heroWordmark.baseDocY) - heroWordmark.fontPx * 0.5
: 0;
const start = Math.max(0, wordTop - NAV_H - mt.heroLead);
const zone = Math.max(1, mt.heroZone);
flipP = clamp01((scrollY - start) / zone);
scrollFlip = { start, end: start + zone };
} else {
flipP = clamp01((T + mt.flipZone - v) / mt.flipZone);
// End-of-page driver: a section too short to ever carry its
// word up to the threshold (its best reachable v is still below
// the flip line) flips from "section fully in view" to "no more
// scroll". At page bottom the pile is complete.
if (s.anchor - maxScroll > T) {
const endStart = Math.min(s.bottom - vh, maxScroll - 24);
const zone = Math.max(24, maxScroll - endStart);
flipP = Math.max(flipP, clamp01((scrollY - endStart) / zone));
scrollFlip = { start: endStart, end: maxScroll };
}
}
return { v, rideLen, flipP, scrollFlip, detachP, yq: yQueueLine };
});
// Backward pass: a word's queue slot depends on the occupancy of the
// words below it, and its detach completes when its riding column's
// BOTTOM reaches that very slot — the whole column ends up above the
// queue line, fully on-screen, and the tail-led letters sweep upward
// in the ease-out arc. (Chosen deliberately over tail-at-the-line
// variants: those either hung letters past the viewport or pinned
// the word at the line until the morph finished.) yq is stable
// during the word's own morph because later words detach much later.
{
let below = 0;
for (let wi = sections.length - 1; wi >= 0; wi--) {
if (sections[wi].isHero) continue; // no queue home, detachP = 1
const ph = phases[wi];
ph.yq = yQueueLine - lay.queueSlot * below;
ph.detachP = clamp01(
(ph.yq + mt.detachZone - (ph.v + ph.rideLen)) / mt.detachZone,
);
below += 1 - ph.detachP;
}
}
railOccupancy.spans.length = 0;
let stackedCount = 0;
let flipShift = 0;
for (const p of phases) {
if (p.flipP >= 1) stackedCount++;
else if (p.flipP > 0) flipShift += p.flipP;
}
// Horizontal home for a word: letters run inward from the rail, with
// the reading direction arranged so the word's tail sits nearest the
// rail on a right-side ruler (and its head nearest on the left).
const horizPose = (
m: WordMetrics,
i: number,
yLine: number,
s: number,
alpha: number,
): Pose => {
const along = (m.cum[i] + m.adv[i] / 2) * hp * s;
const inward =
lay.railW +
lay.textPad +
(side === "right" ? m.total * hp * s - along : along);
return {
x: side === "right" ? vw - inward : inward,
y: yLine,
rot: 0,
scale: s,
alpha,
};
};
// Vertical column with its top at `top`. In transit it shares the
// queue links' alpha — only the top heading is active-bright; the
// flip lerps toward the stack pose, so a word brightens on arrival.
const ridePose = (m: WordMetrics, i: number, top: number): Pose => ({
x: railCenterX,
y: top + (m.total - m.cum[i] - m.adv[i] / 2) * hp * rs,
rot: -90,
scale: rs,
alpha: lk.queueAlpha,
});
for (let wi = 0; wi < sections.length; wi++) {
const s = sections[wi];
const heroDrive = s.isHero && heroWordmark.ready;
const m = heroDrive ? heroWordmark.metrics : metrics[s.label];
const refs = wordRefs.current[wi];
if (!m || !refs || (s.isHero && !heroDrive)) continue;
const { v, rideLen, flipP, detachP, yq } = phases[wi];
const n = m.adv.length;
// The hero's flip "from" home is the wordmark's own resting
// layout — identity transform at rest, so it's pixel-perfect and
// crisp, and the title flies STRAIGHT to the top heading slot.
const heroBase = (i: number): Pose => ({
x: heroWordmark.baseX[i],
y: heroWordmark.baseDocY[i] - scrollY,
rot: 0,
scale: heroWordmark.fontPx / hp,
alpha: 1,
});
// Word-level from/to homes for the active transition.
let p: number;
let from: (i: number) => Pose;
let to: (i: number) => Pose;
let reverseStagger = false;
if (flipP > 0) {
p = flipP;
// Tail-first here too: the tail letter sits at the column's top
// (or, for the hero, nearest the rail) and lands nearest the
// rail — shortest flight leads the peel.
reverseStagger = true;
from = heroDrive ? heroBase : (i) => ridePose(m, i, v);
// Stacked words keep their document-order slot in the top pile
// (wi is the index from the top, since stacked words are always
// a prefix). The newest fully-stacked word holds the "current"
// pose — full size, offset below the pile — and demotes into a
// compact pile entry as the next word flips in.
if (flipP >= 1) {
const demote = wi === stackedCount - 1 ? flipShift : 1;
const y =
yTopLine + wi * lay.queueSlot + lay.currentGap * (1 - demote);
const scale = 1 - (1 - qs) * demote;
const alpha = 1 - (1 - lk.queueAlpha) * demote;
to = (i) => horizPose(m, i, y, scale, alpha);
} else {
to = (i) =>
horizPose(
m,
i,
yTopLine + wi * lay.queueSlot + lay.currentGap,
1,
1,
);
}
} else if (detachP < 1) {
p = detachP;
// Mirrored stagger: the rail-adjacent tail letter peels first, so
// letters lift off in sequence from the rail side instead of the
// lead letter sweeping across the ones still resting in the queue.
reverseStagger = true;
// The morph target is STATIONARY: the riding pose the word holds
// the instant detach completes — full column resting just above
// its queue slot (bottom at the line). Letters sweep up in the
// ease-out arc, fully on-screen; at p=1 this equals the true
// riding pose, which takes over seamlessly.
const vForm = yq - rideLen;
to = (i) => ridePose(m, i, vForm);
from = (i) => horizPose(m, i, yq, qs, lk.queueAlpha);
} else {
p = 1;
from = to = heroDrive ? heroBase : (i) => ridePose(m, i, v);
}
// Letter pass: staggered eased progress along an inward-bowed
// quadratic curve between the two homes.
const sf = mt.stagger;
const span = 1 + (n - 1) * sf;
const mapY = map.base + ((s.isHero ? 0 : s.anchor) / docH) * map.span;
// Hero letters are the wordmark's own spans (base-relative
// transforms, rendered at fontPx so scale writes need the hp/fontPx
// correction). Its zoom-map blend scales with detachP so hovering
// the rail at page top never dismantles the resting wordmark.
const letterEls = heroDrive ? heroWordmark.els : refs.letters;
const scaleFix = heroDrive ? hp / heroWordmark.fontPx : 1;
const wz = heroDrive ? zoomE * Math.min(1, flipP) : zoomE;
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
let maxAlpha = 0;
for (let i = 0; i < n; i++) {
const el = letterEls[i];
if (!el) continue;
const si = reverseStagger ? n - 1 - i : i;
const pi = clamp01(p * span - si * sf);
const e = easeInOutCubic(pi);
const a = from(i);
const b = to(i);
const cx = (a.x + b.x) / 2 + inwardSign * mt.arc;
const cy = (a.y + b.y) / 2;
const u = 1 - e;
let x = u * u * a.x + 2 * u * e * cx + e * e * b.x;
let y = u * u * a.y + 2 * u * e * cy + e * e * b.y;
let rot = a.rot + (b.rot - a.rot) * e;
let scale = a.scale + (b.scale - a.scale) * e;
let alpha = a.alpha + (b.alpha - a.alpha) * e;
if (wz > 0) {
const mp = horizPose(m, i, mapY, qs, lk.mapAlpha);
x += (mp.x - x) * wz;
y += (mp.y - y) * wz;
rot *= 1 - wz;
scale += (mp.scale - scale) * wz;
alpha += (mp.alpha - alpha) * wz;
}
if (heroDrive) {
el.style.transform = `translate3d(${
x - heroWordmark.baseX[i]
}px, ${
y - (heroWordmark.baseDocY[i] - scrollY)
}px, 0) rotate(${rot}deg) scale(${scale * scaleFix})`;
} else {
el.style.transform = `translate3d(${x}px, ${y}px, 0) translate(-50%, -50%) rotate(${rot}deg) scale(${scale})`;
}
el.style.opacity = String(alpha);
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
// neighboring queue links overlap, and the topmost sibling ate
// clicks meant for the one above it.
const wHalf = (m.adv[i] * hp * scale) / 2 + 2;
const hHalf = (hp * scale) / 2 + 2;
const vertical = Math.abs(rot) > 45;
const xHalf = vertical ? hHalf : wHalf;
const yHalf = vertical ? wHalf : hHalf;
if (x - xHalf < minX) minX = x - xHalf;
if (x + xHalf > maxX) maxX = x + xHalf;
if (y - yHalf < minY) minY = y - yHalf;
if (y + yHalf > maxY) maxY = y + yHalf;
}
// Publish the word's riding footprint — the settled column span,
// NOT the in-flight letter bbox (that swept across labels during
// detach/flip flights and made them flicker) — with a strength
// that ramps smoothly with how on-rail the word is, so labels fade
// in proportion instead of blinking.
// (The hero never rides the rail — it flies straight to the top —
// so it never occupies the tick lane.)
const onRail = s.isHero ? 0 : detachP * (1 - flipP);
if (onRail > 0.02) {
railOccupancy.spans.push({ top: v, bottom: v + rideLen, k: onRail });
}
// Hit box hugs the word wherever it is; a fully invisible word
// (the hero before its entrance) must not capture clicks.
if (refs.hit && isFinite(minX)) {
refs.hit.style.left = `${minX - 2}px`;
refs.hit.style.top = `${minY - 2}px`;
refs.hit.style.width = `${maxX - minX + 4}px`;
refs.hit.style.height = `${maxY - minY + 4}px`;
// The resting hero wordmark must stay click-transparent (the
// wall's drag lives underneath it) — its link only arms once the
// morph is underway.
refs.hit.style.pointerEvents =
maxAlpha > 0.05 && (!s.isHero || flipP > 0.05)
? "auto"
: "none";
}
}
// Snap: scroll resting with a top flip half-done resolves to the
// nearest side after a beat, damped through Lenis.
const lenis = lenisRef.current;
const now = performance.now();
const midIdx = phases.findIndex(
(ph) => ph.flipP > 0.04 && ph.flipP < 0.96,
);
if (st.snapping) {
if (midIdx < 0) st.snapping = false;
} else if (
midIdx >= 0 &&
Math.abs(st.vel) < 30 &&
lenis &&
!rulerZoom.dragging &&
rulerZoom.p < 0.3
) {
if (st.snapSince === null) {
st.snapSince = now;
} else if (now - st.snapSince > mt.snapDelay) {
const ph = phases[midIdx];
const sMid = sections[midIdx];
// Scroll-space flips (the hero's straight-to-header morph, and
// end-of-page flips whose v never reaches the threshold) resolve
// against their own bounds; threshold flips resolve in v space.
let raw: number;
if (ph.scrollFlip) {
raw =
ph.flipP >= 0.5 ? ph.scrollFlip.end + 4 : ph.scrollFlip.start - 4;
} else {
const vTarget = ph.flipP >= 0.5 ? T - 4 : T + mt.flipZone + 4;
raw = sMid.anchor + drift - vTarget;
}
const target = Math.max(0, Math.min(maxScroll, raw));
st.snapping = true;
st.snapSince = null;
lenis.scrollTo(target, {
duration: mt.snapDur,
easing: easeInOutCubic,
onComplete: () => {
st.snapping = false;
},
});
}
} else {
st.snapSince = null;
}
};
gsap.ticker.add(tick);
return () => {
gsap.ticker.remove(tick);
};
}, [mode, metrics, sections, geom, side, layoutVersion]);
if (!sections.length) return null;
// Reduced motion: the headings become a plain fixed section index by the
// rail's bottom — same destinations, no choreography.
if (mode === "static") {
const sideStyle =
side === "right"
? { right: layout.railW + layout.textPad, textAlign: "right" as const }
: { left: layout.railW + layout.textPad };
return (
<nav
aria-label="Sections"
className="fixed z-40"
style={{ bottom: layout.queuePad, ...sideStyle }}
>
<ul className="flex flex-col gap-1.5">
{sections.map((s) => (
<li key={s.id}>
<a
href={`#${s.id}`}
className="font-display text-ink hover:text-ink-strong"
style={{ fontSize: layout.queuePx + 2 }}
>
{s.label}
</a>
</li>
))}
</ul>
</nav>
);
}
if (!metrics) return null;
const jump = (s: MeasuredSection) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const lenis = lenisRef.current;
const el = document.getElementById(s.id);
const target = s.isHero
? 0
: el
? el.getBoundingClientRect().top + window.scrollY - NAV_H
: s.anchor - NAV_H;
if (lenis) {
lenis.scrollTo(target, {
duration: rulerMotionTuner.read().jumpDur,
easing: easeOutExpo,
});
} else {
window.scrollTo({ top: target });
}
};
return (
<div className="pointer-events-none fixed inset-0 z-40">
{sections.map((s, wi) => {
const refs = (wordRefs.current[wi] ??= {
letters: [],
hit: null,
});
return (
<Fragment key={s.id}>
<a
href={`#${s.id}`}
ref={(el) => {
refs.hit = el;
}}
onClick={jump(s)}
aria-label={`Jump to ${s.label}`}
className="pointer-events-auto absolute cursor-pointer"
/>
{/* Hero letters live in the hero wordmark itself and are
driven via the shared channel — render none here. */}
{!s.isHero &&
s.label.split("").map((ch, li) => (
<span
key={li}
ref={(el) => {
refs.letters[li] = el;
}}
aria-hidden
className="font-display absolute top-0 left-0 whitespace-pre text-ink-strong"
style={{
fontSize: layout.headingPx,
lineHeight: 1,
opacity: 0,
willChange: "transform, opacity",
textShadow: "0 0 4px var(--bg), 0 0 10px var(--bg)",
}}
>
{ch}
</span>
))}
</Fragment>
);
})}
</div>
);
}
// Measures per-letter advances for each label by laying the letters out as
// spans in a hidden probe styled like the real headings. Normalized to a
// 1px font (measured at 100px for integer-rounding headroom).
function measureLabels(labels: string[]): Record<string, WordMetrics> {
const probe = document.createElement("div");
probe.className = "font-display";
probe.style.cssText =
"position:absolute;left:-9999px;top:0;visibility:hidden;white-space:pre;font-size:100px;line-height:1;";
document.body.appendChild(probe);
const out: Record<string, WordMetrics> = {};
for (const label of labels) {
probe.textContent = "";
const spans = label.split("").map((ch) => {
const span = document.createElement("span");
span.textContent = ch;
probe.appendChild(span);
return span;
});
const adv = spans.map((sp) => sp.getBoundingClientRect().width / 100);
const cum: number[] = [];
let total = 0;
for (const a of adv) {
cum.push(total);
total += a;
}
out[label] = { adv, cum, total };
}
probe.remove();
return out;
}
@@ -0,0 +1,92 @@
// Shared bits for the scroll ruler instrument (see scroll-ruler.tsx for the
// big picture).
/** Height of the sticky SiteNav (h-12). The rail hides under it; the needle's
* travel and the click→progress mapping run over [NAV_H, viewport bottom]. */
export const NAV_H = 48;
/** "full" = the whole instrument, native scrollbar hidden. "static" = the
* reduced-motion rail: ticks + needle + plain section links, native
* scrollbar kept. Coarse pointers and small screens get no ruler at all. */
export type RulerMode = "full" | "static";
export type RulerSide = "left" | "right";
/** A ruler section resolved against the live DOM. `anchor` is the document Y
* the section's word rides at (the section top — except the hero, whose
* anchor hangs `heroOffset` above the hero's bottom so the wordmark handoff
* happens as the visitor leaves the landing area). `bottom` is the document
* Y of the section's end, used to derive the end-of-page flip for sections
* too short to ever reach the threshold. */
export type MeasuredSection = {
id: string;
label: string;
index: number;
anchor: number;
bottom: number;
isHero: boolean;
};
/** The zoom morph's shared per-frame state: ScrollRuler drives it (hover
* intent, drag, damped integration of `p`), HeadingFlow reads `p` to blend
* the heading lifecycle toward the compact map. 0 = 1:1 instrument,
* 1 = compressed full-page map. */
export const rulerZoom = { target: 0, p: 0, dragging: false };
/** The hero wordmark's live letter roster, published by HeroWordmark and
* driven per-frame by HeadingFlow: the SAME spans render the resting hero
* title (identity transform, crisp at full size) and then morph tail-first
* onto the rail as the visitor scrolls out of the landing — one element
* from hero to riding word to top stack. `baseX`/`baseDocY` are each
* letter's natural center (x viewport, y document); `metrics` are advances
* normalized per 1px of font, measured from the rendered letters. */
export const heroWordmark: {
ready: boolean;
els: HTMLSpanElement[];
baseX: number[];
baseDocY: number[];
fontPx: number;
metrics: { adv: number[]; cum: number[]; total: number };
} = {
ready: false,
els: [],
baseX: [],
baseDocY: [],
fontPx: 0,
metrics: { adv: [], cum: [], total: 0 },
};
/** Screen-space y-spans of the heading words' settled riding columns.
* HeadingFlow rewrites it every frame; the tick loop fades percent labels
* within a margin of any span (proximity yield — the scale gets out of a
* passing word's way). `k` ramps 0→1 with how on-rail the word is, so the
* yield eases in/out with the detach/flip transitions instead of blinking
* as letters fly past. */
export const railOccupancy: {
spans: { top: number; bottom: number; k: number }[];
} = {
spans: [],
};
/** The ruler's vertical mapping: progress/percent p ∈ [0,1] maps onto
* [NAV_H + pad, vh − pad] — in BOTH the 1:1 and compact states, so the
* needle-meets-its-tick invariant stays exact and the endpoint ticks
* (with their 0/100 labels) always rest fully inside the viewport.
* `drift(p)` is the track offset reconciling the 1:1 layout with the
* needle's travel; pad = 0 degenerates to the old NAV_H·(1−p). */
export function rulerMap(vh: number, pad: number) {
const base = NAV_H + pad;
return {
base,
span: Math.max(1, vh - NAV_H - 2 * pad),
drift: (p: number) => base - p * (NAV_H + 2 * pad),
};
}
export const clamp01 = (x: number) => Math.max(0, Math.min(1, x));
export const easeOutExpo = (t: number) =>
t >= 1 ? 1 : 1 - Math.pow(2, -10 * t);
export const easeInOutCubic = (t: number) =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
@@ -0,0 +1,358 @@
import { defineTunables } from "@/lib/tuner";
// Structure knobs — changing these rebuilds the tick track / word layouts
// (consumers subscribe and debounce, same pattern as the hero wall).
export const rulerLayoutTuner = defineTunables("rulerLayout", "Ruler layout", {
side: {
label: "Side 0=L 1=R",
min: 0,
max: 1,
step: 1,
default: 1,
info: "Which viewport edge the ruler lives on: 0 = left, 1 = right.",
},
edgePad: {
label: "Edge pad px",
min: 0,
max: 40,
step: 2,
default: 12,
info: "Vertical inset of the ruler's whole mapping (needle travel, clicks, compact map) from the nav bottom and viewport bottom — keeps the 0/100 ticks and labels fully visible.",
},
railW: {
label: "Rail width px",
min: 32,
max: 96,
step: 2,
default: 60,
info: "Width of the tick rail at the viewport edge; also the gutter reserved from page content.",
},
minorPct: {
label: "Tick every %",
min: 0.5,
max: 5,
step: 0.5,
default: 1,
info: "Scroll-percent spacing between minor ticks on the rail.",
},
labelPct: {
label: "Label every %",
min: 5,
max: 25,
step: 5,
default: 5,
info: "Scroll-percent spacing between the longer, number-labeled major ticks.",
},
thresholdPct: {
label: "Flip line vh%",
min: 4,
max: 40,
step: 1,
default: 12,
info: "Viewport-height % from the top where a riding word flips horizontal into the top stack.",
},
headingPx: {
label: "Heading px",
min: 12,
max: 34,
step: 1,
default: 34,
info: "Font size of the full-size current-section heading below the top pile.",
},
ridingPx: {
label: "Riding px",
min: 9,
max: 24,
step: 1,
default: 20,
info: "Font size of the vertical word riding the rail with its section.",
},
queuePx: {
label: "Queue px",
min: 9,
max: 24,
step: 1,
default: 16,
info: "Font size of the compact heading links (bottom queue and top pile).",
},
topPad: {
label: "Top pad px",
min: 4,
max: 120,
step: 4,
default: 28,
info: "Gap between the nav bar and the first heading in the top pile.",
},
currentGap: {
label: "Current gap px",
min: 8,
max: 80,
step: 2,
default: 26,
info: "Extra gap separating the full-size current heading from the compact pile above it.",
},
queueSlot: {
label: "Queue slot px",
min: 12,
max: 48,
step: 2,
default: 20,
info: "Vertical spacing between stacked heading links, top pile and bottom queue alike.",
},
queuePad: {
label: "Queue bottom px",
min: 8,
max: 120,
step: 4,
default: 36,
info: "Gap between the viewport bottom and the lowest queued heading.",
},
textPad: {
label: "Text pad px",
min: 0,
max: 48,
step: 2,
default: 0,
info: "Horizontal gap between the tick rail and the heading text.",
},
});
// Feel knobs — read per frame.
export const rulerMotionTuner = defineTunables("rulerMotion", "Ruler motion", {
flipZone: {
label: "Flip zone px",
min: 60,
max: 600,
step: 10,
default: 200,
info: "Scroll distance over which the vertical-to-horizontal top flip plays out.",
},
detachZone: {
label: "Detach zone px",
min: 60,
max: 600,
step: 10,
default: 240,
info: "Scroll distance over which a queued heading morphs onto the rail beside its slot.",
},
stagger: {
label: "Letter stagger",
min: 0,
max: 0.3,
step: 0.01,
default: 0.16,
info: "Per-letter delay fraction in flips — higher = letters travel more one-by-one.",
},
arc: {
label: "Curve arc px",
min: 0,
max: 200,
step: 4,
default: 28,
info: "How far each letter's flight path bows inward (toward the page) mid-flip.",
},
jumpDur: {
label: "Jump dur s",
min: 0.2,
max: 2.5,
step: 0.05,
default: 1.25,
info: "Max duration of a click jump (rail or heading link); short hops scale it down.",
},
snapDelay: {
label: "Snap delay ms",
min: 100,
max: 1500,
step: 50,
default: 400,
info: "How long scroll must rest with a flip half-done before it auto-resolves.",
},
snapDur: {
label: "Snap dur s",
min: 0.2,
max: 2,
step: 0.05,
default: 0.7,
info: "Duration of the damped auto-scroll that resolves a half-done flip.",
},
introDur: {
label: "Intro dur s",
min: 0.1,
max: 2,
step: 0.05,
default: 1,
info: "Draw-in duration of each tick during the page-load cascade.",
},
introStagger: {
label: "Intro stag s",
min: 0,
max: 0.02,
step: 0.001,
default: 0.01,
info: "Delay between successive ticks in the load cascade (top-down).",
},
velRadius: {
label: "Vel radius px",
min: 0,
max: 500,
step: 10,
default: 180,
info: "Document distance around the needle within which ticks react to scroll speed.",
},
velMax: {
label: "Vel stretch",
min: 0,
max: 2,
step: 0.05,
default: 0.6,
info: "How much fast scrolling stretches the ticks nearest the needle.",
},
zoomInDelay: {
label: "Zoom delay ms",
min: 0,
max: 800,
step: 25,
default: 150,
info: "Hover intent time on the rail before the full-page zoom engages — accidental edge grazes don't fire.",
},
zoomOutDelay: {
label: "Unzoom delay ms",
min: 0,
max: 800,
step: 25,
default: 150,
info: "Grace period after leaving the rail before the zoom relaxes back to 1:1.",
},
zoomLerp: {
label: "Zoom damping",
min: 2,
max: 16,
step: 0.5,
default: 8,
info: "Damping rate of the zoom morph — higher = snappier compression onto the needle.",
},
dragLerp: {
label: "Drag lerp",
min: 0.05,
max: 0.6,
step: 0.01,
default: 0.18,
info: "How tightly the page chases the cursor while thumb-dragging the zoomed rail.",
},
heroLead: {
label: "Hero lead px",
min: 0,
max: 400,
step: 10,
default: 100,
info: "How many scroll px before the wordmark would duck under the nav the hero-to-header morph begins.",
},
heroZone: {
label: "Hero morph px",
min: 100,
max: 900,
step: 20,
default: 320,
info: "Scroll distance the hero wordmark's flight to the top heading slot spans.",
},
});
// Ink knobs — read per frame.
export const rulerLookTuner = defineTunables("rulerLook", "Ruler look", {
minorAlpha: {
label: "Minor alpha",
min: 0,
max: 1,
step: 0.05,
default: 0.4,
info: "Opacity of the minor ticks.",
},
majorAlpha: {
label: "Major alpha",
min: 0,
max: 1,
step: 0.05,
default: 0.8,
info: "Opacity of labeled major ticks and the riding section ticks.",
},
labelAlpha: {
label: "Label alpha",
min: 0,
max: 1,
step: 0.05,
default: 0.5,
info: "Opacity of the mono percent numbers and section index labels.",
},
minorLen: {
label: "Minor len px",
min: 2,
max: 20,
step: 1,
default: 10,
info: "Length of minor ticks, drawn from the outer edge.",
},
majorLen: {
label: "Major len px",
min: 4,
max: 28,
step: 1,
default: 20,
info: "Length of labeled major ticks, drawn from the outer edge.",
},
ghostAlpha: {
label: "Ghost alpha",
min: 0,
max: 1,
step: 0.05,
default: 1,
info: "Opacity of the hover ghost line previewing where a rail click will jump.",
},
ghostThick: {
label: "Ghost thick px",
min: 1,
max: 8,
step: 1,
default: 3,
info: "Thickness of the hover ghost line.",
},
queueAlpha: {
label: "Queue alpha",
min: 0,
max: 1,
step: 0.05,
default: 0.35,
info: "Opacity of the compact heading links (bottom queue and top pile) and of the in-transit riding word — everything that isn't the active top heading.",
},
yieldPad: {
label: "Yield margin px",
min: 0,
max: 80,
step: 2,
default: 24,
info: "Distance around a riding word within which the tick percent labels fade out of its way (proximity yield).",
},
needleFadePct: {
label: "Needle fade %",
min: 0,
max: 10,
step: 0.5,
default: 3,
info: "Progress percent below which the needle and its readout fade out — the instrument stays quiet at the very top of the page.",
},
bracketAlpha: {
label: "Bracket alpha",
min: 0,
max: 0.5,
step: 0.02,
default: 0.1,
info: "Opacity of the shaded bracket marking the visible page span while zoomed.",
},
mapAlpha: {
label: "Map alpha",
min: 0,
max: 1,
step: 0.05,
default: 0.85,
info: "Opacity of the section labels in the zoomed full-page map.",
},
});
@@ -0,0 +1,705 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import gsap from "gsap";
import { HERO_SECTION_ID, RULER_SECTIONS } from "@/lib/landing-data";
import { lenisRef } from "@/lib/lenis-ref";
import { useTunerStore } from "@/lib/tuner";
import HeadingFlow from "./heading-flow";
import {
NAV_H,
clamp01,
easeOutExpo,
railOccupancy,
rulerMap,
rulerZoom,
type MeasuredSection,
type RulerMode,
type RulerSide,
} from "./ruler-shared";
import {
rulerLayoutTuner,
rulerLookTuner,
rulerMotionTuner,
} from "./ruler-tunables";
// The scroll ruler: a vertical instrument replacing the native scrollbar.
//
// The tick track is 1:1 with the document (tick p% sits at p% of the page)
// and translates with scroll. The needle travels the viewport like a
// scrollbar thumb — at progress p it sits p of the way down AND points
// exactly at the tick labeled p, because the track carries a small
// scroll-dependent offset (NAV_H·(1−p)) that reconciles the two mappings.
// So ticks, needle, readout, and click targets always agree.
//
// Clicking the rail jumps to that progress (native-scrollbar semantics, via
// Lenis so the jump is damped); the hover ghost previews the destination.
// Section headings live in HeadingFlow: queued at the bottom (the navbar),
// riding the track as vertical words, flipping horizontal into the top
// stack. See DESIGN.md for the ideology this serves.
//
// Progressive enhancement: fine pointers on md+ get the full instrument
// (native scrollbar hidden); reduced-motion visitors get a static rail and
// keep their scrollbar; coarse pointers and small screens get nothing.
export default function ScrollRuler() {
const [mode, setMode] = useState<RulerMode | null>(null);
const [geom, setGeom] = useState({ docH: 0, vh: 0, vw: 0 });
const [layoutVersion, setLayoutVersion] = useState(0);
const [sections, setSections] = useState<MeasuredSection[]>([]);
const railRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const needleRef = useRef<HTMLDivElement>(null);
const readoutRef = useRef<HTMLSpanElement>(null);
const ghostRef = useRef<HTMLDivElement>(null);
const ghostLineRef = useRef<HTMLDivElement>(null);
const bracketRef = useRef<HTMLDivElement>(null);
const lineRefs = useRef<(HTMLSpanElement | null)[]>([]);
const tickRowRefs = useRef<(HTMLDivElement | null)[]>([]);
const labelRefs = useRef<(HTMLSpanElement | null)[]>([]);
const digitRefs = useRef<(HTMLSpanElement | null)[]>([]);
// Mutable per-frame state, never triggering React.
const fs = useRef({
vel: 0,
lastY: 0,
introDone: false,
prevDigits: [-1, -1, -1],
prevPct: -1,
readoutAbove: false,
geom: { docH: 0, vh: 0, vw: 0 },
zoomIntent: false,
zoomTimer: 0,
zoomWrote: false,
ghostActive: false,
ghostY: 0,
dragStartY: 0,
dragMoved: 0,
});
fs.current.geom = geom;
// Capability gate. Media changes mid-session are rare enough that a
// reload is the supported way to re-evaluate.
useEffect(() => {
const fine = window.matchMedia(
"(pointer: fine) and (min-width: 768px)",
).matches;
if (!fine) return;
const motionOk = window.matchMedia(
"(prefers-reduced-motion: no-preference)",
).matches;
setMode(motionOk ? "full" : "static");
}, []);
// Reset the shared zoom state (and its pending intent timer) on unmount
// so a remount never inherits a half-engaged zoom.
useEffect(() => {
const st = fs.current;
return () => {
window.clearTimeout(st.zoomTimer);
rulerZoom.target = 0;
rulerZoom.p = 0;
rulerZoom.dragging = false;
};
}, []);
// Structural tunables (layout + look) rebuild the rendered rail —
// debounced, same pattern as the hero wall.
useEffect(() => {
const snapshot = () =>
JSON.stringify([rulerLayoutTuner.read(), rulerLookTuner.read()]);
let last = snapshot();
let timer: ReturnType<typeof setTimeout> | undefined;
const unsubscribe = useTunerStore.subscribe(() => {
const now = snapshot();
if (now === last) return;
last = now;
clearTimeout(timer);
timer = setTimeout(() => setLayoutVersion((v) => v + 1), 250);
});
return () => {
clearTimeout(timer);
unsubscribe();
};
}, []);
const layout = useMemo(
() => rulerLayoutTuner.read(),
// eslint-disable-next-line react-hooks/exhaustive-deps
[layoutVersion, mode],
);
const look = useMemo(
() => rulerLookTuner.read(),
// eslint-disable-next-line react-hooks/exhaustive-deps
[layoutVersion, mode],
);
const side: RulerSide = layout.side >= 0.5 ? "right" : "left";
const railW = layout.railW;
// Stamp the mode on <html> so CSS can hide the scrollbar and reserve the
// rail gutter (JS-gated: no-JS visitors keep their scrollbar).
useEffect(() => {
if (!mode) return;
const root = document.documentElement;
root.dataset.ruler = mode === "full" ? "on" : "static";
root.dataset.rulerSide = side;
root.style.setProperty("--ruler-rail", `${railW}px`);
return () => {
delete root.dataset.ruler;
delete root.dataset.rulerSide;
root.style.removeProperty("--ruler-rail");
};
}, [mode, side, railW]);
// Page geometry. Guarded set: ResizeObserver fires on every body change
// and most of them don't move the numbers.
useEffect(() => {
if (!mode) return;
const measure = () => {
const next = {
docH: document.documentElement.scrollHeight,
vh: window.innerHeight,
vw: window.innerWidth,
};
setGeom((prev) =>
prev.docH === next.docH && prev.vh === next.vh && prev.vw === next.vw
? prev
: next,
);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(document.body);
window.addEventListener("resize", measure);
document.fonts?.ready.then(measure).catch(() => {});
return () => {
ro.disconnect();
window.removeEventListener("resize", measure);
};
}, [mode]);
// Section anchors, resolved against the live DOM. (The hero's anchor is
// unused for motion — its morph is driven by the wordmark's own position
// in heading-flow — but measured uniformly anyway.)
useEffect(() => {
if (!mode || !geom.docH) return;
const out: MeasuredSection[] = [];
for (const s of RULER_SECTIONS) {
const el = document.getElementById(s.id);
if (!el) continue;
const rect = el.getBoundingClientRect();
const isHero = s.id === HERO_SECTION_ID;
const anchor = rect.top + window.scrollY;
out.push({
id: s.id,
label: s.label,
index: out.length,
anchor,
bottom: rect.bottom + window.scrollY,
isHero,
});
}
setSections(out);
}, [mode, geom, layoutVersion]);
// Tick roster: minor every `minorPct`, labeled major every `labelPct`.
const ticks = useMemo(() => {
if (!mode || !geom.docH) return [];
const out: { pct: number; docY: number; major: boolean; alpha: number }[] =
[];
for (let p = 0; p <= 100 + 1e-6; p += layout.minorPct) {
const pct = Math.round(p * 100) / 100;
const major = Math.abs(pct % layout.labelPct) < 1e-6;
out.push({
pct,
docY: (pct / 100) * geom.docH,
major,
alpha: major ? look.majorAlpha : look.minorAlpha,
});
}
lineRefs.current.length = out.length;
tickRowRefs.current.length = out.length;
labelRefs.current.length = out.length;
return out;
}, [mode, geom.docH, layout, look]);
// Intro cascade: ticks draw in from the outer edge, top-down, then the
// per-frame velocity writer takes over.
useEffect(() => {
if (mode !== "full" || !ticks.length) return;
const els = lineRefs.current.filter((el): el is HTMLSpanElement => !!el);
if (!els.length) return;
const mt = rulerMotionTuner.read();
fs.current.introDone = false;
const tween = gsap.fromTo(
els,
{ scaleX: 0, opacity: 0 },
{
scaleX: 1,
opacity: (i: number) => ticks[i]?.alpha ?? 0.3,
duration: mt.introDur,
stagger: mt.introStagger,
ease: "power3.out",
overwrite: true,
onComplete: () => {
fs.current.introDone = true;
},
},
);
// No intro tween for the needle: its opacity is owned per-frame by the
// sub-N% progress fade (hidden at page top anyway).
return () => {
tween.kill();
fs.current.introDone = true;
};
}, [mode, ticks]);
// Frame loop: track sync, needle travel, odometer, velocity stretch.
useEffect(() => {
if (!mode || !geom.docH) return;
const full = mode === "full";
const tick = (_time: number, deltaMs: number) => {
const st = fs.current;
const { docH, vh } = st.geom;
if (!vh) return;
const dt = Math.min(deltaMs / 1000, 0.1) || 0.016;
const scrollY = window.scrollY;
const maxScroll = Math.max(1, docH - vh);
const progress = clamp01(scrollY / maxScroll);
const raw = (scrollY - st.lastY) / dt;
st.lastY = scrollY;
st.vel += (raw - st.vel) * (1 - Math.exp(-6 * dt));
const map = rulerMap(vh, rulerLayoutTuner.read().edgePad);
const drift = map.drift(progress);
if (trackRef.current) {
trackRef.current.style.transform = `translate3d(0, ${
-scrollY + drift
}px, 0)`;
}
const lk = rulerLookTuner.read();
const ny = map.base + progress * map.span;
if (needleRef.current) {
needleRef.current.style.transform = `translate3d(0, ${ny}px, 0)`;
// Quiet at the very top: the needle (and readout) fade in over the
// first few percent of progress instead of overlapping the hero.
needleRef.current.style.opacity = String(
clamp01(progress / Math.max(0.001, lk.needleFadePct / 100)),
);
}
const above = ny > vh - 26;
if (readoutRef.current && above !== st.readoutAbove) {
st.readoutAbove = above;
readoutRef.current.style.top = above ? "-14px" : "4px";
}
const pct = Math.round(progress * 100);
if (pct !== st.prevPct) {
st.prevPct = pct;
railRef.current?.setAttribute("aria-valuenow", String(pct));
const str = String(pct).padStart(3, "0");
for (let c = 0; c < 3; c++) {
const d = str.charCodeAt(c) - 48;
if (d !== st.prevDigits[c]) {
st.prevDigits[c] = d;
const col = digitRefs.current[c];
if (col) col.style.transform = `translateY(${-d}em)`;
}
}
}
// Zoom morph: hover intent (or an active drag) compresses the
// document-scale ruler onto the stationary needle — the needle's
// travel position IS tick p's compact position, so it never moves.
const mt = rulerMotionTuner.read();
if (full) {
rulerZoom.target = st.zoomIntent || rulerZoom.dragging ? 1 : 0;
rulerZoom.p +=
(rulerZoom.target - rulerZoom.p) * (1 - Math.exp(-mt.zoomLerp * dt));
if (rulerZoom.target === 0 && rulerZoom.p < 0.0005) rulerZoom.p = 0;
if (rulerZoom.target === 1 && rulerZoom.p > 0.9995) rulerZoom.p = 1;
}
const z = full ? rulerZoom.p : 0;
// Visible-span bracket, only meaningful in map space.
if (bracketRef.current) {
const b = bracketRef.current;
b.style.transform = `translate3d(0, ${
map.base + (scrollY / docH) * map.span
}px, 0)`;
b.style.height = `${(vh / docH) * map.span}px`;
b.style.opacity = String(z);
}
// Ghost follows the cursor; faint in 1:1 (its numbers are map-space),
// full strength once the map has formed.
if (ghostRef.current) {
const g = ghostRef.current;
if (st.ghostActive) {
g.style.transform = `translate3d(0, ${st.ghostY}px, 0)`;
g.style.opacity = String(lk.ghostAlpha * (0.2 + 0.8 * z));
} else {
g.style.opacity = "0";
}
}
// Tick layout + velocity response in one pass: each tick blends from
// its live 1:1 position toward its compact map position by z, and
// ticks near the needle stretch with scroll speed.
if (full && st.introDone && ticks.length) {
const needleDocY = progress * docH;
const velNorm = clamp01(Math.abs(st.vel) / 3000);
const writeZoom = z > 0 || st.zoomWrote;
const spans = railOccupancy.spans;
for (let i = 0; i < ticks.length; i++) {
const liveY = ticks[i].docY - scrollY + drift;
const compactY = map.base + (ticks[i].pct / 100) * map.span;
if (writeZoom) {
const row = tickRowRefs.current[i];
if (row) {
row.style.transform =
z > 0
? `translate3d(0, ${z * (compactY - liveY)}px, 0)`
: "";
}
}
// Proximity yield: percent labels fade under a passing word and
// recover behind it. Fully zoomed, words leave the rail, so the
// yield eases out with z.
const lab = labelRefs.current[i];
if (lab) {
const y = liveY + z * (compactY - liveY);
let fade = 1;
if (lk.yieldPad > 0 && z < 1) {
for (const spn of spans) {
const d =
y < spn.top
? spn.top - y
: y > spn.bottom
? y - spn.bottom
: 0;
if (d < lk.yieldPad) {
// Distance-graded, scaled by the span's on-rail strength
// so the yield ramps with the word's own transitions.
const f = 1 - spn.k * (1 - d / lk.yieldPad);
if (f < fade) fade = f;
}
}
}
lab.style.opacity = String(
lk.labelAlpha * (1 - (1 - fade) * (1 - z)),
);
}
const el = lineRefs.current[i];
if (!el) continue;
const dist = Math.abs(ticks[i].docY - needleDocY);
if (dist < mt.velRadius && mt.velRadius > 0) {
const t = 1 - dist / mt.velRadius;
const boost = velNorm * t * t;
el.style.transform = `scaleX(${1 + mt.velMax * boost})`;
el.style.opacity = String(
Math.min(1, ticks[i].alpha + 0.6 * boost),
);
} else {
el.style.transform = "scaleX(1)";
el.style.opacity = String(ticks[i].alpha);
}
}
st.zoomWrote = z > 0;
}
};
gsap.ticker.add(tick);
return () => {
gsap.ticker.remove(tick);
};
}, [mode, geom, ticks]);
if (!mode || !geom.docH) return null;
const sideStyle =
side === "right" ? { right: 0 as const } : { left: 0 as const };
const outerProp = side === "right" ? "right" : "left";
// Hover intent: the zoom engages/relaxes after a short beat so grazing
// the viewport edge doesn't fire it.
const scheduleZoom = (on: boolean) => {
const st = fs.current;
window.clearTimeout(st.zoomTimer);
const mt = rulerMotionTuner.read();
st.zoomTimer = window.setTimeout(
() => {
st.zoomIntent = on;
},
on ? mt.zoomInDelay : mt.zoomOutDelay,
);
};
const jumpTo = (clientY: number) => {
const st = fs.current;
const map = rulerMap(st.geom.vh, rulerLayoutTuner.read().edgePad);
const p = clamp01((clientY - map.base) / map.span);
const target = p * Math.max(1, st.geom.docH - st.geom.vh);
const lenis = lenisRef.current;
if (lenis) {
const mt = rulerMotionTuner.read();
const dist = Math.abs(target - window.scrollY);
lenis.scrollTo(target, {
duration:
mt.jumpDur * (0.4 + 0.6 * Math.min(1, dist / (st.geom.vh * 2.5))),
easing: easeOutExpo,
});
} else {
window.scrollTo({ top: target });
}
};
const railClick = (e: React.MouseEvent) => {
if (fs.current.dragMoved > 4) return; // that was a drag, not a click
jumpTo(e.clientY);
if (mode === "full" && ghostLineRef.current) {
gsap.fromTo(
ghostLineRef.current,
{ opacity: 1 },
{ opacity: rulerLookTuner.read().ghostAlpha, duration: 0.45 },
);
}
};
const railPointerEnter = () => {
scheduleZoom(true);
};
const railPointerLeave = () => {
fs.current.ghostActive = false;
if (!rulerZoom.dragging) scheduleZoom(false);
};
const railPointerMove = (e: React.PointerEvent) => {
const st = fs.current;
const map = rulerMap(st.geom.vh, rulerLayoutTuner.read().edgePad);
const y = Math.max(map.base, Math.min(map.base + map.span, e.clientY));
const p = clamp01((y - map.base) / map.span);
st.ghostActive = true;
st.ghostY = y;
// Absolute thumb drag: the page chases the cursor through Lenis's
// damping; release stops (no momentum).
if (rulerZoom.dragging) {
st.dragMoved = Math.max(
st.dragMoved,
Math.abs(e.clientY - st.dragStartY),
);
lenisRef.current?.scrollTo(
p * Math.max(1, st.geom.docH - st.geom.vh),
{ lerp: rulerMotionTuner.read().dragLerp },
);
}
};
const railPointerDown = (e: React.PointerEvent) => {
e.preventDefault();
const st = fs.current;
st.dragStartY = e.clientY;
st.dragMoved = 0;
rulerZoom.dragging = true;
e.currentTarget.setPointerCapture(e.pointerId);
};
const railPointerUp = (e: React.PointerEvent) => {
if (!rulerZoom.dragging) return;
rulerZoom.dragging = false;
// Capture suppressed enter/leave while dragging — if the pointer let
// go outside the rail, relax the zoom ourselves.
const rect = railRef.current?.getBoundingClientRect();
const inside =
!!rect &&
e.clientX >= rect.left &&
e.clientX <= rect.right &&
e.clientY >= rect.top &&
e.clientY <= rect.bottom;
if (!inside) {
fs.current.ghostActive = false;
scheduleZoom(false);
}
};
return (
<>
{/* The rail: interactive scrollbar strip at the viewport edge. */}
<div
ref={railRef}
role="scrollbar"
aria-controls="main"
aria-orientation="vertical"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={0}
aria-label="Page position"
className="fixed inset-y-0 z-40 cursor-crosshair overflow-hidden"
style={{ ...sideStyle, width: railW }}
onClick={railClick}
onPointerEnter={mode === "full" ? railPointerEnter : undefined}
onPointerLeave={mode === "full" ? railPointerLeave : undefined}
onPointerMove={mode === "full" ? railPointerMove : undefined}
onPointerDown={mode === "full" ? railPointerDown : undefined}
onPointerUp={mode === "full" ? railPointerUp : undefined}
onPointerCancel={mode === "full" ? railPointerUp : undefined}
>
{/* Tick track — 1:1 with the document, translated per frame. */}
<div
ref={trackRef}
aria-hidden
className="absolute inset-x-0 top-0"
style={{ height: geom.docH }}
>
{ticks.map((t, i) => (
<div
key={t.pct}
ref={(el) => {
tickRowRefs.current[i] = el;
}}
className="absolute inset-x-0"
style={{ top: t.docY, height: 0 }}
>
<span
ref={(el) => {
lineRefs.current[i] = el;
}}
className="absolute block bg-ink"
style={{
[outerProp]: 0,
top: 0,
width: t.major ? look.majorLen : look.minorLen,
height: 1,
opacity: mode === "full" ? 0 : t.alpha,
transformOrigin: `${outerProp} center`,
}}
/>
{t.major && (
<span
ref={(el) => {
labelRefs.current[i] = el;
}}
className="absolute -translate-y-1/2 font-mono text-ink"
style={{
[outerProp]: look.majorLen + 4,
top: 0,
fontSize: 9,
letterSpacing: "0.08em",
opacity: look.labelAlpha,
}}
>
{t.pct}
</span>
)}
</div>
))}
</div>
{/* Visible-span bracket: the current viewport's slice of the page,
shown only while the zoomed map is formed. */}
{mode === "full" && (
<div
ref={bracketRef}
aria-hidden
className="absolute inset-x-0 top-0"
style={{ height: 0, opacity: 0 }}
>
<div
className="absolute inset-0 bg-ink"
style={{ opacity: look.bracketAlpha }}
/>
<span
className="absolute inset-x-0 top-0 block bg-ink"
style={{ height: 1, opacity: 0.5 }}
/>
<span
className="absolute inset-x-0 bottom-0 block bg-ink"
style={{ height: 1, opacity: 0.5 }}
/>
</div>
)}
{/* Needle: travels [NAV_H, vh] with progress, meeting its tick. */}
<div
ref={needleRef}
aria-hidden
className="absolute inset-x-0 top-0"
style={{ height: 0 }}
>
<span
className="absolute inset-x-0 block bg-accent"
style={{ top: 0, height: 1, boxShadow: "0 0 6px var(--accent)" }}
/>
<span
ref={readoutRef}
className="absolute whitespace-nowrap font-mono text-accent-ink"
style={{
[outerProp]: 2,
top: 4,
fontSize: 9,
letterSpacing: "0.08em",
}}
>
<span className="ruler-digits">
{[0, 1, 2].map((c) => (
<span
key={c}
ref={(el) => {
digitRefs.current[c] = el;
}}
className="ruler-digit-col"
>
{"0123456789".split("").map((d) => (
<span key={d}>{d}</span>
))}
</span>
))}
</span>
<span style={{ marginLeft: 2 }}>%</span>
</span>
</div>
</div>
{/* Hover ghost — destination preview, outside the rail so its label
can extend inward past the ticks. */}
{mode === "full" && (
<div
ref={ghostRef}
aria-hidden
className="pointer-events-none fixed inset-y-0 z-40"
style={{
...sideStyle,
width: railW,
height: 0,
opacity: 0,
top: 0,
}}
>
<div
ref={ghostLineRef}
className="absolute block bg-ink"
style={{
[outerProp]: 0,
top: -look.ghostThick / 2,
width: railW,
height: look.ghostThick,
opacity: look.ghostAlpha,
}}
/>
</div>
)}
<HeadingFlow
mode={mode}
side={side}
sections={sections}
geom={geom}
layoutVersion={layoutVersion}
/>
</>
);
}
@@ -1,4 +1,4 @@
import { mediaUrl } from "./links";
import { cdnMediaUrl, mediaUrl } from "./links";
// Product feature roster, ported from the shipping landing page. Copy is the
// approved marketing copy — edit deliberately, not casually.
@@ -73,27 +73,45 @@ export const HERO_VIDEO_URL =
"https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/website-commercial-2026.05.mp4";
// Clips for the hero's render wall. `aspect` is width/height of the panel
// plane (the texture is cover-fitted, so any source aspect works).
// Served through the /ext-media same-origin proxy (see next.config.ts) —
// WebGL video textures require CORS-clean sources. PLACEHOLDERS: these
// currently reuse the feature footage — swap each path for an exported
// Seedance 2.5 clip when the renders are ready; the wall adapts to any
// count and mix of aspects.
// plane (the texture is cover-fitted, so any source aspect works), and the
// wall adapts to any count and mix of aspects, so the count below is the
// only thing to change when clips are added or dropped.
//
// Sources live on the FakeYou CDN as /videos/001.mp4 .. /videos/014.mp4 and
// reach the page through the same-origin /cdn-media proxy, since WebGL video
// textures require CORS-clean sources.
const SHOWCASE_CLIP_COUNT = 14;
export type SeedanceClip = {
src: string;
aspect: number;
};
export const SEEDANCE_SHOWCASE: SeedanceClip[] = [
{ src: "/videos/1.mp4", aspect: 16 / 9 },
{ src: "/videos/2.mp4", aspect: 16 / 9 },
{ src: "/videos/3.mp4", aspect: 16 / 9 },
{ src: "/videos/4.mp4", aspect: 16 / 9 },
{ src: "/videos/5.mp4", aspect: 16 / 9 },
{ src: "/videos/6.mp4", aspect: 16 / 9 },
{ src: "/videos/7.mp4", aspect: 16 / 9 },
{ src: "/videos/8.mp4", aspect: 16 / 9 },
{ src: "/videos/9.mp4", aspect: 16 / 9 },
export const SEEDANCE_SHOWCASE: SeedanceClip[] = Array.from(
{ length: SHOWCASE_CLIP_COUNT },
(_, i) => ({
src: cdnMediaUrl(`/videos/${String(i + 1).padStart(3, "0")}.mp4`),
aspect: 16 / 9,
}),
);
// The scroll ruler's section roster, in document order. `id` must match a
// DOM id on the page; sections missing from the DOM are silently skipped so
// the ruler still works on future pages. The hero is special-cased by id
// (its heading is the wordmark itself — see heading-flow.tsx).
export type RulerSection = {
id: string;
label: string;
};
export const HERO_SECTION_ID = "hero";
export const RULER_SECTIONS: RulerSection[] = [
{ id: HERO_SECTION_ID, label: "ARTCRAFT" },
{ id: "features", label: "FEATURES" },
{ id: "ownership", label: "OWNERSHIP" },
{ id: "made-with", label: "MADE WITH" },
{ id: "start", label: "GET STARTED" },
];
export const MADE_WITH_YOUTUBE_IDS = [
@@ -0,0 +1,7 @@
import type Lenis from "lenis";
// The live Lenis instance, published by MotionProvider for anything that
// needs programmatic smooth scrolling (the scroll ruler's jumps and snap).
// null before mount and for reduced-motion visitors — consumers must fall
// back to native scrolling.
export const lenisRef: { current: Lenis | null } = { current: null };
@@ -18,6 +18,14 @@ export function mediaUrl(path: string): string {
return `${MEDIA_BASE}${path.startsWith("/") ? path : `/${path}`}`;
}
// Showcase clips live on the FakeYou CDN, which serves no
// Access-Control-Allow-Origin header. The hero wall draws them as WebGL
// video textures, which require CORS-clean sources, so they go through the
// same-origin /cdn-media rewrite (see next.config.ts).
export function cdnMediaUrl(path: string): string {
return `/cdn-media${path.startsWith("/") ? path : `/${path}`}`;
}
export const SOCIAL_LINKS = {
DISCORD: "https://discord.gg/artcraft",
YOUTUBE: "https://www.youtube.com/@OfficialArtCraftStudios",
@@ -16,6 +16,8 @@ export type TunableDef = {
max: number;
step: number;
default: number;
/** One-liner shown behind the row's ⓘ hover — what the knob actually does. */
info?: string;
};
export type TunableGroup = {