nazim@os: ~
AVAILABLE FOR COLLABS✦CAPE TOWN, SOUTH AFRICA✦BUILDING SOFTWARE✦PRODUCING MUSIC AS VETKAT✦MAKING VISUAL ART✦AVAILABLE FOR COLLABS✦CAPE TOWN, SOUTH AFRICA✦BUILDING SOFTWARE✦PRODUCING MUSIC AS VETKAT✦MAKING VISUAL ART✦AVAILABLE FOR COLLABS✦CAPE TOWN, SOUTH AFRICA✦BUILDING SOFTWARE✦PRODUCING MUSIC AS VETKAT✦MAKING VISUAL ART✦AVAILABLE FOR COLLABS✦CAPE TOWN, SOUTH AFRICA✦BUILDING SOFTWARE✦PRODUCING MUSIC AS VETKAT✦MAKING VISUAL ART✦
← Work

2026

This site

Next.jsTypeScriptTailwindFramer Motion

DESCLUTION: portfolio of Nazim, software, music and visual art

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)

The /link terminal: log in and you're welcomed into Desclution

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.

VariableWhat it does
NEXT_PUBLIC_CHAT_SERVICE_URLBase 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_SECRETShared 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.

Desclution as building blocks: Link, Lab and the portfolio, with room for the next service

Pages come from Vercel; chat goes through a Tailscale Funnel to the homelab chat service, PostgreSQL and monitoring

  • 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/presence with the shared secret.

Signing up

Signup: email a 6-digit code, always answer 202, store a PBKDF2-hashed spellword, hand back a 7-day JWT, then join the members-only room over a WebSocket

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:
    • signup asks for your email, the 6-digit code that gets sent to it, then a username and a spellword. The word "password" is never used.
    • login asks for your username and spellword.
    • Once you're in, it's the members-only room, with /who, /whoami, /logout and /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 in localStorage under desclution:session.
  • /status. Checks the chat service's /health live 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. Every fill image sets sizes, so a 36px disc doesn't download a full-width picture. Source files in public/ are progressive JPGs, 1600px wide at most.
  • 3D models in public/models are 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's useGLTF decodes them without extra setup. The exceptions are macintosh.glb and crt-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 (frameloop follows 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

Pull request, CI, Vercel preview, merge to main, production, with instant rollback

main deploys to production on Vercel automatically. Before merging:

  1. CI is green and the Vercel preview looks right, including on a phone.
  2. New environment variables are set in Vercel for both Production and Preview.
  3. If the chat service's address changed, update NEXT_PUBLIC_CHAT_SERVICE_URL and redeploy, and add any new site origin to the chat service's ALLOWED_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.