Perf: pre-compile JSX, prod React, WebP assets, immutable cache
This is the big "make scrolling smooth" pass — the biggest wins live in cold-start, not in the scroll loop itself, but a fast cold start means GSAP starts in a non-thrashing state and stays there. - Drop @babel/standalone (~3MB) from the page entirely. app.jsx is pre-compiled to app.js via deploy/build.sh; the browser loads the compiled bundle directly. JSX-in-browser was running babel transform on every page load, which is brutal on phones. - Switch React UMD bundles from .development to .production.min: ~1.1MB → ~140KB, no dev-mode warnings/checks in the hot path. - Add `defer` + `<link rel=preload as=script>` for the React/GSAP CDN scripts so they fetch in parallel with HTML parse, execute in order after DOM is ready, and don't block first paint. - Re-encode the three 1.4–1.8MB PNG backgrounds as WebP at full size (~190KB total) plus a 900px-wide mobile variant (~52KB total). Mobile preload links use `media=` so phones never download the full-size variants. - Move bg-image URLs from inline JSX styles into styles.css so the mobile media query can swap them in cleanly. - nginx: long-cache versioned static assets (Cache-Control immutable, 1 year) since URLs already carry ?v=… cache busters; keep the HTML itself on must-revalidate so the version pointer can update. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
731
app.js
Normal file
731
app.js
Normal file
@@ -0,0 +1,731 @@
|
||||
/* 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://facere.ai/install.sh | sh";
|
||||
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("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("div", {
|
||||
className: "cmd-hint"
|
||||
}, copied ? "✓ COPIED" : "CLICK TO COPY")), /*#__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));
|
||||
Reference in New Issue
Block a user