Author SHA1 Message Date
Quinta 49c79b31a9 added other links 2026-09-19 02:38:55 +02:00
Quinta b37dd7ee3c added nargothron-watcher 2026-09-17 18:30:15 +02:00
Quinta 9a399aba61 mobile layout 2026-09-16 00:21:16 +02:00
Quinta cb88c84777 aspect ratioed 2026-09-16 00:04:23 +02:00
Quinta 6cbe8ae739 Claudio cooked 2026-09-15 23:36:44 +02:00
Quinta 6b139c9901 contour grid 2026-09-15 22:49:14 +02:00
Quinta f400f63321 3js 2026-09-15 17:58:27 +02:00
Quinta bdacc4a895 arrow fix 2026-09-14 21:01:55 +02:00
Quinta 821c8ae6a2 Remove CV Dossier link from footer
Removed CV Dossier link from footer.
2026-09-14 20:57:19 +02:00
Quinta 97a6042782 Remove logo link from site header
Removed logo link from the header.
2026-09-14 20:56:26 +02:00
Quinta 19cce7d75e Update experience period for freelance consultant role 2026-09-14 20:54:52 +02:00
Quinta 1ba1c3123d Delete public directory 2026-09-14 20:53:46 +02:00
Quinta 5e15524ace Remove technician approach from background section
Removed technician approach section from background.
2026-09-14 20:52:53 +02:00
Quinta 4f47a19fd7 restyle 2026-09-14 20:49:39 +02:00
Quinta 0579aa2448 more OSS contribs 2026-08-27 01:14:08 +02:00
Quinta b1ec646587 UI fix 2026-08-09 20:51:55 +02:00
Quinta 03b3e958cb remade in Vitejs 2026-08-09 20:48:12 +02:00
Quinta 23a7c03da5 Merge pull request #16 from Quinta0/dependabot/npm_and_yarn/postcss-8.5.18
Bump postcss from 8.5.10 to 8.5.18
2026-07-28 23:13:59 +02:00
Quinta ca28583823 more contributions 2026-07-28 19:12:58 +02:00
dependabot[bot] c0ec5cf2b2 Bump postcss from 8.5.10 to 8.5.18
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.10 to 8.5.18.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.10...8.5.18)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.18
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-27 17:35:34 +00:00
Quinta b103da0ad7 Merge pull request #15 from Quinta0/dependabot/npm_and_yarn/next-15.5.21
Bump next from 15.5.18 to 15.5.21
2026-07-27 19:34:21 +02:00
dependabot[bot] 09411c50cc Bump next from 15.5.18 to 15.5.21
Bumps [next](https://github.com/vercel/next.js) from 15.5.18 to 15.5.21.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.18...v15.5.21)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.5.21
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-27 07:38:19 +00:00
Quinta 78393c24a1 Fixes in grammar 2026-07-25 19:53:49 +02:00
Quinta 7f328ae3ea Deutch,France and italiano 2026-07-25 19:33:51 +02:00
Quinta 59184d9164 replaced the hardcoded :first-child/:not(:last-child) rules with :nth-child(Nn+1)/:nth-child(Nn) math scoped per breakpoint (5/3/2 columns), plus a single higher-specificity 2026-07-25 17:00:41 +02:00
Quinta 0dccb99b76 Added homelab graph 2026-07-24 21:51:38 +02:00
Quinta 698b76fc86 Recol/DLSS-Updater PR 2026-07-24 19:56:05 +02:00
Quinta 931ae86e7d fixed some paddings 2026-07-23 23:24:05 +02:00
Quinta 9dbf8e4d6a Added riptune to the my projects on gh 2026-07-23 23:00:13 +02:00
Quinta 25dfe21097 fixed nav 2026-07-23 18:42:29 +02:00
Quinta d056462f81 refresh 2026-07-23 18:17:12 +02:00
Quinta c087d42a1c Delete README.md 2026-07-23 17:45:56 +02:00
Quinta 464da42e38 Revamp and restyle 2026-07-23 17:44:49 +02:00
Quinta 688f929e01 Update page and dependencies 2026-07-21 20:11:10 +02:00
Quinta 6cd828130a Add 'Dispatch' personal project section
Added a new personal project 'Dispatch' with details and tags.
2026-07-06 17:15:10 +02:00
Quinta 8e93d167d3 Merge pull request #14 from Quinta0/dependabot/npm_and_yarn/multi-06523197de
Bump glob, sucrase, eslint and eslint-config-next
2026-07-05 22:35:22 +02:00
dependabot[bot] 5940147513 Bump glob, sucrase, eslint and eslint-config-next
Removes [glob](https://github.com/isaacs/node-glob). It's no longer used after updating ancestor dependencies [glob](https://github.com/isaacs/node-glob), [sucrase](https://github.com/alangpierce/sucrase), [eslint](https://github.com/eslint/eslint) and [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next). These dependencies need to be updated together.


Removes `glob`

Updates `sucrase` from 3.35.0 to 3.35.1
- [Changelog](https://github.com/alangpierce/sucrase/blob/main/CHANGELOG.md)
- [Commits](https://github.com/alangpierce/sucrase/commits)

Updates `eslint` from 8.57.1 to 10.6.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v8.57.1...v10.6.0)

Updates `eslint-config-next` from 14.2.13 to 16.2.10
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.10/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: glob
  dependency-version:
  dependency-type: indirect
- dependency-name: sucrase
  dependency-version: 3.35.1
  dependency-type: indirect
- dependency-name: eslint
  dependency-version: 10.6.0
  dependency-type: direct:development
- dependency-name: eslint-config-next
  dependency-version: 16.2.10
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-05 20:34:18 +00:00
Quinta 580be42af4 Merge pull request #13 from Quinta0/copilot/fix-deploy-failing-job
[WIP] Fix failing GitHub Actions job deploy
2026-07-05 22:34:08 +02:00
Quinta db01f865f1 Merge pull request #5 from Quinta0/dependabot/npm_and_yarn/multi-237e9862f4
Bump brace-expansion
2026-07-05 22:33:13 +02:00
Quinta dcd35386ae Merge pull request #4 from Quinta0/dependabot/npm_and_yarn/multi-bf05dc1ecf
Bump picomatch
2026-07-05 22:32:59 +02:00
Quinta 4d34520e19 Merge pull request #6 from Quinta0/dependabot/npm_and_yarn/yaml-2.8.4
Bump yaml from 2.8.0 to 2.8.4
2026-07-05 22:32:48 +02:00
Quinta 5dca84ba70 Merge pull request #10 from Quinta0/dependabot/npm_and_yarn/next-15.5.18
Bump next from 14.2.13 to 15.5.18
2026-07-05 22:32:32 +02:00
Quinta 79dac26536 Merge pull request #12 from Quinta0/dependabot/npm_and_yarn/js-yaml-4.3.0
Bump js-yaml from 4.1.0 to 4.3.0
2026-07-05 22:32:03 +02:00
Quinta 2578befb0e Merge pull request #8 from Quinta0/dependabot/npm_and_yarn/postcss-8.5.10
Bump postcss from 8.5.6 to 8.5.10
2026-07-05 22:30:55 +02:00
Quinta 2da4afe11b Merge pull request #7 from Quinta0/dependabot/npm_and_yarn/flatted-3.4.2
Bump flatted from 3.3.3 to 3.4.2
2026-07-05 22:30:06 +02:00
Quinta ff7422fb3f Replace homelab project with Nargothrond documentation 2026-07-04 23:53:09 +02:00
Quinta 31e7b33c40 Update hint text in input field 2026-07-04 23:14:01 +02:00
Quinta 2d004d934a Remove non-breaking space in t-body div
Removed a non-breaking space from the t-body div.
2026-07-04 20:39:14 +02:00
Quinta 953c11bf09 Remove empty non-breaking space div
Removed unnecessary non-breaking space div.
2026-07-04 20:32:28 +02:00
Quinta 354471daf6 Change terminal title from 'proxmox' to 'server' 2026-07-04 20:29:26 +02:00
dependabot[bot] ce362ce65a Bump js-yaml from 4.1.0 to 4.3.0
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.3.0.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.3.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.3.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-02 09:27:50 +00:00
dependabot[bot] 22631513cd Bump next from 14.2.13 to 15.5.18
Bumps [next](https://github.com/vercel/next.js) from 14.2.13 to 15.5.18.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.13...v15.5.18)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.5.18
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-13 20:41:48 +00:00
dependabot[bot] 852c2249a9 Bump postcss from 8.5.6 to 8.5.10
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.6 to 8.5.10.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.6...8.5.10)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.10
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-07 22:18:35 +00:00
dependabot[bot] 88b81063ca Bump flatted from 3.3.3 to 3.4.2
Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.2.
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-07 22:18:31 +00:00
dependabot[bot] 9503b0501c Bump yaml from 2.8.0 to 2.8.4
Bumps [yaml](https://github.com/eemeli/yaml) from 2.8.0 to 2.8.4.
- [Release notes](https://github.com/eemeli/yaml/releases)
- [Commits](https://github.com/eemeli/yaml/compare/v2.8.0...v2.8.4)

---
updated-dependencies:
- dependency-name: yaml
  dependency-version: 2.8.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-07 22:18:23 +00:00
dependabot[bot] 4fa24c7c18 Bump brace-expansion
Bumps  and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together.

Updates `brace-expansion` from 2.0.2 to 2.1.0
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.0.2...v2.1.0)

Updates `brace-expansion` from 1.1.12 to 1.1.14
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.0.2...v2.1.0)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 2.1.0
  dependency-type: indirect
- dependency-name: brace-expansion
  dependency-version: 1.1.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-07 22:18:16 +00:00
dependabot[bot] d6c6e487ff Bump picomatch
Bumps  and [picomatch](https://github.com/micromatch/picomatch). These dependencies needed to be updated together.

Updates `picomatch` from 2.3.1 to 2.3.2
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

Updates `picomatch` from 4.0.2 to 4.0.4
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-07 22:18:14 +00:00
44 changed files with 2473 additions and 10209 deletions
-11
View File
@@ -1,11 +0,0 @@
{
"extends": ["next/core-web-vitals", "next/typescript"],
"rules": {
"@typescript-eslint/no-unused-vars": "warn",
"@typescript-eslint/no-explicit-any": "warn",
"react-hooks/exhaustive-deps": "warn",
"react/no-unescaped-entities": "off",
"@next/next/no-img-element": "warn",
"jsx-a11y/alt-text": "warn"
}
}
+48
View File
@@ -0,0 +1,48 @@
name: Deploy Vite site to Pages
on:
push:
branches: ["main"]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./dist
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
-93
View File
@@ -1,93 +0,0 @@
# Sample workflow for building and deploying a Next.js site to GitHub Pages
#
# To get started with Next.js see: https://nextjs.org/docs/getting-started
#
name: Deploy Next.js site to Pages
on:
# Runs on pushes targeting the default branch
push:
branches: ["main"]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
# Build job
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect package manager
id: detect-package-manager
run: |
if [ -f "${{ github.workspace }}/yarn.lock" ]; then
echo "manager=yarn" >> $GITHUB_OUTPUT
echo "command=install" >> $GITHUB_OUTPUT
echo "runner=yarn" >> $GITHUB_OUTPUT
exit 0
elif [ -f "${{ github.workspace }}/package.json" ]; then
echo "manager=npm" >> $GITHUB_OUTPUT
echo "command=ci" >> $GITHUB_OUTPUT
echo "runner=npx --no-install" >> $GITHUB_OUTPUT
exit 0
else
echo "Unable to determine package manager"
exit 1
fi
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: ${{ steps.detect-package-manager.outputs.manager }}
- name: Setup Pages
uses: actions/configure-pages@v5
with:
# Automatically inject basePath in your Next.js configuration file and disable
# server side image optimization (https://nextjs.org/docs/api-reference/next/image#unoptimized).
#
# You may remove this line if you want to manage the configuration yourself.
static_site_generator: next
- name: Restore cache
uses: actions/cache@v4
with:
path: |
.next/cache
# Generate a new cache whenever packages or source files change.
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
# If source files changed but packages didn't, rebuild from a prior cache.
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-
- name: Install dependencies
run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }}
- name: Build with Next.js
run: ${{ steps.detect-package-manager.outputs.runner }} next build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./out
# Deployment job
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+4 -10
View File
@@ -9,12 +9,8 @@
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# vite
/dist/
# misc
.DS_Store
@@ -28,9 +24,7 @@ yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/resume
-5
View File
@@ -1,5 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
-6
View File
@@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/personalwebsite.iml" filepath="$PROJECT_DIR$/.idea/personalwebsite.iml" />
</modules>
</component>
</project>
-12
View File
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
{
"hash": "c1692715",
"configHash": "46535a3e",
"lockfileHash": "e3b0c442",
"browserHash": "7d791f90",
"optimized": {},
"chunks": {}
}
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
-36
View File
@@ -1,36 +0,0 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.
Binary file not shown.
-464
View File
@@ -1,464 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--stone:#E8E5DF;
--stone2:#DDD9D2;
--stone3:#D4D0C9;
--navy:#1C2033;
--navy2:#252A42;
--mid:#626880;
--muted:#959BAF;
--line:rgba(28,32,51,0.11);
--line2:rgba(28,32,51,0.18);
--line3:rgba(28,32,51,0.26);
--g1:240px;
--g2:calc(100% - 480px);
}
html{scroll-behavior:smooth}
body{
background:var(--stone);
color:var(--navy);
font-family:'DM Sans',system-ui,sans-serif;
font-size:15px;
line-height:1.6;
-webkit-font-smoothing:antialiased;
overflow-x:hidden;
}
.col-rule-l,.col-rule-r{
position:fixed;top:0;bottom:0;width:1px;
background:var(--line2);z-index:200;pointer-events:none;
}
.col-rule-l{left:var(--g1)}
.col-rule-r{right:var(--g1)}
.col-rule-c1{
position:fixed;top:0;bottom:0;width:1px;
background:var(--line);z-index:200;pointer-events:none;
left:calc(var(--g1) + var(--g2)/3);display:none;
}
.col-rule-c2{
position:fixed;top:0;bottom:0;width:1px;
background:var(--line);z-index:200;pointer-events:none;
left:calc(var(--g1) + var(--g2)*2/3);display:none;
}
nav{
position:sticky;top:0;z-index:300;
background:var(--stone);
border-bottom:1px solid var(--line3);
display:flex;align-items:stretch;
height:52px;
}
.nav-logo-wrap{
width:var(--g1);flex-shrink:0;
display:flex;align-items:center;
padding:0 28px;
border-right:1px solid var(--line3);
gap:9px;
text-decoration:none;color:var(--navy);
}
.nav-logosvg{width:22px;height:22px}
.nav-name{
font-family:'Syne',sans-serif;
font-size:14px;font-weight:700;
letter-spacing:-.01em;
}
.nav-name sup{
font-size:8px;font-weight:500;
opacity:.5;vertical-align:super;
letter-spacing:.06em;font-family:'DM Sans',sans-serif;
}
.nav-links{display:flex;align-items:stretch;flex:1}
.nav-links a{
display:flex;align-items:center;
padding:0 22px;
font-size:13px;color:var(--mid);
text-decoration:none;
border-right:1px solid var(--line);
transition:color .15s;
font-weight:400;
letter-spacing:.01em;
}
.nav-links a:hover{color:var(--navy)}
.nav-socials{
display:flex;align-items:stretch;
width:var(--g1);
border-left:1px solid var(--line3);
}
.nav-socials a{
display:flex;align-items:center;justify-content:center;
padding:0 22px;
color:var(--mid);text-decoration:none;
border-left:1px solid var(--line);
transition:color .15s;
}
.nav-socials a:hover{color:var(--navy)}
.nav-socials svg{width:16px;height:16px;fill:currentColor}
.strip{
background:#D0CCC5;
border-bottom:1px solid var(--line3);
display:flex;align-items:center;justify-content:center;gap:12px;
padding:9px 24px;font-size:12.5px;
color:var(--mid);
}
.strip-pill{
background:var(--navy);color:var(--stone);
border-radius:20px;padding:2px 10px;
font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;
}
.strip a{color:var(--navy);text-decoration:none;font-weight:500;display:flex;align-items:center;gap:5px}
.strip-arr{opacity:.45;font-size:14px}
.row{
display:grid;
grid-template-columns:var(--g1) 1fr var(--g1);
border-bottom:1px solid var(--line3);
}
.row-inner{padding:0}
.row-gutter-l{border-right:1px solid var(--line3)}
.row-gutter-r{border-left:1px solid var(--line3)}
.hero-row{
display:grid;
grid-template-columns:var(--g1) 1fr 300px var(--g1);
border-bottom:1px solid var(--line3);
min-height:340px;
}
.hero-gutter{border-right:1px solid var(--line3)}
.hero-gutter-r{border-left:1px solid var(--line3)}
.hero-main{
padding:60px 52px 52px;
border-right:1px solid var(--line3);
}
.status-row{
display:inline-flex;align-items:center;gap:8px;
font-size:12px;color:var(--mid);font-weight:500;
letter-spacing:.03em;margin-bottom:22px;
}
.green-dot{width:7px;height:7px;border-radius:50%;background:#4CAF7D;position:relative;flex-shrink:0}
.green-dot::after{content:'';position:absolute;inset:-3px;border-radius:50%;background:rgba(76,175,125,.2);animation:gpulse 2.2s ease-in-out infinite}
@keyframes gpulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.7);opacity:0}}
.hero-name{
font-family:'Syne',sans-serif;
font-size:clamp(36px,4vw,56px);
font-weight:700;
line-height:1.03;
letter-spacing:-.035em;
color:var(--navy);
margin-bottom:6px;
}
.hero-role{
font-family:'DM Sans',sans-serif;
font-size:clamp(14px,1.5vw,16px);
color:var(--mid);
font-weight:300;
font-style:italic;
letter-spacing:.01em;
margin-bottom:22px;
}
.hero-lead{
font-size:14.5px;color:var(--mid);line-height:1.68;
max-width:500px;margin-bottom:36px;font-weight:300;
}
.hero-thesis{
font-size:13.5px;color:var(--navy);line-height:1.55;
max-width:480px;margin-bottom:16px;font-weight:500;
font-style:italic;
}
.now-line{
display:inline-flex;align-items:flex-start;gap:7px;
font-size:12px;color:var(--mid);line-height:1.5;
max-width:480px;margin-bottom:20px;
padding:8px 12px;background:var(--stone2);
border:1px solid var(--line3);border-radius:4px;
}
.now-line strong{color:var(--navy);font-weight:600}
.now-dot{color:#4CAF7D;font-size:8px;flex-shrink:0;margin-top:4px}
.status-badge{
display:inline-flex;align-items:center;gap:7px;
margin-bottom:22px;text-decoration:none;
font-size:12px;color:var(--mid);font-family:'DM Mono',monospace;
}
.status-badge img{height:20px;display:block;border-radius:2px}
.status-badge span{border-bottom:1px solid var(--line3)}
.status-badge:hover span{color:var(--navy);border-color:var(--line2)}
.btns{display:flex;gap:9px;flex-wrap:wrap}
.btn{
display:inline-flex;align-items:center;gap:7px;
border-radius:3px;padding:8px 16px;
font-size:13px;font-weight:500;font-family:'DM Sans',sans-serif;
text-decoration:none;cursor:pointer;border:none;
transition:all .14s;letter-spacing:.01em;
}
.btn-d{background:var(--navy);color:var(--stone)}
.btn-d:hover{background:var(--navy2)}
.btn-o{background:transparent;color:var(--navy);border:1px solid var(--line3)}
.btn-o:hover{background:var(--stone2)}
.hero-stats{padding:32px 28px;display:flex;flex-direction:column}
.stat{padding:15px 0;border-bottom:1px solid var(--line);display:flex;gap:11px;align-items:flex-start}
.stat:first-child{border-top:1px solid var(--line)}
.stat-ico{font-size:15px;opacity:.35;flex-shrink:0;margin-top:2px}
.stat-val{font-size:13.5px;font-weight:600;color:var(--navy);letter-spacing:-.01em}
.stat-lbl{font-size:11px;color:var(--muted);margin-top:1px}
.sec{
display:grid;
grid-template-columns:var(--g1) 1fr var(--g1);
border-bottom:1px solid var(--line3);
}
.sec-g{border-right:1px solid var(--line3)}
.sec-gr{border-left:1px solid var(--line3)}
.sec-body{padding:48px 52px}
.slbl{
font-size:10.5px;font-weight:600;letter-spacing:.12em;
text-transform:uppercase;color:var(--muted);
margin-bottom:30px;font-family:'DM Mono',monospace;
}
.sec-h{
font-family:'Syne',sans-serif;font-weight:700;
font-size:clamp(22px,2.4vw,32px);
letter-spacing:-.025em;color:var(--navy);
margin-bottom:12px;line-height:1.1;
}
.about-split{
display:grid;
grid-template-columns:var(--g1) 1fr 1fr var(--g1);
border-bottom:1px solid var(--line3);
}
.about-g{border-right:1px solid var(--line3)}
.about-gr{border-left:1px solid var(--line3)}
.about-left{padding:44px 48px;border-right:1px solid var(--line3)}
.about-right{padding:44px 48px}
.terminal{background:var(--navy);border-radius:6px;overflow:hidden}
.t-bar{background:rgba(255,255,255,.05);padding:10px 15px;display:flex;align-items:center;gap:7px;border-bottom:1px solid rgba(255,255,255,.06)}
.td{width:10px;height:10px;border-radius:50%}
.td.r{background:#FF5F57}.td.y{background:#FEBC2E}.td.g{background:#28C840}
.t-title{flex:1;text-align:center;font-size:10.5px;color:rgba(255,255,255,.2);font-family:'DM Mono',monospace}
.t-body{
padding:18px 20px;
font-family:'DM Mono',monospace;
font-size:13px;line-height:1.85;
color:rgba(232,229,223,.75);
}
.t-p{color:#5FCE8A}.t-c{color:rgba(232,229,223,.35)}
.t-k{color:#82B4D0}.t-v{color:rgba(232,229,223,.8)}
.cur{display:inline-block;width:7px;height:12px;background:rgba(232,229,223,.5);vertical-align:middle;animation:bl 1s step-end infinite}
@keyframes bl{0%,100%{opacity:1}50%{opacity:0}}
.t-stage{position:relative;min-height:400px}
.t-pane{position:absolute;inset:0;opacity:1;transition:opacity .55s ease}
.t-pane.t-hidden{opacity:0;pointer-events:none}
.t-cmd-row{display:flex;align-items:center;gap:6px;margin-top:2px}
.t-input{background:transparent;border:none;outline:none;color:rgba(232,229,223,.85);font:inherit;flex:1;caret-color:#5FCE8A}
.t-hint{color:rgba(232,229,223,.28);font-size:11px;margin-top:10px}
.t-pane.t-egg-pane{
display:flex;flex-direction:column;align-items:center;justify-content:center;
text-align:center;gap:12px;padding:18px 20px;
opacity:0;pointer-events:none;
}
.t-pane.t-egg-pane.t-egg-show{opacity:1;pointer-events:auto}
.t-ascii-scroll{max-width:100%;overflow-x:auto}
.t-ascii{white-space:pre;margin:0;line-height:1.05;font-family:'DM Mono',monospace}
.t-ascii-moria{
font-size:clamp(5px,2.1vw,8.5px);
color:#7ee7ff;
text-shadow:0 0 4px #7ee7ff,0 0 14px rgba(126,231,255,.55),0 0 28px rgba(126,231,255,.3);
animation:t-pulse-cyan 2.2s ease-in-out infinite;
}
.t-ascii-balrog{
font-size:clamp(3px,1vw,4.8px);
color:#ff7a3d;
text-shadow:0 0 4px #ff7a3d,0 0 14px rgba(255,122,61,.6),0 0 30px rgba(255,80,20,.35);
animation:t-pulse-fire 1.4s ease-in-out infinite;
}
@keyframes t-pulse-cyan{0%,100%{filter:brightness(1)}50%{filter:brightness(1.35)}}
@keyframes t-pulse-fire{0%,100%{filter:brightness(1) hue-rotate(0deg)}50%{filter:brightness(1.4) hue-rotate(-6deg)}}
.t-egg-cap{font-size:11px;letter-spacing:.02em;font-family:'DM Mono',monospace;color:rgba(232,229,223,.45)}
.t-egg-cap-moria{color:#a9e9f7}
.t-egg-cap-balrog{color:#ffb08a}
.about-right p{font-size:14px;color:var(--mid);line-height:1.72;font-weight:300;margin-bottom:14px}
.about-right strong{color:var(--navy);font-weight:500}
.sk4{
display:grid;grid-template-columns:repeat(4,1fr);
border:1px solid var(--line3);border-radius:5px;
overflow:hidden;background:var(--line);
}
.sk{
background:var(--stone);padding:22px 20px;
border-right:1px solid var(--line);border-bottom:1px solid var(--line);
transition:background .14s;overflow:hidden;
}
.sk:hover{background:var(--stone2)}
.sk-ico{font-size:17px;margin-bottom:10px;opacity:.38}
.sk-t{font-size:11.5px;font-weight:600;color:var(--navy);letter-spacing:.01em;margin-bottom:10px;font-family:'DM Mono',monospace}
.tags{display:flex;flex-wrap:wrap;gap:3px}
.tag{
background:rgba(28,32,51,.06);border:1px solid var(--line);
border-radius:2px;padding:2px 6px;
font-size:10.5px;color:var(--mid);
font-family:'DM Mono',monospace;
}
.hl3{
display:grid;grid-template-columns:repeat(3,1fr);
border:1px solid var(--line3);border-radius:5px;
overflow:hidden;background:var(--line);
margin-bottom:24px;
}
.hl{
background:var(--stone);padding:24px 20px;
border-right:1px solid var(--line);border-bottom:1px solid var(--line);
transition:background .14s;overflow:hidden;
}
.hl:hover{background:var(--stone2)}
.hl-hd{display:flex;align-items:center;gap:9px;margin-bottom:9px}
.hl-ico{font-size:18px;opacity:.4}
.hl-t{font-size:14px;font-weight:600;color:var(--navy);letter-spacing:-.01em}
.hl-d{font-size:12.5px;color:var(--mid);line-height:1.62;margin-bottom:11px;font-weight:300}
.specs{display:flex;flex-wrap:wrap;gap:3px}
.spec{background:rgba(28,32,51,.05);border:1px solid var(--line);border-radius:2px;padding:2px 6px;font-size:10.5px;color:var(--mid);font-family:'DM Mono',monospace}
.hw-table{width:100%;border-collapse:collapse;margin-bottom:24px;font-size:12.5px}
.hw-table th{text-align:left;font-weight:600;font-family:'DM Mono',monospace;font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:8px 12px;border-bottom:1px solid var(--line3);background:var(--stone2)}
.hw-table td{padding:9px 12px;border-bottom:1px solid var(--line);color:var(--mid);vertical-align:top}
.hw-table td:first-child{color:var(--navy);font-weight:500;width:140px;min-width:100px;font-family:'DM Mono',monospace;font-size:11.5px}
.hw-table tr:last-child td{border-bottom:none}
.hw-table tr:hover td{background:var(--stone2)}
.note{
padding:16px 18px;
background:var(--stone2);border:1px solid var(--line3);border-radius:5px;
display:flex;gap:12px;align-items:flex-start;
}
.note-ico{font-size:16px;opacity:.38;flex-shrink:0;margin-top:2px}
.note-t{font-size:13px;font-weight:500;color:var(--navy);margin-bottom:2px}
.note-d{font-size:12.5px;color:var(--mid);line-height:1.6;font-weight:300}
.pr3{
display:grid;grid-template-columns:repeat(3,1fr);
border:1px solid var(--line3);border-radius:5px;
overflow:hidden;background:var(--line);
}
.pr{
background:var(--stone);padding:24px 20px;
border-right:1px solid var(--line);border-bottom:1px solid var(--line);
display:flex;flex-direction:column;
transition:background .14s;
}
.pr:hover{background:var(--stone2)}
.pr-type{font-size:10px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin-bottom:7px;font-family:'DM Mono',monospace}
.pr-t{font-size:14.5px;font-weight:600;color:var(--navy);letter-spacing:-.01em;margin-bottom:7px;line-height:1.3}
.pr-d{font-size:12.5px;color:var(--mid);line-height:1.62;flex:1;margin-bottom:14px;font-weight:300}
.pr-tags{display:flex;flex-wrap:wrap;gap:3px}
.pr-tag{background:rgba(28,32,51,.05);border:1px solid var(--line);border-radius:2px;padding:2px 6px;font-size:10.5px;color:var(--muted);font-family:'DM Mono',monospace}
.badge{display:inline-flex;align-items:center;gap:4px;border-radius:2px;padding:2px 7px;font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;margin-bottom:8px;font-family:'DM Mono',monospace}
.b-live{background:rgba(76,175,125,.1);color:#2A7A4F}
.b-dev{background:rgba(210,140,0,.1);color:#8A5E00}
.b-oss{background:rgba(28,32,51,.07);color:var(--mid)}
.gh-list{border:1px solid var(--line3);border-radius:5px;overflow:hidden}
.gh-item{
display:flex;align-items:center;justify-content:space-between;
padding:14px 18px;background:var(--stone);
border-bottom:1px solid var(--line);
transition:background .14s;gap:16px;
}
.gh-item:last-child{border-bottom:none}
.gh-item:hover{background:var(--stone2)}
.gh-n{font-size:13.5px;font-weight:500;color:var(--navy);margin-bottom:2px;font-family:'DM Mono',monospace}
.gh-d{font-size:12px;color:var(--mid);font-weight:300}
.gh-r{display:flex;align-items:center;gap:8px;flex-shrink:0}
.gh-lang{font-size:10.5px;font-family:'DM Mono',monospace;color:var(--muted);padding:2px 7px;border:1px solid var(--line);border-radius:2px}
.gh-stars{font-size:10.5px;font-family:'DM Mono',monospace;color:#B08A2E;padding:2px 7px;border:1px solid var(--line);border-radius:2px}
.gh-lnk{color:var(--mid);text-decoration:none;font-size:12px;font-family:'DM Mono',monospace;transition:color .14s}
.gh-lnk:hover{color:var(--navy)}
.acc{border-top:1px solid var(--line3)}
.acc-item{border-bottom:1px solid var(--line3)}
.acc-btn{
width:100%;background:none;border:none;
padding:20px 0;display:flex;align-items:flex-start;
justify-content:space-between;cursor:pointer;
text-align:left;font-family:inherit;gap:20px;
transition:background .15s;
}
.acc-btn:hover .acc-t{color:var(--navy2)}
.acc-l{flex:1}
.acc-t{font-size:16.5px;font-weight:500;color:var(--navy);letter-spacing:-.015em;line-height:1.25;margin-bottom:3px;transition:color .14s;font-family:'Syne',sans-serif}
.acc-s{font-size:12px;color:var(--muted);font-family:'DM Mono',monospace}
.acc-r2{display:flex;align-items:center;gap:12px;flex-shrink:0}
.acc-per{font-size:11.5px;color:var(--muted);font-family:'DM Mono',monospace}
.acc-chev{width:17px;height:17px;color:var(--muted);transition:transform .2s}
.acc-item.open .acc-chev{transform:rotate(180deg)}
.acc-body{display:none;padding:0 0 22px;color:var(--mid);font-size:13.5px;line-height:1.7;font-weight:300}
.acc-item.open .acc-body{display:block}
.acc-body ul{list-style:none;margin-top:10px;display:flex;flex-direction:column;gap:6px}
.acc-body li{display:flex;gap:11px;align-items:flex-start}
.acc-body li::before{content:'--';color:var(--muted);flex-shrink:0;font-family:'DM Mono',monospace;font-size:10px;margin-top:4px}
.econ-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:12px}
.econ-tag{background:rgba(28,32,51,.06);border:1px solid var(--line3);border-radius:2px;padding:3px 8px;font-size:11.5px;color:var(--navy);font-weight:500}
.langs{
display:grid;grid-template-columns:repeat(5,1fr);
border:1px solid var(--line3);border-radius:5px;overflow:hidden;
background:var(--line);margin-top:28px;
}
.langc{background:var(--stone);padding:16px 12px;text-align:center;border-right:1px solid var(--line)}
.langc:last-child{border-right:none}
.lang-n{font-size:13px;font-weight:600;color:var(--navy);margin-bottom:3px;font-family:'Syne',sans-serif}
.lang-l{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;font-family:'DM Mono',monospace}
.certs{display:flex;gap:9px;flex-wrap:wrap;margin-top:24px}
.cert{display:flex;align-items:center;gap:8px;border:1px solid var(--line3);border-radius:4px;padding:10px 14px;font-size:12.5px;color:var(--navy)}
.contact{
display:grid;
grid-template-columns:var(--g1) 1fr var(--g1);
background:var(--navy);border-top:1px solid var(--line3);
}
.contact-g{border-right:1px solid rgba(255,255,255,.07)}
.contact-gr{border-left:1px solid rgba(255,255,255,.07)}
.contact-body{padding:64px 52px}
.contact-body h2{
font-family:'Syne',sans-serif;font-weight:700;
font-size:clamp(28px,3.2vw,46px);letter-spacing:-.03em;
margin-bottom:12px;line-height:1.06;color:var(--stone);
}
.contact-body p{font-size:14px;color:rgba(232,229,223,.45);margin-bottom:32px;line-height:1.65;max-width:440px;font-weight:300}
.contact-ctas{display:flex;gap:9px;flex-wrap:wrap}
.btn-li{background:var(--stone);color:var(--navy);border-radius:3px;padding:8px 16px;font-size:13px;font-weight:500;font-family:inherit;text-decoration:none;transition:all .14s;word-break:break-all}
.btn-li:hover{background:#F0EDE7}
.btn-gh{background:transparent;color:rgba(232,229,223,.55);border:1px solid rgba(255,255,255,.14);border-radius:3px;padding:8px 16px;font-size:13px;font-weight:500;font-family:inherit;text-decoration:none;transition:all .14s}
.btn-gh:hover{border-color:rgba(255,255,255,.32);color:var(--stone)}
footer{
background:var(--navy);border-top:1px solid rgba(255,255,255,.06);
display:grid;grid-template-columns:var(--g1) 1fr var(--g1);
}
.foot{grid-column:2/3;display:flex;align-items:center;justify-content:space-between;padding:18px 52px}
footer p,footer a{font-size:12px;color:rgba(232,229,223,.24);text-decoration:none;font-family:'DM Mono',monospace}
@media(max-width:1100px){
:root{--g1:20px}
.col-rule-l,.col-rule-r,.col-rule-c1,.col-rule-c2{display:none}
.nav-socials{width:auto}
.hero-row{grid-template-columns:20px 1fr 260px 20px}
.sk4,.hl3,.pr3{grid-template-columns:repeat(2,1fr)}
.langs{grid-template-columns:repeat(3,1fr)}
}
@media(max-width:700px){
nav .nav-links{display:none}
.hero-row{grid-template-columns:16px 1fr 16px}
.hero-stats{display:none}
.about-split{grid-template-columns:16px 1fr 16px}
.about-left{display:none}
.sk4,.hl3,.pr3{grid-template-columns:1fr}
.langs{grid-template-columns:repeat(2,1fr)}
.sec-body,.about-right,.contact-body,.hero-main{padding:32px 20px}
}
-28
View File
@@ -1,28 +0,0 @@
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: 'Pietro Quintavalle',
description: 'Systems Engineer · Developer · Homelabber. Economics student at USI Lugano with 5+ years building production-grade infrastructure.',
keywords: ['Pietro Quintavalle', 'Systems Engineer', 'Homelab', 'SysAdmin', 'Economics', 'USI', 'Switzerland'],
authors: [{ name: 'Pietro Quintavalle' }],
openGraph: {
title: 'Pietro Quintavalle',
description: 'Systems Engineer · Developer · Homelabber',
siteName: 'Pietro Quintavalle',
type: 'website',
},
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,300&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>{children}</body>
</html>
)
}
-1292
View File
File diff suppressed because it is too large Load Diff
-646
View File
@@ -1,646 +0,0 @@
"use client"
import { useState } from 'react'
const DURIN_ART = ` ______________ _ _,-----------._ ___
| | (_,.- _,-'_,-----------._\`-._ _)_)
| THE _ _ _ | | ,'_,-' ___________ \`-._\`.
| | / \\|_)| \\ | \`' ,',' _,-'___________\`-._ \`.\`.
| |__\\_/| \\|_/ | ,',' ,'_,-' . \`-._\`. \`.\`.
| | /,' ,',' >|< \`.\`. \`.\\
| OF THE _ _ | // ,',' >< ,^. >< \`.\`. \\\\
| |_)||\\|/_(_ | // /,' >< / | \\ >< \`.\\ \\\\
| | \\|| |\\_|_) | // // >< \\/\\^/\\/ >< \\\\ \\\\
|______________| ;; ;; \`---' :: ::
|| || (____ || ||
DOORS OF DURIN _||__||_ ,'----. _||__||_
(o.____.o)____ \`---' ____(o.____.o)
| | /,--.) (,--.\\ | |
| |(( -\`___ ___\` ))| |
| | \\\\,'', \`. .' .\`\`.// | |
| | // (___,'. .'.___) \\\\ | |
/| | ;;)) ____) . . (____ ((\\\\ | |\\
\\|.__ | ||/ .'.--.\\/ \`/,--.\`. \\;: | __,|;
|\`-,\`;.| :/ /,' \`)-' \`-(' \`.\\ \\: |.;',-'|
| \`.. ' / \\__.' \`.__/ \\ \` ,.' |
| |,\\ /, ,\\ /,| |
| ||: : ) . ( : :|| |
/| |:; |/ . ./|\\, , \\| :;| |\\
\\|.__ |/ : ,/- <--:--> ,\\. ; \\| __,|;
|\`-.\`\`: \`'/-. '\\|/\` ,-\\\`; ;'',-'|
| \`.. ,' \`' ' \` \`. ,.' |
| || : : || |
| || | | || |
| || | | || |
| |' | _ | \`| |
| | | '|)) | | |
;____: \`._ \`' _,' ;____:
{______} \\___________________/ {______}
SSt |______|_______________________________|______|`
const BALROG_ART = `############################+##++++++########+++++-----. ------------ . - --.--- --.--.------...---------------..---
###############################+++++++####+++++----- --- - ---- -- -. ..- - - -------------------------------
##########################+++++++++++++#####+++---. . -- - - - - .--.-----------------------------
########################+++#+++++++++######++++-. ..--++++++------..----- - ------------.- ---------
###########################+##+++++++++++##++- -++++++++--+--------.- .. ...-.. - ----------------.---------
############################+++++++++++++#++-+ - -++++++++++++------+++---++-------------. -.----------.--------
#############################++++++++++++#++++- --- ++++++++++++++++--+++++++------------------------- -------------.---
#############################++++++++++++++++--- .+##+++++++++++++++++++++++++---------------------------.----------------
############++#+###+#+++#####++++++++++++++-- --- -- -+###+++++++++++++++++++++++++----+---------------------------------------
###########++++++++++++#++++++++++++++--..---- . .-- .. -+#####++++++++++++++++++++++++-+--++++-------------------------------------
############+++++++++++++++++++++++++++++++++.+ ..--.- +#########+++++++++++++++++++++++++++----+------------------------.-----------
############++++++++++++++++++++++-+++++++++++++.. + .++#####++#++++#++++++++++++++++++++++++++-------+--------------------- ---------
########+#+++++++++++++++++++++---++++-+++++++++.. -+#########-+ .+++++++++++++++++++++++-++++-+++++----+-------------------+--------
+######+#+++++++++++++++++++++-+++++++-++++++++++##+-.+++++++#+ +++++++++++++++++++++++-----+++++++++++-----+--------+-------------
++##+++++++++++++++++++++++++++++++++++++-+++.++++-+ .+++++-++++++++++++++++++++++----+---------------+++++++-----+----------------
++++++++++++++++++++++++++##+++++++++++++++. ++# ++ +-.++++++++++++++++#++++---+------------------------+-+++---+++-+---+-----.----
+++++++++++++++++++++++###++++++++++++++++++-+++- +++ ++++++++++++++#++++-++----+-+----------------------+---++++++++----+--++++----
+++++++++++++++++++++###++++++++++++++++++++++++ +#+#+#+#+++#######+++#----------------------------------+-++----++-+++--+++++++----.
+++++++++++++++++++++++#++++++++++++-++++++++++##++++#++#+++#+++#+#+++++++++--+----------+------------------+-++--++-+--++++++-++-.---
++++++++++++++++++++++++++++++++++++++++++#+++++-++++##+#++##++####+++++++++++-+-----------+------------------+---++++++++++++++-++---
+++++++++++++++++++++++++++++++++++++++--++++++#++++###+#++#+#++++++++++++++++++++---+---------------------------+++++++++++++++++++--
+++++++++++++++++++++++++++++-------++--+++++#++++#+#######+#++++#+#-++-+++++++++++------------------------------+--++++++++++++++++--
+++++++++++++++++++++++++++-------+-----+#+++#+++# #.# #+####+###++--+++++++++++++--------------.-------------+-++--++++++++++++++--
+++++++++++++++++++++++-----------------+++#+--# . - - .+####+##++-----++++++++--+----+-+--------.--------------+-----+++++++++++++--
+++++++++++++++++++++-------++----------#++++# - #. ####+-+-.+--+++++++---+++++++----------------------------.--++++++++++++--
+++++++++++++--+++++-+-------+----------+++##. . + +#+- -++---+ ++++----+++++++++---------------------------..+++++++++++++--
+++++++++++++-++++++---------------------#++ ---. +#- +++-. -++----++-+-+-+++++--------------------------..-+++++++++++++--
++++++++++++++-----+--------------++++++++#+ +...-- + . - ----+-+++--++-+++-----------------------.---++++++++++++++--
++++++++----++--+++----------+++++++++++-++#++. . . ----+----++---+--++-------------------------+ +++++++++++++---
+++++++++---+--------------++++++++++++++++...-. . -----------+---++++------------------------++++++++++++++++---
+++++++---+-++-+++-+-----+++++++++++++++++------ .. ---------+-----+++++-+-----------------++++++++++++++++++----
+++++++-+++-+----++------+++++++++++++++++++++--- ....----+--+------++++-++------------.+.+++++++++++##+++++++----
++++++++++++++++------+++++++++++++++++++++++++--. . ... ++---+--+----++++---++-------.+-.+++++++++++++#######+------
++++---++++------+---++++++++++++++++++++++++++---. ...-.--+--+---++--+------+-+--.-.++.+#+++#+++++###++++++#---------
-++++.--+++++---+----+++++++++++++++++++++++++++-+-... ......---+++-++----++-----+--.---++####+++++###--++####++-----.----
++++-----+----------++++++++++++++++++++++++++++++--.... ..------++++--------------.+- ############+++##+###+---------------
+++-+++------------++++++++++++++++++++++++++++++----..... ..----+. -+---------- ---..+###+####+###-#####++-+--.---------------
+++++++++--.------+++++++++++++++++++++++++++++. -----.... . . ..-++-+--+----------- .#++++#++##+--++++---+-----------.-----------
-----+-+++--------++++++++++++++++++++++++++++++---.--.... . .. ---++-.+------------ .+-++##--#+--++--.-----------------------------
-------+++--------++++#++++++++++++++++++++++-+-------..-. .---+--+++---------. +#-#+#+--+------.---.---.----- ----------------..
-------++++++-+------.+++++++++++++++++-++++++++-- ----..... ..----+--+.++-------- +--#++-------------------------....-.---------..--
------++++++++++++---+--##++++++++++++++++++++---------......-.--+++-.-++---.---. -##++----------------------- ---.....---------......
-++---++++++#+++++##++...+#++++++++ +++++++++---------...-.---.++++...--.-+-----..+#+-----------.---------------.... ......-.........
-------+++-+++#+# ++#+....+##+#++++#+++++++++----++++---...----++++-..+.--++---.+#+-------------------------..... . ....... . ...
++----+++++++-+---###+-+..-##+##+##+++++++++-.--------.--------+-+--------++---.#-----------..----------...... .. . ..
++++-+++++++++++++++##++-..##++++##+++++++++-+----..----+---+--++---------++-------------------------...-. . . ....
++++++++++++++++++++++##-..+++++++ ++++++ +- - --------------+-.---------------- ----------.----------.... . .
-+++++++-+++++++++++++#+--++.++-----------------------. .--- --------------------------------------...--..... ...... .
-++++--++++++++++++++++-++ -++.-------.------------ ------.---------.....--------------------- .--- ....... ......
----------++++--------+++.----------..--------.------------ ...-.-.-.----------------------..---.--. .. ........
-----.-...-------------------------------.------ -----..----...-.........-----------------.. .-.... ....... ... .
........ .--------------------------------------- --... ---......----.. . - ----------.--...-... ......... ..
... ..---------------.--.-------------...-.---------.. ....-...... --- --- ---.-----...... .
...... .-----.-.----------.---------------..--------.--...... . ..----------------.-.. ..
. ...--------------------------...----.--------.-..---.-. .---------------------. . `
const ChevronDown = () => (
<svg className="acc-chev" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
)
export default function Home() {
const [openAcc, setOpenAcc] = useState<Record<string, boolean>>({
'exp-0': true,
'edu-0': true,
})
const toggle = (id: string) => {
setOpenAcc(prev => ({ ...prev, [id]: !prev[id] }))
}
const [cmd, setCmd] = useState('')
const [eggView, setEggView] = useState<'main' | 'moria' | 'balrog'>('main')
const [eggBusy, setEggBusy] = useState(false)
const triggerEgg = (view: 'moria' | 'balrog') => {
if (eggBusy) return
setEggBusy(true)
setEggView(view)
setTimeout(() => {
setEggView('main')
setCmd('')
setEggBusy(false)
}, 4200)
}
const handleTermKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key !== 'Enter' || eggBusy) return
const value = cmd.trim().toLowerCase()
if (value === 'mellon') {
triggerEgg('moria')
} else if (value === 'you shall not pass') {
triggerEgg('balrog')
} else {
setCmd('')
}
}
return (
<>
<div className="col-rule-l"></div>
<div className="col-rule-r"></div>
<div className="col-rule-c1"></div>
<div className="col-rule-c2"></div>
{/* NAV */}
<nav>
<a href="#" className="nav-logo-wrap">
<svg className="nav-logosvg" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="22" height="22" rx="4" fill="#1C2033"/>
<path d="M5 8L11 5.5L17 8V12.8C17 16.5 11 20 11 20C11 20 5 16.5 5 12.8V8Z" stroke="#E8E5DF" strokeWidth="1.3"/>
<path d="M9 11.8L10.8 13.5L14 9.5" stroke="#E8E5DF" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span className="nav-name">Pietro<sup>DEV</sup></span>
</a>
<div className="nav-links">
<a href="#skills">Skills</a>
<a href="#homelab">Homelab</a>
<a href="#desktop">Desktop</a>
<a href="#projects">Projects</a>
<a href="#github">GitHub</a>
<a href="#experience">Experience</a>
<a href="#contact">Contact</a>
</div>
<div className="nav-socials">
<a href="https://github.com/Quinta0" target="_blank" rel="noopener" title="GitHub">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12c0 4.42 2.87 8.17 6.84 9.49.5.09.68-.22.68-.48v-1.69C6.72 19.9 6.14 18 6.14 18c-.46-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.89 1.52 2.34 1.08 2.91.83.09-.65.35-1.08.63-1.33-2.22-.25-4.56-1.11-4.56-4.95 0-1.09.39-1.98 1.03-2.68-.1-.26-.45-1.27.1-2.64 0 0 .84-.27 2.75 1.02A9.56 9.56 0 0 1 12 7.8c.85.004 1.71.115 2.51.337 1.91-1.29 2.75-1.02 2.75-1.02.55 1.37.2 2.38.1 2.64.64.7 1.03 1.59 1.03 2.68 0 3.85-2.34 4.7-4.57 4.95.36.31.68.92.68 1.85v2.74c0 .27.18.58.69.48A10.01 10.01 0 0 0 22 12c0-5.52-4.48-10-10-10Z"/></svg>
</a>
<a href="https://www.linkedin.com/in/pietro-quintavalle-996b96267/" target="_blank" rel="noopener" title="LinkedIn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14m-.5 15.5v-5.3a3.26 3.26 0 0 0-3.26-3.26c-.85 0-1.84.52-2.32 1.3v-1.11h-2.79v8.37h2.79v-4.93c0-.77.62-1.4 1.39-1.4a1.4 1.4 0 0 1 1.4 1.4v4.93h2.79M6.88 8.56a1.68 1.68 0 0 0 1.68-1.68c0-.93-.75-1.69-1.68-1.69a1.69 1.69 0 0 0-1.69 1.69c0 .93.76 1.68 1.69 1.68m1.39 9.94v-8.37H5.5v8.37h2.77Z"/></svg>
</a>
</div>
</nav>
{/* ANNOUNCE STRIP */}
<div className="strip">
<span className="strip-pill">Open to work</span>
<a href="#contact">Available for Junior SysAdmin &amp; IT consulting &amp; Junior Quant <span className="strip-arr">&#8594;</span></a>
</div>
{/* HERO */}
<div className="hero-row">
<div className="hero-gutter"></div>
<div className="hero-main">
<div className="status-row"><span className="green-dot"></span>Lugano &middot; Switzerland &middot; USI Economics student</div>
<div className="hero-name">Pietro<br/>Quintavalle</div>
<div className="hero-role">Systems Engineer &middot; Developer &middot; Homelabber</div>
<p className="hero-thesis">Economics models incentives under constraints; infrastructure models constraints under load I like sitting where those two disciplines overlap.</p>
<p className="hero-lead">5+ years building production-grade infrastructure from the ground up. I design resilient systems and write the software that runs on top of them then secure and monitor all of it myself.</p>
<div className="btns">
<a href="#contact" className="btn btn-d">Get in touch &#8594;</a>
<a href="#homelab" className="btn btn-o">Explore homelab</a>
<a href="#projects" className="btn btn-o">View projects</a>
</div>
</div>
<div className="hero-stats">
<div className="stat"><span className="stat-ico">&#9889;</span><div><div className="stat-val">90%+ uptime</div><div className="stat-lbl">Production servers</div></div></div>
<div className="stat"><span className="stat-ico">&#128187;</span><div><div className="stat-val">4 servers</div><div className="stat-lbl">Under management</div></div></div>
<div className="stat"><span className="stat-ico">&#128197;</span><div><div className="stat-val">5+ years</div><div className="stat-lbl">SysAdmin experience</div></div></div>
<div className="stat"><span className="stat-ico">&#127891;</span><div><div className="stat-val">BSc Economics</div><div className="stat-lbl">USI &middot; Ongoing</div></div></div>
<div className="stat"><span className="stat-ico">&#127760;</span><div><div className="stat-val">IT &middot; EN &middot; DE &middot; ES &middot; NO</div><div className="stat-lbl">5 languages</div></div></div>
</div>
<div className="hero-gutter-r"></div>
</div>
{/* ABOUT + TERMINAL */}
<div className="about-split">
<div className="about-g"></div>
<div className="about-left">
<div className="terminal">
<div className="t-bar"><span className="td r"></span><span className="td y"></span><span className="td g"></span><span className="t-title">quinta@proxmox:~</span></div>
<div className="t-stage">
<div className={`t-pane t-body${eggView !== 'main' ? ' t-hidden' : ''}`}>
<div><span className="t-p">&#10148;</span> <span className="t-c">~</span> neofetch</div>
<div>&nbsp;</div>
<div><span className="t-k">OS </span><span className="t-v">Proxmox VE 8 / UNRAID 7.3.1 / TrueNAS</span></div>
<div><span className="t-k">Virt </span><span className="t-v">LXC &middot; Docker &middot; KVM</span></div>
<div><span className="t-k">DNS </span><span className="t-v">Cloudflare &middot; AAAA &middot; AdGuard</span></div>
<div><span className="t-k">Tunnels </span><span className="t-v">Cloudflare Tunnel &middot; Newt</span></div>
<div><span className="t-k">Proxy </span><span className="t-v">Pangolin + Traefik + Auto-TLS</span></div>
<div><span className="t-k">Security </span><span className="t-v">Fail2Ban &middot; Crowdsec &middot; Authelia</span></div>
<div><span className="t-k">Storage </span><span className="t-v">UNRAID parity &middot; TrueNAS ZFS RAID 10</span></div>
<div><span className="t-k">Media </span><span className="t-v">Jellyfin &middot; Navidrome &middot; *arr</span></div>
<div><span className="t-k">Music </span><span className="t-v">Listenbrainz &middot; Symfonium</span></div>
<div><span className="t-k">Monitor </span><span className="t-v">Grafana &middot; Prometheus &middot; Kuma</span></div>
<div><span className="t-k">Code </span><span className="t-v">Python &middot; TypeScript &middot; SQL</span></div>
<div><span className="t-k">Status </span><span className="t-v" style={{color:'#5FCE8A'}}>Open for work &#9679;</span></div>
<div>&nbsp;</div>
<div className="t-cmd-row">
<span className="t-p">&#10148;</span> <span className="t-c">~</span>
<input
className="t-input"
type="text"
value={cmd}
onChange={e => setCmd(e.target.value)}
onKeyDown={handleTermKey}
autoComplete="off"
spellCheck={false}
aria-label="terminal command input"
/>
</div>
<div className="t-hint">try typing something...</div>
</div>
<div className={`t-pane t-egg-pane${eggView === 'moria' ? ' t-egg-show' : ''}`}>
<div className="t-ascii-scroll">
<pre className="t-ascii t-ascii-moria">{DURIN_ART}</pre>
</div>
<div className="t-egg-cap t-egg-cap-moria">the doors of durin, lord of moria speak, friend, and enter</div>
</div>
<div className={`t-pane t-egg-pane${eggView === 'balrog' ? ' t-egg-show' : ''}`}>
<div className="t-ascii-scroll">
<pre className="t-ascii t-ascii-balrog">{BALROG_ART}</pre>
</div>
<div className="t-egg-cap t-egg-cap-balrog">a demon of the ancient world YOU SHALL NOT PASS</div>
</div>
</div>
</div>
</div>
<div className="about-right">
<div className="slbl">About me</div>
<p>Hi friends call me <strong>Quinta</strong>. I&rsquo;m an Economics student at USI Lugano and a self-taught systems engineer who&rsquo;s been running homelab infrastructure since 2020.</p>
<p>My setup spans everything from <strong>bare-metal Proxmox clusters</strong> to <strong>DNS and domain management via Cloudflare</strong>, <strong>Pangolin/Traefik reverse proxying</strong>, and a full media+music automation pipeline.</p>
<p>On the academic side I&rsquo;m deepening my understanding of <strong>microeconomics, macroeconomics, game theory, and quantitative finance</strong> and finding more overlap with systems thinking than you&rsquo;d expect.</p>
<p style={{fontSize:'12.5px',color:'var(--muted)',fontFamily:'\'DM Mono\',monospace',marginTop:'4px'}}>Daily driver Ryzen 7 9800X3D &middot; RTX 5080 &middot; hand-built custom keyboard</p>
</div>
<div className="about-gr"></div>
</div>
{/* SKILLS */}
<div className="sec" id="skills">
<div className="sec-g"></div>
<div className="sec-body">
<div className="slbl">Technical Arsenal</div>
<div className="sk4">
<div className="sk"><div className="sk-ico">&#128423;</div><div className="sk-t">System &amp; Infra</div><div className="tags"><span className="tag">Proxmox VE</span><span className="tag">UNRAID</span><span className="tag">TrueNAS</span><span className="tag">Docker</span><span className="tag">LXC</span><span className="tag">KVM</span><span className="tag">Linux</span><span className="tag">Win Server</span><span className="tag">AD</span></div></div>
<div className="sk"><div className="sk-ico">&#128274;</div><div className="sk-t">Network &amp; Security</div><div className="tags"><span className="tag">VLAN</span><span className="tag">Cloudflare</span><span className="tag">AAAA/DNS</span><span className="tag">CF Tunnels</span><span className="tag">Tailscale</span><span className="tag">Fail2Ban</span><span className="tag">Crowdsec</span><span className="tag">UFW</span><span className="tag">Authelia</span><span className="tag">Traefik</span></div></div>
<div className="sk"><div className="sk-ico">&#9000;</div><div className="sk-t">Development</div><div className="tags"><span className="tag">Python</span><span className="tag">TypeScript</span><span className="tag">React</span><span className="tag">Next.js</span><span className="tag">Django</span><span className="tag">SQL</span><span className="tag">Bash</span><span className="tag">Ansible</span><span className="tag">HTML/CSS</span><span className="tag">Soldering</span></div></div>
<div className="sk"><div className="sk-ico">&#128202;</div><div className="sk-t">Data &amp; Monitoring</div><div className="tags"><span className="tag">Grafana</span><span className="tag">Prometheus</span><span className="tag">Uptime Kuma</span><span className="tag">ZFS</span><span className="tag">rsync</span><span className="tag">RAID</span><span className="tag">cAdvisor</span></div></div>
</div>
<div style={{marginTop:'32px'}}>
<div className="slbl">Economics &amp; Quant</div>
<div className="sk4">
<div className="sk"><div className="sk-ico">&#128200;</div><div className="sk-t">Microeconomics</div><div className="tags"><span className="tag">Consumer theory</span><span className="tag">Market structures</span><span className="tag">Production &amp; costs</span><span className="tag">Price theory</span><span className="tag">Welfare econ.</span></div></div>
<div className="sk"><div className="sk-ico">&#127758;</div><div className="sk-t">Macroeconomics</div><div className="tags"><span className="tag">GDP &amp; growth</span><span className="tag">Monetary policy</span><span className="tag">Fiscal policy</span><span className="tag">IS-LM model</span><span className="tag">Open economy</span></div></div>
<div className="sk"><div className="sk-ico">&#9861;</div><div className="sk-t">Game Theory</div><div className="tags"><span className="tag">Nash equilibrium</span><span className="tag">Dominant strategies</span><span className="tag">Extensive form</span><span className="tag">Mechanism design</span><span className="tag">Auctions</span></div></div>
<div className="sk"><div className="sk-ico">&#128185;</div><div className="sk-t">Quantitative Finance</div><div className="tags"><span className="tag">Time value of money</span><span className="tag">Probability</span><span className="tag">Statistics</span><span className="tag">Regression</span><span className="tag">Financial modelling</span></div></div>
</div>
</div>
<div style={{marginTop:'28px'}}>
<div className="slbl">Certifications</div>
<div className="certs">
<div className="cert">&#127891; Google Data Analytics Professional Certificate</div>
<div className="cert">&#127891; Google IT Support Professional Certificate</div>
<div className="cert">&#127891; Microsoft Foundations of IT Systems, Networking &amp; Data Protection</div>
</div>
</div>
</div>
<div className="sec-gr"></div>
</div>
{/* HOMELAB */}
<div className="sec" id="homelab">
<div className="sec-g"></div>
<div className="sec-body">
<div className="slbl">Homelab Infrastructure</div>
<p style={{fontSize:'14px',color:'var(--mid)',marginBottom:'32px',maxWidth:'680px',lineHeight:'1.65',fontWeight:'300'}}>Every service containerised, every entry-point proxied, every failure logged. Domain management via Cloudflare with AAAA records and tunnels. Built to enterprise patterns at personal scale.</p>
<div className="slbl" style={{marginBottom:'14px'}}>Hardware</div>
<table className="hw-table" style={{marginBottom:'32px',border:'1px solid var(--line3)',borderRadius:'5px',overflow:'hidden'}}>
<thead><tr><th>Component</th><th>UNRAID Server</th><th>Proxmox Node</th></tr></thead>
<tbody>
<tr><td>CPU</td><td>Intel Core Ultra 5 225</td><td>Intel Core i7-8700</td></tr>
<tr><td>RAM</td><td>48GB DDR5 5600MHz</td><td>32GB DDR4</td></tr>
<tr><td>Storage</td><td>4&times; HDD parity array + NVMe cache</td><td>SSD (OS) + SATA pool</td></tr>
<tr><td>Network</td><td>1Gbps LAN &middot; VLANs</td><td>1Gbps LAN &middot; VLANs</td></tr>
<tr><td>OS</td><td>UNRAID 7.3.1</td><td>Proxmox VE 8</td></tr>
<tr><td>Access</td><td>Pangolin + Traefik</td><td>Tailscale + SSH</td></tr>
<tr><td>Focus</td><td>Idle power efficiency &middot; perf/watt</td><td>Virtualisation &middot; LXC</td></tr>
</tbody>
</table>
<div className="slbl" style={{marginBottom:'14px'}}>Services &amp; Stack</div>
<div className="hl3">
<div className="hl">
<div className="hl-hd"><span className="hl-ico">&#128190;</span><span className="hl-t">UNRAID NAS</span></div>
<div className="hl-d">Parity-protected array hosting 9-user network shares, Docker Appdata, automated backups. Acts as the central file hub for all services via SMB/NFS.</div>
<div className="specs"><span className="spec">UNRAID 7.3.1</span><span className="spec">Parity array</span><span className="spec">NVMe cache</span><span className="spec">9 users</span><span className="spec">rsync</span><span className="spec">SMB/NFS</span></div>
</div>
<div className="hl">
<div className="hl-hd"><span className="hl-ico">&#127758;</span><span className="hl-t">DNS &amp; Cloudflare</span></div>
<div className="hl-d">Domain managed through Cloudflare with AAAA records for IPv6, Cloudflare Tunnels for zero-open-port external access, and AdGuard Home for internal DNS resolution and ad-blocking.</div>
<div className="specs"><span className="spec">Cloudflare</span><span className="spec">AAAA records</span><span className="spec">CF Tunnels</span><span className="spec">AdGuard Home</span><span className="spec">Split DNS</span><span className="spec">DNSSEC</span></div>
</div>
<div className="hl">
<div className="hl-hd"><span className="hl-ico">&#128256;</span><span className="hl-t">Pangolin + Traefik</span></div>
<div className="hl-d">Pangolin (Traefik-based) handles all inbound HTTPS with automatic TLS, subdomain routing, and middleware. Newt tunnel enables access without exposing raw ports. Authelia adds SSO/2FA.</div>
<div className="specs"><span className="spec">Pangolin</span><span className="spec">Traefik</span><span className="spec">Auto-TLS</span><span className="spec">Newt tunnel</span><span className="spec">Authelia</span><span className="spec">Middleware</span></div>
</div>
<div className="hl">
<div className="hl-hd"><span className="hl-ico">&#128737;</span><span className="hl-t">Fail2Ban + Security</span></div>
<div className="hl-d">Fail2Ban monitors SSH and auth logs, auto-banning brute-force IPs. Crowdsec adds community threat intelligence with real-time blocklist sync. UFW provides host-level firewall rules on each node.</div>
<div className="specs"><span className="spec">Fail2Ban</span><span className="spec">Crowdsec</span><span className="spec">UFW</span><span className="spec">iptables</span><span className="spec">SSH hardening</span><span className="spec">Authelia</span></div>
</div>
<div className="hl">
<div className="hl-hd"><span className="hl-ico">&#127916;</span><span className="hl-t">Jellyfin + Media</span></div>
<div className="hl-d">Jellyfin handles hardware-transcoded streaming with per-user OAuth authentication and Authelia SSO. Sonarr/Radarr auto-manage TV and film libraries; Bazarr handles subtitles; Prowlarr indexes trackers.</div>
<div className="specs"><span className="spec">Jellyfin</span><span className="spec">HW transcode</span><span className="spec">OAuth</span><span className="spec">Sonarr</span><span className="spec">Radarr</span><span className="spec">Bazarr</span><span className="spec">Prowlarr</span></div>
</div>
<div className="hl">
<div className="hl-hd"><span className="hl-ico">&#127925;</span><span className="hl-t">Music Pipeline</span></div>
<div className="hl-d">Lidarr monitors for releases &#8594; Prowlarr indexes &#8594; Beets normalises tags &#8594; Navidrome streams via Subsonic API. Scrobbling to Listenbrainz. Mobile playback via Symfonium and Explo apps.</div>
<div className="specs"><span className="spec">Navidrome</span><span className="spec">Lidarr</span><span className="spec">Prowlarr</span><span className="spec">Beets</span><span className="spec">Listenbrainz</span><span className="spec">Symfonium</span><span className="spec">Explo</span></div>
</div>
</div>
<div className="note">
<span className="note-ico">&#128279;</span>
<div>
<div className="note-t">Zero open ports via Tailscale + Cloudflare Tunnels</div>
<div className="note-d">All services reachable remotely through a combination of Tailscale mesh VPN (for trusted devices) and Cloudflare Tunnels (for public-facing services). No port-forwarding. MagicDNS handles internal name resolution. Pangolin&rsquo;s Newt agent tunnels external traffic into the private LAN.</div>
</div>
</div>
</div>
<div className="sec-gr"></div>
</div>
{/* DESKTOP */}
<div className="sec" id="desktop">
<div className="sec-g"></div>
<div className="sec-body">
<div className="slbl">Personal Workstation</div>
<p style={{fontSize:'14px',color:'var(--mid)',marginBottom:'32px',maxWidth:'680px',lineHeight:'1.65',fontWeight:'300'}}>Daily driver and dev machine. Built around AMD&rsquo;s 3D V-Cache architecture for a blend of high single-threaded performance and serious GPU compute doubles as a gaming rig, 3D modelling workstation, and 3D printing controller.</p>
<table className="hw-table" style={{marginBottom:'32px',border:'1px solid var(--line3)',borderRadius:'5px',overflow:'hidden'}}>
<thead><tr><th>Component</th><th>Spec</th></tr></thead>
<tbody>
<tr><td>CPU</td><td>AMD Ryzen 7 9800X3D (8c/16t &middot; 3D V-Cache)</td></tr>
<tr><td>GPU</td><td>Zotac RTX 5080 Solid OC</td></tr>
<tr><td>RAM</td><td>32GB DDR5 6000MHz</td></tr>
<tr><td>Motherboard</td><td>Gigabyte X870E AORUS PRO X3D ICE</td></tr>
<tr><td>OS</td><td>CachyOS</td></tr>
<tr><td>Role</td><td>Dev &middot; Gaming &middot; 3D Modelling &middot; 3D Printing &middot; Daily Driver</td></tr>
<tr><td>Keyboard</td><td>Weikav Stars 75 &middot; Huano Sakura switches (hand-lubed, custom assembly)</td></tr>
</tbody>
</table>
<div className="hl3" style={{marginBottom:'0',gridTemplateColumns:'repeat(2,1fr)'}}>
<div className="hl">
<div className="hl-hd"><span className="hl-ico">&#9889;</span><span className="hl-t">AMD Ryzen 7 9800X3D</span></div>
<div className="hl-d">Zen 5 architecture with 96MB 3D V-Cache. Exceptional single-thread and gaming performance, with low-latency cache access ideal for simulation and data-heavy workloads.</div>
<div className="specs"><span className="spec">Zen 5</span><span className="spec">8c / 16t</span><span className="spec">96MB L3 3D V-Cache</span><span className="spec">5.0GHz boost</span></div>
</div>
<div className="hl">
<div className="hl-hd"><span className="hl-ico">&#127918;</span><span className="hl-t">Zotac RTX 5080 Solid OC</span></div>
<div className="hl-d">Blackwell architecture. 16GB GDDR7, hardware ray tracing, DLSS 4 with Multi Frame Generation. Used for gaming, 3D rendering, and GPU-accelerated dev and compute tasks.</div>
<div className="specs"><span className="spec">Blackwell</span><span className="spec">16GB GDDR7</span><span className="spec">DLSS 4</span><span className="spec">MFG</span><span className="spec">CUDA</span><span className="spec">Ray Tracing</span></div>
</div>
<div className="hl">
<div className="hl-hd"><span className="hl-ico">&#128268;</span><span className="hl-t">X870E AORUS PRO X3D ICE</span></div>
<div className="hl-d">Gigabyte X870E flagship board with PCIe 5.0, USB4, and optimised X3D power delivery. Supports DDR5 6000MHz XMP out-of-the-box. White aesthetic matches the build&rsquo;s ICE theme.</div>
<div className="specs"><span className="spec">X870E</span><span className="spec">PCIe 5.0</span><span className="spec">USB4</span><span className="spec">DDR5 6000</span><span className="spec">WiFi 7</span></div>
</div>
<div className="hl">
<div className="hl-hd"><span className="hl-ico">&#9000;</span><span className="hl-t">Weikav Stars 75</span></div>
<div className="hl-d">Fully custom 75% aluminium keyboard hand-lubed Huano Sakura tactile switches, gasket mount, per-key RGB, 1000Hz polling. Full assembly and switch tuning done from scratch.</div>
<div className="specs"><span className="spec">75% layout</span><span className="spec">Huano Sakura</span><span className="spec">Tactile</span><span className="spec">Hand-lubed</span><span className="spec">Gasket mount</span><span className="spec">Aluminium</span><span className="spec">1000Hz</span></div>
</div>
</div>
</div>
<div className="sec-gr"></div>
</div>
{/* PROJECTS */}
<div className="sec" id="projects">
<div className="sec-g"></div>
<div className="sec-body">
<div className="slbl">Personal &amp; Client Projects</div>
<div className="pr3">
<div className="pr">
<span className="badge b-live">&#9679; Live</span>
<div className="pr-type">Professional Client</div>
<div className="pr-t">Enterprise Proxmox Cluster</div>
<div className="pr-d">Full single-node virtualisation with VLAN segmentation, Pangolin/Traefik reverse proxy, Cloudflare Tunnels, and centralised Grafana/Prometheus monitoring. 90%+ uptime maintained.</div>
<div className="pr-tags"><span className="pr-tag">Proxmox</span><span className="pr-tag">Docker</span><span className="pr-tag">LXC</span><span className="pr-tag">Traefik</span><span className="pr-tag">Tailscale</span></div>
</div>
<div className="pr">
<span className="badge b-live">&#9679; Live</span>
<div className="pr-type">Professional Client</div>
<div className="pr-t">9-User RAID 10 NAS</div>
<div className="pr-d">TrueNAS on RAID 10 serving as a self-hosted cloud for 9 users. Per-user ACLs, automated rsync offsite backups, ZFS snapshots for instant point-in-time recovery.</div>
<div className="pr-tags"><span className="pr-tag">TrueNAS</span><span className="pr-tag">RAID 10</span><span className="pr-tag">ZFS</span><span className="pr-tag">rsync</span><span className="pr-tag">ACLs</span></div>
</div>
<div className="pr">
<span className="badge b-oss">&#8599; Open Source</span>
<div className="pr-type">Personal Project</div>
<div className="pr-t">Valgo Algorithm Visualizer</div>
<div className="pr-d">Step-by-step algorithm visualisation built with React and Next.js. Sorting, graph, and search algorithms with live code annotations and speed controls.</div>
<div className="pr-tags"><span className="pr-tag">React</span><span className="pr-tag">Next.js</span><span className="pr-tag">TypeScript</span><span className="pr-tag">Tailwind</span></div>
</div>
<div className="pr">
<span className="badge b-dev">&#9680; In Dev</span>
<div className="pr-type">Personal Project</div>
<div className="pr-t">Finance Tracker</div>
<div className="pr-d">50/30/20 budgeting tool with Django backend, SQLite, and Chart.js dashboards. Monthly trend analysis, category breakdowns, and exportable reports.</div>
<div className="pr-tags"><span className="pr-tag">Django</span><span className="pr-tag">Python</span><span className="pr-tag">SQLite</span><span className="pr-tag">Chart.js</span></div>
</div>
<div className="pr">
<span className="badge b-live">&#9679; Live</span>
<div className="pr-type">Personal Homelab</div>
<div className="pr-t">Self-Hosted Stack (IaC)</div>
<div className="pr-d">Entire homelab reproduced from code Ansible playbooks, Docker Compose, and Bash scripts. Idempotent provisioning; full rebuild in under 30 minutes from scratch.</div>
<div className="pr-tags"><span className="pr-tag">Ansible</span><span className="pr-tag">Compose</span><span className="pr-tag">Bash</span><span className="pr-tag">IaC</span></div>
</div>
<div className="pr">
<span className="badge b-oss">&#8599; Open Source</span>
<div className="pr-type">Personal Project</div>
<div className="pr-t">re-soulcommand</div>
<div className="pr-d">Music recommendation downloader integrating ListenBrainz and Last.fm playlists with Navidrome via slskd (Soulseek daemon). Fork rebuilt around a REST API backend stripped of bloat, Docker-native.</div>
<div className="pr-tags"><span className="pr-tag">Python</span><span className="pr-tag">Docker</span><span className="pr-tag">ListenBrainz</span><span className="pr-tag">Last.fm</span><span className="pr-tag">slskd</span><span className="pr-tag">Navidrome</span></div>
</div>
<div className="pr">
<span className="badge b-live">&#9679; Live</span>
<div className="pr-type">Personal Homelab</div>
<div className="pr-t">Ainulindale Music Stack</div>
<div className="pr-d">Fully self-hosted music discovery and streaming platform. Auto-discovers music weekly from listening habits, downloads via Soulseek P2P through a VPN gateway, streams via Navidrome. No commercial services required.</div>
<div className="pr-tags"><span className="pr-tag">Docker</span><span className="pr-tag">Navidrome</span><span className="pr-tag">slskd</span><span className="pr-tag">Gluetun</span><span className="pr-tag">ListenBrainz</span><span className="pr-tag">ProtonVPN</span></div>
</div>
<div className="pr">
<span className="badge b-live">&#9679; Live</span>
<div className="pr-type">Personal Homelab</div>
<div className="pr-t">UNRAID Home Server</div>
<div className="pr-d">Intel Core Ultra 5 225 build running UNRAID 7.3.1 as primary NAS, Docker host, and media stack. Parity-protected array with NVMe cache, 9-user SMB/NFS shares, and all homelab services containerised.</div>
<div className="pr-tags"><span className="pr-tag">UNRAID 7.3.1</span><span className="pr-tag">Docker</span><span className="pr-tag">Parity Array</span><span className="pr-tag">48GB DDR5</span><span className="pr-tag">SMB/NFS</span></div>
</div>
<div className="pr">
<span className="badge b-live">&#9679; Live</span>
<div className="pr-type">Personal Homelab</div>
<div className="pr-t">Proxmox Virtualisation Node</div>
<div className="pr-d">Intel Core i7-8700 node running Proxmox VE 8 for LXC containers and KVM VMs. VLAN-segmented network, Tailscale + SSH access, acts as the compute backbone for all hosted services.</div>
<div className="pr-tags"><span className="pr-tag">Proxmox VE 8</span><span className="pr-tag">LXC</span><span className="pr-tag">KVM</span><span className="pr-tag">Tailscale</span><span className="pr-tag">VLAN</span><span className="pr-tag">32GB DDR4</span></div>
</div>
<div className="pr">
<span className="badge b-oss">&#8599; Open Source &middot; &#9733; 26</span>
<div className="pr-type">Personal Project</div>
<div className="pr-t">ObsidianSetup</div>
<div className="pr-d">Cross-platform utility script that makes installing Obsidian and its plugins consistent across devices PowerShell and Bash installers driven by a single plugins.json manifest.</div>
<div className="pr-tags"><span className="pr-tag">PowerShell</span><span className="pr-tag">Bash</span><span className="pr-tag">Obsidian</span><span className="pr-tag">Automation</span></div>
</div>
<div className="pr">
<span className="badge b-oss">&#8599; Open Source</span>
<div className="pr-type">Academic Project</div>
<div className="pr-t">Boston Housing Price Prediction</div>
<div className="pr-d">Probability &amp; Statistics coursework at USI built and evaluated regression models on the classic Boston housing dataset to predict median home values from neighbourhood features.</div>
<div className="pr-tags"><span className="pr-tag">Python</span><span className="pr-tag">scikit-learn</span><span className="pr-tag">Regression</span><span className="pr-tag">Jupyter</span><span className="pr-tag">Statistics</span></div>
</div>
</div>
<p style={{marginTop:'16px',fontSize:'12px',color:'var(--muted)',fontFamily:'\'DM Mono\',monospace'}}>
More projects at <a href="https://github.com/Quinta0" target="_blank" rel="noopener" style={{color:'var(--mid)',textDecoration:'none',borderBottom:'1px solid var(--line3)'}}>github.com/Quinta0 &#8599;</a>
</p>
</div>
<div className="sec-gr"></div>
</div>
{/* GITHUB */}
<div className="sec" id="github">
<div className="sec-g"></div>
<div className="sec-body">
<div className="slbl">GitHub Contributions &amp; Issues</div>
<div className="gh-list">
<div className="gh-item"><div style={{flex:1}}><div className="gh-n">Quinta0/re-soulcommand</div><div className="gh-d">Music recommendation downloader ListenBrainz &amp; Last.fm playlists into Navidrome via slskd. Author &amp; maintainer.</div></div><div className="gh-r"><span className="gh-lang">Python</span><a href="https://github.com/Quinta0/re-soulcommand" target="_blank" rel="noopener" className="gh-lnk">&#8599; repo</a></div></div>
<div className="gh-item"><div style={{flex:1}}><div className="gh-n">Quinta0/Ainulindale</div><div className="gh-d">Self-hosted music discovery &amp; streaming stack auto-discovery, Soulseek P2P download via VPN, Navidrome streaming. Author &amp; maintainer.</div></div><div className="gh-r"><span className="gh-lang">Shell</span><a href="https://github.com/Quinta0/Ainulindale" target="_blank" rel="noopener" className="gh-lnk">&#8599; repo</a></div></div>
<div className="gh-item"><div style={{flex:1}}><div className="gh-n">Quinta0/homelab</div><div className="gh-d">IaC scripts, Ansible playbooks, Docker Compose stacks for full homelab provisioning.</div></div><div className="gh-r"><span className="gh-lang">Shell</span><a href="https://github.com/Quinta0" target="_blank" rel="noopener" className="gh-lnk">&#8599; repo</a></div></div>
<div className="gh-item"><div style={{flex:1}}><div className="gh-n">Quinta0/ObsidianSetup</div><div className="gh-d">Cross-platform Obsidian + plugin installer, driven by a shared plugins.json manifest. Author &amp; maintainer.</div></div><div className="gh-r"><span className="gh-stars">&#9733; 26</span><span className="gh-lang">PowerShell</span><a href="https://github.com/Quinta0/ObsidianSetup" target="_blank" rel="noopener" className="gh-lnk">&#8599; repo</a></div></div>
<div className="gh-item"><div style={{flex:1}}><div className="gh-n">Quinta0/BostonHousing</div><div className="gh-d">Probability &amp; Statistics coursework at USI regression models predicting Boston-area housing prices.</div></div><div className="gh-r"><span className="gh-lang">Jupyter</span><a href="https://github.com/Quinta0/BostonHousing" target="_blank" rel="noopener" className="gh-lnk">&#8599; repo</a></div></div>
</div>
</div>
<div className="sec-gr"></div>
</div>
{/* EXPERIENCE + EDUCATION */}
<div className="sec" id="experience">
<div className="sec-g"></div>
<div className="sec-body">
<div className="slbl">Experience</div>
<div className="acc">
<div className={`acc-item${openAcc['exp-0'] ? ' open' : ''}`}>
<button className="acc-btn" onClick={() => toggle('exp-0')}>
<div className="acc-l"><div className="acc-t">Systems Engineering Consultant</div><div className="acc-s">Freelance &middot; Ticino, Switzerland</div></div>
<div className="acc-r2"><span className="acc-per">Jan 2020 Present</span><ChevronDown/></div>
</button>
<div className="acc-body">
<p>End-to-end IT infrastructure for private and business clients in Ticino.</p>
<ul>
<li>Delivered and maintained 4 production servers at 90%+ uptime using containerisation, IaC, and automated monitoring all without managed hosting.</li>
<li>Eliminated public exposure across all client servers by routing every external entry-point through Cloudflare Tunnels and Pangolin/Traefik zero open ports, DNSSEC enforced, split-DNS via AdGuard Home.</li>
<li>Pangolin + Traefik reverse proxy with Authelia SSO, automatic TLS, and Newt tunnelling.</li>
<li>Fail2Ban + Crowdsec intrusion detection; UFW + iptables firewall rules across all nodes.</li>
<li>Reduced storage recovery time to under 30 minutes with ZFS snapshots on the TrueNAS RAID 10 array and automated rsync offsite backups across all storage nodes.</li>
<li>Music pipeline: Lidarr &#8594; Prowlarr &#8594; Beets &#8594; Navidrome + Listenbrainz scrobbling + Symfonium/Explo mobile.</li>
<li>Windows Server: Active Directory, DNS, DHCP, Group Policy, Disaster Recovery planning.</li>
</ul>
</div>
</div>
</div>
<div style={{marginTop:'44px'}}><div className="slbl">Education</div></div>
<div className="acc">
<div className={`acc-item${openAcc['edu-0'] ? ' open' : ''}`}>
<button className="acc-btn" onClick={() => toggle('edu-0')}>
<div className="acc-l"><div className="acc-t">BSc in Economics</div><div className="acc-s">USI Universit&agrave; della Svizzera italiana &middot; Lugano</div></div>
<div className="acc-r2"><span className="acc-per">Sep 2024 Present</span><ChevronDown/></div>
</button>
<div className="acc-body">
<p>Ongoing bachelor&rsquo;s focusing on rigorous theory and quantitative methods.</p>
<ul>
<li><strong style={{color:'var(--navy)'}}>Microeconomics</strong> consumer and producer theory, market structures (perfect competition, oligopoly, monopoly), welfare economics, externalities.</li>
<li><strong style={{color:'var(--navy)'}}>Macroeconomics</strong> GDP and growth models, IS-LM, monetary and fiscal policy, open economy models, inflation and unemployment dynamics.</li>
<li><strong style={{color:'var(--navy)'}}>Game Theory</strong> Nash equilibrium, dominant strategies, extensive and normal form games, mechanism design, auction theory, repeated games.</li>
<li><strong style={{color:'var(--navy)'}}>Quantitative Finance &amp; Statistics</strong> probability theory, statistical inference, regression analysis, time series, financial modelling, portfolio theory basics.</li>
<li>Accounting, Statistics, and Quantitative Methods for economic analysis.</li>
</ul>
<div className="econ-tags">
<span className="econ-tag">Game Theory</span><span className="econ-tag">Microeconomics</span><span className="econ-tag">Macroeconomics</span><span className="econ-tag">Quantitative Finance</span><span className="econ-tag">Statistics</span><span className="econ-tag">Financial Modelling</span><span className="econ-tag">IS-LM</span><span className="econ-tag">Nash Equilibrium</span>
</div>
</div>
</div>
<div className={`acc-item${openAcc['edu-1'] ? ' open' : ''}`}>
<button className="acc-btn" onClick={() => toggle('edu-1')}>
<div className="acc-l"><div className="acc-t">BSc in Informatics <span style={{fontSize:'11.5px',color:'var(--muted)',fontWeight:400,fontFamily:'\'DM Mono\',monospace'}}> 2 yrs completed</span></div><div className="acc-s">USI Universit&agrave; della Svizzera italiana &middot; Lugano</div></div>
<div className="acc-r2"><span className="acc-per">Sep 2022 Jun 2024</span><ChevronDown/></div>
</button>
<div className="acc-body">
<p>Two years of rigorous computer science foundations before transitioning to Economics.</p>
<ul>
<li><strong style={{color:'var(--navy)'}}>Software Engineering</strong> agile methodologies, requirements engineering, UML modelling, design patterns, and testing practices.</li>
<li><strong style={{color:'var(--navy)'}}>Data Structures &amp; Algorithms</strong> complexity analysis (Big-O), arrays, linked lists, trees, graphs, and sorting and searching algorithms.</li>
<li><strong style={{color:'var(--navy)'}}>Object-Oriented Programming</strong> Java and C++ with inheritance, polymorphism, encapsulation, and SOLID principles.</li>
<li><strong style={{color:'var(--navy)'}}>Web Development</strong> HTML, CSS, and JavaScript fundamentals with introductory full-stack concepts.</li>
<li>Discrete mathematics, logic, and formal reasoning for computer science.</li>
</ul>
<div className="econ-tags">
<span className="econ-tag">Java</span><span className="econ-tag">C++</span><span className="econ-tag">Algorithms</span><span className="econ-tag">Data Structures</span><span className="econ-tag">OOP</span><span className="econ-tag">Agile</span><span className="econ-tag">Software Engineering</span><span className="econ-tag">Web Dev</span>
</div>
</div>
</div>
<div className={`acc-item${openAcc['edu-2'] ? ' open' : ''}`}>
<button className="acc-btn" onClick={() => toggle('edu-2')}>
<div className="acc-l"><div className="acc-t">Maturit&agrave; Cantonale &amp; AFC Economics</div><div className="acc-s">Scuola cantonale di commercio &middot; Bellinzona</div></div>
<div className="acc-r2"><span className="acc-per">Sep 2018 Jun 2022</span><ChevronDown/></div>
</button>
<div className="acc-body">
<p>Swiss dual-track qualification combining the academic Maturit&agrave; with a commercial AFC diploma. Graduated above average in both tracks.</p>
<ul>
<li><strong style={{color:'var(--navy)'}}>Economics &amp; Business</strong> microeconomic principles, business organisation, entrepreneurship, and market dynamics.</li>
<li><strong style={{color:'var(--navy)'}}>Accounting &amp; Finance</strong> double-entry bookkeeping, financial statements, cost accounting, and Swiss business law.</li>
<li><strong style={{color:'var(--navy)'}}>Mathematics &amp; Statistics</strong> algebra, calculus foundations, probability, and descriptive statistics.</li>
<li><strong style={{color:'var(--navy)'}}>Languages</strong> Italian (native instruction), German, English, and French; professional writing and communication.</li>
<li>Maturit&agrave; Cantonale: <strong>4.5 / 6</strong> &middot; AFC Economics: <strong>5 / 6</strong>. Above average in both tracks.</li>
</ul>
<div className="econ-tags">
<span className="econ-tag">Maturit&agrave; 4.5/6</span><span className="econ-tag">AFC 5/6</span><span className="econ-tag">Accounting</span><span className="econ-tag">Business Law</span><span className="econ-tag">Mathematics</span><span className="econ-tag">Economics</span><span className="econ-tag">Multilingual</span>
</div>
</div>
</div>
</div>
<div style={{marginTop:'36px'}}><div className="slbl">Languages</div></div>
<div className="langs">
<div className="langc"><div className="lang-n">Italian</div><div className="lang-l">Native &middot; C2</div></div>
<div className="langc"><div className="lang-n">English</div><div className="lang-l">Fluent &middot; C2</div></div>
<div className="langc"><div className="lang-n">German</div><div className="lang-l">Conversational &middot; B1&ndash;B2</div></div>
<div className="langc"><div className="lang-n">Spanish</div><div className="lang-l">Basic &middot; A1</div></div>
<div className="langc"><div className="lang-n">Norwegian</div><div className="lang-l">Basic &middot; A1</div></div>
</div>
</div>
<div className="sec-gr"></div>
</div>
{/* CONTACT */}
<div className="contact" id="contact">
<div className="contact-g"></div>
<div className="contact-body">
<h2>Ready to<br/>collaborate?</h2>
<p>Whether you need server infrastructure, network architecture, a self-hosted media stack, or a Junior SysAdmin let&rsquo;s build something reliable together.</p>
<div className="contact-ctas">
<a href="mailto:0pietroquintavalle0@gmail.com" className="btn-li">&#9993; 0pietroquintavalle0@gmail.com</a>
<a href="https://github.com/Quinta0" target="_blank" rel="noopener" className="btn-gh">GitHub &#8599;</a>
<a href="https://www.linkedin.com/in/pietro-quintavalle-996b96267/" target="_blank" rel="noopener" className="btn-gh">LinkedIn &#8599;</a>
</div>
</div>
<div className="contact-gr"></div>
</div>
{/* FOOTER */}
<footer>
<div className="foot">
<p>&#169; 2026 Pietro Quintavalle &middot; Lugano, Switzerland</p>
<a href="#">Back to top &#8593;</a>
</div>
</footer>
</>
)
}
-21
View File
@@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-6
View File
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
-12
View File
@@ -1,12 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
distDir: 'out',
trailingSlash: true,
images: {
unoptimized: true,
domains: ['via.placeholder.com']
}
}
export default nextConfig
+630 -7282
View File
File diff suppressed because it is too large Load Diff
+9 -18
View File
@@ -2,33 +2,24 @@
"name": "pietro-portfolio",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-slot": "^1.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.441.0",
"next": "14.2.13",
"radix-ui": "^1.4.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7"
"three": "^0.186.0"
},
"devDependencies": {
"@types/node": "^20.16.5",
"@types/react": "^18.3.8",
"@types/react-dom": "^18.3.0",
"eslint": "^8.57.1",
"eslint-config-next": "14.2.13",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.12",
"typescript": "^5.6.2"
"@types/three": "^0.186.0",
"@vitejs/plugin-react": "^6.0.5",
"typescript": "^5.6.2",
"vite": "^8.2.1"
}
}
-8
View File
@@ -1,8 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

+173
View File
@@ -0,0 +1,173 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import ParticleScene, { SHAPE_NAMES } from './scene/ParticleScene'
import { usePageMotion } from './usePageMotion'
import * as C from './content'
const KEYFRAMES = ['.hero', '#work', '#skills', '#contact']
function Name({ lines }: { lines: string[] }) {
const minLen = Math.min(...lines.map(l => l.length))
return (
<h1 className="name" id="name" aria-label={lines.join(' ')}>
{lines.map((ln, li) => (
<span className="ln" key={ln} aria-hidden="true" style={{ '--s': Math.max(.55, minLen / ln.length) } as React.CSSProperties}>
{[...ln].map((ch, i) => <span className="ch" key={i} style={{ transitionDelay: `${0.25 + li * 0.35 + i * 0.04}s` }}>{ch}</span>)}
</span>
))}
</h1>
)
}
function Marquee({ items, reverse }: { items: string[]; reverse?: boolean }) {
const row = [...items, ...items]
return <div className={`mq rv${reverse ? ' rev' : ''}`} aria-hidden="true"><div>{row.map((s, i) => <span key={i}>{s}</span>)}</div></div>
}
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 (
<>
<ParticleScene keyframes={KEYFRAMES} onLabel={onLabel} onProgress={onProgress} />
<div className="grain" aria-hidden="true" />
<div className="vignette" aria-hidden="true" />
<div className="cur" aria-hidden="true" /><div className="cur-ring" aria-hidden="true" />
<div className="progress" ref={barRef} aria-hidden="true" />
<nav>
<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">
<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>
<Name lines={['Pietro', 'Quintavalle']} />
<div className="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 className="facts rv">
{C.facts.map(f => <div key={f.label}><b data-count={f.n} data-prefix={f.prefix}>0</b><small>{f.label}</small></div>)}
</div>
<div className="hint rv"><b />Scroll. The particles follow.</div>
</div>
</section>
<section id="qualities">
<div className="wrap">
<h2 className="rv">What you get when you hire me</h2>
<div className="qual rv">
{C.qualities.map(q => <article key={q.title}><h3>{q.title}</h3><p>{q.text}</p></article>)}
</div>
</div>
</section>
<section id="work">
<div className="wrap">
<h2 className="rv">Experience</h2>
<div className="tl rv" id="tl"><div className="beam" />
{C.jobs.map(j => (
<div className="job" key={j.title} data-shape={j.shape} data-label={j.label}>
<time>{j.time}</time>
<h3>{j.title}</h3>
<span className="org">{j.org}</span>
<ul>{j.bullets.map(b => <li key={b}>{b}</li>)}</ul>
</div>
))}
</div>
</div>
</section>
<section id="oss">
<div className="wrap">
<h2 className="rv">Open source, close to the metal</h2>
<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="oss rv">
{C.oss.map(o => (
<a href={o.href} target="_blank" rel="noopener" key={o.href} data-shape={o.shape} data-label={o.label}>
<div><span className="repo">{o.repo}</span><h3>{o.title}</h3><p className="what">{o.text}</p></div>
<span className="ref">{o.ref}</span>
</a>
))}
</div>
</div>
</section>
<section id="projects">
<div className="wrap">
<h2 className="rv">Selected projects</h2>
<div className="proj rv">
{C.projects.map(p => (
<article key={p.title} data-tilt data-shape={p.shape} data-label={p.label}>
<h3>{p.title}{p.star && <> <span className="star">{p.star}</span></>}</h3>
<p>{p.text}</p>
<div className="stack">{p.stack}</div>
</article>
))}
</div>
</div>
</section>
<section id="skills">
<div className="wrap"><h2 className="rv">Tools I reach for</h2></div>
<Marquee items={C.toolsRowA} />
<Marquee items={C.toolsRowB} reverse />
<div className="wrap">
<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>
</section>
<section id="education">
<div className="wrap">
<h2 className="rv">Education and languages</h2>
<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>
</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>
<a data-mag href={C.GITLAB} target="_blank" rel="noopener">GitLab</a>
<a data-mag href={C.CODEBERG} target="_blank" rel="noopener">Codeberg</a>
</div>
</div>
</section>
</main>
<footer><span>Pietro Quintavalle, Ticino, Switzerland</span><span>Built with Three.js, one canvas, fifteen thousand particles.</span></footer>
</>
)
}
+101
View File
@@ -0,0 +1,101 @@
// All page copy lives here so the layout stays clean
export const EMAIL = '0pietroquintavalle0@gmail.com'
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 GITLAB = 'https://gitlab.com/Quinta0'
export const CODEBERG = 'https://codeberg.org/Quinta'
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' },
{ title: 'Nargothrond Watch',
text: 'Scheduled health checks for a home server: container status via the Docker API, ZFS pool health, CrowdSec security events and Traefik routes, with an optional self-contained HTML dashboard and email/Discord alerts. No daemon, no database required, just a script on a schedule.',
stack: 'Python, SQLite, Docker API, Traefik' },
]
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']]
+165
View File
@@ -0,0 +1,165 @@
: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;white-space:nowrap;font-size:calc(1em * var(--s,1))}
.name .ch{display:inline-block;transform:translateY(115%) rotate(6deg);opacity:0;transition:transform 1.1s cubic-bezier(.16,1,.3,1),opacity .6s;transform-origin:0 100%}
.name.on .ch{transform:none;opacity:1}
.tag{display:flex;flex-wrap:wrap;gap:16px 40px;align-items:flex-end;margin-top:6vh}
.tag p{font-size:clamp(18px,1.7vw,24px);max-width:36ch;font-weight:500}
.tag p em{font-style:normal;color:var(--amber)}
.hint{margin-top:9vh;font-size:13px;color:var(--muted);display:flex;align-items:center;gap:14px}
.hint b{display:block;width:1px;height:46px;background:linear-gradient(var(--fg),transparent);animation:drop 1.8s cubic-bezier(.6,0,.2,1) infinite;transform-origin:top}
@keyframes drop{0%{transform:scaleY(0);opacity:0}40%{opacity:1}100%{transform:scaleY(1);opacity:0}}
/* 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}
}
#root{display:contents}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+177
View File
@@ -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" />
}
+5
View File
@@ -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 }
+64
View File
@@ -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);
}`;
+142
View File
@@ -0,0 +1,142 @@
// 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 {
// scale kept small enough that the full width still fits the camera frustum on
// narrow/portrait viewports, where it's forced face-on to stay readable
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.012,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)); }
+95
View File
@@ -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())
}, [])
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+799
View File
@@ -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>
-121
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 140 KiB

-82
View File
@@ -1,82 +0,0 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
nord: {
'0': '#2E3440',
'1': '#3B4252',
'2': '#434C5E',
'3': '#4C566A',
'4': '#D8DEE9',
'5': '#E5E9F0',
'6': '#ECEFF4',
'7': '#8FBCBB',
'8': '#88C0D0',
'9': '#81A1C1',
'10': '#5E81AC',
'11': '#BF616A',
'12': '#D08770',
'13': '#EBCB8B',
'14': '#A3BE8C',
'15': '#B48EAD'
},
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
}
}
},
plugins: [require("tailwindcss-animate")],
};
export default config;
+9 -42
View File
@@ -1,50 +1,17 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"es6"
],
"allowJs": true,
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": [
"./*"
],
"@/components/*": [
"./components/*"
],
"@/lib/*": [
"./lib/*"
],
"@/types/*": [
"./types/*"
]
}
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"types": ["vite/client"]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"out/types/**/*.ts"
],
"exclude": [
"node_modules"
]
"include": ["src", "vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
base: '/whoami/',
plugins: [react()],
})