Claudio cooked
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"hash": "c1692715",
|
||||||
|
"configHash": "46535a3e",
|
||||||
|
"lockfileHash": "e3b0c442",
|
||||||
|
"browserHash": "7d791f90",
|
||||||
|
"optimized": {},
|
||||||
|
"chunks": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
+8
-9
@@ -1,21 +1,20 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" style="color-scheme: light">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#FAF9F6" />
|
<title>Pietro Quintavalle — Bare-metal systems and Linux infrastructure</title>
|
||||||
<title>Pietro Quintavalle — Bare-Metal Systems & Linux Infrastructure</title>
|
<meta name="description" content="Bare-metal deployments, server rack integration and low-level Linux systems diagnostics by Pietro Quintavalle, Ticino, Switzerland." />
|
||||||
<meta name="description" content="Bare-metal deployments, server rack integration, and low-level Linux systems diagnostics by Pietro Quintavalle." />
|
|
||||||
<meta name="keywords" content="Pietro Quintavalle, Bare-Metal Systems, Linux Infrastructure, Server Hardware, Systems Technician, Switzerland" />
|
|
||||||
<meta name="author" content="Pietro Quintavalle" />
|
<meta name="author" content="Pietro Quintavalle" />
|
||||||
<meta property="og:title" content="Pietro Quintavalle — Bare-Metal Systems & Linux Infrastructure" />
|
<meta name="theme-color" content="#eef0f3" />
|
||||||
<meta property="og:description" content="Bare-metal deployments, server rack integration, and low-level Linux systems diagnostics by Pietro Quintavalle." />
|
<meta property="og:title" content="Pietro Quintavalle — Bare-metal systems and Linux infrastructure" />
|
||||||
|
<meta property="og:description" content="Bare-metal deployments, server rack integration and low-level Linux systems diagnostics." />
|
||||||
<meta property="og:site_name" content="Pietro Quintavalle" />
|
<meta property="og:site_name" content="Pietro Quintavalle" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%23141821'/%3E%3Ctext x='32' y='43' text-anchor='middle' font-family='Arial Black,Arial' font-weight='900' font-size='30' fill='%23eef0f3'%3EPQ%3C/text%3E%3C/svg%3E" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=Space+Mono:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@500;700;800&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+136
-350
@@ -1,385 +1,171 @@
|
|||||||
import type { ReactNode } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import ThreeViewport from './ThreeViewport'
|
import ParticleScene, { SHAPE_NAMES } from './scene/ParticleScene'
|
||||||
import ContourBackground from './ContourBackground'
|
import { usePageMotion } from './usePageMotion'
|
||||||
import { buildRackScene, buildClusterScene, buildNasScene } from './scenes'
|
import * as C from './content'
|
||||||
|
|
||||||
const EMAIL = '0pietroquintavalle0@gmail.com'
|
const KEYFRAMES = ['.hero', '#work', '#skills', '#contact']
|
||||||
const GITHUB = 'https://github.com/Quinta0'
|
|
||||||
const LINKEDIN = 'https://www.linkedin.com/in/pietro-quintavalle-996b96267/'
|
|
||||||
|
|
||||||
type CaseStudy = {
|
function Name({ lines }: { lines: string[] }) {
|
||||||
label: string
|
const minLen = Math.min(...lines.map(l => l.length))
|
||||||
title: string
|
return (
|
||||||
desc: string
|
<h1 className="name" id="name" aria-label={lines.join(' ')}>
|
||||||
tags: string[]
|
{lines.map((ln, li) => (
|
||||||
tint: string
|
<span className="ln" key={ln} aria-hidden="true" style={{ '--s': Math.max(.55, minLen / ln.length) } as React.CSSProperties}>
|
||||||
headerL: string
|
{[...ln].map((ch, i) => <span className="ch" key={i} style={{ transitionDelay: `${0.25 + li * 0.35 + i * 0.04}s` }}>{ch}</span>)}
|
||||||
headerR: string
|
</span>
|
||||||
footerL: string
|
))}
|
||||||
footerR: string
|
</h1>
|
||||||
diagram: ReactNode
|
)
|
||||||
interactive?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cases: CaseStudy[] = [
|
function Marquee({ items, reverse }: { items: string[]; reverse?: boolean }) {
|
||||||
{
|
const row = [...items, ...items]
|
||||||
label: '01 / Turnkey Rack Assembly',
|
return <div className={`mq rv${reverse ? ' rev' : ''}`} aria-hidden="true"><div>{row.map((s, i) => <span key={i}>{s}</span>)}</div></div>
|
||||||
title: 'Turnkey Server Cabinet & ZFS Storage Pool',
|
}
|
||||||
desc: 'Procured and physically assembled a turnkey 19" server cabinet, structured patch panels, and storage chassis for a private client. Dressed and labeled all cable runs, deployed TrueNAS with ZFS RAID 10, automated granular user ACLs, and configured offsite snapshot synchronization for a <30 min recovery target.',
|
|
||||||
tags: ['19" EIA-310 Cabinet', 'ZFS RAID 10', 'Structured Cabling'],
|
|
||||||
tint: '#EFECE6',
|
|
||||||
headerL: 'RACK_CHASSIS // EIA-310',
|
|
||||||
headerR: '• DRAG TO ROTATE',
|
|
||||||
footerL: 'STRUCTURED HARNESS',
|
|
||||||
footerR: 'SUB-30M RTO SYNC',
|
|
||||||
diagram: <ThreeViewport build={buildRackScene} className="case-canvas" />,
|
|
||||||
interactive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '02 / Startup Infrastructure • DEC Energy',
|
|
||||||
title: 'Dual-Node Bare-Metal Kubernetes Cluster',
|
|
||||||
desc: 'Serving as freelance hardware partner for USI startup DEC Energy. Sourced parts, built, and deployed an on-premise 2-node cluster to host critical LLM inference, accounting, and Gitea source control. Tuned fan curves and conducted thermal bench stress testing to ensure quiet open-office operation under full tensor load.',
|
|
||||||
tags: ['Bare-Metal K8s', 'LLM Inference', 'Thermal Profiling'],
|
|
||||||
tint: '#EAE8E1',
|
|
||||||
headerL: 'DUAL_NODE // TOPOLOGY',
|
|
||||||
headerR: '• DRAG TO ROTATE',
|
|
||||||
footerL: 'ON-PREMISES INGESTION',
|
|
||||||
footerR: 'USI STARTUP',
|
|
||||||
diagram: <ThreeViewport build={buildClusterScene} className="case-canvas" />,
|
|
||||||
interactive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '03 / Protocol Reverse Engineering',
|
|
||||||
title: 'libratbag USB HID Kernel Driver',
|
|
||||||
desc: "Sniffed and reverse-engineered an undocumented vendor's raw USB HID packets using Wireshark. Decoded byte streams for DPI profiles, polling rates, and on-board memory layouts, writing a complete C device driver merged directly into upstream libratbag.",
|
|
||||||
tags: ['Upstream PR #1873', 'Wireshark Sniffing', 'C Device Driver'],
|
|
||||||
tint: '#F2EFEB',
|
|
||||||
headerL: 'USB HID DECODER',
|
|
||||||
headerR: 'KERNEL C INTERFACE',
|
|
||||||
footerL: 'RAW BYTE INSPECTION',
|
|
||||||
footerR: 'PR #1873 MERGED',
|
|
||||||
diagram: (
|
|
||||||
<svg viewBox="0 0 460 210" className="case-svg" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<line x1="30" y1="40" x2="430" y2="40" stroke="var(--ink)" strokeWidth="1" />
|
|
||||||
<path d="M 60 40 L 90 85" stroke="var(--accent)" strokeWidth="1.2" strokeDasharray="2 2" />
|
|
||||||
<path d="M 180 40 L 150 85" stroke="var(--ink)" strokeWidth="1" strokeDasharray="2 2" />
|
|
||||||
|
|
||||||
<rect x="50" y="85" width="360" height="50" rx="3" stroke="var(--ink)" strokeWidth="1.5" fill="var(--canvas)" />
|
|
||||||
<rect x="50" y="85" width="55" height="50" fill="var(--border)" stroke="var(--ink)" strokeWidth="1" />
|
|
||||||
<text x="77" y="110" fill="var(--ink)" fontFamily="Space Mono" fontSize="9" textAnchor="middle" fontWeight="bold">0x08</text>
|
|
||||||
<text x="77" y="125" fill="var(--secondary)" fontFamily="Space Mono" fontSize="7" textAnchor="middle">REPORT</text>
|
|
||||||
<rect x="105" y="85" width="55" height="50" fill="#FFFFFF" stroke="var(--ink)" strokeWidth="1" />
|
|
||||||
<text x="132" y="110" fill="var(--ink)" fontFamily="Space Mono" fontSize="9" textAnchor="middle" fontWeight="bold">0x14</text>
|
|
||||||
<text x="132" y="125" fill="var(--secondary)" fontFamily="Space Mono" fontSize="7" textAnchor="middle">CMD</text>
|
|
||||||
<rect x="160" y="85" width="65" height="50" fill="#FFFFFF" stroke="var(--ink)" strokeWidth="1" />
|
|
||||||
<text x="192" y="110" fill="var(--ink)" fontFamily="Space Mono" fontSize="9" textAnchor="middle">0x01 02</text>
|
|
||||||
<text x="192" y="125" fill="var(--secondary)" fontFamily="Space Mono" fontSize="7" textAnchor="middle">PROF_2</text>
|
|
||||||
<rect x="225" y="85" width="85" height="50" fill="#FFFFFF" stroke="var(--accent)" strokeWidth="1.5" />
|
|
||||||
<text x="267" y="110" fill="var(--accent)" fontFamily="Space Mono" fontSize="10" textAnchor="middle" fontWeight="bold">0x0640</text>
|
|
||||||
<text x="267" y="125" fill="var(--accent)" fontFamily="Space Mono" fontSize="7" textAnchor="middle">1600 DPI</text>
|
|
||||||
<rect x="310" y="85" width="100" height="50" fill="var(--canvas)" stroke="var(--ink)" strokeWidth="1" />
|
|
||||||
<text x="360" y="113" fill="var(--secondary)" fontFamily="Space Mono" fontSize="8" textAnchor="middle">00 00 ...</text>
|
|
||||||
|
|
||||||
<line x1="267" y1="135" x2="267" y2="165" stroke="var(--accent)" strokeWidth="1" />
|
|
||||||
<rect x="207" y="165" width="120" height="22" rx="2" fill="var(--ink)" />
|
|
||||||
<text x="267" y="179" fill="var(--canvas)" fontFamily="Space Mono" fontSize="8" textAnchor="middle">UPSTREAM KERNEL C</text>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '04 / Self-Directed Production',
|
|
||||||
title: 'Homelab as Code: 4-Node Multi-User Fabric',
|
|
||||||
desc: 'Operating 4 bare-metal production hosts (UNRAID, Proxmox VE 8, TrueNAS, Docker, LXC) actively serving 12+ daily active users without managed third-party hosting. Zero exposed WAN ports enforced via Cloudflare Tunnels, WireGuard, SSO/2FA, and Fail2Ban. Provisioned idempotently via Ansible with <30m bare-metal disaster recovery.',
|
|
||||||
tags: ['Zero-Trust WAN', 'Ansible IaC', 'Proxmox / UNRAID'],
|
|
||||||
tint: '#EDEAE3',
|
|
||||||
headerL: 'TOPOLOGY MAP',
|
|
||||||
headerR: '• HOVER BAYS',
|
|
||||||
footerL: 'ANSIBLE PROVISIONED',
|
|
||||||
footerR: 'DAILY PRODUCTION',
|
|
||||||
diagram: <ThreeViewport build={buildNasScene} className="case-canvas" />,
|
|
||||||
interactive: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const ossCards = [
|
|
||||||
{
|
|
||||||
kicker: 'RUNTIME PIPELINES',
|
|
||||||
title: 'Vysp3r/ProtonPlus • steam-for-linux',
|
|
||||||
meta: 'Issue #1232',
|
|
||||||
desc: 'Diagnosed a deployment pipeline fault in the Steam Linux Runtime installation sequence where unwritten manifest files caused execution crashes. Formulated a console depot recovery workaround and contributed code upstream to automate runtime integrity validation.',
|
|
||||||
tags: ['#SteamRuntime', '#Proton', '#LinuxGaming'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kicker: 'COMPATIBILITY TOOLING',
|
|
||||||
title: 'Recol/DLSS-Updater',
|
|
||||||
meta: 'PR #283, #252, #256',
|
|
||||||
desc: 'Engineered dynamic multi-vendor library detection (NVIDIA DLSS, AMD FSR, Intel XeSS). Replaced brittle static mapping tables with bounded manifest parsing to maintain driver compatibility across DLL upgrades; resolved UI state desynchronization bugs.',
|
|
||||||
tags: ['#Python', '#Qt', '#DriverInterfacing'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kicker: 'AUTOMATION UTILITY',
|
|
||||||
title: 'Quinta0/ObsidianSetup',
|
|
||||||
meta: '26 Stars on GitHub',
|
|
||||||
desc: 'Authored an idempotent cross-platform provisioning utility for automated workspace configuration, plugin synchronization, and environment deployment.',
|
|
||||||
tags: ['#PowerShell', '#EnvironmentProvisioning'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kicker: 'TEAM SYSTEMS ARCHITECTURE',
|
|
||||||
title: 'City Simulator • USI Systems Project',
|
|
||||||
meta: 'Agile / Scrum',
|
|
||||||
desc: 'Coordinated a 12-person development team using Agile/Scrum to build a full-stack city simulation. Owned backend architecture in Spring Boot while integrating JavaScript/HTML/CSS frontend components.',
|
|
||||||
tags: ['#Java', '#SpringBoot', '#ScrumTeam'],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const experience = [
|
|
||||||
{
|
|
||||||
title: 'Infrastructure & Hardware Consultant (Freelance)',
|
|
||||||
period: '2026',
|
|
||||||
org: 'DEC Energy (USI Startup) • Ticino, Switzerland',
|
|
||||||
desc: 'Trusted technical partner advising on workstation hardware (custom PCs, multi-monitor ergonomics, laptop fleet selection). Designed, procured, and deployed an on-premise 2-node bare-metal Kubernetes cluster for local LLM inference, accounting, and Git source control.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'IT Support & Hardware Technician (Freelance)',
|
|
||||||
period: 'Jan 2020 – Present',
|
|
||||||
org: 'Private Clients • Ticino, Switzerland',
|
|
||||||
desc: 'Assembled, configured, and repaired 25+ custom workstations and laptops: component sourcing, OS imaging (Windows/Linux), driver debugging, and remote/onsite diagnostics. Maintained comprehensive hardware logs to eliminate recurring component failures.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Bare-Metal Infrastructure Operator',
|
|
||||||
period: 'Jan 2020 – Present',
|
|
||||||
org: 'Self-Directed Production Homelab',
|
|
||||||
desc: 'Engineered and maintained 4 bare-metal production nodes serving 12+ daily active users without managed external hosting. Deployed turnkey server rack cabinets, configured ZFS RAID 10 arrays, and implemented zero exposed WAN ports via Cloudflare Tunnels and WireGuard meshes.',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const skillColumns = [
|
|
||||||
{
|
|
||||||
heading: 'HARDWARE & LAB',
|
|
||||||
items: ['Desktop / Server Assembly', 'Component Fault Triage', 'Thermal Bench Stressing', 'Firmware / BIOS Flashing', 'Soldering & Harness Wiring', 'Rack Mounting & Cabling'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'SYSTEMS & STORAGE',
|
|
||||||
items: ['Linux Daily (5+ yrs)', 'Debian, Arch, Ubuntu, NixOS', 'Proxmox VE 8, KVM, LXC', 'TrueNAS • ZFS RAID 10', 'UNRAID • Docker / K8s', 'Windows Server (AD, GPO, DNS)'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'NETWORK & TELEMETRY',
|
|
||||||
items: ['VLANs • Ubiquiti UniFi', 'Zero-Trust Tunnels', 'WireGuard / Tailscale', 'UFW, iptables, Fail2Ban', 'Ansible IaC • Bash', 'Grafana, Prometheus, Kuma'],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const education = [
|
|
||||||
{
|
|
||||||
date: '2022 – 2024 • USI Lugano',
|
|
||||||
title: 'Foundational Higher Education in Computer Science',
|
|
||||||
desc: 'Completed 2 full years of core CS curricula: Algorithms & data structures, OOP (Java, C++), discrete logic, and software architecture. Concluded formal coursework to transition full-time into hands-on bare-metal systems and datacenter infrastructure.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2018 – 2022 • SCC Bellinzona',
|
|
||||||
title: 'Maturità Cantonale & Federal VET Diploma (AFC)',
|
|
||||||
desc: 'Completed Swiss dual-track qualification (Maturità: 4.5/6, AFC: 5/6).',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const certifications = ['Google IT Support Professional', 'Google Data Analytics Professional', 'Microsoft Foundations of IT Systems']
|
|
||||||
|
|
||||||
const languages = [
|
|
||||||
{ name: 'Italian', level: 'Native (C2)' },
|
|
||||||
{ name: 'English', level: 'Full Professional (C2)' },
|
|
||||||
{ name: 'German', level: 'Working Proficiency (B1–B2)' },
|
|
||||||
{ name: 'Spanish / Norwegian', level: 'Basic (A1)' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
usePageMotion()
|
||||||
|
const [label, setLabel] = useState(SHAPE_NAMES[0])
|
||||||
|
const [swap, setSwap] = useState(false)
|
||||||
|
const barRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// label swaps with a short fade
|
||||||
|
const pending = useRef<number | null>(null)
|
||||||
|
const onLabel = useCallback((text: string) => {
|
||||||
|
setSwap(true)
|
||||||
|
if (pending.current) clearTimeout(pending.current)
|
||||||
|
pending.current = window.setTimeout(() => { setLabel(text); setSwap(false) }, 400)
|
||||||
|
}, [])
|
||||||
|
useEffect(() => () => { if (pending.current) clearTimeout(pending.current) }, [])
|
||||||
|
const onProgress = useCallback((f: number) => { if (barRef.current) barRef.current.style.transform = `scaleX(${f.toFixed(4)})` }, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="site-header">
|
<ParticleScene keyframes={KEYFRAMES} onLabel={onLabel} onProgress={onProgress} />
|
||||||
<div className="header-bar">
|
<div className="grain" aria-hidden="true" />
|
||||||
<nav className="main-nav">
|
<div className="vignette" aria-hidden="true" />
|
||||||
<a href="#works">WORKS</a>
|
<div className="cur" aria-hidden="true" /><div className="cur-ring" aria-hidden="true" />
|
||||||
<a href="#oss">DIAGNOSTICS & OSS</a>
|
<div className="progress" ref={barRef} aria-hidden="true" />
|
||||||
<a href="#background">EXPERIENCE</a>
|
|
||||||
<a href={`mailto:${EMAIL}`} className="nav-contact">CONTACT</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="site-main">
|
<nav>
|
||||||
{/* HERO */}
|
<a className="brand" href="#top">PQ</a>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#work">Work</a></li>
|
||||||
|
<li><a href="#oss">Open source</a></li>
|
||||||
|
<li><a href="#projects">Projects</a></li>
|
||||||
|
<li><a href="#contact">Contact</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div className="shape-label" aria-hidden="true"><i /><span className={swap ? 'swap' : ''}>{label}</span></div>
|
||||||
|
|
||||||
|
<main id="top">
|
||||||
<section className="hero">
|
<section className="hero">
|
||||||
<ContourBackground className="hero-canvas" />
|
<div className="wrap">
|
||||||
|
<p className="intro rv">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.</p>
|
||||||
<div className="hero-content">
|
<Name lines={['Pietro', 'Quintavalle']} />
|
||||||
<div className="hero-badges">
|
<div className="tag rv">
|
||||||
<span className="badge-secondary">Monte Carasso, CH • Permit C</span>
|
<p>I build infrastructure from the <em>solder joint</em> up. Component-level diagnostics, ZFS storage, zero-trust networks, and the occasional driver written from a packet dump.</p>
|
||||||
<span className="badge-sep">/</span>
|
|
||||||
<span className="badge-accent">Bare-Metal & Linux Systems</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="facts rv">
|
||||||
<h1 className="hero-title">Bridging bare-metal hardware craft with low-level Linux systems.</h1>
|
{C.facts.map(f => <div key={f.label}><b data-count={f.n} data-prefix={f.prefix}>0</b><small>{f.label}</small></div>)}
|
||||||
|
|
||||||
<p className="hero-lead">
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="hero-ctas">
|
|
||||||
<a href={`mailto:${EMAIL}`} className="btn btn-outline">{EMAIL}</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="hint rv"><b />Scroll. The particles follow.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="hero-metrics">
|
<section id="qualities">
|
||||||
<div>
|
<div className="wrap">
|
||||||
<span className="metric-val">25+</span>
|
<h2 className="rv">What you get when you hire me</h2>
|
||||||
<span className="metric-lbl">Workstations Assembled</span>
|
<div className="qual rv">
|
||||||
</div>
|
{C.qualities.map(q => <article key={q.title}><h3>{q.title}</h3><p>{q.text}</p></article>)}
|
||||||
<div>
|
|
||||||
<span className="metric-val">4 Nodes</span>
|
|
||||||
<span className="metric-lbl">12+ Active Tenants</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="metric-val"><30m</span>
|
|
||||||
<span className="metric-lbl">Ansible Disaster RTO</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* WORKS */}
|
<section id="work">
|
||||||
<section id="works" className="works">
|
<div className="wrap">
|
||||||
<div className="section-head">
|
<h2 className="rv">Experience</h2>
|
||||||
<span className="section-kicker">Physical Deployments & Infrastructure</span>
|
<div className="tl rv" id="tl"><div className="beam" />
|
||||||
<span className="section-kicker">[ 01 — 04 ]</span>
|
{C.jobs.map(j => (
|
||||||
</div>
|
<div className="job" key={j.title} data-shape={j.shape} data-label={j.label}>
|
||||||
|
<time>{j.time}</time>
|
||||||
<div className="case-list">
|
<h3>{j.title}</h3>
|
||||||
{cases.map(c => (
|
<span className="org">{j.org}</span>
|
||||||
<article className="case" key={c.title}>
|
<ul>{j.bullets.map(b => <li key={b}>{b}</li>)}</ul>
|
||||||
<div className="case-copy">
|
|
||||||
<span className="case-label">{c.label}</span>
|
|
||||||
<h2 className="case-title">{c.title}</h2>
|
|
||||||
<p className="case-desc">{c.desc}</p>
|
|
||||||
<div className="tag-row">
|
|
||||||
{c.tags.map(tag => <span className="tag-pill" key={tag}>{tag}</span>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="case-diagram" style={{ background: c.tint }}>
|
|
||||||
<div className="diagram-card">
|
|
||||||
<div className="diagram-row diagram-header">
|
|
||||||
<span>{c.headerL}</span>
|
|
||||||
<span className={c.interactive ? 'diagram-interactive' : undefined}>{c.headerR}</span>
|
|
||||||
</div>
|
|
||||||
<div className="diagram-svg-wrap">{c.diagram}</div>
|
|
||||||
<div className="diagram-row diagram-footer">
|
|
||||||
<span>{c.footerL}</span>
|
|
||||||
<span>{c.footerR}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* OSS */}
|
|
||||||
<section id="oss" className="oss">
|
|
||||||
<div className="section-head">
|
|
||||||
<span className="section-kicker">Systems Diagnostics & Open-Source</span>
|
|
||||||
<a href={GITHUB} target="_blank" rel="noopener" className="section-link">github.com/Quinta0 ↗</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="oss-grid">
|
|
||||||
{ossCards.map(card => (
|
|
||||||
<div className="oss-card" key={card.title}>
|
|
||||||
<div className="oss-card-top">
|
|
||||||
<div>
|
|
||||||
<span className="oss-kicker">{card.kicker}</span>
|
|
||||||
<h3>{card.title}</h3>
|
|
||||||
</div>
|
|
||||||
<span className="oss-meta">{card.meta}</span>
|
|
||||||
</div>
|
|
||||||
<p>{card.desc}</p>
|
|
||||||
<div className="tag-row tag-row-mini">
|
|
||||||
{card.tags.map(tag => <span key={tag}>{tag}</span>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* BACKGROUND */}
|
|
||||||
<section id="background" className="background">
|
|
||||||
|
|
||||||
<div className="bg-row bg-row-bordered">
|
|
||||||
<div className="bg-label">Professional Experience</div>
|
|
||||||
<div className="bg-content exp-list">
|
|
||||||
{experience.map(job => (
|
|
||||||
<div className="exp-item" key={job.title}>
|
|
||||||
<div className="exp-head">
|
|
||||||
<h3>{job.title}</h3>
|
|
||||||
<span className="exp-period">{job.period}</span>
|
|
||||||
</div>
|
|
||||||
<div className="exp-org">{job.org}</div>
|
|
||||||
<p>{job.desc}</p>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="bg-row bg-row-bordered">
|
<section id="oss">
|
||||||
<div className="bg-label">Technical Capabilities</div>
|
<div className="wrap">
|
||||||
<div className="bg-content skills-grid">
|
<h2 className="rv">Open source, close to the metal</h2>
|
||||||
{skillColumns.map(col => (
|
<p className="dim rv" style={{ marginBottom: 40 }}>Upstream contributions to projects that sit between hardware and the kernel. Hover a row and the sculpture becomes the work.</p>
|
||||||
<div className="skills-col" key={col.heading}>
|
<div className="oss rv">
|
||||||
<span className="skills-heading">{col.heading}</span>
|
{C.oss.map(o => (
|
||||||
<ul>
|
<a href={o.href} target="_blank" rel="noopener" key={o.href} data-shape={o.shape} data-label={o.label}>
|
||||||
{col.items.map(item => <li key={item}>{item}</li>)}
|
<div><span className="repo">{o.repo}</span><h3>{o.title}</h3><p className="what">{o.text}</p></div>
|
||||||
</ul>
|
<span className="ref">{o.ref}</span>
|
||||||
</div>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="bg-row bg-row-bordered">
|
<section id="projects">
|
||||||
<div className="bg-label">Education & Credentials</div>
|
<div className="wrap">
|
||||||
<div className="bg-content edu-grid">
|
<h2 className="rv">Selected projects</h2>
|
||||||
<div className="edu-list">
|
<div className="proj rv">
|
||||||
{education.map(edu => (
|
{C.projects.map(p => (
|
||||||
<div key={edu.title}>
|
<article key={p.title} data-tilt data-shape={p.shape} data-label={p.label}>
|
||||||
<span className="edu-date">{edu.date}</span>
|
<h3>{p.title}{p.star && <> <span className="star">{p.star}</span></>}</h3>
|
||||||
<h4>{edu.title}</h4>
|
<p>{p.text}</p>
|
||||||
<p>{edu.desc}</p>
|
<div className="stack">{p.stack}</div>
|
||||||
</div>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="creds-list">
|
<section id="skills">
|
||||||
<div>
|
<div className="wrap"><h2 className="rv">Tools I reach for</h2></div>
|
||||||
<span className="creds-heading">CERTIFICATIONS</span>
|
<Marquee items={C.toolsRowA} />
|
||||||
<ul>
|
<Marquee items={C.toolsRowB} reverse />
|
||||||
{certifications.map(cert => <li key={cert}>• {cert}</li>)}
|
<div className="wrap">
|
||||||
</ul>
|
<p className="skills-note rv">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.</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</section>
|
||||||
<span className="creds-heading">LANGUAGES</span>
|
|
||||||
<ul>
|
<section id="education">
|
||||||
{languages.map(l => <li key={l.name}>• <strong>{l.name}</strong>: {l.level}</li>)}
|
<div className="wrap">
|
||||||
</ul>
|
<h2 className="rv">Education and languages</h2>
|
||||||
</div>
|
<div className="edu rv">
|
||||||
|
<dl>{C.education.map(e => <div key={e.title}><dt>{e.title}</dt><dd>{e.text}</dd></div>)}</dl>
|
||||||
|
<div>
|
||||||
|
<dl>
|
||||||
|
<div><dt>Certifications</dt><dd>{C.certifications}</dd></div>
|
||||||
|
<div><dt>Languages</dt><dd className="langs">{C.languages.map(([l, lv]) => <span key={l}><b>{l}</b> {lv}</span>)}</dd></div>
|
||||||
|
<div><dt>Status</dt><dd>Italian national, Swiss Permit C, born 2001.</dd></div>
|
||||||
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="contact" className="contact">
|
||||||
|
<div className="wrap">
|
||||||
|
<h2 className="rv">Need someone who can rack it, image it, and keep it up?</h2>
|
||||||
|
<a className="mail rv" href={`mailto:${C.EMAIL}`}>{C.EMAIL}</a>
|
||||||
|
<div className="links rv">
|
||||||
|
<a data-mag href={C.GITHUB} target="_blank" rel="noopener">GitHub</a>
|
||||||
|
<a data-mag href={C.LINKEDIN} target="_blank" rel="noopener">LinkedIn</a>
|
||||||
|
<a data-mag href={C.WHOAMI} target="_blank" rel="noopener">whoami</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
<footer><span>Pietro Quintavalle, Ticino, Switzerland</span><span>Built with Three.js, one canvas, fifteen thousand particles.</span></footer>
|
||||||
<footer className="site-footer">
|
|
||||||
<div>
|
|
||||||
<span className="footer-name">Pietro Quintavalle</span> • Open for Datacenter & Systems roles across Switzerland
|
|
||||||
</div>
|
|
||||||
<div className="footer-links">
|
|
||||||
<a href={GITHUB} target="_blank" rel="noopener">GitHub ↗</a>
|
|
||||||
<a href={LINKEDIN} target="_blank" rel="noopener">LinkedIn ↗</a>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(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 <div ref={mountRef} className={className} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }} />
|
|
||||||
}
|
|
||||||
@@ -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<HTMLDivElement>(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 <div ref={containerRef} className={className} />
|
|
||||||
}
|
|
||||||
@@ -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']]
|
||||||
+148
-201
@@ -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{
|
:root{
|
||||||
--canvas:#FAF9F6;
|
--bg:#eef0f3; --bg-2:#e3e6eb; --fg:#141821; --muted:#5b6472;
|
||||||
--surface:#FFFFFF;
|
--amber:#c86a00; --ice:#2f5fd6; --line:rgba(20,24,33,.14);
|
||||||
--ink:#121212;
|
--display:'Syne',"Helvetica Neue",Arial,sans-serif;
|
||||||
--secondary:#6E6B65;
|
--body:'IBM Plex Sans',"Segoe UI",system-ui,sans-serif;
|
||||||
--border:#E6E4DD;
|
|
||||||
--accent:#C1121F;
|
|
||||||
--container:1152px;
|
|
||||||
--pad:24px;
|
|
||||||
--f-sans:'Plus Jakarta Sans',sans-serif;
|
|
||||||
--f-mono:'Space Mono',monospace;
|
|
||||||
}
|
}
|
||||||
|
@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}
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
body{
|
html{scroll-behavior:auto}
|
||||||
background:var(--canvas);
|
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}
|
||||||
color:var(--ink);
|
@media (pointer:coarse){ body{cursor:auto} }
|
||||||
font-family:var(--f-sans);
|
a{color:inherit;text-decoration:none}
|
||||||
font-size:15px;
|
::selection{background:var(--amber);color:#fff}
|
||||||
line-height:1.5;
|
|
||||||
-webkit-font-smoothing:antialiased;
|
|
||||||
}
|
|
||||||
::selection{background:var(--ink);color:#fff}
|
|
||||||
|
|
||||||
/* ---------- HEADER ---------- */
|
#scene{position:fixed;inset:0;z-index:0;display:block}
|
||||||
.site-header{
|
.grain{position:fixed;inset:-100%;z-index:1;pointer-events:none;opacity:.05;
|
||||||
position:fixed;top:0;left:0;right:0;z-index:50;
|
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");
|
||||||
background:rgba(250,249,246,.9);
|
animation:grain 1.2s steps(6) infinite}
|
||||||
backdrop-filter:blur(10px);
|
@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)}}
|
||||||
-webkit-backdrop-filter:blur(10px);
|
.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%)}
|
||||||
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)}
|
|
||||||
|
|
||||||
/* ---------- LAYOUT ---------- */
|
/* cursor */
|
||||||
.site-main{max-width:var(--container);margin:0 auto;padding:176px var(--pad) 128px;display:flex;flex-direction:column;gap:144px}
|
.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}
|
||||||
.section-head{display:flex;justify-content:space-between;align-items:baseline;border-bottom:1px solid var(--border);padding-bottom:12px;margin-bottom:64px}
|
.cur{width:6px;height:6px;background:#fff}
|
||||||
.section-kicker{font-family:var(--f-mono);font-size:12px;color:var(--secondary);letter-spacing:.05em;text-transform:uppercase}
|
.cur-ring{width:34px;height:34px;border:1px solid #fff;transition:width .25s,height .25s,opacity .25s}
|
||||||
.section-link{font-family:var(--f-mono);font-size:12px;color:var(--ink);text-decoration:none}
|
.cur-ring.big{width:64px;height:64px;opacity:.6}
|
||||||
.section-link:hover{color:var(--accent)}
|
@media (pointer:coarse){ .cur,.cur-ring{display:none} }
|
||||||
|
|
||||||
/* ---------- HERO ---------- */
|
/* nav */
|
||||||
.hero{position:relative;max-width:820px}
|
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}
|
||||||
.hero-canvas{z-index:0}
|
nav .brand{font-family:var(--display);font-weight:800;letter-spacing:-.02em;font-size:17px}
|
||||||
.hero-content{position:relative;z-index:1;display:flex;flex-direction:column;gap:32px}
|
nav ul{display:flex;gap:26px;list-style:none}
|
||||||
.hero-badges{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
nav a{position:relative;padding:4px 0}
|
||||||
.badge-secondary,.badge-accent{font-family:var(--f-mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase}
|
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)}
|
||||||
.badge-secondary{color:var(--secondary)}
|
nav a:hover::after,nav a:focus-visible::after{right:0}
|
||||||
.badge-accent{color:var(--accent)}
|
@media (max-width:720px){ nav ul{display:none} }
|
||||||
.badge-sep{color:var(--border)}
|
.progress{position:fixed;top:0;left:0;height:2px;width:100%;transform-origin:0 0;transform:scaleX(0);background:var(--amber);z-index:21}
|
||||||
.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}
|
|
||||||
|
|
||||||
/* ---------- CASE STUDIES ---------- */
|
/* shape label */
|
||||||
.case-list{display:flex;flex-direction:column;gap:112px}
|
.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}
|
||||||
.case{display:grid;grid-template-columns:5fr 7fr;gap:32px;align-items:center}
|
.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}
|
||||||
.case-copy{display:flex;flex-direction:column;gap:16px}
|
@keyframes blink{0%,100%{opacity:1}50%{opacity:.25}}
|
||||||
.case-label{font-family:var(--f-mono);font-size:12px;color:var(--secondary);text-transform:uppercase;letter-spacing:.03em}
|
.shape-label span{display:inline-block;transition:opacity .4s,transform .4s}
|
||||||
.case-title{font-size:30px;font-weight:400;letter-spacing:-.01em;color:var(--ink)}
|
.shape-label span.swap{opacity:0;transform:translateY(6px)}
|
||||||
.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}
|
|
||||||
|
|
||||||
.case-canvas{
|
/* layout */
|
||||||
position:relative;width:100%;height:288px;border-radius:6px;overflow:hidden;
|
main{position:relative;z-index:2}
|
||||||
cursor:grab;touch-action:pan-y;
|
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}
|
||||||
.case-canvas:active{cursor:grabbing}
|
section:not(.hero):not(.contact) .wrap::before{content:"";position:absolute;inset:-60px -80px;z-index:-1;pointer-events:none;
|
||||||
.case-canvas canvas{display:block;touch-action:pan-y}
|
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%)}
|
||||||
.nas-hud{
|
h1,h2,h3{font-family:var(--display);letter-spacing:-.03em;line-height:.95}
|
||||||
position:absolute;z-index:5;pointer-events:none;
|
h2{font-size:clamp(38px,6vw,84px);font-weight:800;margin-bottom:.5em;max-width:14ch}
|
||||||
background:var(--ink);color:var(--canvas);
|
h3{font-size:clamp(20px,2.2vw,28px);font-weight:700;letter-spacing:-.02em;line-height:1.15}
|
||||||
font-family:var(--f-mono);font-size:10px;
|
p.lead{font-size:clamp(18px,1.5vw,22px);max-width:56ch;color:var(--fg)}
|
||||||
padding:4px 8px;border-radius:4px;box-shadow:0 1px 2px rgba(0,0,0,.2);
|
p.dim{color:var(--muted);max-width:60ch}
|
||||||
}
|
|
||||||
|
|
||||||
.led-pulse{animation:ledBlink 2.4s ease-in-out infinite}
|
/* reveal: opacity only, one quiet fade */
|
||||||
@keyframes ledBlink{0%,100%{opacity:.3}50%{opacity:1}}
|
.rv{opacity:0;transition:opacity 1s ease}
|
||||||
.bus-flow{stroke-dasharray:4,4;animation:busTravel 12s linear infinite}
|
.rv.in{opacity:1}
|
||||||
@keyframes busTravel{to{stroke-dashoffset:-100}}
|
|
||||||
|
|
||||||
/* ---------- OSS ---------- */
|
/* hero */
|
||||||
.oss-grid{display:grid;grid-template-columns:1fr 1fr;gap:24px}
|
.hero{min-height:100vh;padding-top:24vh;justify-content:flex-start;pointer-events:none}
|
||||||
.oss-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:24px;display:flex;flex-direction:column;gap:12px}
|
.hero .wrap>*{pointer-events:auto}
|
||||||
.oss-card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
|
.intro{font-size:15px;color:var(--muted);margin-bottom:2.2em;max-width:44ch}
|
||||||
.oss-kicker{display:block;font-family:var(--f-mono);font-size:10px;color:var(--accent);font-weight:600;letter-spacing:.03em}
|
.name{font-size:clamp(56px,12.5vw,190px);font-weight:800;line-height:.86;letter-spacing:-.05em;overflow:hidden}
|
||||||
.oss-card-top h3{font-size:16px;font-weight:700;color:var(--ink)}
|
.name .ln{display:block;overflow:hidden;padding-bottom:.06em;white-space:nowrap;font-size:calc(1em * var(--s,1))}
|
||||||
.oss-meta{font-family:var(--f-mono);font-size:11px;color:var(--secondary);white-space:nowrap}
|
.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%}
|
||||||
.oss-card p{font-size:12.5px;color:var(--secondary);line-height:1.65}
|
.name.on .ch{transform:none;opacity:1}
|
||||||
.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}
|
.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 ---------- */
|
/* facts row */
|
||||||
.background{display:flex;flex-direction:column;gap:64px;padding-top:48px;border-top:1px solid var(--border)}
|
.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}
|
||||||
.bg-row{display:grid;grid-template-columns:4fr 8fr;gap:48px}
|
.facts div{display:flex;flex-direction:column;gap:4px}
|
||||||
.bg-row-bordered{padding-top:32px;border-top:1px solid rgba(230,228,221,.6)}
|
.facts b{font-family:var(--display);font-size:clamp(30px,3vw,44px);font-weight:700;letter-spacing:-.03em;line-height:1}
|
||||||
.bg-label{font-family:var(--f-mono);font-size:12px;color:var(--secondary);text-transform:uppercase;letter-spacing:.1em}
|
.facts small{font-size:13px;color:var(--muted)}
|
||||||
.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}
|
|
||||||
|
|
||||||
.exp-list{display:flex;flex-direction:column;gap:32px}
|
/* qualities: ruled list, not cards */
|
||||||
.exp-item{display:flex;flex-direction:column;gap:4px}
|
.qual{display:grid;grid-template-columns:1fr 1fr;gap:0 60px;border-top:1px solid var(--line)}
|
||||||
.exp-head{display:flex;justify-content:space-between;align-items:baseline;gap:12px}
|
.qual article{padding:34px 0 36px;border-bottom:1px solid var(--line)}
|
||||||
.exp-head h3{font-size:14px;font-weight:700;color:var(--ink)}
|
.qual article:nth-child(-n+2){border-top:0}
|
||||||
.exp-period{font-family:var(--f-mono);font-size:12px;color:var(--secondary);white-space:nowrap}
|
.qual h3{margin-bottom:12px;max-width:16ch}
|
||||||
.exp-org{font-family:var(--f-mono);font-size:12px;color:var(--accent)}
|
.qual p{color:var(--muted);max-width:40ch}
|
||||||
.exp-item p{color:var(--secondary);font-size:12.5px;line-height:1.65;padding-top:4px}
|
@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}
|
/* timeline */
|
||||||
.skills-col{display:flex;flex-direction:column;gap:8px}
|
.tl{position:relative;padding-left:44px;max-width:860px}
|
||||||
.skills-heading{color:var(--ink);font-weight:700;border-bottom:1px solid var(--border);padding-bottom:4px}
|
.tl::before{content:"";position:absolute;left:8px;top:0;bottom:0;width:1px;background:var(--line)}
|
||||||
.skills-col ul{list-style:none;display:flex;flex-direction:column;gap:4px}
|
.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)}
|
||||||
.skills-col li{color:var(--secondary);font-size:11px}
|
.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}
|
/* oss */
|
||||||
.edu-list{display:flex;flex-direction:column;gap:16px}
|
.oss{display:grid;gap:0;border-top:1px solid var(--line);max-width:960px}
|
||||||
.edu-date{font-family:var(--f-mono);font-size:12px;color:var(--secondary)}
|
.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)}
|
||||||
.edu-list h4{font-size:14px;font-weight:700;color:var(--ink);margin-top:2px}
|
.oss a:hover,.oss a:focus-visible{padding-left:14px}
|
||||||
.edu-list p{font-size:12px;color:var(--secondary);margin-top:4px;line-height:1.5}
|
.oss .repo{font-size:14px;color:var(--ice);margin-bottom:8px;display:block}
|
||||||
.creds-list{display:flex;flex-direction:column;gap:16px;font-family:var(--f-mono);font-size:12px}
|
.oss .what{color:var(--muted);max-width:56ch}
|
||||||
.creds-heading{display:block;color:var(--ink);font-weight:700;border-bottom:1px solid var(--border);padding-bottom:4px}
|
.oss .ref{font-family:var(--display);font-weight:700;font-size:15px;color:var(--fg);white-space:nowrap;padding-top:2px;opacity:.7}
|
||||||
.creds-list ul{list-style:none;display:flex;flex-direction:column;gap:4px;padding-top:6px}
|
@media (max-width:640px){ .oss a{grid-template-columns:1fr} }
|
||||||
.creds-list li{color:var(--secondary);font-size:11px}
|
|
||||||
.creds-list li strong{color:var(--ink)}
|
|
||||||
|
|
||||||
/* ---------- FOOTER ---------- */
|
/* projects: staggered, not a card grid */
|
||||||
.site-footer{
|
.proj{display:grid;grid-template-columns:repeat(12,1fr);gap:40px 24px}
|
||||||
max-width:var(--container);margin:0 auto;padding:80px var(--pad);
|
.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)}
|
||||||
border-top:1px solid var(--border);
|
.proj article:nth-child(2){grid-column:7/span 6;margin-top:80px}
|
||||||
display:flex;justify-content:space-between;align-items:baseline;gap:16px;flex-wrap:wrap;
|
.proj article:nth-child(3){grid-column:2/span 5;margin-top:-30px}
|
||||||
font-family:var(--f-mono);font-size:12px;color:var(--secondary);
|
.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)}
|
||||||
.footer-name{color:var(--ink);font-weight:500}
|
.proj article:hover::after{width:100%}
|
||||||
.footer-links{display:flex;gap:24px;color:var(--ink)}
|
.proj .stack{font-size:13px;color:var(--muted);margin:14px 0 0;line-height:1.8}
|
||||||
.footer-links a{color:inherit;text-decoration:none}
|
.proj p{color:#2b3140;max-width:44ch;margin-top:10px}
|
||||||
.footer-links a:hover{color:var(--accent)}
|
.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 ---------- */
|
/* skills: two counter-rotating marquees */
|
||||||
@media(max-width:1024px){
|
.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}
|
||||||
.case{grid-template-columns:1fr}
|
.mq div{display:inline-block;animation:mq 60s linear infinite}
|
||||||
.bg-row{grid-template-columns:1fr;gap:16px}
|
.mq.rev div{animation-direction:reverse;color:var(--muted)}
|
||||||
.bg-content{max-width:100%}
|
.mq span{padding:0 .55em}
|
||||||
}
|
.mq span::after{content:"/";color:var(--amber);padding-left:1.1em;font-weight:500}
|
||||||
@media(max-width:768px){
|
@keyframes mq{to{transform:translateX(-50%)}}
|
||||||
.oss-grid{grid-template-columns:1fr}
|
.skills-note{margin-top:40px;color:var(--muted);max-width:60ch}
|
||||||
.skills-grid,.edu-grid{grid-template-columns:1fr}
|
|
||||||
}
|
/* education */
|
||||||
@media(max-width:640px){
|
.edu{display:grid;grid-template-columns:1fr 1fr;gap:40px 60px;max-width:960px}
|
||||||
:root{--pad:16px}
|
.edu dl{display:grid;gap:22px}
|
||||||
.header-bar{height:64px;gap:16px}
|
.edu dt{font-weight:600}
|
||||||
.main-nav{gap:16px;font-size:10px}
|
.edu dd{color:var(--muted);font-size:15px}
|
||||||
.site-main{padding:96px var(--pad) 80px;gap:80px}
|
.edu .langs{display:flex;flex-wrap:wrap;gap:10px 22px;font-size:15px;color:var(--muted)}
|
||||||
.section-head{margin-bottom:48px}
|
.edu .langs b{color:var(--fg);font-weight:500}
|
||||||
.hero{gap:24px}
|
@media (max-width:760px){ .edu{grid-template-columns:1fr} }
|
||||||
.hero-title{font-size:30px;line-height:1.12}
|
|
||||||
.hero-lead{font-size:16px}
|
/* contact */
|
||||||
.hero-metrics{grid-template-columns:repeat(3,1fr);gap:12px}
|
.contact{min-height:100vh;justify-content:center;text-align:left}
|
||||||
.case-list{gap:64px}
|
.contact h2{font-size:clamp(44px,8.5vw,140px);max-width:none;line-height:.9}
|
||||||
.case{gap:24px}
|
.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}
|
||||||
.case-title{font-size:20px}
|
.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)}
|
||||||
.case-desc{font-size:12px}
|
.mail:hover::after,.mail:focus-visible::after{transform:scaleX(1)}
|
||||||
.case-diagram{padding:16px;border-radius:12px}
|
.links{display:flex;flex-wrap:wrap;gap:14px;margin-top:48px}
|
||||||
.diagram-card{padding:16px}
|
.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}
|
||||||
.case-canvas{height:256px}
|
.links a:hover,.links a:focus-visible{border-color:var(--fg);background:rgba(20,24,33,.05)}
|
||||||
.site-footer{padding:48px var(--pad);flex-direction:column;align-items:flex-start}
|
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}
|
||||||
|
|||||||
@@ -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<HTMLCanvasElement>(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<HTMLElement>(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 <canvas id="scene" ref={canvasRef} aria-hidden="true" />
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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);
|
||||||
|
}`;
|
||||||
|
|
||||||
@@ -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<n;i++){
|
||||||
|
let x,y,z; const r=Math.random();
|
||||||
|
if(r<0.42){ // horizontal chassis edges
|
||||||
|
const u=Math.floor(rnd(units+1)), edge=Math.floor(rnd(4)), t=rnd(1);
|
||||||
|
y=y0+u*H;
|
||||||
|
if(edge===0){x=-W/2+t*W;z=D/2}else if(edge===1){x=-W/2+t*W;z=-D/2}else if(edge===2){x=W/2;z=-D/2+t*D}else{x=-W/2;z=-D/2+t*D}
|
||||||
|
} else if(r<0.55){ // vertical rails
|
||||||
|
const c=Math.floor(rnd(4)); x=(c%2?1:-1)*W/2; z=(c<2?1:-1)*D/2; y=y0+rnd(units*H);
|
||||||
|
} else if(r<0.88){ // front face drive bays, dense
|
||||||
|
x=rnd(W/2-0.15,-W/2+0.15); const u=Math.floor(rnd(units)); const bay=Math.floor(rnd(3));
|
||||||
|
y=y0+u*H+0.12+bay*0.2+rnd(0.06); z=D/2+0.02;
|
||||||
|
} else { // rear cable loom drooping
|
||||||
|
const t=rnd(1); x=rnd(0.6,-0.6)+Math.sin(t*9+i)*0.25; y=y0+t*units*H; z=-D/2-0.35-Math.sin(t*3.1)*0.3;
|
||||||
|
}
|
||||||
|
a[i*3]=x;a[i*3+1]=y;a[i*3+2]=z;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
/* Shape 1 — torus knot: the mesh network */
|
||||||
|
export function shapeKnot(n: number): Float32Array {
|
||||||
|
const a=new Float32Array(n*3), p=2,q=3, R=3.0, r=1.35, tube=0.55;
|
||||||
|
const P=(t: number)=>{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<n;i++){
|
||||||
|
const t=rnd(Math.PI*2), c=P(t), c2=P(t+0.001);
|
||||||
|
let tx=c2[0]-c[0],ty=c2[1]-c[1],tz=c2[2]-c[2]; const tl=Math.hypot(tx,ty,tz); tx/=tl;ty/=tl;tz/=tl;
|
||||||
|
// normal ~ toward the ring centre, then binormal = T x N
|
||||||
|
let nx=-c[0],ny=-c[1],nz=0; const dn=tx*nx+ty*ny; nx-=tx*dn;ny-=ty*dn;nz-=tz*dn; const nl=Math.hypot(nx,ny,nz)||1; nx/=nl;ny/=nl;nz/=nl;
|
||||||
|
const bx=ty*nz-tz*ny, by=tz*nx-tx*nz, bz=tx*ny-ty*nx;
|
||||||
|
const ang=rnd(Math.PI*2), rad=Math.random()<0.7?tube:tube*Math.sqrt(Math.random());
|
||||||
|
const ca=Math.cos(ang)*rad, sa=Math.sin(ang)*rad;
|
||||||
|
a[i*3]=c[0]+nx*ca+bx*sa; a[i*3+1]=c[1]+ny*ca+by*sa; a[i*3+2]=c[2]+nz*ca+bz*sa;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
/* Shape 2 — globe with latitude bands + a defensive shell: the zero-trust perimeter */
|
||||||
|
export function shapeGlobe(n: number): Float32Array {
|
||||||
|
const a=new Float32Array(n*3);
|
||||||
|
for(let i=0;i<n;i++){
|
||||||
|
const r=Math.random();
|
||||||
|
let R=4.2, theta, phi;
|
||||||
|
if(r<0.6){ // lat/long grid
|
||||||
|
const useLat=Math.random()<0.5;
|
||||||
|
if(useLat){ phi=Math.acos(1-2*(Math.floor(rnd(13))/12)); theta=rnd(Math.PI*2); }
|
||||||
|
else { theta=Math.floor(rnd(24))*(Math.PI/12); phi=Math.acos(1-2*Math.random()); }
|
||||||
|
} else if(r<0.85){ phi=Math.acos(1-2*Math.random()); theta=rnd(Math.PI*2); R=4.2+rnd(0.05); }
|
||||||
|
else { phi=Math.acos(1-2*Math.random()); theta=rnd(Math.PI*2); R=5.6+rnd(0.15); } // outer shell
|
||||||
|
a[i*3]=R*Math.sin(phi)*Math.cos(theta); a[i*3+1]=R*Math.cos(phi); a[i*3+2]=R*Math.sin(phi)*Math.sin(theta);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
/* Canvas sampler: anything drawn white on black becomes particles */
|
||||||
|
function sampleCanvas(n: number, w: number, h: number, draw: (ctx: CanvasRenderingContext2D) => 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;y<h;y+=2)for(let x=0;x<w;x+=2){ if(img[(y*w+x)*4]>128) pts.push(x,y); }
|
||||||
|
const a=new Float32Array(n*3);
|
||||||
|
for(let i=0;i<n;i++){
|
||||||
|
const k=Math.floor(rnd(pts.length/2))*2;
|
||||||
|
a[i*3]=(pts[k]-w/2)*S+rnd(0.04,-0.04); a[i*3+1]=-(pts[k+1]-h/2)*S+rnd(0.04,-0.04);
|
||||||
|
a[i*3+2]=rnd(zSpread,-zSpread)*(Math.random()<0.15?1:0.15);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
/* Shape 3 — the initials */
|
||||||
|
export function shapeText(n: number): Float32Array {
|
||||||
|
return sampleCanvas(n,720,360,ctx=>{ 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<n;i++){
|
||||||
|
let r=Math.random()*tot, pr=prims[0]; for(const q of prims){ pr=q; if(r<q.w)break; r-=q.w; }
|
||||||
|
let x=0,y=0,z=0;
|
||||||
|
if(pr.t==='line'){ const u=rnd(1); x=pr.a[0]+(pr.b[0]-pr.a[0])*u; y=pr.a[1]+(pr.b[1]-pr.a[1])*u; z=pr.a[2]+(pr.b[2]-pr.a[2])*u; }
|
||||||
|
else {
|
||||||
|
const [cx,cy,cz]=pr.c,[w,h,d]=pr.s;
|
||||||
|
if(pr.t==='edges'){ const e=Math.floor(rnd(12)), u=rnd(1)-0.5, s1=(e&1)?0.5:-0.5, s2=(e&2)?0.5:-0.5;
|
||||||
|
if(e<4){x=u*w;y=s1*h;z=s2*d} else if(e<8){x=s1*w;y=u*h;z=s2*d} else {x=s1*w;y=s2*h;z=u*d} }
|
||||||
|
else { x=(rnd(1)-0.5)*w; y=(rnd(1)-0.5)*h; z=(rnd(1)-0.5)*d; }
|
||||||
|
x+=cx;y+=cy;z+=cz;
|
||||||
|
}
|
||||||
|
a[i*3]=x*scale;a[i*3+1]=y*scale;a[i*3+2]=z*scale;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
export const hoverShapes: Record<string, (n: number) => 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<string, boolean> = { hid: true };
|
||||||
|
const shapeCache: Record<string, Float32Array> = {};
|
||||||
|
export function getHoverShape(k: string, count: number): Float32Array { return shapeCache[k] || (shapeCache[k] = hoverShapes[k](count)); }
|
||||||
|
|
||||||
-195
@@ -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()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('.cur'), ring = document.querySelector<HTMLElement>('.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<HTMLAnchorElement>('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())
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
+799
@@ -0,0 +1,799 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Pietro Quintavalle — Bare-metal systems and Linux infrastructure</title>
|
||||||
|
<meta name="description" content="Bare-metal deployments, server rack integration and low-level Linux systems diagnostics by Pietro Quintavalle, Ticino, Switzerland.">
|
||||||
|
<meta name="author" content="Pietro Quintavalle">
|
||||||
|
<meta name="theme-color" content="#eef0f3">
|
||||||
|
<meta property="og:title" content="Pietro Quintavalle — Bare-metal systems and Linux infrastructure">
|
||||||
|
<meta property="og:description" content="Bare-metal deployments, server rack integration and low-level Linux systems diagnostics.">
|
||||||
|
<meta property="og:site_name" content="Pietro Quintavalle">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%23141821'/%3E%3Ctext x='32' y='43' text-anchor='middle' font-family='Arial Black,Arial' font-weight='900' font-size='30' fill='%23eef0f3'%3EPQ%3C/text%3E%3C/svg%3E">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@500;700;800&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--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; }
|
||||||
|
|
||||||
|
*{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}
|
||||||
|
|
||||||
|
#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%)}
|
||||||
|
|
||||||
|
/* 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} }
|
||||||
|
|
||||||
|
/* 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}
|
||||||
|
|
||||||
|
/* 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)}
|
||||||
|
|
||||||
|
/* 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}
|
||||||
|
|
||||||
|
/* reveal: opacity only, one quiet fade */
|
||||||
|
.rv{opacity:0;transition:opacity 1s ease}
|
||||||
|
.rv.in{opacity:1}
|
||||||
|
|
||||||
|
/* 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}
|
||||||
|
.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}}
|
||||||
|
|
||||||
|
/* 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)}
|
||||||
|
|
||||||
|
/* 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} }
|
||||||
|
|
||||||
|
/* 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)}
|
||||||
|
|
||||||
|
/* 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} }
|
||||||
|
|
||||||
|
/* 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} }
|
||||||
|
|
||||||
|
/* 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}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<canvas id="scene" aria-hidden="true"></canvas>
|
||||||
|
<div class="grain" aria-hidden="true"></div>
|
||||||
|
<div class="vignette" aria-hidden="true"></div>
|
||||||
|
<div class="cur" aria-hidden="true"></div><div class="cur-ring" aria-hidden="true"></div>
|
||||||
|
<div class="progress" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<a class="brand" href="#top">PQ</a>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#work">Work</a></li>
|
||||||
|
<li><a href="#oss">Open source</a></li>
|
||||||
|
<li><a href="#projects">Projects</a></li>
|
||||||
|
<li><a href="#contact">Contact</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="shape-label" aria-hidden="true"><i></i><span id="shapeName">Rack: four production servers, bare metal</span></div>
|
||||||
|
|
||||||
|
<main id="top">
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<div class="wrap">
|
||||||
|
<p class="intro rv">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.</p>
|
||||||
|
<h1 class="name" id="name"><span class="ln">Pietro</span><span class="ln">Quintavalle</span></h1>
|
||||||
|
<div class="tag rv">
|
||||||
|
<p>I build infrastructure from the <em>solder joint</em> up. Component-level diagnostics, ZFS storage, zero-trust networks, and the occasional driver written from a packet dump.</p>
|
||||||
|
</div>
|
||||||
|
<div class="facts rv">
|
||||||
|
<div><b data-count="6">0</b><small>years assembling and repairing hardware</small></div>
|
||||||
|
<div><b data-count="25">0</b><small>systems built for private clients</small></div>
|
||||||
|
<div><b data-count="12">0</b><small>active users on self-hosted infrastructure</small></div>
|
||||||
|
<div><b data-count="30" data-prefix="<">0</b><small>minutes to rebuild a cluster from bare metal</small></div>
|
||||||
|
</div>
|
||||||
|
<div class="hint rv"><b></b>Scroll. The particles follow.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="qualities">
|
||||||
|
<div class="wrap">
|
||||||
|
<h2 class="rv">What you get when you hire me</h2>
|
||||||
|
<div class="qual rv">
|
||||||
|
<article>
|
||||||
|
<h3>Diagnoses at the component, not the ticket</h3>
|
||||||
|
<p>Thermal bench-testing, firmware and BIOS flashing, soldering and harness wiring. When a node fails I find the part, not the symptom.</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h3>Treats every host as disposable</h3>
|
||||||
|
<p>Ansible playbooks and Docker Compose rebuild the whole environment from a blank disk in under thirty minutes. Nothing important lives only in one place.</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h3>Zero exposed WAN ports, by default</h3>
|
||||||
|
<p>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.</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h3>Reads the protocol when there is no documentation</h3>
|
||||||
|
<p>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.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="work">
|
||||||
|
<div class="wrap">
|
||||||
|
<h2 class="rv">Experience</h2>
|
||||||
|
<div class="tl rv" id="tl"><div class="beam"></div>
|
||||||
|
<div class="job" data-shape="cluster" data-label="DEC Energy: two nodes, one GPU, one HBA">
|
||||||
|
<time>2026 to now</time>
|
||||||
|
<h3>Infrastructure and hardware consultant</h3>
|
||||||
|
<span class="org">DEC Energy, USI startup, freelance</span>
|
||||||
|
<ul>
|
||||||
|
<li>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.</li>
|
||||||
|
<li>Tuned fan curves and ran thermal bench stress tests so the nodes stay quiet in an open office under full inference load.</li>
|
||||||
|
<li>Specified and assembled the engineering team's workstations and remain on-call for support.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="job">
|
||||||
|
<time>January 2020 to now</time>
|
||||||
|
<h3>IT support technician</h3>
|
||||||
|
<span class="org">Private clients across Ticino, freelance</span>
|
||||||
|
<ul>
|
||||||
|
<li>Built, configured and repaired more than 25 desktop and laptop systems: sourcing, assembly, Windows and Linux deployment, driver debugging, ongoing maintenance.</li>
|
||||||
|
<li>Remote and on-site support for hardware failures, software conflicts and local networking faults, with repair logs kept to stop issues recurring.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="job" data-shape="homelab" data-label="Homelab as code: nine bays, twelve users, no open ports">
|
||||||
|
<time>January 2020 to now</time>
|
||||||
|
<h3>Infrastructure engineer, self-directed</h3>
|
||||||
|
<span class="org">Four bare-metal servers serving 12+ users</span>
|
||||||
|
<ul>
|
||||||
|
<li>UNRAID, Proxmox VE 8, TrueNAS, Docker and LXC running production workloads without any managed hosting.</li>
|
||||||
|
<li>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.</li>
|
||||||
|
<li>Hardened every edge: no open WAN ports, SSO and 2FA, intrusion detection, host-level firewalls.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="oss">
|
||||||
|
<div class="wrap">
|
||||||
|
<h2 class="rv">Open source, close to the metal</h2>
|
||||||
|
<p class="dim rv" style="margin-bottom:40px">Upstream contributions to projects that sit between hardware and the kernel. Hover a row and the sculpture becomes the work.</p>
|
||||||
|
<div class="oss rv">
|
||||||
|
<a href="https://github.com/libratbag/libratbag/pull/1873" target="_blank" rel="noopener" data-shape="hid" data-label="HID report: the bytes that became a driver">
|
||||||
|
<div><span class="repo">libratbag/libratbag</span><h3>A complete USB HID device driver, written from a packet capture</h3><p class="what">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.</p></div>
|
||||||
|
<span class="ref">PR #1873</span>
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/ValveSoftware/steam-for-linux/issues/13248" target="_blank" rel="noopener">
|
||||||
|
<div><span class="repo">ValveSoftware/steam-for-linux, Vysp3r/ProtonPlus</span><h3>Runtime installation pipeline fault, diagnosed and fixed upstream</h3><p class="what">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.</p></div>
|
||||||
|
<span class="ref">Issues #13248, #1232</span>
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/Recol/DLSS-Updater/pull/283" target="_blank" rel="noopener">
|
||||||
|
<div><span class="repo">Recol/DLSS-Updater</span><h3>Multi-vendor upscaler library detection</h3><p class="what">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.</p></div>
|
||||||
|
<span class="ref">PRs #283, #252, #256</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="projects">
|
||||||
|
<div class="wrap">
|
||||||
|
<h2 class="rv">Selected projects</h2>
|
||||||
|
<div class="proj rv">
|
||||||
|
<article data-tilt data-shape="homelab" data-label="Homelab as code: nine bays, twelve users, no open ports">
|
||||||
|
<h3>Homelab as code</h3>
|
||||||
|
<p>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.</p>
|
||||||
|
<div class="stack">Ansible, Docker Compose, UNRAID, Grafana, Prometheus</div>
|
||||||
|
</article>
|
||||||
|
<article data-tilt data-shape="nas" data-label="Client rack: the cabinet that opens this page">
|
||||||
|
<h3>Turnkey TrueNAS rack</h3>
|
||||||
|
<p>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.</p>
|
||||||
|
<div class="stack">TrueNAS, ZFS, rack infrastructure, structured cabling</div>
|
||||||
|
</article>
|
||||||
|
<article data-tilt>
|
||||||
|
<h3>ObsidianSetup <span class="star">26 stars on GitHub</span></h3>
|
||||||
|
<p>Cross-platform provisioning utility for environment configuration and plugin management, adopted by other users.</p>
|
||||||
|
<div class="stack">PowerShell</div>
|
||||||
|
</article>
|
||||||
|
<article data-tilt data-shape="city" data-label="City simulator: twelve people, one Spring Boot backend">
|
||||||
|
<h3>City simulator, USI group project</h3>
|
||||||
|
<p>Coordinated a twelve-person Scrum team building a full-stack city simulation. Owned the Spring Boot backend and its integration with the web frontend.</p>
|
||||||
|
<div class="stack">Java, Spring Boot, JavaScript, HTML, CSS</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="skills">
|
||||||
|
<div class="wrap">
|
||||||
|
<h2 class="rv">Tools I reach for</h2>
|
||||||
|
</div>
|
||||||
|
<div class="mq rv"><div id="mq1"></div></div>
|
||||||
|
<div class="mq rev rv"><div id="mq2"></div></div>
|
||||||
|
<div class="wrap">
|
||||||
|
<p class="skills-note rv">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.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="education">
|
||||||
|
<div class="wrap">
|
||||||
|
<h2 class="rv">Education and languages</h2>
|
||||||
|
<div class="edu rv">
|
||||||
|
<dl>
|
||||||
|
<div><dt>BSc Economics, English stream</dt><dd>Università della Svizzera italiana, Lugano, since September 2024. Microeconomics, game theory, quantitative finance, statistics, financial modelling.</dd></div>
|
||||||
|
<div><dt>Computer science coursework, two full years</dt><dd>USI, 2022 to 2024. Algorithms and data structures, object-oriented systems in Java and C++, software architecture, discrete logic.</dd></div>
|
||||||
|
<div><dt>Maturità Cantonale and Federal VET Diploma, AFC Economics</dt><dd>Scuola Cantonale di Commercio, Bellinzona, 2018 to 2022. Maturità 4.5/6, AFC 5/6.</dd></div>
|
||||||
|
</dl>
|
||||||
|
<div>
|
||||||
|
<dl>
|
||||||
|
<div><dt>Certifications</dt><dd>Google IT Support Professional Certificate. Google Data Analytics Professional Certificate. Microsoft Foundations of IT Systems, Networking and Data Protection.</dd></div>
|
||||||
|
<div><dt>Languages</dt><dd class="langs"><span><b>Italian</b> native</span><span><b>English</b> C2</span><span><b>German</b> B1–B2</span><span><b>Spanish</b> A1</span><span><b>Norwegian</b> A1</span></dd></div>
|
||||||
|
<div><dt>Status</dt><dd>Italian national, Swiss Permit C, born 2001.</dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="contact" class="contact">
|
||||||
|
<div class="wrap">
|
||||||
|
<h2 class="rv">Need someone who can rack it, image it, and keep it up?</h2>
|
||||||
|
<a class="mail rv" href="mailto:0pietroquintavalle0@gmail.com">0pietroquintavalle0@gmail.com</a>
|
||||||
|
<div class="links rv">
|
||||||
|
<a data-mag href="tel:+41799068253">+41 79 906 82 53</a>
|
||||||
|
<a data-mag href="https://github.com/Quinta0" target="_blank" rel="noopener">GitHub</a>
|
||||||
|
<a data-mag href="https://linkedin.com/in/pietro-quintavalle" target="_blank" rel="noopener">LinkedIn</a>
|
||||||
|
<a data-mag href="https://quinta0.github.io/whoami" target="_blank" rel="noopener">whoami</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
<footer><span>Pietro Quintavalle, Ticino, Switzerland</span><span>Built with Three.js, one canvas, fifteen thousand particles.</span></footer>
|
||||||
|
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
'use strict';
|
||||||
|
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
const coarse = matchMedia('(pointer: coarse)').matches;
|
||||||
|
const isMobile = coarse || innerWidth < 760;
|
||||||
|
|
||||||
|
/* ---------- Three.js particle sculpture ---------- */
|
||||||
|
const canvas = document.getElementById('scene');
|
||||||
|
let renderer;
|
||||||
|
try { renderer = new THREE.WebGLRenderer({canvas, antialias:false, alpha:true, powerPreference:'high-performance'}); }
|
||||||
|
catch(e){ canvas.style.display='none'; }
|
||||||
|
|
||||||
|
const COUNT = isMobile ? 6000 : 15000;
|
||||||
|
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 rnd = (a=1,b=0)=>Math.random()*(a-b)+b;
|
||||||
|
|
||||||
|
/* Shape 0 — a server rack: stacked chassis outlines, drive bays, cable loom */
|
||||||
|
function shapeRack(n){
|
||||||
|
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<n;i++){
|
||||||
|
let x,y,z; const r=Math.random();
|
||||||
|
if(r<0.42){ // horizontal chassis edges
|
||||||
|
const u=Math.floor(rnd(units+1)), edge=Math.floor(rnd(4)), t=rnd(1);
|
||||||
|
y=y0+u*H;
|
||||||
|
if(edge===0){x=-W/2+t*W;z=D/2}else if(edge===1){x=-W/2+t*W;z=-D/2}else if(edge===2){x=W/2;z=-D/2+t*D}else{x=-W/2;z=-D/2+t*D}
|
||||||
|
} else if(r<0.55){ // vertical rails
|
||||||
|
const c=Math.floor(rnd(4)); x=(c%2?1:-1)*W/2; z=(c<2?1:-1)*D/2; y=y0+rnd(units*H);
|
||||||
|
} else if(r<0.88){ // front face drive bays, dense
|
||||||
|
x=rnd(W/2-0.15,-W/2+0.15); const u=Math.floor(rnd(units)); const bay=Math.floor(rnd(3));
|
||||||
|
y=y0+u*H+0.12+bay*0.2+rnd(0.06); z=D/2+0.02;
|
||||||
|
} else { // rear cable loom drooping
|
||||||
|
const t=rnd(1); x=rnd(0.6,-0.6)+Math.sin(t*9+i)*0.25; y=y0+t*units*H; z=-D/2-0.35-Math.sin(t*3.1)*0.3;
|
||||||
|
}
|
||||||
|
a[i*3]=x;a[i*3+1]=y;a[i*3+2]=z;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
/* Shape 1 — torus knot: the mesh network */
|
||||||
|
function shapeKnot(n){
|
||||||
|
const a=new Float32Array(n*3), p=2,q=3, R=3.0, r=1.35, tube=0.55;
|
||||||
|
const P=t=>{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<n;i++){
|
||||||
|
const t=rnd(Math.PI*2), c=P(t), c2=P(t+0.001);
|
||||||
|
let tx=c2[0]-c[0],ty=c2[1]-c[1],tz=c2[2]-c[2]; const tl=Math.hypot(tx,ty,tz); tx/=tl;ty/=tl;tz/=tl;
|
||||||
|
// normal ~ toward the ring centre, then binormal = T x N
|
||||||
|
let nx=-c[0],ny=-c[1],nz=0; const dn=tx*nx+ty*ny; nx-=tx*dn;ny-=ty*dn;nz-=tz*dn; const nl=Math.hypot(nx,ny,nz)||1; nx/=nl;ny/=nl;nz/=nl;
|
||||||
|
const bx=ty*nz-tz*ny, by=tz*nx-tx*nz, bz=tx*ny-ty*nx;
|
||||||
|
const ang=rnd(Math.PI*2), rad=Math.random()<0.7?tube:tube*Math.sqrt(Math.random());
|
||||||
|
const ca=Math.cos(ang)*rad, sa=Math.sin(ang)*rad;
|
||||||
|
a[i*3]=c[0]+nx*ca+bx*sa; a[i*3+1]=c[1]+ny*ca+by*sa; a[i*3+2]=c[2]+nz*ca+bz*sa;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
/* Shape 2 — globe with latitude bands + a defensive shell: the zero-trust perimeter */
|
||||||
|
function shapeGlobe(n){
|
||||||
|
const a=new Float32Array(n*3);
|
||||||
|
for(let i=0;i<n;i++){
|
||||||
|
const r=Math.random();
|
||||||
|
let R=4.2, theta, phi;
|
||||||
|
if(r<0.6){ // lat/long grid
|
||||||
|
const useLat=Math.random()<0.5;
|
||||||
|
if(useLat){ phi=Math.acos(1-2*(Math.floor(rnd(13))/12)); theta=rnd(Math.PI*2); }
|
||||||
|
else { theta=Math.floor(rnd(24))*(Math.PI/12); phi=Math.acos(1-2*Math.random()); }
|
||||||
|
} else if(r<0.85){ phi=Math.acos(1-2*Math.random()); theta=rnd(Math.PI*2); R=4.2+rnd(0.05); }
|
||||||
|
else { phi=Math.acos(1-2*Math.random()); theta=rnd(Math.PI*2); R=5.6+rnd(0.15); } // outer shell
|
||||||
|
a[i*3]=R*Math.sin(phi)*Math.cos(theta); a[i*3+1]=R*Math.cos(phi); a[i*3+2]=R*Math.sin(phi)*Math.sin(theta);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
/* Canvas sampler: anything drawn white on black becomes particles */
|
||||||
|
function sampleCanvas(n,w,h,draw,S,zSpread){
|
||||||
|
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=[];
|
||||||
|
for(let y=0;y<h;y+=2)for(let x=0;x<w;x+=2){ if(img[(y*w+x)*4]>128) pts.push(x,y); }
|
||||||
|
const a=new Float32Array(n*3);
|
||||||
|
for(let i=0;i<n;i++){
|
||||||
|
const k=Math.floor(rnd(pts.length/2))*2;
|
||||||
|
a[i*3]=(pts[k]-w/2)*S+rnd(0.04,-0.04); a[i*3+1]=-(pts[k+1]-h/2)*S+rnd(0.04,-0.04);
|
||||||
|
a[i*3+2]=rnd(zSpread,-zSpread)*(Math.random()<0.15?1:0.15);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
/* Shape 3 — the initials */
|
||||||
|
function shapeText(n){
|
||||||
|
return sampleCanvas(n,720,360,ctx=>{ 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 */
|
||||||
|
function fromPrims(n,prims,scale){
|
||||||
|
const tot=prims.reduce((t,p)=>t+p.w,0), a=new Float32Array(n*3);
|
||||||
|
for(let i=0;i<n;i++){
|
||||||
|
let r=Math.random()*tot, pr=prims[0]; for(const q of prims){ pr=q; if(r<q.w)break; r-=q.w; }
|
||||||
|
let x,y,z;
|
||||||
|
if(pr.t==='line'){ const u=rnd(1); x=pr.a[0]+(pr.b[0]-pr.a[0])*u; y=pr.a[1]+(pr.b[1]-pr.a[1])*u; z=pr.a[2]+(pr.b[2]-pr.a[2])*u; }
|
||||||
|
else {
|
||||||
|
const [cx,cy,cz]=pr.c,[w,h,d]=pr.s;
|
||||||
|
if(pr.t==='edges'){ const e=Math.floor(rnd(12)), u=rnd(1)-0.5, s1=(e&1)?0.5:-0.5, s2=(e&2)?0.5:-0.5;
|
||||||
|
if(e<4){x=u*w;y=s1*h;z=s2*d} else if(e<8){x=s1*w;y=u*h;z=s2*d} else {x=s1*w;y=s2*h;z=u*d} }
|
||||||
|
else { x=(rnd(1)-0.5)*w; y=(rnd(1)-0.5)*h; z=(rnd(1)-0.5)*d; }
|
||||||
|
x+=cx;y+=cy;z+=cz;
|
||||||
|
}
|
||||||
|
a[i*3]=x*scale;a[i*3+1]=y*scale;a[i*3+2]=z*scale;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
const hoverShapes={
|
||||||
|
homelab(n){ // the homelab chassis: nine bays, copper strip, grille top
|
||||||
|
const pr=[{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){ // DEC Energy: two boards, GPU on one, HBA on the other, bridged
|
||||||
|
const pr=[];
|
||||||
|
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){ // four hosts on the floor, one hub, one tunnel out
|
||||||
|
const pr=[]; 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){ // the simulated city: a grid of blocks
|
||||||
|
const pr=[{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){ // 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){ 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
|
||||||
|
const hoverFlat={hid:true};
|
||||||
|
const shapeCache={};
|
||||||
|
function getHoverShape(k){ return shapeCache[k]||(shapeCache[k]=hoverShapes[k](COUNT)); }
|
||||||
|
|
||||||
|
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)));
|
||||||
|
}`;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}`;
|
||||||
|
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);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
let uniforms, points;
|
||||||
|
function buildParticles(){
|
||||||
|
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));
|
||||||
|
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 m=new THREE.ShaderMaterial({uniforms,vertexShader:vert,fragmentShader:frag,transparent:true,depthWrite:false,blending:THREE.NormalBlending});
|
||||||
|
points=new THREE.Points(g,m);
|
||||||
|
group.add(points);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resize(){
|
||||||
|
if(!renderer)return;
|
||||||
|
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
|
||||||
|
renderer.setSize(innerWidth,innerHeight,false);
|
||||||
|
camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix();
|
||||||
|
camera.position.z = innerWidth<760 ? 26 : 18;
|
||||||
|
}
|
||||||
|
addEventListener('resize',()=>{resize();updateScroll();});
|
||||||
|
|
||||||
|
/* scroll + mouse state */
|
||||||
|
let hoverT=0, hoverCur=0, swapCur=1, hoverKey=null, hoverLabel=null;
|
||||||
|
let progT=0, progCur=0, keyEls=null;
|
||||||
|
let scrollT=0, scrollCur=0, mx=0,my=0, mxCur=0,myCur=0, mouseOn=0, mouseOnCur=0;
|
||||||
|
function updateScroll(){
|
||||||
|
const max=document.documentElement.scrollHeight-innerHeight; scrollT=max>0?scrollY/max:0;
|
||||||
|
if(!keyEls) keyEls=['.hero','#work','#skills','#contact'].map(q=>document.querySelector(q));
|
||||||
|
const c=keyEls.map(el=>el.offsetTop+el.offsetHeight/2-innerHeight/2), y=scrollY;
|
||||||
|
let v=3;
|
||||||
|
if(y<=c[0]) v=0; else for(let i=0;i<3;i++){ if(y<c[i+1]){ const f=(y-c[i])/Math.max(1,c[i+1]-c[i]); v=i+THREE.MathUtils.smoothstep(f,0.45,1); break; } }
|
||||||
|
progT=v/3;
|
||||||
|
}
|
||||||
|
addEventListener('scroll',updateScroll,{passive:true});
|
||||||
|
addEventListener('pointermove',e=>{ mx=(e.clientX/innerWidth)*2-1; my=-(e.clientY/innerHeight)*2+1; mouseOn=1; },{passive:true});
|
||||||
|
addEventListener('pointerleave',()=>{mouseOn=0});
|
||||||
|
document.addEventListener('mouseleave',()=>{mouseOn=0});
|
||||||
|
|
||||||
|
const plane=new THREE.Plane(new THREE.Vector3(0,0,1),0), ray=new THREE.Raycaster(), hit=new THREE.Vector3(), ndc=new THREE.Vector2();
|
||||||
|
const clock=new THREE.Clock();
|
||||||
|
let introT=0;
|
||||||
|
|
||||||
|
function frame(){
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
if(!renderer||!points)return;
|
||||||
|
const dt=Math.min(clock.getDelta(),0.05);
|
||||||
|
const t=clock.elapsedTime;
|
||||||
|
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.0;
|
||||||
|
uniforms.uProgress.value=prog;
|
||||||
|
uniforms.uTime.value=t;
|
||||||
|
|
||||||
|
// sculpture drifts as you scroll; camera parallax from cursor
|
||||||
|
group.rotation.y = scrollCur*Math.PI*1.35 + t*0.03;
|
||||||
|
group.rotation.x = Math.sin(scrollCur*Math.PI)*0.35;
|
||||||
|
// last shape (initials) faces the camera flat
|
||||||
|
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);
|
||||||
|
// hover: settle into a three-quarter view (or flat for the packet diagram)
|
||||||
|
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];
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
// rack sits slightly right of the name on wide screens
|
||||||
|
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);
|
||||||
|
|
||||||
|
// mouse -> world on z=0 plane -> group local
|
||||||
|
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;
|
||||||
|
|
||||||
|
renderer.render(scene,camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* shape label */
|
||||||
|
const 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'];
|
||||||
|
const labelEl=document.getElementById('shapeName'); let labelIdx=0;
|
||||||
|
function setLabel(txt){ if(labelEl.textContent===txt)return; labelEl.classList.add('swap'); setTimeout(()=>{labelEl.textContent=txt;labelEl.classList.remove('swap')},400); }
|
||||||
|
setInterval(()=>{ if(hoverT>0){ setLabel(hoverLabel); return; } const i=Math.min(3,Math.round(progCur*3)); if(i!==labelIdx||labelEl.textContent!==names[i]){ labelIdx=i; setLabel(names[i]);} },200);
|
||||||
|
|
||||||
|
/* progress bar */
|
||||||
|
const bar=document.querySelector('.progress');
|
||||||
|
(function pb(){ bar.style.transform='scaleX('+scrollCur.toFixed(4)+')'; requestAnimationFrame(pb); })();
|
||||||
|
|
||||||
|
/* ---------- DOM motion ---------- */
|
||||||
|
// split hero name into characters
|
||||||
|
const name=document.getElementById('name');
|
||||||
|
name.querySelectorAll('.ln').forEach((ln,li)=>{
|
||||||
|
const txt=ln.textContent; ln.textContent='';
|
||||||
|
[...txt].forEach((ch,i)=>{ const s=document.createElement('span'); s.className='ch'; s.textContent=ch; s.style.transitionDelay=(0.25+li*0.35+i*0.04)+'s'; ln.appendChild(s); });
|
||||||
|
});
|
||||||
|
name.setAttribute('aria-label','Pietro Quintavalle');
|
||||||
|
|
||||||
|
// counters
|
||||||
|
function countUp(el){
|
||||||
|
const target=+el.dataset.count, prefix=el.dataset.prefix||''; const t0=performance.now(), dur=1600;
|
||||||
|
(function step(now){ const k=Math.min(1,(now-t0)/dur); const e=1-Math.pow(1-k,3); el.textContent=prefix+Math.round(target*e)+(k===1&&!prefix?'+':''); if(k<1)requestAnimationFrame(step); })(t0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// reveal (opacity only)
|
||||||
|
const io=new IntersectionObserver(es=>es.forEach(e=>{ if(e.isIntersecting){ 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));
|
||||||
|
|
||||||
|
// marquees
|
||||||
|
const rowA=['Proxmox VE','TrueNAS','ZFS','UNRAID','Kubernetes','Docker','LXC','KVM','Ansible','GitHub Actions','Bash','PowerShell','Debian','Arch','NixOS','Windows Server','Active Directory'];
|
||||||
|
const rowB=['WireGuard','Tailscale','Cloudflare Tunnels','UniFi','VLANs','iptables','Fail2Ban','AdGuard Home','Grafana','Prometheus','Uptime Kuma','Spring Boot','TypeScript','llama.cpp','Rust','C'];
|
||||||
|
function fill(id,arr){ const el=document.getElementById(id); const h=arr.map(s=>'<span>'+s+'</span>').join(''); el.innerHTML=h+h; }
|
||||||
|
fill('mq1',rowA); fill('mq2',rowB);
|
||||||
|
|
||||||
|
// hover-morph: rows that carry a shape
|
||||||
|
function hoverIn(el){
|
||||||
|
const k=el.dataset.shape; if(!points||!hoverShapes[k])return;
|
||||||
|
if(hoverKey!==k){
|
||||||
|
const ph=points.geometry.getAttribute('aPH'), ph2=points.geometry.getAttribute('aPH2');
|
||||||
|
if(hoverKey){ ph2.array.set(ph.array); ph2.needsUpdate=true; swapCur=0; } else { ph2.array.set(getHoverShape(k)); ph2.needsUpdate=true; swapCur=1; }
|
||||||
|
ph.array.set(getHoverShape(k)); ph.needsUpdate=true; hoverKey=k;
|
||||||
|
}
|
||||||
|
hoverLabel=el.dataset.label; hoverT=1;
|
||||||
|
}
|
||||||
|
function hoverOut(){ hoverT=0; }
|
||||||
|
document.querySelectorAll('[data-shape]').forEach(el=>{
|
||||||
|
el.addEventListener('pointerenter',()=>hoverIn(el));
|
||||||
|
el.addEventListener('pointerleave',hoverOut);
|
||||||
|
el.addEventListener('focusin',()=>hoverIn(el)); el.addEventListener('focusout',hoverOut);
|
||||||
|
});
|
||||||
|
if(coarse){ // touch: the row nearest the middle of the screen drives the sculpture
|
||||||
|
const rows=[...document.querySelectorAll('[data-shape]')];
|
||||||
|
setInterval(()=>{ const mid=innerHeight/2; let best=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); else hoverOut(); },250);
|
||||||
|
}
|
||||||
|
// timeline beam
|
||||||
|
const tl=document.getElementById('tl'); new IntersectionObserver(es=>es.forEach(e=>{ if(e.isIntersecting){tl.classList.add('in')} }),{threshold:0.2}).observe(tl);
|
||||||
|
|
||||||
|
// tilt on projects (pointer-driven only)
|
||||||
|
if(!coarse&&!reduce){
|
||||||
|
document.querySelectorAll('[data-tilt]').forEach(el=>{
|
||||||
|
el.addEventListener('pointermove',e=>{ 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)'; });
|
||||||
|
el.addEventListener('pointerleave',()=>{ el.style.transform=''; });
|
||||||
|
});
|
||||||
|
// magnetic links
|
||||||
|
document.querySelectorAll('[data-mag]').forEach(el=>{
|
||||||
|
el.addEventListener('pointermove',e=>{ 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'; });
|
||||||
|
el.addEventListener('pointerleave',()=>{ el.style.transition='transform .5s cubic-bezier(.16,1,.3,1)'; el.style.transform=''; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// custom cursor
|
||||||
|
if(!coarse){
|
||||||
|
const dot=document.querySelector('.cur'), ring=document.querySelector('.cur-ring'); let cx=innerWidth/2,cy=innerHeight/2,rx=cx,ry=cy;
|
||||||
|
addEventListener('pointermove',e=>{cx=e.clientX;cy=e.clientY;},{passive:true});
|
||||||
|
(function cl(){ 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%)'; requestAnimationFrame(cl); })();
|
||||||
|
document.querySelectorAll('a,button').forEach(a=>{ a.addEventListener('pointerenter',()=>ring.classList.add('big')); a.addEventListener('pointerleave',()=>ring.classList.remove('big')); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// smooth anchor scroll
|
||||||
|
document.querySelectorAll('nav a[href^="#"]').forEach(a=>a.addEventListener('click',e=>{ const t=document.querySelector(a.getAttribute('href')); if(t){e.preventDefault(); t.scrollIntoView({behavior:reduce?'auto':'smooth'});} }));
|
||||||
|
|
||||||
|
/* boot: wait for the display font so the PQ sampling is right, then go */
|
||||||
|
function boot(){
|
||||||
|
if(!renderer)return;
|
||||||
|
buildParticles(); resize(); updateScroll(); frame();
|
||||||
|
setTimeout(()=>name.classList.add('on'),150);
|
||||||
|
}
|
||||||
|
if(document.fonts&&document.fonts.load){ Promise.race([document.fonts.load('800 300px Syne'),new Promise(r=>setTimeout(r,1500))]).then(boot,boot); } else boot();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user