2026
This site
My portfolio (software, music as vetkat, visual art) and the front door to Desclution Link, a members-only chatroom served from my own homelab.
Live: desclution.vercel.app · Chat backend: desclution-chatservice (ASP.NET Core + SignalR + PostgreSQL)
Next.js 16 (App Router), React, Tailwind CSS, three.js / react-three-fiber, GSAP and Framer Motion, SignalR client.
Run it
npm ci
cp .env.example .env.local # then fill it in, see below
npm run dev # http://localhost:3000
Start the chat service too (docker compose up -d in the other repo) if you want chat, /link and /status to show as online. Without it the site still works and they show as offline.
| Variable | What it does |
|---|---|
NEXT_PUBLIC_CHAT_SERVICE_URL | Base URL of the chat service, http://localhost:5000 locally. Unset means chat is shown as offline. It's baked in at build time, so changing it on Vercel needs a redeploy. |
PRESENCE_HEARTBEAT_SECRET | Shared secret that scripts/presence-heartbeat.sh sends to POST /api/presence for the online badge. Server-only. |
How it fits together
Desclution is a set of building blocks: Link (the chat), Lab (the homelab it runs on) and this portfolio, with room for whatever comes next.
- The site is static and server-rendered pages on Vercel. Nothing on it depends on the homelab being up.
- Chat goes straight from the browser to the chat service through a Tailscale Funnel, so the homelab never needs an open port. The chat service only accepts browsers from the origins in its
ALLOWED_ORIGINS. - The online badge is a heartbeat script on my machine posting to
/api/presencewith the shared secret.
Signing up
Signup always answers 202, so it can't be used to find out who has an account. Spellwords are hashed with PBKDF2, sessions are 7-day JWTs, and the auth endpoints are limited to 20 requests a minute per IP.
Desclution Link
- Hero console. The ASCII box on the home page is a small menu. Use ↑/↓ or the scroll wheel while hovering it, then Enter or click. Past the first or last row the page scrolls as usual, and the chevron jumps to the next section.
/link. A terminal that opens with the DESCLUTION block art and a welcome line. Its commands:signupasks for your email, the 6-digit code that gets sent to it, then a username and a spellword. The word "password" is never used.loginasks for your username and spellword.- Once you're in, it's the members-only room, with
/who,/whoami,/logoutand/clear.
- The Desclution environment. Logging in plays a DESCLUTION boot splash and rebrands the site: the wordmark, the tab title, and the footer, where the two ASCII hands close the gap and touch. It stays that way until
/logout. The session is stored inlocalStorageunderdesclution:session. /status. Checks the chat service's/healthlive from your browser every 30 s. The machine is my desktop, so "down" usually just means it's off.
Performance
What keeps the site light, and what to keep in mind when adding to it:
- Images go through
next/image, served as AVIF or WebP. Everyfillimage setssizes, so a 36px disc doesn't download a full-width picture. Source files inpublic/are progressive JPGs, 1600px wide at most. - 3D models in
public/modelsare meshopt-compressed with 512px WebP textures:npx @gltf-transform/cli optimize in.glb out.glb --compress meshopt --texture-compress webp --texture-size 512 --join false. drei'suseGLTFdecodes them without extra setup. The exceptions aremacintosh.glbandcrt-monitor.glb, which stay uncompressed: their screens play video mapped onto the mesh's own UVs, and compression quantises those UVs, which throws the video off. - Canvases only render while they're on screen (
frameloopfollows an in-view check). - The SoundCloud player (about 1.3 MB of third-party script) isn't part of first paint. It loads when sound is turned on, or once the page is idle.
Tests and CI
python3 tests/smoke_test.py --base-url http://localhost:3000 # unit + browser
python3 tests/smoke_test.py --unit-only # no browser
CHROME_PATH=/usr/bin/chromium python3 tests/smoke_test.py ... # system Chromium
GitHub Actions (.github/workflows/ci.yml) runs lint and build, then runs the smoke suite against the production build. Vercel builds a preview for every pull request.
Deploying
main deploys to production on Vercel automatically. Before merging:
- CI is green and the Vercel preview looks right, including on a phone.
- New environment variables are set in Vercel for both Production and Preview.
- If the chat service's address changed, update
NEXT_PUBLIC_CHAT_SERVICE_URLand redeploy, and add any new site origin to the chat service'sALLOWED_ORIGINS.
If a deploy breaks something, use Instant Rollback in the Vercel dashboard to go back to the previous deployment, then fix it on a branch.
Diagrams
Everything in docs/readme/ is generated. Change the diagrams in scripts/docs/make_svgs.py, then run:
python3 scripts/docs/make_svgs.py # SVGs, and copies into public/diagrams
terminal.svg and divider.svg are hand-written and aren't touched by the script.