Bilingual (ID/EN) developer portfolio where the full-tier experience is an explorable 3D world built with React Three Fiber. A semantic DOM fallback is always served underneath.
My personal site, rebuilt as a small navigable space instead of a scroll page. Nine destinations float in a dark field — 4 infrastructure nodes, 4 project case studies, 1 operator beacon. You fly between them: drag to orbit, scroll or W/S to dolly, click a node to dock, Esc to leave. Case studies render on in-world billboards rather than DOM overlays. If your device can't or shouldn't run it, you get a plain crawlable page instead — that fallback is a hard requirement in the spec, not a degradation path bolted on later.
The parts I'd actually point at in a review:
- Three-tier capability gate (
src/components/webgl/capability.tsx):off(prefers-reduced-motion or no WebGL2 → semantic DOM + poster),lite(coarse pointer or <1024px → lightweight scene),full(desktop, post-processed). Decided once at mount, not sniffed per-frame. - Hash deep-links (
src/components/webgl/hashSync.ts):#destinationdocks the camera on load and onhashchange. Lookups go throughObject.hasOwnso a hash like#constructorcan't walk the prototype chain and crash the dock. Docking rewrites the hash without pushing history entries; unknown hashes fall through to native anchor behavior. - Pure camera math (
src/components/webgl/orbitMath.ts): orbit/dolly/fly-to as a dependency-free module with pitch and distance clamps and a documented calibration knob. 9 unit tests, zero three.js imports. - React 19 + postprocessing fix (commit
c4e36b1): React 19 passesrefas a regular prop, which breaks@react-three/postprocessing'sJSON.stringify-based memoization. Switched to callback refs on effect components. - Typed bilingual content: next-intl locale routing (id/en), 71 message keys per locale in exact parity, plus a
Localized<T>model so no in-world copy is hard-coded. Parity is checked by a content-integrity test, not by hand. - Case studies as data (
src/content/work.ts, W-01..W-04): each entry follows a fixed schema — problem, decision, tradeoff, metric, outcome, what I'd change, point of view. No MDX, no CMS, no database. - Command palette (
src/components/agent/CommandPalette.tsx): scripted keyword Q&A whose answers can fly the camera to the node they reference. The resolver contract ({answer, focus?, scrollTo?}) is deliberately narrow so an LLM backend could replace the script without touching the UI. - Build isolation:
next.config.tshonorsNEXT_BUILD_DIR, so CI builds never clobber.nextunder a running dev server. - Test surface: 41 cases in 14 files — 13 Vitest suites (orbit math, map physics, hash sync, contact schema, i18n routing, sitemap, content integrity) plus a Playwright e2e covering home load, language toggle, and contact submit.
The world supersedes an earlier pin-scroll journey (GSAP ScrollTrigger + Lenis) — +2,898/−211 across 32 files on top of it; the src/components/webgl module alone is 2,193 LOC across 25 files.
- Next.js 15 (App Router), React 19, TypeScript 6
- three 0.184 / @react-three/fiber 9 / drei 10 / postprocessing 3
- Tailwind CSS 4, next-intl 3, GSAP 3 + Lenis
- Zod 4 + Resend for the contact API
- Vitest 4 + Testing Library, Playwright
pnpm install
pnpm dev # http://localhost:3100pnpm test # Vitest unit/component suites
pnpm e2e # Playwright (needs a built or running app)
pnpm typecheck
pnpm buildContact form needs RESEND_API_KEY — copy .env.example to .env and fill it in. Everything else runs without configuration; content is static typed TS, no database.
MIT

