mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-10 12:01:36 +00: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.
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user