The "CLICK TO COPY" hint was absolute-positioned at top:10/right:14 of the cmd-box, landing on top of "~/facere — bash" in the bar header. Move the hint into the bar as a flex sibling so the title and hint share the row cleanly without overlap. Replace the placeholder install command with the real installer pulled from raw.githubusercontent.com, with FACERE_GH_TOKEN as a "contact us for demo" placeholder. Also fix deploy/build.sh: `npm init -y` rejects the leading-dot dir name `.build`, and the preset must be resolved by absolute path since babel runs from the repo root.
732 lines
23 KiB
JavaScript
732 lines
23 KiB
JavaScript
/* Facere — cinematic scroll landing
|
||
Three sections, three production-art backgrounds, one continuous camera push. */
|
||
|
||
const {
|
||
useEffect,
|
||
useRef,
|
||
useState
|
||
} = React;
|
||
|
||
// Mobile heuristic — captured once at boot. Used to skip expensive
|
||
// effects (extra particles, heavy scrub smoothing) on phones.
|
||
const IS_MOBILE = typeof window !== "undefined" && window.matchMedia("(max-width: 760px), (pointer: coarse) and (max-width: 900px)").matches;
|
||
|
||
/* ───────────────── PixelLogo ─────────────────
|
||
Bitmap glyphs rendered as a CSS grid of <span>s, so we control individual
|
||
pixel colors / glow / animations. Original blocky design (no copyrighted mark). */
|
||
// 7 wide × 9 tall chunky glyphs, designed in the style of the reference:
|
||
// thick stems, rounded/beveled corners on C/E/R, no diagonals.
|
||
const GLYPH_W = 7;
|
||
const GLYPH_H = 9;
|
||
const GLYPHS = {
|
||
F: ["#######", "#######", "##.....", "##.....", "######.", "######.", "##.....", "##.....", "##....."],
|
||
A: [".#####.", "#######", "##...##", "##...##", "#######", "#######", "##...##", "##...##", "##...##"],
|
||
C: [".######", "#######", "##.....", "##.....", "##.....", "##.....", "##.....", "#######", ".######"],
|
||
E: ["#######", "#######", "##.....", "##.....", "######.", "######.", "##.....", "#######", "#######"],
|
||
R: ["######.", "#######", "##...##", "##...##", "######.", "#####..", "##.##..", "##..##.", "##...##"]
|
||
};
|
||
function useResponsiveLogoScale(word = "FACERE") {
|
||
const [scale, setScale] = useState(() => computeLogoScale(word));
|
||
useEffect(() => {
|
||
let raf = 0;
|
||
const onResize = () => {
|
||
cancelAnimationFrame(raf);
|
||
raf = requestAnimationFrame(() => setScale(computeLogoScale(word)));
|
||
};
|
||
window.addEventListener("resize", onResize);
|
||
window.addEventListener("orientationchange", onResize);
|
||
return () => {
|
||
cancelAnimationFrame(raf);
|
||
window.removeEventListener("resize", onResize);
|
||
window.removeEventListener("orientationchange", onResize);
|
||
};
|
||
}, [word]);
|
||
return scale;
|
||
}
|
||
function computeLogoScale(word) {
|
||
// Total logo width = letters * GLYPH_W * scale + (letters-1) * scale * 1.6
|
||
// Solve for scale that fits the viewport with side margins.
|
||
if (typeof window === "undefined") return 11;
|
||
const vw = window.innerWidth;
|
||
const letters = word.length;
|
||
const sideMargin = vw < 480 ? 0.10 : vw < 760 ? 0.12 : 0.16; // each side
|
||
const usable = vw * (1 - 2 * sideMargin);
|
||
const denom = letters * GLYPH_W + (letters - 1) * 1.6;
|
||
const fit = Math.floor(usable / denom);
|
||
// Clamp so it doesn't get too small or absurdly large on ultrawide.
|
||
return Math.max(4, Math.min(11, fit));
|
||
}
|
||
function PixelLogo({
|
||
word = "FACERE",
|
||
scale,
|
||
gap = 0
|
||
}) {
|
||
const responsiveScale = useResponsiveLogoScale(word);
|
||
const px = scale ?? responsiveScale;
|
||
const letters = word.split("");
|
||
return /*#__PURE__*/React.createElement("div", {
|
||
className: "pixel-logo",
|
||
"data-text": word
|
||
}, /*#__PURE__*/React.createElement("div", {
|
||
className: "pixel-logo-grid",
|
||
style: {
|
||
gap: `0 ${px * 1.6}px`
|
||
}
|
||
}, letters.map((ch, li) => {
|
||
const g = GLYPHS[ch];
|
||
if (!g) return null;
|
||
return /*#__PURE__*/React.createElement("div", {
|
||
key: li,
|
||
className: "pixel-letter",
|
||
style: {
|
||
gridTemplateColumns: `repeat(${GLYPH_W}, ${px}px)`,
|
||
gridTemplateRows: `repeat(${GLYPH_H}, ${px}px)`,
|
||
gap: `${gap}px`
|
||
}
|
||
}, g.flatMap((row, ri) => row.split("").map((c, ci) => /*#__PURE__*/React.createElement("span", {
|
||
key: `${ri}-${ci}`,
|
||
className: c === "#" ? "px on" : "px"
|
||
}))));
|
||
})), /*#__PURE__*/React.createElement("div", {
|
||
className: "pixel-logo-ghost ghost-r",
|
||
"aria-hidden": "true"
|
||
}, /*#__PURE__*/React.createElement(PixelLogoStatic, {
|
||
word: word,
|
||
scale: px,
|
||
gap: gap,
|
||
color: "rgba(255,80,120,0.45)"
|
||
})), /*#__PURE__*/React.createElement("div", {
|
||
className: "pixel-logo-ghost ghost-c",
|
||
"aria-hidden": "true"
|
||
}, /*#__PURE__*/React.createElement(PixelLogoStatic, {
|
||
word: word,
|
||
scale: px,
|
||
gap: gap,
|
||
color: "rgba(33,234,255,0.55)"
|
||
})));
|
||
}
|
||
function PixelLogoStatic({
|
||
word,
|
||
scale,
|
||
gap,
|
||
color
|
||
}) {
|
||
const letters = word.split("");
|
||
return /*#__PURE__*/React.createElement("div", {
|
||
className: "pixel-logo-grid",
|
||
style: {
|
||
gap: `0 ${scale * 1.6}px`
|
||
}
|
||
}, letters.map((ch, li) => {
|
||
const g = GLYPHS[ch];
|
||
if (!g) return null;
|
||
return /*#__PURE__*/React.createElement("div", {
|
||
key: li,
|
||
className: "pixel-letter",
|
||
style: {
|
||
gridTemplateColumns: `repeat(${GLYPH_W}, ${scale}px)`,
|
||
gridTemplateRows: `repeat(${GLYPH_H}, ${scale}px)`,
|
||
gap: `${gap}px`
|
||
}
|
||
}, g.flatMap((row, ri) => row.split("").map((c, ci) => /*#__PURE__*/React.createElement("span", {
|
||
key: `${ri}-${ci}`,
|
||
className: "px",
|
||
style: c === "#" ? {
|
||
background: color,
|
||
boxShadow: "none"
|
||
} : {
|
||
background: "transparent"
|
||
}
|
||
}))));
|
||
}));
|
||
}
|
||
|
||
/* ───────────────── SmokeLayer ─────────────────
|
||
Pixel-art smoke puffs above each chimney, positioned as % over the bg image.
|
||
Each puff is a chunky, low-res-feeling translucent disc that drifts up + fades. */
|
||
function SmokeLayer({
|
||
chimneys,
|
||
scale = 1,
|
||
intensity = 1
|
||
}) {
|
||
// Each chimney emits N puffs on staggered phases.
|
||
const PUFFS_PER = IS_MOBILE ? 2 : 3;
|
||
const puffs = [];
|
||
chimneys.forEach((ch, ci) => {
|
||
for (let p = 0; p < PUFFS_PER; p++) {
|
||
const delay = p / PUFFS_PER * ch.dur + ci * 0.3;
|
||
puffs.push({
|
||
id: `${ci}-${p}`,
|
||
x: ch.x,
|
||
y: ch.y,
|
||
size: ch.size * (0.85 + p % 2 * 0.25),
|
||
dur: ch.dur,
|
||
delay,
|
||
drift: ch.drift + (p % 3 - 1) * 0.4
|
||
});
|
||
}
|
||
});
|
||
return /*#__PURE__*/React.createElement("div", {
|
||
className: "smoke-layer",
|
||
style: {
|
||
"--smoke-scale": scale,
|
||
"--smoke-intensity": intensity
|
||
}
|
||
}, puffs.map(p => /*#__PURE__*/React.createElement("span", {
|
||
key: p.id,
|
||
className: "smoke-puff",
|
||
style: {
|
||
left: `${p.x}%`,
|
||
top: `${p.y}%`,
|
||
width: `${p.size}px`,
|
||
height: `${p.size}px`,
|
||
animationDuration: `${p.dur}s`,
|
||
animationDelay: `-${p.delay}s`,
|
||
"--drift": `${p.drift}rem`
|
||
}
|
||
})));
|
||
}
|
||
|
||
/* ───────────────── EmberLayer ─────────────────
|
||
Tiny pulsing orange points scattered across the horizon — gives the city
|
||
/factory a flicker without recreating any structure in CSS. */
|
||
function EmberLayer({
|
||
count = 18,
|
||
area
|
||
}) {
|
||
const embers = useRef(null);
|
||
if (!embers.current) {
|
||
const arr = [];
|
||
for (let i = 0; i < count; i++) {
|
||
arr.push({
|
||
x: area.x + Math.random() * area.w,
|
||
y: area.y + Math.random() * area.h,
|
||
size: 2 + Math.random() * 2,
|
||
dur: 1.6 + Math.random() * 3,
|
||
delay: Math.random() * 4,
|
||
hue: Math.random() > 0.7 ? "warm" : "soft"
|
||
});
|
||
}
|
||
embers.current = arr;
|
||
}
|
||
return /*#__PURE__*/React.createElement("div", {
|
||
className: "ember-layer"
|
||
}, embers.current.map((e, i) => /*#__PURE__*/React.createElement("span", {
|
||
key: i,
|
||
className: `ember ember-${e.hue}`,
|
||
style: {
|
||
left: `${e.x}%`,
|
||
top: `${e.y}%`,
|
||
width: `${e.size}px`,
|
||
height: `${e.size}px`,
|
||
animationDuration: `${e.dur}s`,
|
||
animationDelay: `-${e.delay}s`
|
||
}
|
||
})));
|
||
}
|
||
|
||
/* ───────────────── SparkLayer ─────────────────
|
||
For factory interior — small orange sparks bursting from the welding zone. */
|
||
function SparkLayer() {
|
||
const sparks = useRef(null);
|
||
if (!sparks.current) {
|
||
const arr = [];
|
||
const count = IS_MOBILE ? 7 : 14;
|
||
for (let i = 0; i < count; i++) {
|
||
arr.push({
|
||
a: -90 + (Math.random() - 0.5) * 160,
|
||
// angle deg
|
||
d: 18 + Math.random() * 36,
|
||
// distance
|
||
size: 2 + Math.random() * 2,
|
||
dur: 0.6 + Math.random() * 1.0,
|
||
delay: Math.random() * 1.6
|
||
});
|
||
}
|
||
sparks.current = arr;
|
||
}
|
||
return /*#__PURE__*/React.createElement("div", {
|
||
className: "spark-layer"
|
||
}, /*#__PURE__*/React.createElement("span", {
|
||
className: "spark-core"
|
||
}), sparks.current.map((s, i) => {
|
||
const rad = s.a * Math.PI / 180;
|
||
const tx = Math.cos(rad) * s.d;
|
||
const ty = Math.sin(rad) * s.d;
|
||
return /*#__PURE__*/React.createElement("span", {
|
||
key: i,
|
||
className: "spark",
|
||
style: {
|
||
"--tx": `${tx}px`,
|
||
"--ty": `${ty}px`,
|
||
width: `${s.size}px`,
|
||
height: `${s.size}px`,
|
||
animationDuration: `${s.dur}s`,
|
||
animationDelay: `-${s.delay}s`
|
||
}
|
||
});
|
||
}));
|
||
}
|
||
|
||
/* ───────────────── ChipPulses ─────────────────
|
||
Subtle pulsing orange dots over the PCB area. */
|
||
function ChipPulses() {
|
||
// hand-placed % over the chip image
|
||
const allDots = [{
|
||
x: 76,
|
||
y: 38,
|
||
dur: 2.4,
|
||
delay: 0
|
||
}, {
|
||
x: 82,
|
||
y: 28,
|
||
dur: 2.0,
|
||
delay: 0.7
|
||
}, {
|
||
x: 88,
|
||
y: 33,
|
||
dur: 2.8,
|
||
delay: 1.2
|
||
}, {
|
||
x: 73,
|
||
y: 60,
|
||
dur: 2.2,
|
||
delay: 0.4
|
||
}, {
|
||
x: 80,
|
||
y: 75,
|
||
dur: 2.6,
|
||
delay: 1.0
|
||
}, {
|
||
x: 92,
|
||
y: 55,
|
||
dur: 2.0,
|
||
delay: 1.6
|
||
}, {
|
||
x: 70,
|
||
y: 48,
|
||
dur: 3.0,
|
||
delay: 0.2
|
||
}, {
|
||
x: 95,
|
||
y: 70,
|
||
dur: 2.4,
|
||
delay: 0.9
|
||
}];
|
||
const dots = IS_MOBILE ? allDots.filter((_, i) => i % 2 === 0) : allDots;
|
||
return /*#__PURE__*/React.createElement("div", {
|
||
className: "chip-pulses"
|
||
}, dots.map((d, i) => /*#__PURE__*/React.createElement("span", {
|
||
key: i,
|
||
className: "chip-pulse",
|
||
style: {
|
||
left: `${d.x}%`,
|
||
top: `${d.y}%`,
|
||
animationDuration: `${d.dur}s`,
|
||
animationDelay: `-${d.delay}s`
|
||
}
|
||
})), /*#__PURE__*/React.createElement("span", {
|
||
className: "chip-glow"
|
||
}));
|
||
}
|
||
|
||
/* ───────────────── HeroSection ───────────────── */
|
||
function HeroSection({
|
||
bgRef,
|
||
contentRef
|
||
}) {
|
||
// Chimney positions (% over the exterior factory image) — hand-tuned to artwork.
|
||
// The three tall chimneys cluster at ~74%, 78%, 83% with tops near y≈49.5%.
|
||
// Smaller stacks at 71%, 80%, 85.5%.
|
||
// Image 1: assets/exterior-factory.png
|
||
const chimneys = [{
|
||
x: 71.5,
|
||
y: 51.0,
|
||
size: 22,
|
||
dur: 7.2,
|
||
drift: -0.3
|
||
}, {
|
||
x: 74.5,
|
||
y: 49.5,
|
||
size: 32,
|
||
dur: 8.0,
|
||
drift: -0.4
|
||
}, {
|
||
x: 78.5,
|
||
y: 50.4,
|
||
size: 30,
|
||
dur: 7.6,
|
||
drift: 0.2
|
||
}, {
|
||
x: 80.5,
|
||
y: 51.0,
|
||
size: 22,
|
||
dur: 7.0,
|
||
drift: 0.3
|
||
}, {
|
||
x: 83.5,
|
||
y: 49.8,
|
||
size: 32,
|
||
dur: 8.2,
|
||
drift: -0.2
|
||
}, {
|
||
x: 86.0,
|
||
y: 51.4,
|
||
size: 22,
|
||
dur: 7.4,
|
||
drift: 0.4
|
||
}];
|
||
return /*#__PURE__*/React.createElement("section", {
|
||
className: "scene scene-hero",
|
||
"data-screen-label": "01 Hero"
|
||
}, /*#__PURE__*/React.createElement("div", {
|
||
className: "bg-wrap",
|
||
ref: bgRef
|
||
}, /*#__PURE__*/React.createElement("div", {
|
||
className: "image-frame"
|
||
}, /*#__PURE__*/React.createElement("div", {
|
||
className: "bg-image"
|
||
}), /*#__PURE__*/React.createElement(SmokeLayer, {
|
||
chimneys: IS_MOBILE ? chimneys.filter((_, i) => i % 2 === 0) : chimneys
|
||
}), /*#__PURE__*/React.createElement(EmberLayer, {
|
||
count: IS_MOBILE ? 10 : 22,
|
||
area: {
|
||
x: 32,
|
||
y: 48,
|
||
w: 65,
|
||
h: 8
|
||
}
|
||
})), /*#__PURE__*/React.createElement("div", {
|
||
className: "vignette"
|
||
}), /*#__PURE__*/React.createElement("div", {
|
||
className: "haze"
|
||
})), /*#__PURE__*/React.createElement("div", {
|
||
className: "content content-hero",
|
||
ref: contentRef
|
||
}, /*#__PURE__*/React.createElement("div", {
|
||
className: "logo-wrap"
|
||
}, /*#__PURE__*/React.createElement(PixelLogo, {
|
||
word: "FACERE"
|
||
}), /*#__PURE__*/React.createElement("div", {
|
||
className: "logo-scanlines"
|
||
}))), /*#__PURE__*/React.createElement("div", {
|
||
className: "scroll-cue",
|
||
"aria-hidden": "true"
|
||
}, /*#__PURE__*/React.createElement("span", {
|
||
className: "scroll-cue-line"
|
||
}), /*#__PURE__*/React.createElement("span", {
|
||
className: "scroll-cue-label"
|
||
}, "SCROLL")));
|
||
}
|
||
|
||
/* ───────────────── ProductSection ───────────────── */
|
||
function ProductSection({
|
||
bgRef,
|
||
contentRef
|
||
}) {
|
||
return /*#__PURE__*/React.createElement("section", {
|
||
className: "scene scene-product",
|
||
"data-screen-label": "02 Product"
|
||
}, /*#__PURE__*/React.createElement("div", {
|
||
className: "bg-wrap",
|
||
ref: bgRef
|
||
}, /*#__PURE__*/React.createElement("div", {
|
||
className: "image-frame"
|
||
}, /*#__PURE__*/React.createElement("div", {
|
||
className: "bg-image"
|
||
}), /*#__PURE__*/React.createElement("div", {
|
||
className: "arm-pivot arm-left"
|
||
}), /*#__PURE__*/React.createElement("div", {
|
||
className: "arm-pivot arm-right"
|
||
}), /*#__PURE__*/React.createElement(SparkLayer, null), /*#__PURE__*/React.createElement("div", {
|
||
className: "conveyor-pulse"
|
||
})), /*#__PURE__*/React.createElement("div", {
|
||
className: "vignette product-vignette"
|
||
}), /*#__PURE__*/React.createElement("div", {
|
||
className: "haze"
|
||
})), /*#__PURE__*/React.createElement("div", {
|
||
className: "content content-product",
|
||
ref: contentRef
|
||
}, /*#__PURE__*/React.createElement("div", {
|
||
className: "tag"
|
||
}, /*#__PURE__*/React.createElement("span", {
|
||
className: "tag-dot"
|
||
}), /*#__PURE__*/React.createElement("span", null, "SYSTEM ONLINE \u2014 NODE 02")), /*#__PURE__*/React.createElement("h1", {
|
||
className: "headline"
|
||
}, "Facere is a", /*#__PURE__*/React.createElement("br", null), /*#__PURE__*/React.createElement("span", {
|
||
className: "hl"
|
||
}, "Hardware Design Agent")), /*#__PURE__*/React.createElement("p", {
|
||
className: "body"
|
||
}, "From natural-language requirements to engineering files, Facere executes an end-to-end agent workflow")));
|
||
}
|
||
|
||
/* ───────────────── InstallSection ───────────────── */
|
||
function InstallSection({
|
||
bgRef,
|
||
contentRef
|
||
}) {
|
||
const [copied, setCopied] = useState(false);
|
||
const cmd = `curl -fsSL https://raw.githubusercontent.com/ZhaoYiping789/facere-installer/main/bootstrap.sh | FACERE_GH_TOKEN="contact us for demo"`;
|
||
const copy = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(cmd);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 1600);
|
||
} catch (e) {
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 1600);
|
||
}
|
||
};
|
||
return /*#__PURE__*/React.createElement("section", {
|
||
className: "scene scene-install",
|
||
"data-screen-label": "03 Install"
|
||
}, /*#__PURE__*/React.createElement("div", {
|
||
className: "bg-wrap",
|
||
ref: bgRef
|
||
}, /*#__PURE__*/React.createElement("div", {
|
||
className: "image-frame"
|
||
}, /*#__PURE__*/React.createElement("div", {
|
||
className: "bg-image"
|
||
}), /*#__PURE__*/React.createElement(ChipPulses, null)), /*#__PURE__*/React.createElement("div", {
|
||
className: "vignette"
|
||
}), /*#__PURE__*/React.createElement("div", {
|
||
className: "haze haze-deep"
|
||
})), /*#__PURE__*/React.createElement("div", {
|
||
className: "content content-install",
|
||
ref: contentRef
|
||
}, /*#__PURE__*/React.createElement("div", {
|
||
className: "tag"
|
||
}, /*#__PURE__*/React.createElement("span", {
|
||
className: "tag-dot"
|
||
}), /*#__PURE__*/React.createElement("span", null, "NODE 03 \u2014 SUBSTRATE")), /*#__PURE__*/React.createElement("h1", {
|
||
className: "headline"
|
||
}, "Get started with Facere"), /*#__PURE__*/React.createElement("p", {
|
||
className: "subheadline"
|
||
}, "Install the CLI"), /*#__PURE__*/React.createElement("div", {
|
||
className: `cmd-box ${copied ? "copied" : ""}`,
|
||
onClick: copy
|
||
}, /*#__PURE__*/React.createElement("div", {
|
||
className: "cmd-bar"
|
||
}, /*#__PURE__*/React.createElement("span", {
|
||
className: "cmd-led"
|
||
}), /*#__PURE__*/React.createElement("span", {
|
||
className: "cmd-led led-amber"
|
||
}), /*#__PURE__*/React.createElement("span", {
|
||
className: "cmd-led led-cyan"
|
||
}), /*#__PURE__*/React.createElement("span", {
|
||
className: "cmd-title"
|
||
}, "~/facere \u2014 bash"), /*#__PURE__*/React.createElement("span", {
|
||
className: "cmd-bar-hint"
|
||
}, copied ? "✓ COPIED" : "CLICK TO COPY")), /*#__PURE__*/React.createElement("div", {
|
||
className: "cmd-body"
|
||
}, /*#__PURE__*/React.createElement("span", {
|
||
className: "cmd-prompt"
|
||
}, "$"), /*#__PURE__*/React.createElement("span", {
|
||
className: "cmd-text"
|
||
}, cmd), /*#__PURE__*/React.createElement("span", {
|
||
className: "cmd-cursor"
|
||
}))), /*#__PURE__*/React.createElement("a", {
|
||
className: "docs-link",
|
||
href: "#",
|
||
onClick: e => e.preventDefault()
|
||
}, "docs.facere.ai/cli ", /*#__PURE__*/React.createElement("span", {
|
||
className: "arrow"
|
||
}, "\u2192"))));
|
||
}
|
||
|
||
/* ───────────────── App / ScrollScene ─────────────────
|
||
Pin the stage, push the camera deeper through each section.
|
||
Each section's bg scales up while the next fades in over it. */
|
||
function App() {
|
||
const stageRef = useRef(null);
|
||
const heroBg = useRef(null),
|
||
heroContent = useRef(null);
|
||
const prodBg = useRef(null),
|
||
prodContent = useRef(null);
|
||
const instBg = useRef(null),
|
||
instContent = useRef(null);
|
||
const heroSceneRef = useRef(null);
|
||
const prodSceneRef = useRef(null);
|
||
const instSceneRef = useRef(null);
|
||
const [progress, setProgress] = useState(0);
|
||
const lastDepth = useRef(-1);
|
||
useEffect(() => {
|
||
if (!window.gsap || !window.ScrollTrigger) return;
|
||
const {
|
||
gsap,
|
||
ScrollTrigger
|
||
} = window;
|
||
gsap.registerPlugin(ScrollTrigger);
|
||
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||
|
||
// Initial state: only hero visible
|
||
gsap.set(prodSceneRef.current, {
|
||
opacity: 0
|
||
});
|
||
gsap.set(instSceneRef.current, {
|
||
opacity: 0
|
||
});
|
||
const ctx = gsap.context(() => {
|
||
// Master timeline pinned to stage; scrub linked to scroll.
|
||
const tl = gsap.timeline({
|
||
scrollTrigger: {
|
||
trigger: ".scroll-track",
|
||
start: "top top",
|
||
end: "bottom bottom",
|
||
// On mobile, scrub:true (no smoothing lag) feels snappier and
|
||
// skips an extra rAF interpolation per frame.
|
||
scrub: IS_MOBILE ? true : 0.6,
|
||
onUpdate: self => {
|
||
// Re-render only when the displayed depth (0..100) changes —
|
||
// otherwise we re-render the whole HUD on every scroll frame.
|
||
const depth = Math.round(self.progress * 100);
|
||
if (depth !== lastDepth.current) {
|
||
lastDepth.current = depth;
|
||
setProgress(self.progress);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Timing map (extra-long product dwell):
|
||
// 0.00 → 0.20 Hero camera push
|
||
// 0.20 → 0.25 Hero → Product cross-fade
|
||
// 0.25 → 0.82 Product (very long dwell)
|
||
// 0.82 → 0.86 Product → Install cross-fade
|
||
// 0.86 → 1.00 Install settle
|
||
|
||
// Phase 1: Hero camera push-in
|
||
tl.to(heroBg.current, {
|
||
scale: reduceMotion ? 1.05 : 1.35,
|
||
ease: "none"
|
||
}, 0);
|
||
tl.to(heroContent.current, {
|
||
scale: reduceMotion ? 1.02 : 1.18,
|
||
opacity: 0,
|
||
ease: "none"
|
||
}, 0);
|
||
|
||
// Phase 2: cross-fade hero → product
|
||
tl.to(heroSceneRef.current, {
|
||
opacity: 0,
|
||
ease: "none"
|
||
}, 0.20);
|
||
tl.fromTo(prodSceneRef.current, {
|
||
opacity: 0
|
||
}, {
|
||
opacity: 1,
|
||
ease: "none"
|
||
}, 0.20);
|
||
tl.fromTo(prodBg.current, {
|
||
scale: reduceMotion ? 1.05 : 1.18
|
||
}, {
|
||
scale: 1,
|
||
ease: "none"
|
||
}, 0.20);
|
||
tl.fromTo(prodContent.current, {
|
||
y: 40,
|
||
opacity: 0
|
||
}, {
|
||
y: 0,
|
||
opacity: 1,
|
||
ease: "none"
|
||
}, 0.23);
|
||
|
||
// Phase 3: Long product dwell with slow camera creep
|
||
tl.to(prodBg.current, {
|
||
scale: reduceMotion ? 1.05 : 1.22,
|
||
ease: "none"
|
||
}, 0.27);
|
||
tl.to(prodContent.current, {
|
||
scale: reduceMotion ? 1.02 : 1.08,
|
||
opacity: 0,
|
||
ease: "none",
|
||
duration: 0.06
|
||
}, 0.78);
|
||
|
||
// Phase 4: cross-fade product → install
|
||
tl.to(prodSceneRef.current, {
|
||
opacity: 0,
|
||
ease: "none"
|
||
}, 0.82);
|
||
tl.fromTo(instSceneRef.current, {
|
||
opacity: 0
|
||
}, {
|
||
opacity: 1,
|
||
ease: "none"
|
||
}, 0.82);
|
||
tl.fromTo(instBg.current, {
|
||
scale: reduceMotion ? 1.05 : 1.18
|
||
}, {
|
||
scale: 1,
|
||
ease: "none"
|
||
}, 0.82);
|
||
tl.fromTo(instContent.current, {
|
||
y: 40,
|
||
opacity: 0
|
||
}, {
|
||
y: 0,
|
||
opacity: 1,
|
||
ease: "none"
|
||
}, 0.86);
|
||
|
||
// Phase 5: Install gentle settle
|
||
tl.to(instBg.current, {
|
||
scale: reduceMotion ? 1.0 : 1.05,
|
||
ease: "none"
|
||
}, 0.90);
|
||
}, stageRef);
|
||
return () => ctx.revert();
|
||
}, []);
|
||
|
||
// Section indicator
|
||
const section = progress < 0.22 ? 0 : progress < 0.84 ? 1 : 2;
|
||
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
|
||
className: "hud"
|
||
}, /*#__PURE__*/React.createElement("div", {
|
||
className: "hud-brand"
|
||
}, "FACERE"), /*#__PURE__*/React.createElement("div", {
|
||
className: "hud-nav",
|
||
"aria-hidden": "true"
|
||
}, ["EXT", "INT", "SUB"].map((label, i) => /*#__PURE__*/React.createElement("div", {
|
||
key: i,
|
||
className: `hud-step ${section === i ? "is-active" : section > i ? "is-past" : ""}`
|
||
}, /*#__PURE__*/React.createElement("span", {
|
||
className: "hud-step-dot"
|
||
}), /*#__PURE__*/React.createElement("span", {
|
||
className: "hud-step-label"
|
||
}, label)))), /*#__PURE__*/React.createElement("div", {
|
||
className: "hud-meta"
|
||
}, /*#__PURE__*/React.createElement("span", {
|
||
className: "hud-meta-line"
|
||
}), /*#__PURE__*/React.createElement("span", null, "DEPTH ", Math.round(progress * 100).toString().padStart(2, "0")))), /*#__PURE__*/React.createElement("div", {
|
||
className: "scroll-track"
|
||
}, /*#__PURE__*/React.createElement("div", {
|
||
className: "stage",
|
||
ref: stageRef
|
||
}, /*#__PURE__*/React.createElement("div", {
|
||
className: "scene-stack"
|
||
}, /*#__PURE__*/React.createElement("div", {
|
||
className: "scene-slot",
|
||
ref: heroSceneRef
|
||
}, /*#__PURE__*/React.createElement(HeroSection, {
|
||
bgRef: heroBg,
|
||
contentRef: heroContent
|
||
})), /*#__PURE__*/React.createElement("div", {
|
||
className: "scene-slot",
|
||
ref: prodSceneRef
|
||
}, /*#__PURE__*/React.createElement(ProductSection, {
|
||
bgRef: prodBg,
|
||
contentRef: prodContent
|
||
})), /*#__PURE__*/React.createElement("div", {
|
||
className: "scene-slot",
|
||
ref: instSceneRef
|
||
}, /*#__PURE__*/React.createElement(InstallSection, {
|
||
bgRef: instBg,
|
||
contentRef: instContent
|
||
}))), /*#__PURE__*/React.createElement("div", {
|
||
className: "grain"
|
||
}))));
|
||
}
|
||
ReactDOM.createRoot(document.getElementById("root")).render(/*#__PURE__*/React.createElement(App, null));
|