- Monte Carasso, CH • Permit C
- /
- Bare-Metal & Linux Systems
+
+
Monte Carasso, Ticino. Six years at the bench, five years on Linux, four servers in production. Looking for datacenter operations, hardware integration or Linux systems support, on-site or hybrid.
+
+
+
I build infrastructure from the solder joint up. Component-level diagnostics, ZFS storage, zero-trust networks, and the occasional driver written from a packet dump.
-
-
Bridging bare-metal hardware craft with low-level Linux systems.
-
-
- Systems technician with 6+ years assembling, repairing, and bench-testing desktop, laptop, and server hardware, backed by 5+ years administering Linux environments. Taking physical infrastructure from component-level triage to production reliability.
-
Also on the bench: Rust, C, C++, Python and SQL for diagnostics, and a self-directed track on local LLM deployment and quantisation on bare-metal hardware with llama.cpp and Claude Code.
+
+
+
+
+
+
Education and languages
+
+
{C.education.map(e =>
{e.title}
{e.text}
)}
+
+
+
Certifications
{C.certifications}
+
Languages
{C.languages.map(([l, lv]) => {l} {lv})}
+
Status
Italian national, Swiss Permit C, born 2001.
+
+
+
+
+
Need someone who can rack it, image it, and keep it up?
+
-
-
+
>
)
}
diff --git a/src/ContourBackground.tsx b/src/ContourBackground.tsx
deleted file mode 100644
index f356327..0000000
--- a/src/ContourBackground.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import { useEffect, useRef } from 'react'
-import * as THREE from 'three'
-
-const INK = 0x121212
-
-export default function ContourBackground({ className }: { className?: string }) {
- const mountRef = useRef(null)
-
- useEffect(() => {
- const mount = mountRef.current
- if (!mount) return undefined
-
- const width = mount.clientWidth || 800
- const height = mount.clientHeight || 600
-
- const scene = new THREE.Scene()
- const frustum = 9
- let aspect = width / height
- const camera = new THREE.OrthographicCamera(-(frustum * aspect) / 2, (frustum * aspect) / 2, frustum / 2, -frustum / 2, 0.1, 100)
- camera.position.set(6, 4, 10)
- camera.lookAt(0, 0, 0)
-
- const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
- renderer.setSize(width, height)
- renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.75))
- mount.appendChild(renderer.domElement)
-
- const group = new THREE.Group()
- const geo = new THREE.PlaneGeometry(8, 8, 22, 22)
- geo.rotateX(-Math.PI / 2)
- const mat = new THREE.MeshBasicMaterial({ color: INK, wireframe: true, transparent: true, opacity: 0.16 })
- group.add(new THREE.Mesh(geo, mat))
- group.position.set(-0.9, 1.6, 0)
- scene.add(group)
-
- const posAttr = geo.attributes.position
- const base = Float32Array.from(posAttr.array)
-
- // pointermove on window: mount itself is pointer-events:none so it never sees events directly
- const mouse = { x: 0, y: 0 }
- const onMove = (e: PointerEvent) => {
- const rect = mount.getBoundingClientRect()
- mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1
- mouse.y = ((e.clientY - rect.top) / rect.height) * 2 - 1
- }
- window.addEventListener('pointermove', onMove)
-
- const ro = new ResizeObserver(() => {
- const w = mount.clientWidth
- const h = mount.clientHeight
- if (!w || !h) return
- aspect = w / h
- camera.left = -(frustum * aspect) / 2
- camera.right = (frustum * aspect) / 2
- camera.top = frustum / 2
- camera.bottom = -frustum / 2
- camera.updateProjectionMatrix()
- renderer.setSize(w, h)
- })
- ro.observe(mount)
-
- let raf = 0
- const timer = new THREE.Timer()
- const tick = () => {
- timer.update()
- const t = timer.getElapsed()
- group.rotation.y = mouse.x * 0.15
- group.rotation.x = mouse.y * 0.06
- for (let i = 0; i < posAttr.count; i++) {
- const x = base[i * 3]
- const z = base[i * 3 + 2]
- posAttr.setY(i, Math.sin(x * 0.55 + t * 0.5) * 0.28 + Math.cos(z * 0.55 + t * 0.35) * 0.28)
- }
- posAttr.needsUpdate = true
- renderer.render(scene, camera)
- raf = requestAnimationFrame(tick)
- }
- tick()
-
- return () => {
- cancelAnimationFrame(raf)
- ro.disconnect()
- window.removeEventListener('pointermove', onMove)
- renderer.dispose()
- geo.dispose()
- mat.dispose()
- if (mount.contains(renderer.domElement)) mount.removeChild(renderer.domElement)
- }
- }, [])
-
- return
-}
diff --git a/src/ThreeViewport.tsx b/src/ThreeViewport.tsx
deleted file mode 100644
index 261f80b..0000000
--- a/src/ThreeViewport.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import { useEffect, useRef } from 'react'
-import * as THREE from 'three'
-import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
-
-export type ViewportHandle = {
- update?: () => void
- dispose?: () => void
-}
-
-export type BuildFn = (
- scene: THREE.Scene,
- camera: THREE.PerspectiveCamera,
- controls: OrbitControls,
- renderer: THREE.WebGLRenderer,
- container: HTMLDivElement,
-) => ViewportHandle | void
-
-export default function ThreeViewport({ build, className }: { build: BuildFn; className?: string }) {
- const containerRef = useRef(null)
-
- useEffect(() => {
- const container = containerRef.current
- if (!container) return
-
- const scene = new THREE.Scene()
- const camera = new THREE.PerspectiveCamera(38, container.clientWidth / container.clientHeight, 0.1, 100)
- const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
- renderer.setSize(container.clientWidth, container.clientHeight)
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
- container.appendChild(renderer.domElement)
-
- const controls = new OrbitControls(camera, renderer.domElement)
- controls.enableDamping = true
- controls.dampingFactor = 0.05
- controls.enableZoom = false
- controls.autoRotate = true
- controls.autoRotateSpeed = 1
-
- const handle = build(scene, camera, controls, renderer, container) ?? undefined
-
- let frameId = 0
- const animate = () => {
- frameId = requestAnimationFrame(animate)
- controls.update()
- handle?.update?.()
- renderer.render(scene, camera)
- }
- animate()
-
- const onResize = () => {
- camera.aspect = container.clientWidth / container.clientHeight
- camera.updateProjectionMatrix()
- renderer.setSize(container.clientWidth, container.clientHeight)
- }
- window.addEventListener('resize', onResize)
-
- return () => {
- cancelAnimationFrame(frameId)
- window.removeEventListener('resize', onResize)
- handle?.dispose?.()
- controls.dispose()
- renderer.dispose()
- container.removeChild(renderer.domElement)
- }
- }, [build])
-
- return
-}
diff --git a/src/content.ts b/src/content.ts
new file mode 100644
index 0000000..30b7349
--- /dev/null
+++ b/src/content.ts
@@ -0,0 +1,97 @@
+// All page copy lives here so the layout stays clean
+export const EMAIL = '0pietroquintavalle0@gmail.com'
+export const PHONE = '+41 79 906 82 53'
+export const GITHUB = 'https://github.com/Quinta0'
+export const LINKEDIN = 'https://linkedin.com/in/pietro-quintavalle'
+export const WHOAMI = 'https://quinta0.github.io/whoami'
+
+export const facts = [
+ { n: 6, label: 'years assembling and repairing hardware' },
+ { n: 25, label: 'systems built for private clients' },
+ { n: 12, label: 'active users on self-hosted infrastructure' },
+ { n: 30, prefix: '<', label: 'minutes to rebuild a cluster from bare metal' },
+]
+
+export const qualities = [
+ { title: 'Diagnoses at the component, not the ticket', text: 'Thermal bench-testing, firmware and BIOS flashing, soldering and harness wiring. When a node fails I find the part, not the symptom.' },
+ { title: 'Treats every host as disposable', text: 'Ansible playbooks and Docker Compose rebuild the whole environment from a blank disk in under thirty minutes. Nothing important lives only in one place.' },
+ { title: 'Zero exposed WAN ports, by default', text: 'Cloudflare Tunnels, WireGuard and Tailscale meshes, SSO with 2FA, host firewalls and intrusion detection on every node. Security is part of the build, not a later phase.' },
+ { title: 'Reads the protocol when there is no documentation', text: 'Reverse-engineered a vendor USB HID protocol from packet captures and shipped a working Linux driver upstream. Undocumented hardware is a puzzle, not a blocker.' },
+]
+
+export type Shaped = { shape?: string; label?: string }
+
+export const jobs: (Shaped & { time: string; title: string; org: string; bullets: string[] })[] = [
+ {
+ time: '2026 to now', title: 'Infrastructure and hardware consultant', org: 'DEC Energy, USI startup, freelance',
+ shape: 'cluster', label: 'DEC Energy: two nodes, one GPU, one HBA',
+ bullets: [
+ 'Designed, procured and deployed an on-premise two-node Kubernetes cluster from parts to production: local LLM inference, web hosting, accounting and Gitea/GitLab source control.',
+ 'Tuned fan curves and ran thermal bench stress tests so the nodes stay quiet in an open office under full inference load.',
+ "Specified and assembled the engineering team's workstations and remain on-call for support.",
+ ],
+ },
+ {
+ time: 'January 2020 to now', title: 'IT support technician', org: 'Private clients across Ticino, freelance',
+ bullets: [
+ 'Built, configured and repaired more than 25 desktop and laptop systems: sourcing, assembly, Windows and Linux deployment, driver debugging, ongoing maintenance.',
+ 'Remote and on-site support for hardware failures, software conflicts and local networking faults, with repair logs kept to stop issues recurring.',
+ ],
+ },
+ {
+ time: 'January 2020 to now', title: 'Infrastructure engineer, self-directed', org: 'Four bare-metal servers serving 12+ users',
+ shape: 'homelab', label: 'Homelab as code: nine bays, twelve users, no open ports',
+ bullets: [
+ 'UNRAID, Proxmox VE 8, TrueNAS, Docker and LXC running production workloads without any managed hosting.',
+ 'Delivered a turnkey rack for a client: cabinet, chassis, structured cabling, TrueNAS with ZFS RAID 10, per-user ACLs and automated offsite snapshots with under thirty minutes of recovery time.',
+ 'Hardened every edge: no open WAN ports, SSO and 2FA, intrusion detection, host-level firewalls.',
+ ],
+ },
+]
+
+export const oss: (Shaped & { href: string; repo: string; title: string; text: string; ref: string })[] = [
+ {
+ href: 'https://github.com/libratbag/libratbag/pull/1873', repo: 'libratbag/libratbag',
+ title: 'A complete USB HID device driver, written from a packet capture',
+ text: "Sniffed the vendor's raw HID reports in Wireshark, decoded the byte layout for DPI profiles, polling rates and on-board memory, then wrote a complete C driver that was merged upstream.",
+ ref: 'PR #1873', shape: 'hid', label: 'HID report: the bytes that became a driver',
+ },
+ {
+ href: 'https://github.com/ValveSoftware/steam-for-linux/issues/13248', repo: 'ValveSoftware/steam-for-linux, Vysp3r/ProtonPlus',
+ title: 'Runtime installation pipeline fault, diagnosed and fixed upstream',
+ text: 'Traced execution crashes in the Steam Linux Runtime to unwritten manifest files, documented a depot recovery workaround and contributed code to automate runtime integrity checks.',
+ ref: 'Issues #13248, #1232',
+ },
+ {
+ href: 'https://github.com/Recol/DLSS-Updater/pull/283', repo: 'Recol/DLSS-Updater',
+ title: 'Multi-vendor upscaler library detection',
+ text: 'Replaced brittle static tables with bounded manifest parsing so NVIDIA DLSS, AMD FSR and Intel XeSS libraries survive DLL upgrades; also fixed UI state desynchronisation.',
+ ref: 'PRs #283, #252, #256',
+ },
+]
+
+export const projects: (Shaped & { title: string; star?: string; text: string; stack: string })[] = [
+ { title: 'Homelab as code', shape: 'homelab', label: 'Homelab as code: nine bays, twelve users, no open ports',
+ text: 'Four production hosts rebuilt from bare metal by Ansible in under thirty minutes. Twelve-plus daily users, no exposed WAN ports: Cloudflare Tunnels, WireGuard, SSO with 2FA and Fail2Ban on every node.',
+ stack: 'Ansible, Docker Compose, UNRAID, Grafana, Prometheus' },
+ { title: 'Turnkey TrueNAS rack', shape: 'nas', label: 'Client rack: the cabinet that opens this page',
+ text: 'A 19-inch cabinet for a private client: patch panels, storage chassis and nine drive bays, every cable run dressed and labelled. TrueNAS on top with ZFS RAID 10, per-user ACLs and offsite snapshots for a sub-30-minute recovery target.',
+ stack: 'TrueNAS, ZFS, rack infrastructure, structured cabling' },
+ { title: 'ObsidianSetup', star: '26 stars on GitHub',
+ text: 'Cross-platform provisioning utility for environment configuration and plugin management, adopted by other users.',
+ stack: 'PowerShell' },
+ { title: 'City simulator, USI group project', shape: 'city', label: 'City simulator: twelve people, one Spring Boot backend',
+ text: 'Coordinated a twelve-person Scrum team building a full-stack city simulation. Owned the Spring Boot backend and its integration with the web frontend.',
+ stack: 'Java, Spring Boot, JavaScript, HTML, CSS' },
+]
+
+export const toolsRowA = ['Proxmox VE', 'TrueNAS', 'ZFS', 'UNRAID', 'Kubernetes', 'Docker', 'LXC', 'KVM', 'Ansible', 'GitHub Actions', 'Bash', 'PowerShell', 'Debian', 'Arch', 'NixOS', 'Windows Server', 'Active Directory']
+export const toolsRowB = ['WireGuard', 'Tailscale', 'Cloudflare Tunnels', 'UniFi', 'VLANs', 'iptables', 'Fail2Ban', 'AdGuard Home', 'Grafana', 'Prometheus', 'Uptime Kuma', 'Spring Boot', 'TypeScript', 'llama.cpp', 'Rust', 'C']
+
+export const education = [
+ { title: 'BSc Economics, English stream', text: 'Università della Svizzera italiana, Lugano, since September 2024. Microeconomics, game theory, quantitative finance, statistics, financial modelling.' },
+ { title: 'Computer science coursework, two full years', text: 'USI, 2022 to 2024. Algorithms and data structures, object-oriented systems in Java and C++, software architecture, discrete logic.' },
+ { title: 'Maturità Cantonale and Federal VET Diploma, AFC Economics', text: 'Scuola Cantonale di Commercio, Bellinzona, 2018 to 2022. Maturità 4.5/6, AFC 5/6.' },
+]
+export const certifications = 'Google IT Support Professional Certificate. Google Data Analytics Professional Certificate. Microsoft Foundations of IT Systems, Networking and Data Protection.'
+export const languages = [['Italian', 'native'], ['English', 'C2'], ['German', 'B1–B2'], ['Spanish', 'A1'], ['Norwegian', 'A1']]
diff --git a/src/index.css b/src/index.css
index fa72a26..e0d9172 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,218 +1,165 @@
-*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
-a:focus-visible,button:focus-visible{outline:2px solid var(--ink);outline-offset:2px}
-
:root{
- --canvas:#FAF9F6;
- --surface:#FFFFFF;
- --ink:#121212;
- --secondary:#6E6B65;
- --border:#E6E4DD;
- --accent:#C1121F;
- --container:1152px;
- --pad:24px;
- --f-sans:'Plus Jakarta Sans',sans-serif;
- --f-mono:'Space Mono',monospace;
+ --bg:#eef0f3; --bg-2:#e3e6eb; --fg:#141821; --muted:#5b6472;
+ --amber:#c86a00; --ice:#2f5fd6; --line:rgba(20,24,33,.14);
+ --display:'Syne',"Helvetica Neue",Arial,sans-serif;
+ --body:'IBM Plex Sans',"Segoe UI",system-ui,sans-serif;
}
+@media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){ --bg:#eef0f3; --bg-2:#e3e6eb; --fg:#141821; --muted:#5b6472; } }
+:root[data-theme="dark"]{ --bg:#eef0f3; --bg-2:#e3e6eb; --fg:#141821; --muted:#5b6472; }
+:root[data-theme="light"]{ --bg:#eef0f3; --fg:#141821; }
-html{scroll-behavior:smooth}
-body{
- background:var(--canvas);
- color:var(--ink);
- font-family:var(--f-sans);
- font-size:15px;
- line-height:1.5;
- -webkit-font-smoothing:antialiased;
-}
-::selection{background:var(--ink);color:#fff}
+*{box-sizing:border-box;margin:0;padding:0}
+html{scroll-behavior:auto}
+body{background:var(--bg);color:var(--fg);font-family:var(--body);font-size:17px;line-height:1.55;overflow-x:hidden;-webkit-font-smoothing:antialiased;cursor:none}
+@media (pointer:coarse){ body{cursor:auto} }
+a{color:inherit;text-decoration:none}
+::selection{background:var(--amber);color:#fff}
-/* ---------- HEADER ---------- */
-.site-header{
- position:fixed;top:0;left:0;right:0;z-index:50;
- background:rgba(250,249,246,.9);
- backdrop-filter:blur(10px);
- -webkit-backdrop-filter:blur(10px);
- border-bottom:1px solid rgba(230,228,221,.6);
-}
-.header-bar{
- max-width:var(--container);margin:0 auto;padding:0 var(--pad);
- height:80px;display:flex;justify-content:space-between;align-items:center;
-}
-.logo{
- display:flex;align-items:center;gap:8px;flex-shrink:0;white-space:nowrap;
- font-size:13px;font-weight:500;letter-spacing:-.01em;
- color:var(--ink);text-decoration:none;
-}
-.logo-dot{width:6px;height:6px;border-radius:50%;background:var(--accent);flex-shrink:0}
-.main-nav{
- display:flex;align-items:center;gap:32px;font-family:var(--f-mono);font-size:12px;color:var(--secondary);
- min-width:0;overflow-x:auto;scrollbar-width:none;
-}
-.main-nav::-webkit-scrollbar{display:none}
-.main-nav a{color:inherit;text-decoration:none;transition:color .15s;white-space:nowrap}
-.main-nav a:hover{color:var(--ink)}
-.nav-contact{color:var(--ink);text-decoration:underline;text-decoration-color:var(--border)}
-.nav-contact:hover{text-decoration-color:var(--ink)}
+#scene{position:fixed;inset:0;z-index:0;display:block}
+.grain{position:fixed;inset:-100%;z-index:1;pointer-events:none;opacity:.05;
+ background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
+ animation:grain 1.2s steps(6) infinite}
+@keyframes grain{0%{transform:translate(0,0)}20%{transform:translate(-3%,2%)}40%{transform:translate(2%,-4%)}60%{transform:translate(-4%,-1%)}80%{transform:translate(3%,3%)}100%{transform:translate(0,0)}}
+.vignette{position:fixed;inset:0;z-index:1;pointer-events:none;background:radial-gradient(ellipse 80% 70% at 50% 45%,transparent 45%,rgba(238,240,243,.7) 100%)}
-/* ---------- LAYOUT ---------- */
-.site-main{max-width:var(--container);margin:0 auto;padding:176px var(--pad) 128px;display:flex;flex-direction:column;gap:144px}
-.section-head{display:flex;justify-content:space-between;align-items:baseline;border-bottom:1px solid var(--border);padding-bottom:12px;margin-bottom:64px}
-.section-kicker{font-family:var(--f-mono);font-size:12px;color:var(--secondary);letter-spacing:.05em;text-transform:uppercase}
-.section-link{font-family:var(--f-mono);font-size:12px;color:var(--ink);text-decoration:none}
-.section-link:hover{color:var(--accent)}
+/* cursor */
+.cur,.cur-ring{position:fixed;top:0;left:0;z-index:50;pointer-events:none;border-radius:50%;transform:translate(-50%,-50%);mix-blend-mode:difference}
+.cur{width:6px;height:6px;background:#fff}
+.cur-ring{width:34px;height:34px;border:1px solid #fff;transition:width .25s,height .25s,opacity .25s}
+.cur-ring.big{width:64px;height:64px;opacity:.6}
+@media (pointer:coarse){ .cur,.cur-ring{display:none} }
-/* ---------- HERO ---------- */
-.hero{position:relative;max-width:820px}
-.hero-canvas{z-index:0}
-.hero-content{position:relative;z-index:1;display:flex;flex-direction:column;gap:32px}
-.hero-badges{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
-.badge-secondary,.badge-accent{font-family:var(--f-mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase}
-.badge-secondary{color:var(--secondary)}
-.badge-accent{color:var(--accent)}
-.badge-sep{color:var(--border)}
-.hero-title{font-size:60px;font-weight:300;letter-spacing:-.02em;line-height:1.08;color:var(--ink)}
-.hero-lead{color:var(--secondary);font-size:20px;font-weight:300;line-height:1.65;max-width:640px}
-.hero-ctas{display:flex;gap:16px;flex-wrap:wrap}
-.btn{
- display:inline-flex;align-items:center;gap:8px;
- padding:8px 16px;border-radius:6px;font-family:var(--f-mono);font-size:12px;
- text-decoration:none;transition:background .15s,color .15s;
-}
-.btn-dark{background:var(--ink);color:var(--canvas);border:1px solid var(--ink)}
-.btn-dark:hover{background:var(--surface);color:var(--ink)}
-.btn-outline{background:var(--surface);color:var(--ink);border:1px solid var(--border)}
-.btn-outline:hover{border-color:var(--ink)}
-.hero-metrics{
- padding-top:16px;display:grid;grid-template-columns:repeat(3,1fr);gap:24px;
- font-family:var(--f-mono);font-size:12px;color:var(--secondary);border-top:1px solid var(--border);max-width:576px;
-}
-.hero-metrics>div{display:flex;flex-direction:column}
-.metric-val{color:var(--ink);font-size:16px;font-weight:500;font-family:var(--f-sans)}
-.metric-lbl{color:var(--secondary);line-height:1.3}
+/* nav */
+nav{position:fixed;top:0;left:0;right:0;z-index:20;display:flex;justify-content:space-between;align-items:center;padding:22px clamp(20px,4vw,56px);font-size:14px;font-weight:500;mix-blend-mode:difference}
+nav .brand{font-family:var(--display);font-weight:800;letter-spacing:-.02em;font-size:17px}
+nav ul{display:flex;gap:26px;list-style:none}
+nav a{position:relative;padding:4px 0}
+nav a::after{content:"";position:absolute;left:0;right:100%;bottom:0;height:1px;background:#fff;transition:right .3s cubic-bezier(.6,0,.2,1)}
+nav a:hover::after,nav a:focus-visible::after{right:0}
+@media (max-width:720px){ nav ul{display:none} }
+.progress{position:fixed;top:0;left:0;height:2px;width:100%;transform-origin:0 0;transform:scaleX(0);background:var(--amber);z-index:21}
-/* ---------- CASE STUDIES ---------- */
-.case-list{display:flex;flex-direction:column;gap:112px}
-.case{display:grid;grid-template-columns:5fr 7fr;gap:32px;align-items:center}
-.case-copy{display:flex;flex-direction:column;gap:16px}
-.case-label{font-family:var(--f-mono);font-size:12px;color:var(--secondary);text-transform:uppercase;letter-spacing:.03em}
-.case-title{font-size:30px;font-weight:400;letter-spacing:-.01em;color:var(--ink)}
-.case-desc{color:var(--secondary);font-size:14px;line-height:1.65}
-.tag-row{display:flex;flex-wrap:wrap;gap:6px}
-.tag-pill{
- padding:4px 10px;background:var(--surface);border:1px solid var(--border);border-radius:999px;
- font-family:var(--f-mono);font-size:11px;color:var(--secondary);
-}
-.case-diagram{border-radius:16px;padding:32px;overflow:hidden}
-.diagram-card{
- background:var(--surface);border-radius:12px;border:1px solid rgba(230,228,221,.6);
- box-shadow:0 1px 2px rgba(0,0,0,.04);padding:24px;display:flex;flex-direction:column;justify-content:space-between;
-}
-.diagram-row{display:flex;justify-content:space-between;font-family:var(--f-mono);font-size:11px;color:var(--secondary)}
-.diagram-header{padding-bottom:12px}
-.diagram-footer{padding-top:12px;border-top:1px solid rgba(230,228,221,.4);font-size:10px}
-.diagram-svg-wrap{width:100%;overflow-x:auto;padding:8px 0}
-.case-svg{width:100%;min-width:320px;height:auto}
-.diagram-interactive{color:var(--accent);font-weight:600}
+/* shape label */
+.shape-label{position:fixed;left:clamp(20px,4vw,56px);bottom:26px;z-index:20;font-size:13px;color:var(--muted);display:flex;align-items:center;gap:12px;pointer-events:none}
+.shape-label i{width:6px;height:6px;border-radius:50%;background:var(--amber);box-shadow:0 0 10px rgba(200,106,0,.6);animation:blink 2.4s infinite}
+@keyframes blink{0%,100%{opacity:1}50%{opacity:.25}}
+.shape-label span{display:inline-block;transition:opacity .4s,transform .4s}
+.shape-label span.swap{opacity:0;transform:translateY(6px)}
-.case-canvas{
- position:relative;width:100%;height:288px;border-radius:6px;overflow:hidden;
- cursor:grab;touch-action:pan-y;
-}
-.case-canvas:active{cursor:grabbing}
-.case-canvas canvas{display:block;touch-action:pan-y}
-.nas-hud{
- position:absolute;z-index:5;pointer-events:none;
- background:var(--ink);color:var(--canvas);
- font-family:var(--f-mono);font-size:10px;
- padding:4px 8px;border-radius:4px;box-shadow:0 1px 2px rgba(0,0,0,.2);
-}
+/* layout */
+main{position:relative;z-index:2}
+section{min-height:100vh;padding:20vh clamp(20px,6vw,120px) 12vh;display:flex;flex-direction:column;justify-content:center}
+.wrap{max-width:1180px;width:100%;margin:0 auto;position:relative}
+section:not(.hero):not(.contact) .wrap::before{content:"";position:absolute;inset:-60px -80px;z-index:-1;pointer-events:none;
+ background:radial-gradient(ellipse 70% 60% at 38% 50%,rgba(238,240,243,.94) 35%,rgba(238,240,243,.75) 60%,rgba(238,240,243,0) 85%)}
+h1,h2,h3{font-family:var(--display);letter-spacing:-.03em;line-height:.95}
+h2{font-size:clamp(38px,6vw,84px);font-weight:800;margin-bottom:.5em;max-width:14ch}
+h3{font-size:clamp(20px,2.2vw,28px);font-weight:700;letter-spacing:-.02em;line-height:1.15}
+p.lead{font-size:clamp(18px,1.5vw,22px);max-width:56ch;color:var(--fg)}
+p.dim{color:var(--muted);max-width:60ch}
-.led-pulse{animation:ledBlink 2.4s ease-in-out infinite}
-@keyframes ledBlink{0%,100%{opacity:.3}50%{opacity:1}}
-.bus-flow{stroke-dasharray:4,4;animation:busTravel 12s linear infinite}
-@keyframes busTravel{to{stroke-dashoffset:-100}}
+/* reveal: opacity only, one quiet fade */
+.rv{opacity:0;transition:opacity 1s ease}
+.rv.in{opacity:1}
-/* ---------- OSS ---------- */
-.oss-grid{display:grid;grid-template-columns:1fr 1fr;gap:24px}
-.oss-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:24px;display:flex;flex-direction:column;gap:12px}
-.oss-card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
-.oss-kicker{display:block;font-family:var(--f-mono);font-size:10px;color:var(--accent);font-weight:600;letter-spacing:.03em}
-.oss-card-top h3{font-size:16px;font-weight:700;color:var(--ink)}
-.oss-meta{font-family:var(--f-mono);font-size:11px;color:var(--secondary);white-space:nowrap}
-.oss-card p{font-size:12.5px;color:var(--secondary);line-height:1.65}
-.tag-row-mini{padding-top:8px;border-top:1px solid rgba(230,228,221,.6);font-family:var(--f-mono);font-size:10px;color:var(--secondary);gap:8px}
+/* hero */
+.hero{min-height:100vh;padding-top:24vh;justify-content:flex-start;pointer-events:none}
+.hero .wrap>*{pointer-events:auto}
+.intro{font-size:15px;color:var(--muted);margin-bottom:2.2em;max-width:44ch}
+.name{font-size:clamp(56px,12.5vw,190px);font-weight:800;line-height:.86;letter-spacing:-.05em;overflow:hidden}
+.name .ln{display:block;overflow:hidden;padding-bottom:.06em;white-space:nowrap;font-size:calc(1em * var(--s,1))}
+.name .ch{display:inline-block;transform:translateY(115%) rotate(6deg);opacity:0;transition:transform 1.1s cubic-bezier(.16,1,.3,1),opacity .6s;transform-origin:0 100%}
+.name.on .ch{transform:none;opacity:1}
+.tag{display:flex;flex-wrap:wrap;gap:16px 40px;align-items:flex-end;margin-top:6vh}
+.tag p{font-size:clamp(18px,1.7vw,24px);max-width:36ch;font-weight:500}
+.tag p em{font-style:normal;color:var(--amber)}
+.hint{margin-top:9vh;font-size:13px;color:var(--muted);display:flex;align-items:center;gap:14px}
+.hint b{display:block;width:1px;height:46px;background:linear-gradient(var(--fg),transparent);animation:drop 1.8s cubic-bezier(.6,0,.2,1) infinite;transform-origin:top}
+@keyframes drop{0%{transform:scaleY(0);opacity:0}40%{opacity:1}100%{transform:scaleY(1);opacity:0}}
-/* ---------- BACKGROUND ---------- */
-.background{display:flex;flex-direction:column;gap:64px;padding-top:48px;border-top:1px solid var(--border)}
-.bg-row{display:grid;grid-template-columns:4fr 8fr;gap:48px}
-.bg-row-bordered{padding-top:32px;border-top:1px solid rgba(230,228,221,.6)}
-.bg-label{font-family:var(--f-mono);font-size:12px;color:var(--secondary);text-transform:uppercase;letter-spacing:.1em}
-.bg-content{max-width:640px}
-.quote{font-size:24px;font-weight:300;line-height:1.4;color:var(--ink);margin-bottom:16px}
-.bg-text{color:var(--secondary);font-size:14px;line-height:1.65}
+/* facts row */
+.facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:24px 40px;margin-top:9vh;border-top:1px solid var(--line);padding-top:26px;max-width:900px}
+.facts div{display:flex;flex-direction:column;gap:4px}
+.facts b{font-family:var(--display);font-size:clamp(30px,3vw,44px);font-weight:700;letter-spacing:-.03em;line-height:1}
+.facts small{font-size:13px;color:var(--muted)}
-.exp-list{display:flex;flex-direction:column;gap:32px}
-.exp-item{display:flex;flex-direction:column;gap:4px}
-.exp-head{display:flex;justify-content:space-between;align-items:baseline;gap:12px}
-.exp-head h3{font-size:14px;font-weight:700;color:var(--ink)}
-.exp-period{font-family:var(--f-mono);font-size:12px;color:var(--secondary);white-space:nowrap}
-.exp-org{font-family:var(--f-mono);font-size:12px;color:var(--accent)}
-.exp-item p{color:var(--secondary);font-size:12.5px;line-height:1.65;padding-top:4px}
+/* qualities: ruled list, not cards */
+.qual{display:grid;grid-template-columns:1fr 1fr;gap:0 60px;border-top:1px solid var(--line)}
+.qual article{padding:34px 0 36px;border-bottom:1px solid var(--line)}
+.qual article:nth-child(-n+2){border-top:0}
+.qual h3{margin-bottom:12px;max-width:16ch}
+.qual p{color:var(--muted);max-width:40ch}
+@media (max-width:760px){ .qual{grid-template-columns:1fr} }
-.skills-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px;font-family:var(--f-mono);font-size:12px}
-.skills-col{display:flex;flex-direction:column;gap:8px}
-.skills-heading{color:var(--ink);font-weight:700;border-bottom:1px solid var(--border);padding-bottom:4px}
-.skills-col ul{list-style:none;display:flex;flex-direction:column;gap:4px}
-.skills-col li{color:var(--secondary);font-size:11px}
+/* timeline */
+.tl{position:relative;padding-left:44px;max-width:860px}
+.tl::before{content:"";position:absolute;left:8px;top:0;bottom:0;width:1px;background:var(--line)}
+.tl .beam{position:absolute;left:8px;top:0;width:1px;height:100%;background:linear-gradient(var(--amber),var(--ice));transform:scaleY(0);transform-origin:top;transition:transform 1.6s cubic-bezier(.6,0,.2,1)}
+.tl.in .beam{transform:scaleY(1)}
+.job{position:relative;padding:0 0 64px}
+.job::before{content:"";position:absolute;left:-40px;top:10px;width:9px;height:9px;border-radius:50%;background:var(--bg);border:1px solid var(--fg)}
+.job time{display:block;font-size:14px;color:var(--muted);margin-bottom:8px}
+.job h3{margin-bottom:6px}
+.job .org{font-size:15px;color:var(--amber);margin-bottom:16px;display:block}
+.job ul{list-style:none;display:grid;gap:10px;max-width:60ch;color:var(--fg)}
+.job li{padding-left:18px;position:relative;color:#2b3140}
+.job li::before{content:"";position:absolute;left:0;top:.65em;width:8px;height:1px;background:var(--muted)}
-.edu-grid{display:grid;grid-template-columns:1fr 1fr;gap:32px}
-.edu-list{display:flex;flex-direction:column;gap:16px}
-.edu-date{font-family:var(--f-mono);font-size:12px;color:var(--secondary)}
-.edu-list h4{font-size:14px;font-weight:700;color:var(--ink);margin-top:2px}
-.edu-list p{font-size:12px;color:var(--secondary);margin-top:4px;line-height:1.5}
-.creds-list{display:flex;flex-direction:column;gap:16px;font-family:var(--f-mono);font-size:12px}
-.creds-heading{display:block;color:var(--ink);font-weight:700;border-bottom:1px solid var(--border);padding-bottom:4px}
-.creds-list ul{list-style:none;display:flex;flex-direction:column;gap:4px;padding-top:6px}
-.creds-list li{color:var(--secondary);font-size:11px}
-.creds-list li strong{color:var(--ink)}
+/* oss */
+.oss{display:grid;gap:0;border-top:1px solid var(--line);max-width:960px}
+.oss a{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:20px 40px;align-items:start;padding:32px 0;border-bottom:1px solid var(--line);transition:padding-left .4s cubic-bezier(.16,1,.3,1)}
+.oss a:hover,.oss a:focus-visible{padding-left:14px}
+.oss .repo{font-size:14px;color:var(--ice);margin-bottom:8px;display:block}
+.oss .what{color:var(--muted);max-width:56ch}
+.oss .ref{font-family:var(--display);font-weight:700;font-size:15px;color:var(--fg);white-space:nowrap;padding-top:2px;opacity:.7}
+@media (max-width:640px){ .oss a{grid-template-columns:1fr} }
-/* ---------- FOOTER ---------- */
-.site-footer{
- max-width:var(--container);margin:0 auto;padding:80px var(--pad);
- border-top:1px solid var(--border);
- display:flex;justify-content:space-between;align-items:baseline;gap:16px;flex-wrap:wrap;
- font-family:var(--f-mono);font-size:12px;color:var(--secondary);
-}
-.footer-name{color:var(--ink);font-weight:500}
-.footer-links{display:flex;gap:24px;color:var(--ink)}
-.footer-links a{color:inherit;text-decoration:none}
-.footer-links a:hover{color:var(--accent)}
+/* projects: staggered, not a card grid */
+.proj{display:grid;grid-template-columns:repeat(12,1fr);gap:40px 24px}
+.proj article{grid-column:span 5;padding:28px 0 0;border-top:1px solid var(--line);position:relative;transform-style:preserve-3d;transition:transform .5s cubic-bezier(.16,1,.3,1)}
+.proj article:nth-child(2){grid-column:7/span 6;margin-top:80px}
+.proj article:nth-child(3){grid-column:2/span 5;margin-top:-30px}
+.proj article:nth-child(4){grid-column:8/span 5;margin-top:20px}
+.proj article::after{content:"";position:absolute;left:0;top:-1px;width:0;height:1px;background:var(--amber);transition:width .6s cubic-bezier(.16,1,.3,1)}
+.proj article:hover::after{width:100%}
+.proj .stack{font-size:13px;color:var(--muted);margin:14px 0 0;line-height:1.8}
+.proj p{color:#2b3140;max-width:44ch;margin-top:10px}
+.proj .star{color:var(--amber);font-size:14px}
+@media (max-width:860px){ .proj article,.proj article:nth-child(n){grid-column:1/-1;margin-top:0} }
-/* ---------- RESPONSIVE ---------- */
-@media(max-width:1024px){
- .case{grid-template-columns:1fr}
- .bg-row{grid-template-columns:1fr;gap:16px}
- .bg-content{max-width:100%}
-}
-@media(max-width:768px){
- .oss-grid{grid-template-columns:1fr}
- .skills-grid,.edu-grid{grid-template-columns:1fr}
-}
-@media(max-width:640px){
- :root{--pad:16px}
- .header-bar{height:64px;gap:16px}
- .main-nav{gap:16px;font-size:10px}
- .site-main{padding:96px var(--pad) 80px;gap:80px}
- .section-head{margin-bottom:48px}
- .hero{gap:24px}
- .hero-title{font-size:30px;line-height:1.12}
- .hero-lead{font-size:16px}
- .hero-metrics{grid-template-columns:repeat(3,1fr);gap:12px}
- .case-list{gap:64px}
- .case{gap:24px}
- .case-title{font-size:20px}
- .case-desc{font-size:12px}
- .case-diagram{padding:16px;border-radius:12px}
- .diagram-card{padding:16px}
- .case-canvas{height:256px}
- .site-footer{padding:48px var(--pad);flex-direction:column;align-items:flex-start}
+/* skills: two counter-rotating marquees */
+.mq{overflow:hidden;white-space:nowrap;padding:18px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin-top:-1px;font-family:var(--display);font-weight:700;font-size:clamp(22px,3.2vw,44px);letter-spacing:-.02em}
+.mq div{display:inline-block;animation:mq 60s linear infinite}
+.mq.rev div{animation-direction:reverse;color:var(--muted)}
+.mq span{padding:0 .55em}
+.mq span::after{content:"/";color:var(--amber);padding-left:1.1em;font-weight:500}
+@keyframes mq{to{transform:translateX(-50%)}}
+.skills-note{margin-top:40px;color:var(--muted);max-width:60ch}
+
+/* education */
+.edu{display:grid;grid-template-columns:1fr 1fr;gap:40px 60px;max-width:960px}
+.edu dl{display:grid;gap:22px}
+.edu dt{font-weight:600}
+.edu dd{color:var(--muted);font-size:15px}
+.edu .langs{display:flex;flex-wrap:wrap;gap:10px 22px;font-size:15px;color:var(--muted)}
+.edu .langs b{color:var(--fg);font-weight:500}
+@media (max-width:760px){ .edu{grid-template-columns:1fr} }
+
+/* contact */
+.contact{min-height:100vh;justify-content:center;text-align:left}
+.contact h2{font-size:clamp(44px,8.5vw,140px);max-width:none;line-height:.9}
+.mail{display:inline-block;font-family:var(--display);font-weight:700;font-size:clamp(20px,2.6vw,34px);letter-spacing:-.02em;margin-top:20px;position:relative;padding:8px 0}
+.mail::after{content:"";position:absolute;left:0;bottom:0;height:2px;width:100%;background:var(--amber);transform:scaleX(.18);transform-origin:left;transition:transform .5s cubic-bezier(.16,1,.3,1)}
+.mail:hover::after,.mail:focus-visible::after{transform:scaleX(1)}
+.links{display:flex;flex-wrap:wrap;gap:14px;margin-top:48px}
+.links a{padding:14px 22px;border:1px solid var(--line);border-radius:999px;font-size:15px;font-weight:500;transition:border-color .3s,background .3s;will-change:transform}
+.links a:hover,.links a:focus-visible{border-color:var(--fg);background:rgba(20,24,33,.05)}
+footer{padding:40px clamp(20px,6vw,120px);font-size:13px;color:var(--muted);display:flex;justify-content:space-between;flex-wrap:wrap;gap:10px;position:relative;z-index:2}
+
+:focus-visible{outline:2px solid var(--amber);outline-offset:4px}
+@media (prefers-reduced-motion:reduce){
+ .grain,.hint b,.shape-label i,.mq div{animation:none}
+ .name .ch{transition:none;transform:none;opacity:1}
+ .rv{transition:none;opacity:1}
}
+#root{display:contents}
diff --git a/src/scene/ParticleScene.tsx b/src/scene/ParticleScene.tsx
new file mode 100644
index 0000000..b5ad8e1
--- /dev/null
+++ b/src/scene/ParticleScene.tsx
@@ -0,0 +1,177 @@
+import { useEffect, useRef } from 'react'
+import * as THREE from 'three'
+import { vert, frag } from './shaders'
+import { shapeRack, shapeKnot, shapeGlobe, shapeText, getHoverShape, hoverFlat } from './shapes'
+import { hover } from './hoverStore'
+
+export const SHAPE_NAMES = [
+ 'Rack: four production servers, bare metal',
+ 'Mesh: zero exposed ports, every node peered',
+ 'Perimeter: SSO, 2FA, host firewalls, intrusion detection',
+ 'Contact: the whole thing reassembles on request',
+]
+
+type Props = {
+ /** CSS selectors of the sections each scroll shape is keyed to, in order */
+ keyframes: string[]
+ onLabel?: (text: string) => void
+ onProgress?: (scrollFraction: number) => void
+}
+
+export default function ParticleScene({ keyframes, onLabel, onProgress }: Props) {
+ const canvasRef = useRef(null)
+
+ useEffect(() => {
+ const canvas = canvasRef.current
+ if (!canvas) return
+ const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches
+ const coarse = matchMedia('(pointer: coarse)').matches
+ const isMobile = coarse || innerWidth < 760
+ const COUNT = isMobile ? 6000 : 15000
+
+ let renderer: THREE.WebGLRenderer
+ try { renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: true, powerPreference: 'high-performance' }) }
+ catch { canvas.style.display = 'none'; return }
+
+ const scene = new THREE.Scene()
+ const camera = new THREE.PerspectiveCamera(42, innerWidth / innerHeight, 0.1, 100)
+ camera.position.set(0, 0, 18)
+ const group = new THREE.Group()
+ scene.add(group)
+
+ const g = new THREE.BufferGeometry()
+ const rand = new Float32Array(COUNT); for (let i = 0; i < COUNT; i++) rand[i] = Math.random()
+ g.setAttribute('position', new THREE.BufferAttribute(shapeRack(COUNT), 3))
+ g.setAttribute('aP0', new THREE.BufferAttribute(shapeRack(COUNT), 3))
+ g.setAttribute('aP1', new THREE.BufferAttribute(shapeKnot(COUNT), 3))
+ g.setAttribute('aP2', new THREE.BufferAttribute(shapeGlobe(COUNT), 3))
+ g.setAttribute('aP3', new THREE.BufferAttribute(shapeText(COUNT), 3))
+ g.setAttribute('aPH', new THREE.BufferAttribute(new Float32Array(COUNT * 3), 3))
+ g.setAttribute('aPH2', new THREE.BufferAttribute(new Float32Array(COUNT * 3), 3))
+ g.setAttribute('aRand', new THREE.BufferAttribute(rand, 1))
+ const uniforms = {
+ uProgress: { value: 0 }, uTime: { value: 0 }, uPR: { value: Math.min(devicePixelRatio, 2) },
+ uMouse: { value: new THREE.Vector3(999, 999, 0) }, uMouseOn: { value: 0 }, uIntro: { value: reduce ? 1 : 0 },
+ uDim: { value: 1 }, uHover: { value: 0 }, uSwap: { value: 1 },
+ uC1: { value: new THREE.Color('#1f3d9e') }, uC2: { value: new THREE.Color('#d9741a') },
+ }
+ const material = new THREE.ShaderMaterial({ uniforms, vertexShader: vert, fragmentShader: frag, transparent: true, depthWrite: false, blending: THREE.NormalBlending })
+ const points = new THREE.Points(g, material)
+ group.add(points)
+
+ const resize = () => {
+ renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
+ renderer.setSize(innerWidth, innerHeight, false)
+ camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix()
+ camera.position.z = innerWidth < 760 ? 26 : 18
+ }
+
+ // scroll: real fraction for the bar, section-keyed progress for the morph
+ let scrollT = 0, scrollCur = 0, progT = 0, progCur = 0
+ let mx = 0, my = 0, mxCur = 0, myCur = 0, mouseOn = 0, mouseOnCur = 0
+ let hoverT = 0, hoverCur = 0, swapCur = 1, hoverKey: string | null = null
+ const updateScroll = () => {
+ const max = document.documentElement.scrollHeight - innerHeight
+ scrollT = max > 0 ? scrollY / max : 0
+ const els = keyframes.map(q => document.querySelector(q)).filter(Boolean) as HTMLElement[]
+ if (els.length < 2) return
+ const c = els.map(el => el.offsetTop + el.offsetHeight / 2 - innerHeight / 2), y = scrollY, last = els.length - 1
+ let v = last
+ if (y <= c[0]) v = 0
+ else for (let i = 0; i < last; i++) { if (y < c[i + 1]) { v = i + THREE.MathUtils.smoothstep((y - c[i]) / Math.max(1, c[i + 1] - c[i]), 0.45, 1); break } }
+ progT = v / 3
+ }
+ const onMove = (e: PointerEvent) => { mx = (e.clientX / innerWidth) * 2 - 1; my = -(e.clientY / innerHeight) * 2 + 1; mouseOn = 1 }
+ const onLeave = () => { mouseOn = 0 }
+ const onResize = () => { resize(); updateScroll() }
+ addEventListener('resize', onResize)
+ addEventListener('scroll', updateScroll, { passive: true })
+ addEventListener('pointermove', onMove, { passive: true })
+ document.addEventListener('mouseleave', onLeave)
+
+ // hover shapes come from the shared store; swapping cross-fades through a scatter
+ const applyHover = () => {
+ if (!hover.on) { hoverT = 0; return }
+ const k = hover.key!
+ if (hoverKey !== k) {
+ const ph = g.getAttribute('aPH') as THREE.BufferAttribute, ph2 = g.getAttribute('aPH2') as THREE.BufferAttribute
+ if (hoverKey) { (ph2.array as Float32Array).set(ph.array as Float32Array); ph2.needsUpdate = true; swapCur = 0 }
+ else { (ph2.array as Float32Array).set(getHoverShape(k, COUNT)); ph2.needsUpdate = true; swapCur = 1 }
+ ;(ph.array as Float32Array).set(getHoverShape(k, COUNT)); ph.needsUpdate = true; hoverKey = k
+ }
+ hoverT = 1
+ }
+
+ const plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0), ray = new THREE.Raycaster(), hit = new THREE.Vector3(), ndc = new THREE.Vector2()
+ let last = performance.now(), elapsed = 0, introT = 0, labelIdx = -1, lastLabel = ''
+ let raf = 0
+ const frame = (now: number) => {
+ raf = requestAnimationFrame(frame)
+ const dt = Math.min((now - last) / 1000, 0.05); last = now; elapsed += dt
+ const t = elapsed
+ applyHover()
+ scrollCur += (scrollT - scrollCur) * 0.06; progCur += (progT - progCur) * 0.07
+ mxCur += (mx - mxCur) * 0.08; myCur += (my - myCur) * 0.08
+ mouseOnCur += (mouseOn - mouseOnCur) * 0.1
+ if (!reduce) { introT = Math.min(1, introT + dt * 0.55); uniforms.uIntro.value = 1 - Math.pow(1 - introT, 3) }
+
+ const prog = progCur * 3
+ uniforms.uProgress.value = prog
+ uniforms.uTime.value = t
+
+ group.rotation.y = scrollCur * Math.PI * 1.35 + t * 0.03
+ group.rotation.x = Math.sin(scrollCur * Math.PI) * 0.35
+ const face = THREE.MathUtils.smoothstep(prog, 2.55, 3.0)
+ group.rotation.y = THREE.MathUtils.lerp(group.rotation.y, Math.round(group.rotation.y / (Math.PI * 2)) * Math.PI * 2, face)
+ group.rotation.x = THREE.MathUtils.lerp(group.rotation.x, 0, face)
+
+ hoverCur += (hoverT - hoverCur) * 0.06; swapCur += (1 - swapCur) * 0.08
+ const hEase = hoverCur * hoverCur * (3 - 2 * hoverCur)
+ uniforms.uHover.value = hEase; uniforms.uSwap.value = swapCur
+ if (hoverCur > 0.001) {
+ const flat = hoverKey ? hoverFlat[hoverKey] : false
+ const ty = (flat ? 0 : 0.62) + t * 0.08, tx = flat ? 0 : 0.3
+ const near = ty + Math.round((group.rotation.y - ty) / (Math.PI * 2)) * Math.PI * 2
+ group.rotation.y = THREE.MathUtils.lerp(group.rotation.y, near, hEase)
+ group.rotation.x = THREE.MathUtils.lerp(group.rotation.x, tx, hEase)
+ }
+
+ const mid = THREE.MathUtils.smoothstep(prog, 0.15, 0.6) * (1 - THREE.MathUtils.smoothstep(prog, 2.5, 2.95))
+ const offX = innerWidth < 760 ? 0 : THREE.MathUtils.lerp(5.5, 4.5, THREE.MathUtils.smoothstep(prog, 0, 0.8)) * (1 - THREE.MathUtils.smoothstep(prog, 2.5, 2.95))
+ const hx = innerWidth < 760 ? 0 : 3.6, hz = innerWidth < 760 ? 2 : 5.5
+ group.position.x += (THREE.MathUtils.lerp(offX, hx, hEase) - group.position.x) * 0.05
+ group.position.z += (hz * hEase - group.position.z) * 0.05
+ uniforms.uDim.value = THREE.MathUtils.lerp(1 - mid * 0.55, 1, hEase)
+
+ camera.position.x += (mxCur * 0.9 - camera.position.x) * 0.05
+ camera.position.y += (myCur * 0.6 - camera.position.y) * 0.05
+ camera.lookAt(0, 0, 0)
+
+ ndc.set(mxCur, myCur); ray.setFromCamera(ndc, camera)
+ if (ray.ray.intersectPlane(plane, hit)) { group.worldToLocal(hit); uniforms.uMouse.value.copy(hit) }
+ uniforms.uMouseOn.value = coarse ? 0 : mouseOnCur
+
+ // label + progress callbacks
+ const label = hover.on && hover.label ? hover.label : SHAPE_NAMES[Math.min(3, Math.round(progCur * 3))]
+ if (label !== lastLabel) { lastLabel = label; labelIdx = -1; onLabel?.(label) }
+ void labelIdx
+ onProgress?.(scrollCur)
+
+ renderer.render(scene, camera)
+ }
+
+ const boot = () => { resize(); updateScroll(); raf = requestAnimationFrame(frame) }
+ // wait for the display font so the "PQ" sampling uses it
+ const ready = document.fonts?.load ? Promise.race([document.fonts.load('800 300px Syne'), new Promise(r => setTimeout(r, 1500))]) : Promise.resolve()
+ ready.then(boot, boot)
+
+ return () => {
+ cancelAnimationFrame(raf)
+ removeEventListener('resize', onResize); removeEventListener('scroll', updateScroll)
+ removeEventListener('pointermove', onMove); document.removeEventListener('mouseleave', onLeave)
+ g.dispose(); material.dispose(); renderer.dispose()
+ }
+ }, [keyframes, onLabel, onProgress])
+
+ return
+}
diff --git a/src/scene/hoverStore.ts b/src/scene/hoverStore.ts
new file mode 100644
index 0000000..d60b7b7
--- /dev/null
+++ b/src/scene/hoverStore.ts
@@ -0,0 +1,5 @@
+// Tiny shared state between the page and the particle scene
+export type HoverState = { key: string | null; label: string | null; on: boolean }
+export const hover: HoverState = { key: null, label: null, on: false }
+export function hoverIn(key: string, label: string) { hover.key = key; hover.label = label; hover.on = true }
+export function hoverOut() { hover.on = false }
diff --git a/src/scene/shaders.ts b/src/scene/shaders.ts
new file mode 100644
index 0000000..7f9e193
--- /dev/null
+++ b/src/scene/shaders.ts
@@ -0,0 +1,64 @@
+// GLSL for the particle sculpture
+export const noiseGLSL = `
+vec3 mod289(vec3 x){return x-floor(x*(1.0/289.0))*289.0;}
+vec4 mod289(vec4 x){return x-floor(x*(1.0/289.0))*289.0;}
+vec4 permute(vec4 x){return mod289(((x*34.0)+1.0)*x);}
+vec4 taylorInvSqrt(vec4 r){return 1.79284291400159-0.85373472095314*r;}
+float snoise(vec3 v){
+ const vec2 C=vec2(1.0/6.0,1.0/3.0); const vec4 D=vec4(0.0,0.5,1.0,2.0);
+ vec3 i=floor(v+dot(v,C.yyy)); vec3 x0=v-i+dot(i,C.xxx);
+ vec3 g=step(x0.yzx,x0.xyz); vec3 l=1.0-g; vec3 i1=min(g.xyz,l.zxy); vec3 i2=max(g.xyz,l.zxy);
+ vec3 x1=x0-i1+C.xxx; vec3 x2=x0-i2+C.yyy; vec3 x3=x0-D.yyy;
+ i=mod289(i);
+ vec4 p=permute(permute(permute(i.z+vec4(0.0,i1.z,i2.z,1.0))+i.y+vec4(0.0,i1.y,i2.y,1.0))+i.x+vec4(0.0,i1.x,i2.x,1.0));
+ float n_=0.142857142857; vec3 ns=n_*D.wyz-D.xzx;
+ vec4 j=p-49.0*floor(p*ns.z*ns.z); vec4 x_=floor(j*ns.z); vec4 y_=floor(j-7.0*x_);
+ vec4 x=x_*ns.x+ns.yyyy; vec4 y=y_*ns.x+ns.yyyy; vec4 h=1.0-abs(x)-abs(y);
+ vec4 b0=vec4(x.xy,y.xy); vec4 b1=vec4(x.zw,y.zw);
+ vec4 s0=floor(b0)*2.0+1.0; vec4 s1=floor(b1)*2.0+1.0; vec4 sh=-step(h,vec4(0.0));
+ vec4 a0=b0.xzyw+s0.xzyw*sh.xxyy; vec4 a1=b1.xzyw+s1.xzyw*sh.zzww;
+ vec3 p0=vec3(a0.xy,h.x); vec3 p1=vec3(a0.zw,h.y); vec3 p2=vec3(a1.xy,h.z); vec3 p3=vec3(a1.zw,h.w);
+ vec4 norm=taylorInvSqrt(vec4(dot(p0,p0),dot(p1,p1),dot(p2,p2),dot(p3,p3)));
+ p0*=norm.x;p1*=norm.y;p2*=norm.z;p3*=norm.w;
+ vec4 m=max(0.6-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.0); m=m*m;
+ return 42.0*dot(m*m,vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3)));
+}`;
+
+export const vert = noiseGLSL + `
+attribute vec3 aP0; attribute vec3 aP1; attribute vec3 aP2; attribute vec3 aP3; attribute vec3 aPH; attribute vec3 aPH2; attribute float aRand;
+uniform float uProgress; uniform float uTime; uniform float uPR; uniform vec3 uMouse; uniform float uMouseOn; uniform float uIntro; uniform float uDim; uniform float uHover; uniform float uSwap;
+varying float vA; varying float vHeat;
+void main(){
+ float p=clamp(uProgress,0.0,3.0);
+ float s01=smoothstep(0.0,1.0,p), s12=smoothstep(1.0,2.0,p), s23=smoothstep(2.0,3.0,p);
+ vec3 pos = aP0*(1.0-s01) + aP1*(s01-s12) + aP2*(s12-s23) + aP3*s23;
+ float f=fract(p); float tr = (p>=3.0)?0.0:f*(1.0-f)*4.0; // 0 at rest, 1 mid-morph
+ // hover: the sculpture becomes the project under the cursor
+ vec3 hp = mix(aPH2, aPH, uSwap);
+ pos = mix(pos, hp, uHover);
+ tr = tr*(1.0-uHover) + max(uHover*(1.0-uHover)*4.0, uSwap*(1.0-uSwap)*4.0)*0.7;
+ vec3 nv = vec3(snoise(pos*0.45+uTime*0.12), snoise(pos*0.45+vec3(13.7)+uTime*0.12), snoise(pos*0.45+vec3(71.2)+uTime*0.12));
+ pos += nv*(0.07*(1.0-uHover*0.85) + tr*(2.2+aRand*2.0));
+ // intro: particles fall in from a scattered cloud
+ vec3 spread = (aRand-0.5)*vec3(30.0,20.0,30.0) + nv*6.0;
+ pos = mix(spread, pos, uIntro);
+ // mouse repulsion
+ vec3 d = pos - uMouse; float dist=length(d);
+ float rep = smoothstep(3.2,0.0,dist)*uMouseOn;
+ pos += (d/max(dist,0.001))*rep*1.6;
+ vec4 mv = modelViewMatrix*vec4(pos,1.0);
+ gl_Position = projectionMatrix*mv;
+ float size = (0.8+aRand*1.4)*uPR*(1.0+tr*1.0+rep*1.4);
+ gl_PointSize = min(size*(200.0/-mv.z), 9.0*uPR);
+ vA = (0.35+0.55*aRand)*uIntro*uDim*(1.0-tr*0.45);
+ vHeat = clamp(tr*0.9+rep+aRand*0.25,0.0,1.0);
+}`;
+export const frag = `
+uniform vec3 uC1; uniform vec3 uC2; varying float vA; varying float vHeat;
+void main(){
+ vec2 uv=gl_PointCoord-0.5; float d=length(uv); if(d>0.5) discard;
+ float a=smoothstep(0.5,0.12,d);
+ vec3 c=mix(uC1,uC2,vHeat);
+ gl_FragColor=vec4(c, a*vA);
+}`;
+
diff --git a/src/scene/shapes.ts b/src/scene/shapes.ts
new file mode 100644
index 0000000..5b0cffc
--- /dev/null
+++ b/src/scene/shapes.ts
@@ -0,0 +1,140 @@
+// Point-cloud generators: the four scroll shapes and the hover dioramas
+const rnd = (a = 1, b = 0): number =>Math.random()*(a-b)+b;
+
+/* Shape 0 — a server rack: stacked chassis outlines, drive bays, cable loom */
+export function shapeRack(n: number): Float32Array {
+ const a = new Float32Array(n*3);
+ const W=4.6, D=3.2, units=9, H=0.72, y0=-(units*H)/2;
+ for(let i=0;i{const c=R+r*Math.cos(q*t);return [c*Math.cos(p*t),c*Math.sin(p*t),r*Math.sin(q*t)];};
+ for(let i=0;i void, S: number, zSpread: number): Float32Array {
+ const c=document.createElement('canvas'); c.width=w; c.height=h;
+ const ctx=c.getContext('2d')!; ctx.fillStyle='#000'; ctx.fillRect(0,0,w,h); ctx.fillStyle='#fff'; ctx.strokeStyle='#fff';
+ draw(ctx);
+ const img=ctx.getImageData(0,0,w,h).data, pts: number[]=[];
+ for(let y=0;y128) pts.push(x,y); }
+ const a=new Float32Array(n*3);
+ for(let i=0;i{ ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.font='800 300px Syne, "Helvetica Neue", Arial, sans-serif'; ctx.fillText('PQ',360,194); },0.0215,0.9);
+}
+/* Primitive builder for the project dioramas: boxes as edges or solid fill, plus lines */
+type Prim = { t: 'edges' | 'fill'; c: number[]; s: number[]; w: number } | { t: 'line'; a: number[]; b: number[]; w: number }
+function fromPrims(n: number, prims: Prim[], scale: number): Float32Array {
+ const tot=prims.reduce((t,p)=>t+p.w,0), a=new Float32Array(n*3);
+ for(let i=0;i Float32Array> = {
+ homelab(n: number){ // the homelab chassis: nine bays, copper strip, grille top
+ const pr: Prim[]=[{t:'edges',c:[0,0,0],s:[2.3,2.4,2.6],w:3},{t:'fill',c:[0,1.16,0],s:[2.15,0.01,2.45],w:0.9},{t:'fill',c:[0,0.25,1.32],s:[2.3,0.22,0.06],w:0.9}];
+ const bw=0.17,g=0.02,x0=-(9*bw+8*g)/2+bw/2;
+ for(let i=0;i<9;i++){ const x=x0+i*(bw+g); pr.push({t:'edges',c:[x,-0.43,1.3],s:[bw,1.05,0.18],w:0.35}); pr.push({t:'fill',c:[x,-0.43,1.12],s:[bw-0.04,0.9,0.3],w:0.32}); }
+ return fromPrims(n,pr,2.1);
+ },
+ cluster(n: number){ // DEC Energy: two boards, GPU on one, HBA on the other, bridged
+ const pr: Prim[]=[];
+ for(const bx of [-0.95,0.95]){ pr.push({t:'fill',c:[bx,0,0],s:[1.5,0.08,1.8],w:0.7},{t:'edges',c:[bx,0,0],s:[1.5,0.08,1.8],w:1.6},{t:'fill',c:[bx,0.1,-0.25],s:[0.45,0.12,0.45],w:0.5}); }
+ pr.push({t:'fill',c:[-0.45,0.25,0.1],s:[0.12,0.42,1.2],w:1.0},{t:'fill',c:[0.45,0.22,0.1],s:[0.12,0.35,1.0],w:0.8});
+ pr.push({t:'line',a:[-0.5,0.12,-0.25],b:[0.5,0.12,-0.25],w:0.5});
+ for(const bx of [-0.95,0.95]) for(let k=0;k<3;k++) pr.push({t:'line',a:[bx-0.55,0.05,0.5+k*0.2],b:[bx+0.55,0.05,0.5+k*0.2],w:0.12});
+ return fromPrims(n,pr,2.4);
+ },
+ hosts(n: number){ // four hosts on the floor, one hub, one tunnel out
+ const pr: Prim[]=[]; const hosts=[[-1.3,-1.2,-1.0],[1.3,-1.2,-1.0],[-1.3,-1.2,1.0],[1.3,-1.2,1.0]];
+ for(const h of hosts){ pr.push({t:'edges',c:h,s:[1.7,0.5,1.4],w:1},{t:'fill',c:[h[0],h[1],h[2]+0.68],s:[1.5,0.32,0.05],w:0.45},{t:'line',a:[h[0],h[1]+0.25,h[2]],b:[0,0.6,0],w:0.35}); }
+ pr.push({t:'fill',c:[0,0.6,0],s:[0.45,0.45,0.45],w:0.6},{t:'line',a:[0,0.6,0],b:[0,2.6,0],w:0.5},{t:'edges',c:[0,2.9,0],s:[1.2,0.5,0.9],w:0.5});
+ return fromPrims(n,pr,1.55);
+ },
+ city(n: number){ // the simulated city: a grid of blocks
+ const pr: Prim[]=[{t:'edges',c:[0,-0.05,0],s:[9.2,0.02,9.2],w:1.2}]; let seed=7; const rr=()=>{seed=(seed*16807)%2147483647;return seed/2147483647;};
+ for(let i=0;i<8;i++)for(let j=0;j<8;j++){ const hgt=0.3+rr()*rr()*3.2, x=-3.85+i*1.1, z=-3.85+j*1.1; pr.push({t:'edges',c:[x,hgt/2,z],s:[0.8,hgt,0.8],w:0.35+hgt*0.15}); if(rr()<0.35)pr.push({t:'fill',c:[x,hgt/2,z],s:[0.78,hgt,0.78],w:0.08}); }
+ return fromPrims(n,pr,0.72);
+ },
+ hid(n: number){ // the libratbag report: header, command, profile, DPI, then upstream
+ return sampleCanvas(n,1000,420,ctx=>{
+ ctx.lineWidth=5; ctx.beginPath(); ctx.moveTo(40,40); ctx.lineTo(960,40); ctx.stroke();
+ ctx.setLineDash([8,10]); ctx.beginPath(); ctx.moveTo(110,40); ctx.lineTo(160,130); ctx.moveTo(420,40); ctx.lineTo(380,130); ctx.stroke(); ctx.setLineDash([]);
+ const boxes=[[60,120,'0x08'],[180,120,'0x14'],[300,140,'0x01 02'],[440,190,'0x0640'],[630,300,'00 00 …']];
+ ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.font='700 42px Syne, Arial, sans-serif';
+ for(const [x,w,t] of boxes as [number,number,string][]){ ctx.lineWidth=(t==='0x0640')?10:5; ctx.strokeRect(x,130,w,110); ctx.fillText(t,x+w/2,185); }
+ ctx.lineWidth=5; ctx.beginPath(); ctx.moveTo(535,240); ctx.lineTo(535,300); ctx.stroke();
+ ctx.fillRect(360,300,350,70); ctx.fillStyle='#000'; ctx.font='700 30px Syne, Arial, sans-serif'; ctx.fillText('MERGED UPSTREAM',535,336);
+ ctx.fillStyle='#fff'; ctx.font='500 26px Syne, Arial, sans-serif'; ctx.textAlign='left';
+ ctx.fillText('REPORT',60,275); ctx.fillText('CMD',180,275); ctx.fillText('PROFILE 2',300,275); ctx.fillText('1600 DPI',440,275);
+ },0.0142,0.5);
+ }
+};
+hoverShapes.nas=shapeRack; // the client rack is the same one that opens the page
+export const hoverFlat: Record = { hid: true };
+const shapeCache: Record = {};
+export function getHoverShape(k: string, count: number): Float32Array { return shapeCache[k] || (shapeCache[k] = hoverShapes[k](count)); }
+
diff --git a/src/scenes.ts b/src/scenes.ts
deleted file mode 100644
index d5e8883..0000000
--- a/src/scenes.ts
+++ /dev/null
@@ -1,195 +0,0 @@
-import * as THREE from 'three'
-import type { BuildFn } from './ThreeViewport'
-
-const wireMat = new THREE.LineBasicMaterial({ color: 0x121212 })
-const chassisMat = new THREE.MeshBasicMaterial({ color: 0xf7f6f2, transparent: true, opacity: 0.75 })
-const darkPartMat = new THREE.MeshBasicMaterial({ color: 0x1c1b19 })
-const swissRedMat = new THREE.MeshBasicMaterial({ color: 0xc1121f })
-const copperMat = new THREE.MeshBasicMaterial({ color: 0xb9814f })
-const tealDriveMat = new THREE.MeshBasicMaterial({ color: 0x1f8f86, transparent: true, opacity: 0.75 })
-
-export const buildRackScene: BuildFn = (scene, camera) => {
- camera.position.set(4, 2.5, 4.5)
- const rack = new THREE.Group()
- rack.add(new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(1.6, 2.8, 1.4)), wireMat))
-
- for (let i = 0; i < 4; i++) {
- const server = new THREE.Mesh(new THREE.BoxGeometry(1.45, 0.38, 1.25), chassisMat)
- server.position.set(0, -0.9 + i * 0.55, 0)
- server.add(new THREE.LineSegments(new THREE.EdgesGeometry(server.geometry), wireMat))
-
- for (let d = 0; d < 4; d++) {
- const drive = new THREE.Mesh(new THREE.BoxGeometry(0.24, 0.22, 0.05), darkPartMat)
- drive.position.set(-0.45 + d * 0.3, 0, 0.63)
- server.add(drive)
- }
-
- const led = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.04, 0.04), swissRedMat)
- led.position.set(0.6, 0.1, 0.64)
- server.add(led)
- rack.add(server)
- }
-
- scene.add(rack)
-}
-
-export const buildClusterScene: BuildFn = (scene, camera) => {
- camera.position.set(3.8, 3.5, 3.8)
- const cluster = new THREE.Group()
-
- const makeBoard = (x: number) => {
- const board = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.08, 1.8), chassisMat)
- board.position.set(x, 0, 0)
- board.add(new THREE.LineSegments(new THREE.EdgesGeometry(board.geometry), wireMat))
- const cpu = new THREE.Mesh(new THREE.BoxGeometry(0.45, 0.12, 0.45), darkPartMat)
- cpu.position.set(0, 0.06, -0.2)
- board.add(cpu)
- return board
- }
-
- const b1 = makeBoard(-0.9)
- const gpu = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.4, 1.2), darkPartMat)
- gpu.position.set(0.5, 0.2, 0.1)
- b1.add(gpu)
- cluster.add(b1)
-
- const b2 = makeBoard(0.9)
- const hba = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.35, 1), darkPartMat)
- hba.position.set(-0.5, 0.18, 0.1)
- b2.add(hba)
- cluster.add(b2)
-
- const bridge = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.04, 0.06), swissRedMat)
- bridge.position.set(0, 0.08, -0.2)
- cluster.add(bridge)
-
- scene.add(cluster)
-}
-
-function makeGrilleTexture() {
- const c = document.createElement('canvas')
- c.width = c.height = 128
- const ctx = c.getContext('2d')!
- ctx.fillStyle = '#1a1a1a'
- ctx.fillRect(0, 0, 128, 128)
- ctx.fillStyle = '#3a3a3a'
- for (let x = 5; x < 128; x += 10) {
- for (let y = 5; y < 128; y += 10) ctx.fillRect(x - 1.5, y - 1.5, 3, 3)
- }
- const tex = new THREE.CanvasTexture(c)
- tex.wrapS = tex.wrapT = THREE.RepeatWrapping
- tex.repeat.set(2, 2)
- return tex
-}
-
-export const buildNasScene: BuildFn = (scene, camera, controls, renderer, container) => {
- camera.position.set(3.6, 2.6, 3.8)
-
- const nas = new THREE.Group()
- const W = 2.3, H = 2.4, D = 2.6
- const halfH = H / 2
- const stripY = 0.25
- const bayTopY = stripY - 0.15
- const bayHeight = bayTopY - (-halfH + 0.25)
-
- nas.add(new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(W, H, D)), wireMat))
-
- const grilleTex = makeGrilleTexture()
- const topMat = new THREE.MeshBasicMaterial({ map: grilleTex, transparent: true, opacity: 0.85 })
- const top = new THREE.Mesh(new THREE.PlaneGeometry(W - 0.12, D - 0.12), topMat)
- top.rotation.x = -Math.PI / 2
- top.position.y = halfH - 0.05
- nas.add(top)
-
- const strip = new THREE.Group()
- strip.add(new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(W, 0.26, 0.08)), new THREE.LineBasicMaterial({ color: 0xb9814f })))
- const stripFace = new THREE.Mesh(new THREE.PlaneGeometry(W - 0.04, 0.22), copperMat)
- stripFace.position.z = 0.045
- strip.add(stripFace)
- strip.position.set(0, stripY, D / 2 + 0.02)
- nas.add(strip)
-
- const bayWidth = 0.17, gap = 0.02
- const startX = -(9 * bayWidth + 8 * gap) / 2 + bayWidth / 2
- const hitMeshes: THREE.Mesh[] = []
- const leds: { mat: THREE.MeshBasicMaterial; phase: number }[] = []
-
- for (let i = 0; i < 9; i++) {
- const x = startX + i * (bayWidth + gap)
- const bay = new THREE.Group()
- bay.position.set(x, bayTopY - bayHeight / 2, D / 2)
- bay.add(new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(bayWidth, bayHeight, 0.18)), wireMat))
-
- const drive = new THREE.Mesh(new THREE.BoxGeometry(bayWidth - 0.03, bayHeight - 0.12, 0.32), tealDriveMat)
- drive.position.z = -0.16
- bay.add(drive)
-
- const hitMesh = new THREE.Mesh(new THREE.BoxGeometry(bayWidth, bayHeight, 0.5), new THREE.MeshBasicMaterial({ transparent: true, opacity: 0 }))
- hitMesh.position.z = -0.1
- hitMesh.userData.label = `BAY ${String(i + 1).padStart(2, '0')} // WD RED 10TB (ONLINE)`
- bay.add(hitMesh)
- hitMeshes.push(hitMesh)
-
- const ledMat = new THREE.MeshBasicMaterial({ color: 0xc1121f, transparent: true, opacity: 0.5 })
- const led = new THREE.Mesh(new THREE.CircleGeometry(0.018, 8), ledMat)
- led.position.set(0, bayHeight / 2 - 0.08, 0.1)
- bay.add(led)
- leds.push({ mat: ledMat, phase: i * 0.5 })
-
- nas.add(bay)
- }
-
- scene.add(nas)
-
- const hud = document.createElement('div')
- hud.className = 'nas-hud'
- hud.hidden = true
- container.appendChild(hud)
-
- const raycaster = new THREE.Raycaster()
- const pointer = new THREE.Vector2()
-
- const onPointerMove = (e: PointerEvent) => {
- const rect = renderer.domElement.getBoundingClientRect()
- pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1
- pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1
-
- raycaster.setFromCamera(pointer, camera)
- const hit = raycaster.intersectObjects(hitMeshes)[0]
-
- if (hit) {
- hud.textContent = hit.object.userData.label
- hud.style.left = `${Math.min(e.clientX - rect.left + 12, rect.width - 180)}px`
- hud.style.top = `${Math.max(e.clientY - rect.top - 28, 10)}px`
- hud.hidden = false
- controls.autoRotate = false
- } else {
- hud.hidden = true
- controls.autoRotate = true
- }
- }
-
- const onPointerLeave = () => {
- hud.hidden = true
- controls.autoRotate = true
- }
-
- renderer.domElement.addEventListener('pointermove', onPointerMove)
- renderer.domElement.addEventListener('pointerleave', onPointerLeave)
-
- const clock = new THREE.Clock()
-
- return {
- update: () => {
- const t = clock.getElapsedTime()
- for (const l of leds) l.mat.opacity = 0.3 + Math.abs(Math.sin(t * 1.6 + l.phase)) * 0.6
- },
- dispose: () => {
- renderer.domElement.removeEventListener('pointermove', onPointerMove)
- renderer.domElement.removeEventListener('pointerleave', onPointerLeave)
- container.removeChild(hud)
- grilleTex.dispose()
- topMat.dispose()
- },
- }
-}
diff --git a/src/usePageMotion.ts b/src/usePageMotion.ts
new file mode 100644
index 0000000..c4194b5
--- /dev/null
+++ b/src/usePageMotion.ts
@@ -0,0 +1,95 @@
+import { useEffect } from 'react'
+import { hoverIn, hoverOut } from './scene/hoverStore'
+
+// Everything in the page that moves: name reveal, counters, fades, tilt, magnetic links, cursor, hover wiring.
+export function usePageMotion() {
+ useEffect(() => {
+ const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches
+ const coarse = matchMedia('(pointer: coarse)').matches
+ const cleanups: (() => void)[] = []
+
+ // hero name
+ const nameT = setTimeout(() => document.getElementById('name')?.classList.add('on'), 150)
+ cleanups.push(() => clearTimeout(nameT))
+
+ // counters
+ const countUp = (el: HTMLElement) => {
+ const target = +el.dataset.count!, prefix = el.dataset.prefix || '', t0 = performance.now(), dur = 1600
+ const step = (now: number) => {
+ const k = Math.min(1, (now - t0) / dur), e = 1 - Math.pow(1 - k, 3)
+ el.textContent = prefix + Math.round(target * e) + (k === 1 && !prefix ? '+' : '')
+ if (k < 1) requestAnimationFrame(step)
+ }
+ requestAnimationFrame(step)
+ }
+
+ // one quiet fade per block
+ const io = new IntersectionObserver(es => es.forEach(e => {
+ if (!e.isIntersecting) return
+ e.target.classList.add('in'); io.unobserve(e.target)
+ if (e.target.classList.contains('facts')) e.target.querySelectorAll('[data-count]').forEach(countUp)
+ }), { threshold: 0.15 })
+ document.querySelectorAll('.rv').forEach(el => io.observe(el))
+ cleanups.push(() => io.disconnect())
+
+ // timeline beam
+ const tl = document.getElementById('tl')
+ if (tl) { const tio = new IntersectionObserver(es => es.forEach(e => { if (e.isIntersecting) tl.classList.add('in') }), { threshold: 0.2 }); tio.observe(tl); cleanups.push(() => tio.disconnect()) }
+
+ // hover-morph rows
+ const rows = [...document.querySelectorAll('[data-shape]')]
+ rows.forEach(el => {
+ const on = () => hoverIn(el.dataset.shape!, el.dataset.label || '')
+ el.addEventListener('pointerenter', on); el.addEventListener('pointerleave', hoverOut)
+ el.addEventListener('focusin', on); el.addEventListener('focusout', hoverOut)
+ cleanups.push(() => { el.removeEventListener('pointerenter', on); el.removeEventListener('pointerleave', hoverOut); el.removeEventListener('focusin', on); el.removeEventListener('focusout', hoverOut) })
+ })
+ if (coarse) { // touch: the row nearest the middle of the screen drives the sculpture
+ const iv = setInterval(() => {
+ const mid = innerHeight / 2; let best: HTMLElement | null = null, bd = 1e9
+ for (const r of rows) { const b = r.getBoundingClientRect(); if (b.bottom < 0 || b.top > innerHeight) continue; const d = Math.abs((b.top + b.bottom) / 2 - mid); if (d < bd) { bd = d; best = r } }
+ if (best && bd < innerHeight * 0.3) hoverIn(best.dataset.shape!, best.dataset.label || ''); else hoverOut()
+ }, 250)
+ cleanups.push(() => clearInterval(iv))
+ }
+
+ if (!coarse && !reduce) {
+ document.querySelectorAll('[data-tilt]').forEach(el => {
+ const mv = (e: PointerEvent) => { const r = el.getBoundingClientRect(); const x = (e.clientX - r.left) / r.width - 0.5, y = (e.clientY - r.top) / r.height - 0.5; el.style.transform = `perspective(900px) rotateX(${-y * 6}deg) rotateY(${x * 8}deg) translateZ(6px)` }
+ const lv = () => { el.style.transform = '' }
+ el.addEventListener('pointermove', mv); el.addEventListener('pointerleave', lv)
+ cleanups.push(() => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerleave', lv) })
+ })
+ document.querySelectorAll('[data-mag]').forEach(el => {
+ const mv = (e: PointerEvent) => { const r = el.getBoundingClientRect(); const x = e.clientX - (r.left + r.width / 2), y = e.clientY - (r.top + r.height / 2); el.style.transform = `translate(${x * 0.25}px,${y * 0.35}px)`; el.style.transition = 'transform .15s' }
+ const lv = () => { el.style.transition = 'transform .5s cubic-bezier(.16,1,.3,1)'; el.style.transform = '' }
+ el.addEventListener('pointermove', mv); el.addEventListener('pointerleave', lv)
+ cleanups.push(() => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerleave', lv) })
+ })
+ }
+
+ // custom cursor
+ if (!coarse) {
+ const dot = document.querySelector('.cur'), ring = document.querySelector('.cur-ring')
+ if (dot && ring) {
+ let cx = innerWidth / 2, cy = innerHeight / 2, rx = cx, ry = cy, raf = 0
+ const mv = (e: PointerEvent) => { cx = e.clientX; cy = e.clientY }
+ addEventListener('pointermove', mv, { passive: true })
+ const loop = () => { rx += (cx - rx) * 0.18; ry += (cy - ry) * 0.18; dot.style.transform = `translate(${cx}px,${cy}px) translate(-50%,-50%)`; ring.style.transform = `translate(${rx}px,${ry}px) translate(-50%,-50%)`; raf = requestAnimationFrame(loop) }
+ loop()
+ const big = () => ring.classList.add('big'), small = () => ring.classList.remove('big')
+ const links = [...document.querySelectorAll('a,button')]
+ links.forEach(a => { a.addEventListener('pointerenter', big); a.addEventListener('pointerleave', small) })
+ cleanups.push(() => { cancelAnimationFrame(raf); removeEventListener('pointermove', mv); links.forEach(a => { a.removeEventListener('pointerenter', big); a.removeEventListener('pointerleave', small) }) })
+ }
+ }
+
+ // smooth anchors
+ const anchors = [...document.querySelectorAll('nav a[href^="#"]')]
+ const click = (e: Event) => { const a = e.currentTarget as HTMLAnchorElement; const t = document.querySelector(a.getAttribute('href')!); if (t) { e.preventDefault(); t.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth' }) } }
+ anchors.forEach(a => a.addEventListener('click', click))
+ cleanups.push(() => anchors.forEach(a => a.removeEventListener('click', click)))
+
+ return () => cleanups.forEach(f => f())
+ }, [])
+}
diff --git a/standalone.html b/standalone.html
new file mode 100644
index 0000000..cf48206
--- /dev/null
+++ b/standalone.html
@@ -0,0 +1,799 @@
+
+
+
+
+
+Pietro Quintavalle — Bare-metal systems and Linux infrastructure
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Rack: four production servers, bare metal
+
+
+
+
+
+
Monte Carasso, Ticino. Six years at the bench, five years on Linux, four servers in production. Looking for datacenter operations, hardware integration or Linux systems support, on-site or hybrid.
+
PietroQuintavalle
+
+
I build infrastructure from the solder joint up. Component-level diagnostics, ZFS storage, zero-trust networks, and the occasional driver written from a packet dump.
+
+
+
0years assembling and repairing hardware
+
0systems built for private clients
+
0active users on self-hosted infrastructure
+
0minutes to rebuild a cluster from bare metal
+
+
Scroll. The particles follow.
+
+
+
+
+
+
What you get when you hire me
+
+
+
Diagnoses at the component, not the ticket
+
Thermal bench-testing, firmware and BIOS flashing, soldering and harness wiring. When a node fails I find the part, not the symptom.
+
+
+
Treats every host as disposable
+
Ansible playbooks and Docker Compose rebuild the whole environment from a blank disk in under thirty minutes. Nothing important lives only in one place.
+
+
+
Zero exposed WAN ports, by default
+
Cloudflare Tunnels, WireGuard and Tailscale meshes, SSO with 2FA, host firewalls and intrusion detection on every node. Security is part of the build, not a later phase.
+
+
+
Reads the protocol when there is no documentation
+
Reverse-engineered a vendor USB HID protocol from packet captures and shipped a working Linux driver upstream. Undocumented hardware is a puzzle, not a blocker.
+
+
+
+
+
+
+
+
Experience
+
+
+
+
Infrastructure and hardware consultant
+ DEC Energy, USI startup, freelance
+
+
Designed, procured and deployed an on-premise two-node Kubernetes cluster from parts to production: local LLM inference, web hosting, accounting and Gitea/GitLab source control.
+
Tuned fan curves and ran thermal bench stress tests so the nodes stay quiet in an open office under full inference load.
+
Specified and assembled the engineering team's workstations and remain on-call for support.
+
+
+
+
+
IT support technician
+ Private clients across Ticino, freelance
+
+
Built, configured and repaired more than 25 desktop and laptop systems: sourcing, assembly, Windows and Linux deployment, driver debugging, ongoing maintenance.
+
Remote and on-site support for hardware failures, software conflicts and local networking faults, with repair logs kept to stop issues recurring.
+
+
+
+
+
Infrastructure engineer, self-directed
+ Four bare-metal servers serving 12+ users
+
+
UNRAID, Proxmox VE 8, TrueNAS, Docker and LXC running production workloads without any managed hosting.
+
Delivered a turnkey rack for a client: cabinet, chassis, structured cabling, TrueNAS with ZFS RAID 10, per-user ACLs and automated offsite snapshots with under thirty minutes of recovery time.
+
Hardened every edge: no open WAN ports, SSO and 2FA, intrusion detection, host-level firewalls.
+
+
+
+
+
+
+
+
+
Open source, close to the metal
+
Upstream contributions to projects that sit between hardware and the kernel. Hover a row and the sculpture becomes the work.
Four production hosts rebuilt from bare metal by Ansible in under thirty minutes. Twelve-plus daily users, no exposed WAN ports: Cloudflare Tunnels, WireGuard, SSO with 2FA and Fail2Ban on every node.
A 19-inch cabinet for a private client: patch panels, storage chassis and nine drive bays, every cable run dressed and labelled. TrueNAS on top with ZFS RAID 10, per-user ACLs and offsite snapshots for a sub-30-minute recovery target.
Cross-platform provisioning utility for environment configuration and plugin management, adopted by other users.
+
PowerShell
+
+
+
City simulator, USI group project
+
Coordinated a twelve-person Scrum team building a full-stack city simulation. Owned the Spring Boot backend and its integration with the web frontend.
+
Java, Spring Boot, JavaScript, HTML, CSS
+
+
+
+
+
+
+
+
Tools I reach for
+
+
+
+
+
Also on the bench: Rust, C, C++, Python and SQL for diagnostics, and a self-directed track on local LLM deployment and quantisation on bare-metal hardware with llama.cpp and Claude Code.
+
+
+
+
+
+
Education and languages
+
+
+
BSc Economics, English stream
Università della Svizzera italiana, Lugano, since September 2024. Microeconomics, game theory, quantitative finance, statistics, financial modelling.
+
Computer science coursework, two full years
USI, 2022 to 2024. Algorithms and data structures, object-oriented systems in Java and C++, software architecture, discrete logic.
+
Maturità Cantonale and Federal VET Diploma, AFC Economics
Scuola Cantonale di Commercio, Bellinzona, 2018 to 2022. Maturità 4.5/6, AFC 5/6.
+
+
+
+
Certifications
Google IT Support Professional Certificate. Google Data Analytics Professional Certificate. Microsoft Foundations of IT Systems, Networking and Data Protection.
+
Languages
Italian nativeEnglish C2German B1–B2Spanish A1Norwegian A1
+
Status
Italian national, Swiss Permit C, born 2001.
+
+
+
+
+
+
+
+
+
Need someone who can rack it, image it, and keep it up?