Files
whoami/standalone.html
2026-09-15 23:36:44 +02:00

800 lines
48 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>