Files
FacereWeb/app.jsx
Knowit ea85a01edf Install scene: fix cmd-bar overlap, swap install command
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.
2026-05-08 00:45:21 +08:00

623 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 (
<div className="pixel-logo" data-text={word}>
<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 (
<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) => (
<span
key={`${ri}-${ci}`}
className={c === "#" ? "px on" : "px"}
/>
))
)}
</div>
);
})}
</div>
{/* Ghost layers for subtle electric jitter */}
<div className="pixel-logo-ghost ghost-r" aria-hidden="true">
<PixelLogoStatic word={word} scale={px} gap={gap} color="rgba(255,80,120,0.45)" />
</div>
<div className="pixel-logo-ghost ghost-c" aria-hidden="true">
<PixelLogoStatic word={word} scale={px} gap={gap} color="rgba(33,234,255,0.55)" />
</div>
</div>
);
}
function PixelLogoStatic({ word, scale, gap, color }) {
const letters = word.split("");
return (
<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 (
<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) => (
<span
key={`${ri}-${ci}`}
className="px"
style={
c === "#"
? { background: color, boxShadow: "none" }
: { background: "transparent" }
}
/>
))
)}
</div>
);
})}
</div>
);
}
/* ───────────────── 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 (
<div className="smoke-layer" style={{ "--smoke-scale": scale, "--smoke-intensity": intensity }}>
{puffs.map((p) => (
<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`,
}}
/>
))}
</div>
);
}
/* ───────────────── 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 (
<div className="ember-layer">
{embers.current.map((e, i) => (
<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`,
}}
/>
))}
</div>
);
}
/* ───────────────── 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 (
<div className="spark-layer">
<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 (
<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`,
}}
/>
);
})}
</div>
);
}
/* ───────────────── 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 (
<div className="chip-pulses">
{dots.map((d, i) => (
<span
key={i}
className="chip-pulse"
style={{
left: `${d.x}%`,
top: `${d.y}%`,
animationDuration: `${d.dur}s`,
animationDelay: `-${d.delay}s`,
}}
/>
))}
{/* Soft chip breathing glow */}
<span className="chip-glow" />
</div>
);
}
/* ───────────────── 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 (
<section className="scene scene-hero" data-screen-label="01 Hero">
<div className="bg-wrap" ref={bgRef}>
<div className="image-frame">
{/* Image 1 — exterior factory (background-image set in styles.css) */}
<div className="bg-image" />
<SmokeLayer chimneys={IS_MOBILE ? chimneys.filter((_, i) => i % 2 === 0) : chimneys} />
<EmberLayer
count={IS_MOBILE ? 10 : 22}
area={{ x: 32, y: 48, w: 65, h: 8 }}
/>
</div>
<div className="vignette" />
<div className="haze" />
</div>
<div className="content content-hero" ref={contentRef}>
<div className="logo-wrap">
<PixelLogo word="FACERE" />
<div className="logo-scanlines" />
</div>
</div>
<div className="scroll-cue" aria-hidden="true">
<span className="scroll-cue-line" />
<span className="scroll-cue-label">SCROLL</span>
</div>
</section>
);
}
/* ───────────────── ProductSection ───────────────── */
function ProductSection({ bgRef, contentRef }) {
return (
<section className="scene scene-product" data-screen-label="02 Product">
<div className="bg-wrap" ref={bgRef}>
<div className="image-frame">
{/* Image 2 — factory interior / assembly line */}
<div className="bg-image" />
{/* Robotic arm idle motion: anchored to where the arms sit in the image */}
<div className="arm-pivot arm-left" />
<div className="arm-pivot arm-right" />
<SparkLayer />
{/* Conveyor pulse strip */}
<div className="conveyor-pulse" />
</div>
<div className="vignette product-vignette" />
<div className="haze" />
</div>
<div className="content content-product" ref={contentRef}>
<div className="tag">
<span className="tag-dot" />
<span>SYSTEM ONLINE NODE 02</span>
</div>
<h1 className="headline">
Facere is a
<br />
<span className="hl">Hardware Design Agent</span>
</h1>
<p className="body">
From natural-language requirements to engineering files, Facere executes an end-to-end agent workflow
</p>
</div>
</section>
);
}
/* ───────────────── 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 (
<section className="scene scene-install" data-screen-label="03 Install">
<div className="bg-wrap" ref={bgRef}>
<div className="image-frame">
{/* Image 3 — PCB / chip */}
<div className="bg-image" />
<ChipPulses />
</div>
<div className="vignette" />
<div className="haze haze-deep" />
</div>
<div className="content content-install" ref={contentRef}>
<div className="tag">
<span className="tag-dot" />
<span>NODE 03 SUBSTRATE</span>
</div>
<h1 className="headline">
Get started with Facere
</h1>
<p className="subheadline">Install the CLI</p>
<div className={`cmd-box ${copied ? "copied" : ""}`} onClick={copy}>
<div className="cmd-bar">
<span className="cmd-led" />
<span className="cmd-led led-amber" />
<span className="cmd-led led-cyan" />
<span className="cmd-title">~/facere bash</span>
<span className="cmd-bar-hint">
{copied ? "✓ COPIED" : "CLICK TO COPY"}
</span>
</div>
<div className="cmd-body">
<span className="cmd-prompt">$</span>
<span className="cmd-text">{cmd}</span>
<span className="cmd-cursor" />
</div>
</div>
<a className="docs-link" href="#" onClick={(e) => e.preventDefault()}>
docs.facere.ai/cli <span className="arrow"></span>
</a>
</div>
</section>
);
}
/* ───────────────── 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 (
<>
{/* Persistent UI: section indicator + brand mark in corner */}
<div className="hud">
<div className="hud-brand">FACERE</div>
<div className="hud-nav" aria-hidden="true">
{["EXT", "INT", "SUB"].map((label, i) => (
<div key={i} className={`hud-step ${section === i ? "is-active" : section > i ? "is-past" : ""}`}>
<span className="hud-step-dot" />
<span className="hud-step-label">{label}</span>
</div>
))}
</div>
<div className="hud-meta">
<span className="hud-meta-line" />
<span>DEPTH {Math.round(progress * 100).toString().padStart(2, "0")}</span>
</div>
</div>
{/* Scroll track sets total scrollable height; stage is pinned & sticky */}
<div className="scroll-track">
<div className="stage" ref={stageRef}>
<div className="scene-stack">
<div className="scene-slot" ref={heroSceneRef}>
<HeroSection bgRef={heroBg} contentRef={heroContent} />
</div>
<div className="scene-slot" ref={prodSceneRef}>
<ProductSection bgRef={prodBg} contentRef={prodContent} />
</div>
<div className="scene-slot" ref={instSceneRef}>
<InstallSection bgRef={instBg} contentRef={instContent} />
</div>
</div>
<div className="grain" />
</div>
</div>
</>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);