- {/* eslint-disable-next-line @next/next/no-img-element -- remote SVG banner; next/image would need remotePatterns + dangerouslyAllowSVG */}
-
-
-
- Rust / cross-platform system fetcher
-
-
-
- System information,
-
- rendered with precision.
-
-
-
- A cross-platform system information fetching tool written in Rust.
-
-
-
-
-
- {FACTS.map((fact) => (
- {fact}
- ))}
-
+
+
+ System information,
+
+ rendered with precision.
+
+
+
+ One binary, 18 modules and 16 layouts — your system, drawn in the terminal.
+
+
+
-
-
+
+
);
}
diff --git a/src/components/landing/HeroScene.tsx b/src/components/landing/HeroScene.tsx
new file mode 100644
index 0000000..8110d7e
--- /dev/null
+++ b/src/components/landing/HeroScene.tsx
@@ -0,0 +1,893 @@
+"use client";
+
+import { memo, useCallback, useEffect, useMemo, useRef } from "react";
+import { Canvas, useFrame, useThree } from "@react-three/fiber";
+import { Edges, Grid, RoundedBox } from "@react-three/drei";
+import * as THREE from "three";
+import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
+import { HERO_ASCII_LOGO, HERO_PALETTE, HERO_ROWS } from "./hero-data";
+import {
+ seededRandom,
+ useInView,
+ useReducedMotion,
+ useScenePalette,
+ type ScenePalette,
+} from "./three/scene";
+
+const MONO = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
+const KEY_PITCH = 0.62;
+const KEY_SIZE = 0.56;
+const KEY_HEIGHT = 0.16;
+const REST_Y = 0.32;
+const PRESS_Y = 0.23;
+const KEY_ROWS = [
+ "`1234567890-=",
+ "qwertyuiop[]\\",
+ "asdfghjkl;'",
+ "zxcvbnm,./",
+];
+const BOTTOM_KEYS = [
+ { label: "ctrl", x: -3.8, width: 1.3, char: "" },
+ { label: "xfetch", x: 0, width: 5.3, char: "xfetch " },
+ { label: "enter", x: 3.8, width: 1.3, char: "" },
+];
+const SCREEN_W = 1592;
+const SCREEN_H = 877;
+
+function roundedRect(
+ ctx: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ radius: number,
+) {
+ const r = Math.min(radius, width / 2, height / 2);
+ ctx.beginPath();
+ ctx.moveTo(x + r, y);
+ ctx.lineTo(x + width - r, y);
+ ctx.quadraticCurveTo(x + width, y, x + width, y + r);
+ ctx.lineTo(x + width, y + height - r);
+ ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
+ ctx.lineTo(x + r, y + height);
+ ctx.quadraticCurveTo(x, y + height, x, y + height - r);
+ ctx.lineTo(x, y + r);
+ ctx.quadraticCurveTo(x, y, x + r, y);
+ ctx.closePath();
+}
+
+const labelCache = new Map
();
+
+function getLabelTexture(text: string, color: string): THREE.CanvasTexture {
+ const cacheKey = `${color}:${text}`;
+ const cached = labelCache.get(cacheKey);
+ if (cached) return cached;
+
+ const wide = text.length > 1;
+ const width = wide ? 512 : 128;
+ const height = 128;
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext("2d");
+ if (ctx) {
+ ctx.clearRect(0, 0, width, height);
+ ctx.fillStyle = color;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.font = wide ? `600 60px ${MONO}` : `600 62px ${MONO}`;
+ ctx.fillText(text, width / 2, height / 2 + 2);
+ }
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ texture.minFilter = THREE.LinearFilter;
+ texture.magFilter = THREE.LinearFilter;
+ labelCache.set(cacheKey, texture);
+ return texture;
+}
+
+function drawScreen(
+ ctx: CanvasRenderingContext2D,
+ typed: string,
+ palette: ScenePalette,
+) {
+ const width = SCREEN_W;
+ const height = SCREEN_H;
+ const screen = {
+ accent: palette.dark ? palette.accent : "#ffb27a",
+ cyan: palette.dark ? palette.cyan : "#7dd3fc",
+ success: palette.dark ? palette.success : "#7ee2b8",
+ text: "#e8ecf5",
+ muted: "#98a2b8",
+ };
+ ctx.clearRect(0, 0, width, height);
+ roundedRect(ctx, 0, 0, width, height, 26);
+ ctx.save();
+ ctx.clip();
+
+ // Desktop wallpaper
+ const background = ctx.createLinearGradient(0, 0, 0, height);
+ background.addColorStop(0, "#0a1020");
+ background.addColorStop(0.55, "#070b14");
+ background.addColorStop(1, "#05070d");
+ ctx.fillStyle = background;
+ ctx.fillRect(0, 0, width, height);
+
+ const warmGlow = ctx.createRadialGradient(180, 20, 0, 180, 20, 820);
+ warmGlow.addColorStop(0, `${screen.accent}30`);
+ warmGlow.addColorStop(0.5, `${screen.accent}0b`);
+ warmGlow.addColorStop(1, "rgba(0,0,0,0)");
+ ctx.fillStyle = warmGlow;
+ ctx.fillRect(0, 0, width, height);
+
+ const coolGlow = ctx.createRadialGradient(width - 160, height, 0, width - 160, height, 700);
+ coolGlow.addColorStop(0, `${screen.cyan}1a`);
+ coolGlow.addColorStop(1, "rgba(0,0,0,0)");
+ ctx.fillStyle = coolGlow;
+ ctx.fillRect(0, 0, width, height);
+
+ ctx.strokeStyle = "rgba(148,163,184,0.045)";
+ ctx.lineWidth = 1;
+ for (let x = 0; x <= width; x += 56) {
+ ctx.beginPath();
+ ctx.moveTo(x + 0.5, 0);
+ ctx.lineTo(x + 0.5, height);
+ ctx.stroke();
+ }
+ for (let y = 0; y <= height; y += 56) {
+ ctx.beginPath();
+ ctx.moveTo(0, y + 0.5);
+ ctx.lineTo(width, y + 0.5);
+ ctx.stroke();
+ }
+
+ // Desktop icons
+ const iconColors = [screen.accent, screen.cyan, palette.violet];
+ iconColors.forEach((color, index) => {
+ roundedRect(ctx, 66, 150 + index * 52, 30, 30, 8);
+ ctx.fillStyle = `${color}22`;
+ ctx.fill();
+ ctx.strokeStyle = `${color}66`;
+ ctx.stroke();
+ roundedRect(ctx, 74, 158 + index * 52, 14, 14, 4);
+ ctx.fillStyle = `${color}99`;
+ ctx.fill();
+ ctx.fillStyle = "rgba(152,162,184,0.38)";
+ roundedRect(ctx, 70, 188 + index * 52, 22, 3, 1.5);
+ ctx.fill();
+ });
+
+ // Desktop X watermark
+ ctx.save();
+ ctx.globalAlpha = 0.07;
+ ctx.font = `700 70px ${MONO}`;
+ ctx.fillStyle = screen.accent;
+ HERO_ASCII_LOGO.forEach((line, index) => {
+ ctx.fillText(line, 1282, 220 + index * 70);
+ });
+ ctx.restore();
+
+ // Desktop topbar
+ ctx.fillStyle = "rgba(6,9,16,0.8)";
+ ctx.fillRect(0, 0, width, 40);
+ ctx.fillStyle = "rgba(255,255,255,0.08)";
+ ctx.fillRect(0, 40, width, 1);
+
+ roundedRect(ctx, 20, 11, 18, 18, 4);
+ ctx.fillStyle = `${screen.muted}18`;
+ ctx.fill();
+ ctx.strokeStyle = `${screen.muted}44`;
+ ctx.stroke();
+ ctx.fillStyle = `${screen.muted}cc`;
+ [
+ [24, 15],
+ [31, 15],
+ [24, 22],
+ [31, 22],
+ ].forEach(([x, y]) => {
+ roundedRect(ctx, x, y, 4, 4, 1);
+ ctx.fill();
+ });
+
+ ctx.font = `600 15px ${MONO}`;
+ ctx.fillStyle = "rgba(232,236,245,0.92)";
+ ctx.fillText("X Linux", 48, 26);
+
+ ctx.fillStyle = "rgba(152,162,184,0.45)";
+ roundedRect(ctx, width - 176, 18, 48, 4, 2);
+ ctx.fill();
+ roundedRect(ctx, width - 120, 18, 24, 4, 2);
+ ctx.fill();
+ ctx.textAlign = "right";
+ ctx.font = `500 14px ${MONO}`;
+ ctx.fillStyle = "rgba(152,162,184,0.72)";
+ ctx.fillText("22:15", width - 24, 26);
+ ctx.textAlign = "left";
+
+ // Floating terminal window
+ const wx = 316;
+ const wy = 174;
+ const ww = 960;
+ const wh = 530;
+
+ ctx.save();
+ ctx.shadowColor = "rgba(0,0,0,0.65)";
+ ctx.shadowBlur = 44;
+ ctx.shadowOffsetY = 22;
+ roundedRect(ctx, wx, wy, ww, wh, 22);
+ ctx.fillStyle = "#090d17";
+ ctx.fill();
+ ctx.restore();
+
+ const windowGlow = ctx.createLinearGradient(0, wy, 0, wy + wh);
+ windowGlow.addColorStop(0, "#0d1424");
+ windowGlow.addColorStop(1, "#070a12");
+
+ ctx.save();
+ roundedRect(ctx, wx, wy, ww, wh, 22);
+ ctx.clip();
+ ctx.fillStyle = windowGlow;
+ ctx.fillRect(wx, wy, ww, wh);
+
+ const windowWash = ctx.createRadialGradient(wx + 130, wy + 10, 0, wx + 130, wy + 10, 620);
+ windowWash.addColorStop(0, `${screen.accent}18`);
+ windowWash.addColorStop(1, "rgba(0,0,0,0)");
+ ctx.fillStyle = windowWash;
+ ctx.fillRect(wx, wy, ww, wh);
+
+ // Window chrome
+ ctx.fillStyle = "rgba(13,19,34,0.97)";
+ ctx.fillRect(wx, wy, ww, 50);
+ ctx.fillStyle = "rgba(255,255,255,0.1)";
+ ctx.fillRect(wx, wy + 50, ww, 1);
+
+ ["#ff5f56", "#ffbd2e", "#27c93f"].forEach((color, index) => {
+ ctx.beginPath();
+ ctx.arc(wx + 26 + index * 19, wy + 25, 5.5, 0, Math.PI * 2);
+ ctx.fillStyle = color;
+ ctx.fill();
+ });
+
+ roundedRect(ctx, wx + 88, wy + 13, 26, 26, 7);
+ ctx.fillStyle = `${screen.accent}1f`;
+ ctx.fill();
+ ctx.strokeStyle = `${screen.accent}66`;
+ ctx.lineWidth = 1;
+ ctx.stroke();
+ ctx.font = `700 15px ${MONO}`;
+ ctx.fillStyle = screen.accent;
+ ctx.textAlign = "center";
+ ctx.fillText("X", wx + 101, wy + 31);
+ ctx.textAlign = "left";
+
+ ctx.font = `600 20px ${MONO}`;
+ ctx.fillStyle = "rgba(226,232,244,0.94)";
+ ctx.fillText("xfetch — zsh", wx + 128, wy + 32);
+
+ ctx.textAlign = "right";
+ ctx.font = `500 15px ${MONO}`;
+ ctx.fillStyle = "rgba(152,162,184,0.6)";
+ ctx.fillText("v1.0.4", wx + ww - 24, wy + 31);
+ ctx.textAlign = "left";
+
+ // Window terminal content
+ const contentTop = wy + 50;
+ ctx.font = `700 30px ${MONO}`;
+ ctx.fillStyle = screen.accent;
+ HERO_ASCII_LOGO.forEach((line, index) => {
+ ctx.fillText(line, wx + 34, contentTop + 46 + index * 30);
+ });
+
+ const startX = wx + 240;
+ const startY = contentTop + 40;
+ const lineHeight = 42;
+ HERO_ROWS.forEach((row, index) => {
+ const y = startY + index * lineHeight;
+ ctx.font = `700 30px ${MONO}`;
+ ctx.fillStyle = screen.cyan;
+ ctx.fillText(row.key, startX, y);
+ ctx.font = `500 30px ${MONO}`;
+ ctx.fillStyle = screen.text;
+ ctx.fillText(row.value, startX + 142, y);
+ });
+
+ const paletteY = startY + HERO_ROWS.length * lineHeight + 8;
+ HERO_PALETTE.forEach((color, index) => {
+ roundedRect(ctx, startX + index * 27, paletteY, 17, 17, 3);
+ ctx.fillStyle = color;
+ ctx.fill();
+ });
+
+ ctx.fillStyle = "rgba(148,163,184,0.14)";
+ ctx.fillRect(wx + 30, wy + 360, ww - 60, 1);
+
+ ctx.font = `700 30px ${MONO}`;
+ ctx.fillStyle = screen.success;
+ ctx.fillText("$", wx + 34, wy + 450);
+ ctx.fillStyle = screen.text;
+ const prompt = typed || "xfetch";
+ ctx.fillText(prompt, wx + 64, wy + 450);
+ const cursorX = wx + 64 + ctx.measureText(prompt).width + 8;
+ ctx.fillStyle = screen.success;
+ ctx.fillRect(cursorX, wy + 428, 13, 27);
+
+ ctx.font = `500 16px ${MONO}`;
+ ctx.fillStyle = "rgba(152,162,184,0.56)";
+ ctx.fillText("hover or tap the keys below — every key types", wx + 34, wy + 500);
+
+ ctx.restore();
+
+ // Window border
+ roundedRect(ctx, wx + 0.5, wy + 0.5, ww - 1, wh - 1, 22);
+ ctx.strokeStyle = "rgba(255,255,255,0.18)";
+ ctx.lineWidth = 1;
+ ctx.stroke();
+
+ // Dock
+ const dockX = width / 2 - 45;
+ HERO_PALETTE.slice(0, 5).forEach((color, index) => {
+ roundedRect(ctx, dockX + index * 20, height - 22, 10, 10, 3);
+ ctx.fillStyle = `${color}99`;
+ ctx.fill();
+ });
+
+ // Monitor scanlines
+ for (let y = 44; y < height; y += 4) {
+ ctx.fillStyle = "rgba(255,255,255,0.012)";
+ ctx.fillRect(0, y, width, 1);
+ }
+
+ ctx.restore();
+ roundedRect(ctx, 0.75, 0.75, width - 1.5, height - 1.5, 26);
+ ctx.strokeStyle = "rgba(255,255,255,0.17)";
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+}
+
+function makeScreenTexture(palette: ScenePalette, initial: string) {
+ const canvas = document.createElement("canvas");
+ canvas.width = SCREEN_W;
+ canvas.height = SCREEN_H;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) throw new Error("2d context unavailable");
+
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ texture.minFilter = THREE.LinearFilter;
+ texture.magFilter = THREE.LinearFilter;
+ texture.anisotropy = 8;
+
+ const draw = (typed: string) => {
+ drawScreen(ctx, typed, palette);
+ texture.needsUpdate = true;
+ };
+ draw(initial);
+
+ return { texture, draw };
+}
+
+function makeShadowTexture() {
+ const canvas = document.createElement("canvas");
+ canvas.width = 256;
+ canvas.height = 128;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) throw new Error("2d context unavailable");
+ const gradient = ctx.createRadialGradient(128, 64, 4, 128, 64, 120);
+ gradient.addColorStop(0, "rgba(0,0,0,0.62)");
+ gradient.addColorStop(0.55, "rgba(0,0,0,0.28)");
+ gradient.addColorStop(1, "rgba(0,0,0,0)");
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, 256, 128);
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.minFilter = THREE.LinearFilter;
+ texture.magFilter = THREE.LinearFilter;
+ return texture;
+}
+
+function FitCamera() {
+ const aspectRef = useRef(0);
+
+ useFrame((state) => {
+ const camera = state.camera;
+ if (!(camera instanceof THREE.PerspectiveCamera)) return;
+ const aspect = state.size.width / Math.max(state.size.height, 1);
+ if (Math.abs(aspect - aspectRef.current) < 0.005) return;
+ aspectRef.current = aspect;
+
+ const portrait = aspect < 1.3;
+ const medium = aspect < 1.85;
+
+ if (portrait) {
+ camera.position.set(0.2, 5.0, 13.0);
+ camera.fov = 42;
+ } else if (medium) {
+ camera.position.set(0.45, 4.9, 12.8);
+ camera.fov = 36;
+ } else {
+ camera.position.set(0.6, 5.0, 13.5);
+ camera.fov = 32;
+ }
+ camera.lookAt(0, 1.5, -0.6);
+ camera.updateProjectionMatrix();
+ });
+
+ return null;
+}
+
+function SceneEnvironment() {
+ const gl = useThree((state) => state.gl);
+ const scene = useThree((state) => state.scene);
+
+ useEffect(() => {
+ const pmrem = new THREE.PMREMGenerator(gl);
+ const texture = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
+ // eslint-disable-next-line react-hooks/immutability -- three.js scene graph is mutable by design
+ scene.environment = texture;
+ scene.environmentIntensity = 0.32;
+ return () => {
+ scene.environment = null;
+ texture.dispose();
+ pmrem.dispose();
+ };
+ }, [gl, scene]);
+
+ return null;
+}
+
+function ParticleField({ palette, reduced }: { palette: ScenePalette; reduced: boolean }) {
+ const points = useRef(null);
+
+ const geometry = useMemo(() => {
+ const count = 200;
+ const rand = seededRandom(0x4b3d);
+ const positions = new Float32Array(count * 3);
+ for (let i = 0; i < count; i += 1) {
+ positions[i * 3] = (rand() - 0.5) * 17;
+ positions[i * 3 + 1] = rand() * 5.2 - 0.2;
+ positions[i * 3 + 2] = (rand() - 0.5) * 9;
+ }
+ const buffer = new THREE.BufferGeometry();
+ buffer.setAttribute("position", new THREE.BufferAttribute(positions, 3));
+ return buffer;
+ }, []);
+
+ useEffect(() => () => geometry.dispose(), [geometry]);
+
+ useFrame((state, delta) => {
+ if (!points.current || reduced) return;
+ points.current.rotation.y += delta * 0.012;
+ points.current.position.y = Math.sin(state.clock.elapsedTime * 0.22) * 0.06;
+ });
+
+ return (
+
+
+
+ );
+}
+
+type KeySpec = {
+ id: string;
+ label: string;
+ char: string;
+ x: number;
+ z: number;
+ width: number;
+ wide?: boolean;
+};
+
+function buildKeys(): KeySpec[] {
+ const keys: KeySpec[] = [];
+ KEY_ROWS.forEach((row, r) => {
+ const chars = row.split("");
+ const half = (chars.length - 1) / 2;
+ chars.forEach((char, c) => {
+ keys.push({
+ id: `${r}-${c}`,
+ label: char,
+ char,
+ x: (c - half) * KEY_PITCH,
+ z: (r - 1.5) * KEY_PITCH - 0.04,
+ width: KEY_SIZE,
+ });
+ });
+ });
+ BOTTOM_KEYS.forEach((key, index) => {
+ keys.push({
+ id: `bottom-${index}`,
+ label: key.label,
+ char: key.char,
+ x: key.x,
+ z: 1.68,
+ width: key.width,
+ wide: true,
+ });
+ });
+ return keys;
+}
+
+const KEYS = buildKeys();
+
+function Key({
+ spec,
+ index,
+ palette,
+ onType,
+ pressed,
+ register,
+}: {
+ spec: KeySpec;
+ index: number;
+ palette: ScenePalette;
+ onType: (text: string) => void;
+ pressed: React.RefObject>;
+ register: (index: number, group: THREE.Group | null) => void;
+}) {
+ const group = useRef(null);
+ const labelTexture = useMemo(
+ () => (spec.label ? getLabelTexture(spec.label, palette.text) : null),
+ [spec.label, palette.text],
+ );
+ const labelWidth = spec.wide ? Math.min(spec.width * 0.5, 2.9) : 0.46;
+
+ useEffect(() => {
+ register(index, group.current);
+ return () => register(index, null);
+ }, [index, register]);
+
+ return (
+ {
+ event.stopPropagation();
+ if (pressed.current?.has(index)) return;
+ pressed.current?.add(index);
+ if (spec.char) onType(spec.char);
+ }}
+ onPointerOut={() => pressed.current?.delete(index)}
+ onClick={(event) => {
+ event.stopPropagation();
+ if (spec.char && !pressed.current?.has(index)) onType(spec.char);
+ }}
+ >
+
+
+
+ {labelTexture ? (
+
+
+
+
+ ) : null}
+
+ );
+}
+
+function Keyboard({
+ palette,
+ reduced,
+ onType,
+}: {
+ palette: ScenePalette;
+ reduced: boolean;
+ onType: (text: string) => void;
+}) {
+ const groups = useRef>([]);
+ const pressed = useRef(new Set());
+
+ const register = useCallback((index: number, group: THREE.Group | null) => {
+ groups.current[index] = group;
+ }, []);
+
+ useFrame((_, delta) => {
+ groups.current.forEach((group, index) => {
+ if (!group) return;
+ const isPressed = pressed.current.has(index);
+ group.position.y = THREE.MathUtils.damp(
+ group.position.y,
+ isPressed ? PRESS_Y : REST_Y,
+ 14,
+ delta,
+ );
+ const targetScale = isPressed ? 1.045 : 1;
+ const nextScale = THREE.MathUtils.damp(group.scale.x, targetScale, 12, delta);
+ group.scale.setScalar(nextScale);
+ const mesh = group.children[0] as THREE.Mesh | undefined;
+ const material = mesh?.material as THREE.MeshStandardMaterial | undefined;
+ if (material) {
+ material.emissiveIntensity = THREE.MathUtils.damp(
+ material.emissiveIntensity,
+ isPressed ? (reduced ? 0.45 : 1.1) : 0.02,
+ 10,
+ delta,
+ );
+ }
+ });
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {KEYS.map((spec, index) => (
+
+ ))}
+
+ );
+}
+
+function LaptopLid({
+ texture,
+ palette,
+}: {
+ texture: THREE.Texture;
+ palette: ScenePalette;
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function Mouse({ palette }: { palette: ScenePalette }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function Desk({ palette }: { palette: ScenePalette }) {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+function ContactShadow() {
+ const texture = useMemo(() => makeShadowTexture(), []);
+ useEffect(() => () => texture.dispose(), [texture]);
+
+ return (
+
+
+
+
+ );
+}
+
+const LabStage = memo(function LabStage({
+ palette,
+ reduced,
+ onType,
+}: {
+ palette: ScenePalette;
+ reduced: boolean;
+ onType?: (text: string) => void;
+}) {
+ const world = useRef(null);
+ const pointer = useRef({ x: 0, y: 0 });
+ const typedRef = useRef("");
+ const dirtyRef = useRef(false);
+
+ const screen = useMemo(() => makeScreenTexture(palette, ""), [palette]);
+ useEffect(() => {
+ screen.draw(typedRef.current);
+ return () => screen.texture.dispose();
+ }, [screen]);
+
+ const typeText = useCallback(
+ (text: string) => {
+ typedRef.current = (typedRef.current + text).slice(-46);
+ dirtyRef.current = true;
+ onType?.(typedRef.current);
+ },
+ [onType],
+ );
+
+ useEffect(() => {
+ if (reduced) return;
+ const onMove = (event: PointerEvent) => {
+ pointer.current.x = (event.clientX / window.innerWidth - 0.5) * 2;
+ pointer.current.y = (event.clientY / window.innerHeight - 0.5) * 2;
+ };
+ window.addEventListener("pointermove", onMove, { passive: true });
+ return () => window.removeEventListener("pointermove", onMove);
+ }, [reduced]);
+
+ useFrame((state, delta) => {
+ if (dirtyRef.current) {
+ dirtyRef.current = false;
+ screen.draw(typedRef.current);
+ }
+ const group = world.current;
+ if (!group) return;
+ const targetY = reduced ? 0 : pointer.current.x * 0.032;
+ const targetX = reduced ? 0 : pointer.current.y * 0.016;
+ group.rotation.y = THREE.MathUtils.damp(group.rotation.y, targetY, 3, delta);
+ group.rotation.x = THREE.MathUtils.damp(group.rotation.x, targetX, 3, delta);
+ group.position.y = reduced ? 0 : Math.sin(state.clock.elapsedTime * 0.28) * 0.025;
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+});
+
+export default function HeroScene({
+ onType,
+}: {
+ onType?: (text: string) => void;
+}) {
+ const wrapper = useRef(null);
+ const inView = useInView(wrapper);
+ const palette = useScenePalette();
+ const reduced = useReducedMotion();
+
+ return (
+
+ {
+ gl.setClearAlpha(0);
+ gl.toneMapping = THREE.ACESFilmicToneMapping;
+ gl.toneMappingExposure = 1.05;
+ }}
+ >
+
+
+
+
+
+ );
+}
diff --git a/src/components/landing/HeroVisual.tsx b/src/components/landing/HeroVisual.tsx
index e080d71..fa7eb19 100644
--- a/src/components/landing/HeroVisual.tsx
+++ b/src/components/landing/HeroVisual.tsx
@@ -1,20 +1,12 @@
"use client";
-import { useEffect, useRef } from "react";
+import dynamic from "next/dynamic";
+import { useEffect, useRef, useState } from "react";
+import { HERO_ASCII_LOGO, HERO_PALETTE, HERO_ROWS } from "./hero-data";
import { VERSION } from "./links";
+import { useWebGL } from "./three/scene";
-const ASCII_LOGO = ["██ ██", " ██ ██ ", " ███ ", " ██ ██ ", "██ ██"];
-
-const ROWS = [
- { key: "os", value: "Arch Linux x86_64" },
- { key: "wm", value: "Hyprland" },
- { key: "cpu", value: "Ryzen 9 7950X · 16" },
- { key: "gpu", value: "RTX 4090" },
- { key: "memory", value: "8.25 / 31.20 GiB" },
- { key: "uptime", value: "2h 15m" },
-];
-
-const PALETTE = ["#f07178", "#f78c6c", "#ffcb6b", "#78dba9", "#78dce8", "#82aaff", "#b388ff", "#dea584"];
+const HeroScene = dynamic(() => import("./HeroScene"), { ssr: false });
const CHIPS = [
{ className: "xf-chip--tl", label: "JSONC", meta: "config" },
@@ -25,10 +17,12 @@ const CHIPS = [
export default function HeroVisual() {
const iso = useRef(null);
+ const webgl = useWebGL();
+ const [typed, setTyped] = useState("");
useEffect(() => {
const el = iso.current;
- if (!el) return;
+ if (!el || webgl !== false) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
const fine = window.matchMedia("(pointer: fine)");
@@ -58,78 +52,81 @@ export default function HeroVisual() {
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerleave", onLeave);
};
- }, []);
+ }, [webgl]);
return (
+ <>
-
+ {webgl !== true ?
: null}
+ {webgl === true ?
: null}
+
+ {webgl === false ? (
+
+
+
+
+
+
+ xfetch — zsh
+ v{VERSION}
+
-
-
-
{ASCII_LOGO.join("\n")}
-
-
-
-
- {ROWS.slice(0, 4).map((row) => (
-
- {row.key} {row.value}
-
- ))}
-
-
-
-
-
-
-
-
- xfetch — zsh
- v{VERSION}
-
-
-
-
{ASCII_LOGO.join("\n")}
-
- {ROWS.map((row) => (
-
- {row.key}
- {row.value}
-
- ))}
-
- {PALETTE.map((color) => (
-
+
+
{HERO_ASCII_LOGO.join("\n")}
+
+ {HERO_ROWS.map((row) => (
+
+ {row.key}
+ {row.value}
+
))}
-
+
+ {HERO_PALETTE.map((color) => (
+
+ ))}
+
+
-
-
-
$
-
xfetch
-
+
+ $
+ xfetch
+
+
-
- {CHIPS.map((chip) => (
-
- {chip.label} {chip.meta}
-
- ))}
-
+ {CHIPS.map((chip) => (
+
+ {chip.label} {chip.meta}
+
+ ))}
+
+ ) : null}
+ {webgl === true ? (
+
+ Hover or tap the keys — every key types into the terminal.
+
+ ) : null}
+
xfetch renders system information from a layered pipeline: configuration, probes,
- renderer and terminal output.
+ renderer and terminal output. The 3D keyboard is decorative; the same information is
+ available in the page copy.
+ {webgl === true ? (
+
+ {typed ? `Terminal input: ${typed}` : ""}
+
+ ) : null}
+ >
);
}
diff --git a/src/components/landing/PluginSandbox.tsx b/src/components/landing/PluginSandbox.tsx
index 0a5d8b1..ddf797f 100644
--- a/src/components/landing/PluginSandbox.tsx
+++ b/src/components/landing/PluginSandbox.tsx
@@ -1,3 +1,11 @@
+"use client";
+
+import dynamic from "next/dynamic";
+import PluginSandboxStatic from "./PluginSandboxStatic";
+import { useWebGL } from "./three/scene";
+
+const PluginSandboxScene = dynamic(() => import("./PluginSandboxScene"), { ssr: false });
+
const CAPABILITIES = [
"Deny-by-default capabilities",
"Time, memory and output limits",
@@ -6,59 +14,23 @@ const CAPABILITIES = [
];
export default function PluginSandbox() {
+ const webgl = useWebGL();
+ const live = webgl === true;
+
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- WASM
-
-
- sandbox
-
-
- deny-by-default
-
-
-
+
+ {live ?
:
}
+ {live ? (
+
+
+ WASM
+ sandbox
+
+
deny-by-default
+
+ ) : null}
+
diff --git a/src/components/landing/PluginSandboxScene.tsx b/src/components/landing/PluginSandboxScene.tsx
new file mode 100644
index 0000000..f2b7230
--- /dev/null
+++ b/src/components/landing/PluginSandboxScene.tsx
@@ -0,0 +1,253 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { Canvas, useFrame, useThree } from "@react-three/fiber";
+import { Edges, RoundedBox } from "@react-three/drei";
+import * as THREE from "three";
+import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
+import {
+ useInView,
+ useReducedMotion,
+ useScenePalette,
+ type ScenePalette,
+} from "./three/scene";
+
+const RING_TILTS: Array<[number, number, number]> = [
+ [0.55, 0.2, 0.35],
+ [1.25, -0.35, -0.55],
+ [0.25, 0.85, 0.2],
+];
+
+function StudioEnvironment() {
+ const gl = useThree((state) => state.gl);
+ const scene = useThree((state) => state.scene);
+
+ useEffect(() => {
+ const pmrem = new THREE.PMREMGenerator(gl);
+ const texture = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
+ // eslint-disable-next-line react-hooks/immutability -- three.js scene graph is mutable by design
+ scene.environment = texture;
+ scene.environmentIntensity = 0.38;
+ return () => {
+ scene.environment = null;
+ texture.dispose();
+ pmrem.dispose();
+ };
+ }, [gl, scene]);
+
+ return null;
+}
+
+function Rings({ palette, reduced }: { palette: ScenePalette; reduced: boolean }) {
+ const groups = useRef>([]);
+ const colors = [palette.accent, palette.cyan, palette.violet];
+
+ useFrame((state, delta) => {
+ if (reduced) return;
+ groups.current.forEach((group, index) => {
+ if (!group) return;
+ group.rotation.z += delta * (0.18 + index * 0.06);
+ group.rotation.x += delta * (0.03 + index * 0.015);
+ });
+ });
+
+ return (
+ <>
+ {RING_TILTS.map((tilt, index) => {
+ const color = colors[index % colors.length];
+ const radius = 1.4 + index * 0.16;
+ return (
+ {
+ groups.current[index] = node;
+ }}
+ rotation={tilt}
+ >
+
+
+
+
+
+
+
+
+
+ );
+ })}
+ >
+ );
+}
+
+function Sandbox({ palette, reduced }: { palette: ScenePalette; reduced: boolean }) {
+ const core = useRef(null);
+
+ useFrame((state, delta) => {
+ if (!reduced && core.current) {
+ core.current.rotation.y += delta * 0.35;
+ core.current.rotation.x += delta * 0.12;
+ const pulse = 1 + Math.sin(state.clock.elapsedTime * 1.4) * 0.06;
+ core.current.scale.setScalar(pulse);
+ }
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function World({
+ palette,
+ reduced,
+}: {
+ palette: ScenePalette;
+ reduced: boolean;
+}) {
+ const world = useRef(null);
+ const pointer = useRef({ x: 0, y: 0 });
+
+ useEffect(() => {
+ if (reduced) return;
+ const onMove = (event: PointerEvent) => {
+ pointer.current.x = (event.clientX / window.innerWidth - 0.5) * 2;
+ pointer.current.y = (event.clientY / window.innerHeight - 0.5) * 2;
+ };
+ window.addEventListener("pointermove", onMove, { passive: true });
+ return () => window.removeEventListener("pointermove", onMove);
+ }, [reduced]);
+
+ useFrame((state, delta) => {
+ const node = world.current;
+ if (!node) return;
+ const idle = reduced ? 0 : Math.sin(state.clock.elapsedTime * 0.32) * 0.03;
+ const targetY = (reduced ? 0 : pointer.current.x * 0.18) + idle;
+ const targetX = reduced ? 0 : pointer.current.y * 0.1;
+ node.rotation.y = THREE.MathUtils.damp(node.rotation.y, targetY, 3, delta);
+ node.rotation.x = THREE.MathUtils.damp(node.rotation.x, targetX, 3, delta);
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function PluginSandboxScene() {
+ const wrapper = useRef(null);
+ const inView = useInView(wrapper);
+ const palette = useScenePalette();
+ const reduced = useReducedMotion();
+
+ return (
+
+ {
+ gl.setClearAlpha(0);
+ gl.toneMapping = THREE.ACESFilmicToneMapping;
+ gl.toneMappingExposure = 1.05;
+ }}
+ >
+
+
+
+ );
+}
diff --git a/src/components/landing/PluginSandboxStatic.tsx b/src/components/landing/PluginSandboxStatic.tsx
new file mode 100644
index 0000000..7316148
--- /dev/null
+++ b/src/components/landing/PluginSandboxStatic.tsx
@@ -0,0 +1,55 @@
+export default function PluginSandboxStatic() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ WASM
+
+
+ sandbox
+
+
+ deny-by-default
+
+
+
+ );
+}
diff --git a/src/components/landing/SystemMap.tsx b/src/components/landing/SystemMap.tsx
index 6f6c48f..83daaf1 100644
--- a/src/components/landing/SystemMap.tsx
+++ b/src/components/landing/SystemMap.tsx
@@ -1,7 +1,12 @@
"use client";
+import dynamic from "next/dynamic";
import { useState } from "react";
import SectionHeader from "./SectionHeader";
+import SystemMapStatic from "./SystemMapStatic";
+import { useWebGL } from "./three/scene";
+
+const SystemMapScene = dynamic(() => import("./SystemMapScene"), { ssr: false });
type Layer = {
id: string;
@@ -34,7 +39,7 @@ const LAYERS: Layer[] = [
kicker: "Composition layer",
detail:
"The layout engine arranges modules and applies colors, Nerd Font icons, ASCII or image logos, and logo animations. Every layout is driven by the same resolved config.",
- points: ["9 built-in layouts", "Icons + colors", "Logo animation"],
+ points: ["16 built-in layouts", "Icons + colors", "Logo animation"],
},
{
id: "terminal",
@@ -64,6 +69,7 @@ const HOTSPOTS = [
export default function SystemMap() {
const [active, setActive] = useState(2);
+ const webgl = useWebGL();
const layer = LAYERS[active];
return (
@@ -79,99 +85,25 @@ export default function SystemMap() {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {[
- { y: 120, num: "01", name: "Config", sub: "config.jsonc + theme merge" },
- { y: 240, num: "02", name: "Probes", sub: "parallel module collection" },
- { y: 360, num: "03", name: "Renderer", sub: "layout · icons · colors" },
- { y: 480, num: "04", name: "Terminal", sub: "ANSI stdout + live daemon" },
- ].map((row) => (
-
-
-
-
-
- {row.num}
-
-
- {row.name}
-
-
- {row.sub}
-
-
- ))}
-
-
-
-
-
- WASM
-
-
- sandbox
-
-
-
- plugins
-
-
-
- effects
-
-
-
- extensions
-
-
-
-
- {LAYERS.map((item, index) => (
-
setActive(index)}
- onFocus={() => setActive(index)}
- onClick={() => setActive(index)}
- >
- {String(index + 1).padStart(2, "0")}
-
- ))}
+ {webgl === true ? (
+ <>
+
+
+ {String(active + 1).padStart(2, "0")}
+ {layer.title}
+
+
+
+ data flow
+
+
+ wasm sandbox
+
+
+ >
+ ) : (
+
+ )}
@@ -196,8 +128,8 @@ export default function SystemMap() {
- Hover, focus or tap a layer. The blue links are data flow; the violet plane is the
- sandbox boundary.
+ Select a slot to inspect a layer. The blue path is data flow; the violet
+ plane is the sandbox boundary.
diff --git a/src/components/landing/SystemMapScene.tsx b/src/components/landing/SystemMapScene.tsx
new file mode 100644
index 0000000..526a38a
--- /dev/null
+++ b/src/components/landing/SystemMapScene.tsx
@@ -0,0 +1,510 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import { Canvas, useFrame, useThree } from "@react-three/fiber";
+import { Edges, Grid, RoundedBox } from "@react-three/drei";
+import * as THREE from "three";
+import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
+import {
+ useInView,
+ useReducedMotion,
+ useScenePalette,
+ type ScenePalette,
+} from "./three/scene";
+
+type Props = {
+ active: number;
+ onActive: (index: number) => void;
+};
+
+const MONO = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
+const SLAB_COUNT = 4;
+const SLAB_Y = [1.38, 0.68, -0.02, -0.72];
+const SLAB_STEP = 0.7;
+const SLAB_LABELS = [
+ { num: "01", name: "CONFIG" },
+ { num: "02", name: "PROBES" },
+ { num: "03", name: "RENDERER" },
+ { num: "04", name: "TERMINAL" },
+];
+
+const labelCache = new Map
();
+
+function getSlabLabel(
+ num: string,
+ name: string,
+ numColor: string,
+ textColor: string,
+): THREE.CanvasTexture {
+ const key = `${numColor}:${textColor}:${num}:${name}`;
+ const cached = labelCache.get(key);
+ if (cached) return cached;
+
+ const canvas = document.createElement("canvas");
+ canvas.width = 512;
+ canvas.height = 128;
+ const ctx = canvas.getContext("2d");
+ if (ctx) {
+ ctx.clearRect(0, 0, 512, 128);
+ ctx.textBaseline = "middle";
+ ctx.font = `700 52px ${MONO}`;
+ ctx.fillStyle = numColor;
+ ctx.fillText(num, 18, 66);
+ ctx.font = `600 46px ${MONO}`;
+ ctx.fillStyle = textColor;
+ ctx.fillText(name, 96, 66);
+ ctx.fillStyle = numColor;
+ ctx.fillRect(18, 100, 470, 2);
+ }
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ texture.minFilter = THREE.LinearFilter;
+ texture.magFilter = THREE.LinearFilter;
+ labelCache.set(key, texture);
+ return texture;
+}
+
+function StudioEnvironment() {
+ const gl = useThree((state) => state.gl);
+ const scene = useThree((state) => state.scene);
+
+ useEffect(() => {
+ const pmrem = new THREE.PMREMGenerator(gl);
+ const texture = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
+ // eslint-disable-next-line react-hooks/immutability -- three.js scene graph is mutable by design
+ scene.environment = texture;
+ scene.environmentIntensity = 0.35;
+ return () => {
+ scene.environment = null;
+ texture.dispose();
+ pmrem.dispose();
+ };
+ }, [gl, scene]);
+
+ return null;
+}
+
+function Aim() {
+ const camera = useThree((state) => state.camera);
+ useEffect(() => {
+ camera.lookAt(0, 0.18, 0);
+ if (camera instanceof THREE.PerspectiveCamera) {
+ camera.updateProjectionMatrix();
+ }
+ }, [camera]);
+ return null;
+}
+
+function Slab({
+ index,
+ y,
+ active,
+ onActive,
+ palette,
+}: {
+ index: number;
+ y: number;
+ active: boolean;
+ onActive: (index: number) => void;
+ palette: ScenePalette;
+}) {
+ const group = useRef(null);
+ const scale = useRef(1);
+ const [hovered, setHovered] = useState(false);
+ const label = SLAB_LABELS[index];
+
+ const labelTexture = useMemo(
+ () =>
+ getSlabLabel(
+ label.num,
+ label.name,
+ active ? palette.accent : palette.muted,
+ palette.text,
+ ),
+ [label, active, palette.accent, palette.muted, palette.text],
+ );
+
+ useEffect(() => {
+ if (!hovered) return;
+ const previous = document.body.style.cursor;
+ document.body.style.cursor = "pointer";
+ return () => {
+ document.body.style.cursor = previous;
+ };
+ }, [hovered]);
+
+ useFrame((state, delta) => {
+ const node = group.current;
+ if (!node) return;
+ const target = active ? 1.045 : hovered ? 1.02 : 1;
+ scale.current = THREE.MathUtils.damp(scale.current, target, 9, delta);
+ node.scale.setScalar(scale.current);
+ node.position.y = THREE.MathUtils.damp(node.position.y, y + (active ? 0.06 : 0), 9, delta);
+
+ const mesh = node.children[0] as THREE.Mesh | undefined;
+ const material = mesh?.material as THREE.MeshStandardMaterial | undefined;
+ if (material) {
+ const pulse = active ? 0.032 + Math.sin(state.clock.elapsedTime * 1.8) * 0.012 : 0;
+ material.emissiveIntensity = THREE.MathUtils.damp(
+ material.emissiveIntensity,
+ active ? pulse : hovered ? 0.05 : 0.02,
+ 8,
+ delta,
+ );
+ }
+ });
+
+ const tint = active ? palette.accent : palette.cyan;
+
+ return (
+ {
+ event.stopPropagation();
+ onActive(index);
+ }}
+ onPointerOver={(event) => {
+ event.stopPropagation();
+ setHovered(true);
+ }}
+ onPointerOut={() => setHovered(false)}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {active ? (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : null}
+
+ );
+}
+
+function DataPackets({ palette, reduced }: { palette: ScenePalette; reduced: boolean }) {
+ const packets = useRef>([]);
+
+ useFrame((state) => {
+ if (reduced) return;
+ const time = state.clock.elapsedTime * 0.28;
+ packets.current.forEach((mesh, index) => {
+ if (!mesh) return;
+ const progress = (time + index * 0.34) % 1;
+ mesh.position.y = 1.38 - progress * 2.3;
+ mesh.position.x = 0.2;
+ mesh.position.z = 0.55;
+ const scale = 0.8 + Math.sin(progress * Math.PI) * 0.7;
+ mesh.scale.setScalar(scale);
+ });
+ });
+
+ return (
+ <>
+ {[0, 1, 2].map((index) => (
+ {
+ packets.current[index] = node;
+ }}
+ position={[0.2, 1.38 - index * 0.7, 0.55]}
+ >
+
+
+
+ ))}
+ >
+ );
+}
+
+function Connectors({ palette }: { palette: ScenePalette }) {
+ return (
+
+ {Array.from({ length: SLAB_COUNT - 1 }, (_, index) => {
+ const top = SLAB_Y[index];
+ const bottom = SLAB_Y[index + 1];
+ const mid = (top + bottom) / 2;
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+ );
+}
+
+function SandboxPlane({ palette }: { palette: ScenePalette }) {
+ const nodes: Array<[number, number, string]> = [
+ [0.95, 0.55, "plugin"],
+ [0.35, 0.5, "effect"],
+ [-0.25, 0.45, "extension"],
+ ];
+
+ return (
+
+
+
+
+
+
+
+
+
+ {nodes.map(([y, z, key]) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+}
+
+function BasePlate({ palette }: { palette: ScenePalette }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function World({
+ active,
+ onActive,
+ palette,
+ reduced,
+}: {
+ active: number;
+ onActive: (index: number) => void;
+ palette: ScenePalette;
+ reduced: boolean;
+}) {
+ const world = useRef(null);
+ const pointer = useRef({ x: 0, y: 0 });
+
+ useEffect(() => {
+ if (reduced) return;
+ const onMove = (event: PointerEvent) => {
+ pointer.current.x = (event.clientX / window.innerWidth - 0.5) * 2;
+ pointer.current.y = (event.clientY / window.innerHeight - 0.5) * 2;
+ };
+ window.addEventListener("pointermove", onMove, { passive: true });
+ return () => window.removeEventListener("pointermove", onMove);
+ }, [reduced]);
+
+ useFrame((state, delta) => {
+ const node = world.current;
+ if (!node) return;
+ const idle = reduced ? 0 : Math.sin(state.clock.elapsedTime * 0.24) * 0.035;
+ const targetY = (reduced ? 0 : pointer.current.x * 0.1) + idle;
+ const targetX = reduced ? 0 : pointer.current.y * 0.05;
+ node.rotation.y = THREE.MathUtils.damp(node.rotation.y, targetY, 3, delta);
+ node.rotation.x = THREE.MathUtils.damp(node.rotation.x, targetX, 3, delta);
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {SLAB_Y.map((y, index) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function SystemMapScene({ active, onActive }: Props) {
+ const wrapper = useRef(null);
+ const inView = useInView(wrapper);
+ const palette = useScenePalette();
+ const reduced = useReducedMotion();
+
+ return (
+
+
{
+ gl.setClearAlpha(0);
+ gl.toneMapping = THREE.ACESFilmicToneMapping;
+ gl.toneMappingExposure = 1.05;
+ }}
+ >
+
+
+
+
+ );
+}
diff --git a/src/components/landing/SystemMapStatic.tsx b/src/components/landing/SystemMapStatic.tsx
new file mode 100644
index 0000000..7bac13b
--- /dev/null
+++ b/src/components/landing/SystemMapStatic.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+type Props = {
+ active: number;
+ onActive: (index: number) => void;
+ hotspots: Array<{ left: string; top: string; width: string; height: string }>;
+};
+
+const LAYERS = ["Config", "Probes", "Renderer", "Terminal", "WASM sandbox"];
+
+/**
+ * Static SVG fallback used when WebGL is unavailable. It mirrors the 3D scene:
+ * four stacked layers, the sandbox plane and the same hotspots.
+ */
+export default function SystemMapStatic({ active, onActive, hotspots }: Props) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {[
+ { y: 120, num: "01", name: "Config", sub: "config.jsonc + theme merge" },
+ { y: 240, num: "02", name: "Probes", sub: "parallel module collection" },
+ { y: 360, num: "03", name: "Renderer", sub: "layout · icons · colors" },
+ { y: 480, num: "04", name: "Terminal", sub: "ANSI stdout + live daemon" },
+ ].map((row, index) => (
+
+
+
+
+
+ {row.num}
+
+
+ {row.name}
+
+
+ {row.sub}
+
+
+ ))}
+
+
+
+
+
+ WASM
+
+
+ sandbox
+
+
+
+ plugins
+
+
+
+ effects
+
+
+
+ extensions
+
+
+
+
+ {hotspots.map((spot, index) => (
+
onActive(index)}
+ onFocus={() => onActive(index)}
+ onClick={() => onActive(index)}
+ >
+ {String(index + 1).padStart(2, "0")}
+
+ ))}
+
+ );
+}
diff --git a/src/components/landing/data.ts b/src/components/landing/data.ts
index 2b5d081..081ddb1 100644
--- a/src/components/landing/data.ts
+++ b/src/components/landing/data.ts
@@ -22,7 +22,7 @@ export const FEATURES: Feature[] = [
key: "layouts",
icon: "layouts-icon",
title: "Multiple layouts",
- desc: "Classic side-by-side, Pac-Man, tree, section, side-block, box, lines, dots, and bottom-line.",
+ desc: "Classic side-by-side, right, Pac-Man, tree, section, side-block, box, lines, dots, and bottom-line.",
},
{
key: "jsonc",
@@ -162,8 +162,10 @@ export type InstallContent = Record;
export const INSTALL_TABS: InstallTab[] = [
{ id: "linux", label: "Linux / macOS" },
{ id: "windows", label: "Windows" },
+ { id: "arch", label: "Arch Linux" },
+ { id: "xlinux", label: "X Linux" },
+ { id: "cargo", label: "Cargo" },
{ id: "source", label: "Build from source" },
- { id: "arch", label: "Arch/X Linux" },
];
export const INSTALL_CONTENT: InstallContent = {
@@ -181,7 +183,16 @@ export const INSTALL_CONTENT: InstallContent = {
},
windows: {
lines: [
- { text: "# PowerShell one-liner", comment: true },
+ { text: "# Official package (WinGet)", comment: true },
+ { text: "winget install xfetch", comment: false },
+ { text: "", comment: false },
+ { text: "# Prebuilt binary (no Rust toolchain)", comment: true },
+ {
+ text: "irm https://raw.githubusercontent.com/xfetch-cli/xfetch/main/install-prebuilt.ps1 | iex",
+ comment: false,
+ },
+ { text: "", comment: false },
+ { text: "# From source (requires Rust)", comment: true },
{
text: "irm https://raw.githubusercontent.com/xfetch-cli/xfetch/main/install.ps1 | iex",
comment: false,
@@ -205,13 +216,31 @@ export const INSTALL_CONTENT: InstallContent = {
},
arch: {
lines: [
- { text: "# Using the PKGBUILD in the repo", comment: true },
- { text: "git clone https://github.com/xfetch-cli/xfetch.git", comment: false },
- { text: "cd xfetch", comment: false },
+ { text: "# AUR (precompiled binary)", comment: true },
+ { text: "yay -S xfetch-core-bin", comment: false },
+ { text: "", comment: false },
+ { text: "# AUR (build from source)", comment: true },
+ { text: "yay -S xfetch-git", comment: false },
+ { text: "", comment: false },
+ { text: "# Without an AUR helper", comment: true },
+ { text: "git clone https://aur.archlinux.org/xfetch-core-bin.git", comment: false },
+ { text: "cd xfetch-core-bin", comment: false },
{ text: "makepkg -si", comment: false },
+ ],
+ },
+ xlinux: {
+ lines: [
+ { text: "# Official X Linux package", comment: true },
+ { text: "xpm-aur install xfetch-core-bin", comment: false },
+ ],
+ },
+ cargo: {
+ lines: [
+ { text: "# From crates.io", comment: true },
+ { text: "cargo install xfetch-cli", comment: false },
{ text: "", comment: false },
- { text: "# Installed to /usr/bin/xfetch", comment: true },
- { text: "pacman -R xfetch-git # to uninstall", comment: false },
+ { text: "# Update an existing install", comment: true },
+ { text: "cargo install xfetch-cli --force", comment: false },
],
},
};
@@ -239,14 +268,20 @@ export const ALL_MODULES = [
export const LAYOUT_OPTIONS = [
{ value: "null", label: "Default (classic)" },
+ { value: "right", label: "Right (logo on the right)" },
{ value: "pacman", label: "Pac-Man" },
{ value: "box", label: "Box" },
{ value: "section", label: "Section" },
+ { value: "section-box", label: "Section Box" },
{ value: "tree", label: "Tree" },
{ value: "side-block", label: "Side Block" },
{ value: "line", label: "Line" },
{ value: "dots", label: "Dots" },
{ value: "bottom_line", label: "Bottom Line" },
+ { value: "compact", label: "Compact" },
+ { value: "minimal", label: "Minimal" },
+ { value: "horizontal", label: "Horizontal" },
+ { value: "bottom", label: "Bottom" },
];
export const DEFAULT_MODULES = [
@@ -329,18 +364,23 @@ export const PHASES: Phase[] = [
{
title: "Phase 11 — Testing & QA",
desc: "Unit/integration tests, clippy, rustfmt, platform-specific tests, cross-platform suite, coverage.",
- status: "partial",
+ status: "done",
},
{
title: "Phase 12 — Advanced features",
- desc: "Daemon mode, custom scripting, conditional modules, theme variables, telemetry (opt-in); pending: real-time stats in daemon mode, config hot-reload, animations, accessibility.",
- status: "partial",
+ desc: "Daemon mode, live stats, config hot-reload, conditional modules, theme variables, logo animations and accessibility.",
+ status: "done",
},
{
title: "Phase 13 — Marketing",
desc: "User manual, video tutorials, project website, blog posts, comparison guide, community channel.",
status: "partial",
},
+ {
+ title: "Phase 14 — CLI & automation",
+ desc: "Machine-readable JSON, runtime overrides, doctor, config backup/restore, shell completions, man page and command addons.",
+ status: "done",
+ },
];
export const INSTALL_AFTER = "$ xfetch\n$ xfetch --version\n$ xfetch --gen-config";
diff --git a/src/components/landing/hero-data.ts b/src/components/landing/hero-data.ts
new file mode 100644
index 0000000..63a5cc9
--- /dev/null
+++ b/src/components/landing/hero-data.ts
@@ -0,0 +1,21 @@
+export const HERO_ASCII_LOGO = ["██ ██", " ██ ██ ", " ███ ", " ██ ██ ", "██ ██"];
+
+export const HERO_ROWS = [
+ { key: "os", value: "X Linux x86_64" },
+ { key: "wm", value: "Hyprland" },
+ { key: "cpu", value: "Ryzen 9 7950X · 16" },
+ { key: "gpu", value: "RTX 4090" },
+ { key: "memory", value: "8.25 / 31.20 GiB" },
+ { key: "uptime", value: "2h 15m" },
+];
+
+export const HERO_PALETTE = [
+ "#f07178",
+ "#f78c6c",
+ "#ffcb6b",
+ "#78dba9",
+ "#78dce8",
+ "#82aaff",
+ "#b388ff",
+ "#dea584",
+];
diff --git a/src/components/landing/links.ts b/src/components/landing/links.ts
index a68fdc5..af7f089 100644
--- a/src/components/landing/links.ts
+++ b/src/components/landing/links.ts
@@ -1,2 +1,2 @@
export const REPO_URL = "https://github.com/xfetch-cli/xfetch";
-export const VERSION = "1.0.1";
+export const VERSION = "1.0.4";
diff --git a/src/components/landing/three/scene.ts b/src/components/landing/three/scene.ts
new file mode 100644
index 0000000..33d99d1
--- /dev/null
+++ b/src/components/landing/three/scene.ts
@@ -0,0 +1,171 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+export type ScenePalette = {
+ accent: string;
+ accentStrong: string;
+ cyan: string;
+ violet: string;
+ success: string;
+ surface: string;
+ surface2: string;
+ surface3: string;
+ text: string;
+ muted: string;
+ line: string;
+ bg: string;
+ dark: boolean;
+};
+
+const FALLBACK: ScenePalette = {
+ accent: "#ffb27a",
+ accentStrong: "#ffc79e",
+ cyan: "#7dd3fc",
+ violet: "#a78bfa",
+ success: "#7ee2b8",
+ surface: "#0d111a",
+ surface2: "#121826",
+ surface3: "#182033",
+ text: "#e8ecf5",
+ muted: "#98a2b8",
+ line: "rgba(148, 163, 184, 0.22)",
+ bg: "#07090f",
+ dark: true,
+};
+
+function normalizeColor(value: string, fallback: string): string {
+ const raw = value || fallback;
+ const hex8 = raw.match(/^#([0-9a-f]{6})[0-9a-f]{2}$/i);
+ if (hex8) return `#${hex8[1]}`;
+ const rgb = raw.match(/rgba?\(([^)]+)\)/i);
+ if (rgb) {
+ const [r, g, b] = rgb[1].split(",").map((part) => part.trim());
+ return `rgb(${r}, ${g}, ${b})`;
+ }
+ return raw;
+}
+
+function readPalette(): ScenePalette {
+ if (typeof window === "undefined") return FALLBACK;
+ const root = document.documentElement;
+ const style = getComputedStyle(root);
+ const value = (name: string, fallback: string) => {
+ const v = style.getPropertyValue(name).trim();
+ return v || fallback;
+ };
+ return {
+ accent: value("--xf-accent", FALLBACK.accent),
+ accentStrong: value("--xf-accent-strong", FALLBACK.accentStrong),
+ cyan: value("--xf-cyan", FALLBACK.cyan),
+ violet: value("--xf-violet", FALLBACK.violet),
+ success: value("--xf-success", FALLBACK.success),
+ surface: value("--xf-surface", FALLBACK.surface),
+ surface2: value("--xf-surface-2", FALLBACK.surface2),
+ surface3: value("--xf-surface-3", FALLBACK.surface3),
+ text: value("--xf-text", FALLBACK.text),
+ muted: value("--xf-muted", FALLBACK.muted),
+ line: normalizeColor(value("--xf-line-strong", FALLBACK.line), FALLBACK.line),
+ bg: value("--xf-bg", FALLBACK.bg),
+ dark: root.getAttribute("data-theme") === "dark",
+ };
+}
+
+/**
+ * Reads the xfetch landing tokens from the document root and re-reads them
+ * when the theme toggle rewrites the `data-theme` attribute. Keeps WebGL
+ * materials in sync with the CSS palette without duplicating values.
+ */
+export function useScenePalette(): ScenePalette {
+ const [palette, setPalette] = useState(readPalette);
+
+ useEffect(() => {
+ const update = () => setPalette(readPalette());
+ update();
+
+ const observer = new MutationObserver(update);
+ observer.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["data-theme", "class", "style"],
+ });
+ return () => observer.disconnect();
+ }, []);
+
+ return palette;
+}
+
+/** Deterministic PRNG so particle layouts are stable across renders. */
+export function seededRandom(seed: number): () => number {
+ let state = seed >>> 0;
+ return () => {
+ state = (state * 1664525 + 1013904223) >>> 0;
+ return state / 4294967296;
+ };
+}
+
+export function useReducedMotion(): boolean {
+ const [reduced, setReduced] = useState(false);
+
+ useEffect(() => {
+ const query = window.matchMedia("(prefers-reduced-motion: reduce)");
+ const update = () => setReduced(query.matches);
+ update();
+ query.addEventListener?.("change", update);
+ return () => query.removeEventListener?.("change", update);
+ }, []);
+
+ return reduced;
+}
+
+/**
+ * `null` while the capability probe has not run yet so server markup and the
+ * first client paint stay identical.
+ */
+export function useWebGL(): boolean | null {
+ const [supported, setSupported] = useState(null);
+
+ useEffect(() => {
+ let ok = false;
+ try {
+ const canvas = document.createElement("canvas");
+ ok = Boolean(
+ window.WebGLRenderingContext &&
+ (canvas.getContext("webgl2") || canvas.getContext("webgl")),
+ );
+ } catch {
+ ok = false;
+ }
+ // Capability probe: runs once after mount so SSR and the first paint agree.
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setSupported(ok);
+ }, []);
+
+ return supported;
+}
+
+export function useInView(
+ ref: React.RefObject,
+ rootMargin = "160px",
+): boolean {
+ const [inView, setInView] = useState(true);
+
+ useEffect(() => {
+ const element = ref.current;
+ if (!element || typeof IntersectionObserver === "undefined") {
+ setInView(true);
+ return;
+ }
+ const observer = new IntersectionObserver(
+ (entries) => setInView(entries.some((entry) => entry.isIntersecting)),
+ { rootMargin },
+ );
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, [ref, rootMargin]);
+
+ return inView;
+}
+
+export function toThreeColor(value: string, fallback = "#ffffff"): string {
+ return value && value !== "rgba(0, 0, 0, 0)" ? value : fallback;
+}
diff --git a/src/lib/docs.ts b/src/lib/docs.ts
index 3185071..55a9eb6 100644
--- a/src/lib/docs.ts
+++ b/src/lib/docs.ts
@@ -77,6 +77,7 @@ const GROUP_BY_SLUG: Record = {
"getting-started": "start",
"gen-config": "start",
configuration: "reference",
+ cli: "reference",
modules: "reference",
layouts: "reference",
"custom-x": "reference",