diff --git a/.gitignore b/.gitignore index d18689f..2ec99cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ uploads/ *.zip +/.build/ diff --git a/app.js b/app.js new file mode 100644 index 0000000..674d438 --- /dev/null +++ b/app.js @@ -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 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)); diff --git a/app.jsx b/app.jsx index 7fe5aca..c6637ea 100644 --- a/app.jsx +++ b/app.jsx @@ -367,11 +367,8 @@ function HeroSection({ bgRef, contentRef }) {
- {/* Image 1 — exterior factory */} -
+ {/* Image 1 — exterior factory (background-image set in styles.css) */} +
i % 2 === 0) : chimneys} />
{/* Image 2 — factory interior / assembly line */} -
+
{/* Robotic arm idle motion: anchored to where the arms sit in the image */}
@@ -458,10 +452,7 @@ function InstallSection({ bgRef, contentRef }) {
{/* Image 3 — PCB / chip */} -
+
diff --git a/assets/exterior-factory-mobile.webp b/assets/exterior-factory-mobile.webp new file mode 100644 index 0000000..9236621 Binary files /dev/null and b/assets/exterior-factory-mobile.webp differ diff --git a/assets/exterior-factory.webp b/assets/exterior-factory.webp new file mode 100644 index 0000000..c293273 Binary files /dev/null and b/assets/exterior-factory.webp differ diff --git a/assets/factory-interior-mobile.webp b/assets/factory-interior-mobile.webp new file mode 100644 index 0000000..c0d79ba Binary files /dev/null and b/assets/factory-interior-mobile.webp differ diff --git a/assets/factory-interior.webp b/assets/factory-interior.webp new file mode 100644 index 0000000..e330339 Binary files /dev/null and b/assets/factory-interior.webp differ diff --git a/assets/pcb-chip-mobile.webp b/assets/pcb-chip-mobile.webp new file mode 100644 index 0000000..7cc8f70 Binary files /dev/null and b/assets/pcb-chip-mobile.webp differ diff --git a/assets/pcb-chip.webp b/assets/pcb-chip.webp new file mode 100644 index 0000000..ef7d375 Binary files /dev/null and b/assets/pcb-chip.webp differ diff --git a/deploy/build.sh b/deploy/build.sh new file mode 100755 index 0000000..1685476 --- /dev/null +++ b/deploy/build.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Re-compile app.jsx -> app.js so the browser can load it directly, +# without shipping ~3MB of @babel/standalone to every visitor. +# +# Usage: deploy/build.sh +# Run this before `git commit` whenever app.jsx changes. +set -euo pipefail + +cd "$(dirname "$0")/.." +ROOT="$PWD" + +# Use a dedicated build dir so we don't litter the repo root with +# node_modules / package-lock.json. +BUILD_DIR=".build" +mkdir -p "$BUILD_DIR" + +if [ ! -f "$BUILD_DIR/package.json" ]; then + ( cd "$BUILD_DIR" && npm init -y >/dev/null \ + && npm install --silent --no-audit --no-fund \ + @babel/core@7 @babel/cli@7 @babel/preset-react@7 ) +fi + +"$BUILD_DIR/node_modules/.bin/babel" app.jsx \ + --presets=@babel/preset-react \ + -o app.js + +echo "Built app.js ($(wc -c < app.js) bytes)" diff --git a/deploy/conf.d/default.conf b/deploy/conf.d/default.conf index dfcafd7..8d24970 100644 --- a/deploy/conf.d/default.conf +++ b/deploy/conf.d/default.conf @@ -17,28 +17,38 @@ server { try_files /facere.html =404; } + # The HTML entrypoint must NOT cache long — it carries the + # ?v=… cache-busting tags that everything else relies on. + location = /facere.html { + add_header Cache-Control "public, max-age=0, must-revalidate"; + expires off; + } + location / { try_files $uri $uri/ /facere.html; } - # CSS/JS/JSX: short cache so future deploys are picked up promptly + # CSS/JS: long-cache + immutable. URLs are versioned with ?v=… so + # each deploy is a brand-new key; anything served here will never + # need to be re-fetched in a session. location ~* \.(?:css|js|jsx)$ { - expires 5m; - add_header Cache-Control "public, max-age=300, must-revalidate"; + expires 1y; + add_header Cache-Control "public, max-age=31536000, immutable"; types { text/css css; application/javascript js; application/javascript jsx; } try_files $uri =404; } - # Static media: long cache (filenames are stable / change when content changes) - location ~* \.(?:png|jpe?g|gif|webp|svg|mp4|webm|woff2?|ttf|otf|ico)$ { - expires 7d; - add_header Cache-Control "public, max-age=604800"; + # Static media: long cache + immutable (filenames change when content does). + location ~* \.(?:png|jpe?g|gif|webp|avif|svg|mp4|webm|woff2?|ttf|otf|ico)$ { + expires 1y; + add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404; } gzip on; gzip_types text/plain text/css application/javascript application/json image/svg+xml; gzip_min_length 1024; + gzip_vary on; access_log /var/log/nginx/facere.access.log; error_log /var/log/nginx/facere.error.log; diff --git a/facere.html b/facere.html index 1cd1be8..a177f3f 100644 --- a/facere.html +++ b/facere.html @@ -5,29 +5,43 @@ FACERE — Hardware Design Agent + + + + - + - - - - + + + + + + + + + + + + + +
- - - - + + + - - + + - + + diff --git a/styles.css b/styles.css index 73adced..eb88e71 100644 --- a/styles.css +++ b/styles.css @@ -94,6 +94,13 @@ a { color: inherit; text-decoration: none; } image-rendering: -webkit-optimize-contrast; } +/* WebP-encoded backgrounds for each scene. Default is the full-res + variant; the mobile media query below swaps in a smaller one so + phones decode ~50KB instead of ~190KB. */ +.scene-hero .bg-image { background-image: url("assets/exterior-factory.webp"); } +.scene-product .bg-image { background-image: url("assets/factory-interior.webp"); } +.scene-install .bg-image { background-image: url("assets/pcb-chip.webp"); } + .vignette { position: absolute; inset: 0; @@ -931,6 +938,11 @@ a { color: inherit; text-decoration: none; } .scene-product .bg-wrap { transform-origin: 55% 70% !important; } .scene-install .bg-wrap { transform-origin: 70% 55% !important; } + /* Use the smaller WebP variants — ~52KB total instead of ~190KB. */ + .scene-hero .bg-image { background-image: url("assets/exterior-factory-mobile.webp"); } + .scene-product .bg-image { background-image: url("assets/factory-interior-mobile.webp"); } + .scene-install .bg-image { background-image: url("assets/pcb-chip-mobile.webp"); } + /* Hero: center the (now responsively sized) logo. */ .content-hero { align-items: center;