- Add client-side router with History API pushState (router.ts, pages.ts) - Extend overlay with projects grid/category/detail views and resume section - Add Gitea CI/CD workflow and webhook deployment server - Improve Docker Compose with nginx reverse proxy config - Add deployment script and .env.example
19 KiB
AGENTS.md
Conventions and operational notes for AI agents (and humans) working in this repo. Read this before editing. Keep it updated when conventions change.
Project
A portfolio website using a hybrid architecture: a WebGPU <canvas> renders
the generative-art background, and a DOM overlay on top of it owns every UI
element (nav, titles, body text, button). The canvas is the only GPU surface;
all interactive UI is standard HTML/CSS. Pointer position is still captured at
the window level and fed to the shader as a uniform for background reactivity.
North-star goals (from the kickoff decisions)
- Tech: Vite + TypeScript,
@webgpu/typesfor ambient types. - Rendering: a single fullscreen-triangle fragment shader draws the procedural generative-art background only. All UI (text, nav, button) is DOM overlay.
- Aesthetic: abstract generative art (domain-warped fbm flow fields, noise).
- Sections: Hero / About / Projects / Contact, driven by
scenestate in the app loop. Scene changes update the DOM overlay content and shift the shader'spalette. - Deliver style: solid foundation first, extend later.
Commands
npm install
npm run dev # vite dev server, opens browser
npm run build # tsc --noEmit (typecheck) + vite build
npm run typecheck # tsc --noEmit — run before considering work done
npm run preview # serve the built dist/
Every task that changes code MUST end with npm run typecheck (and npm run build
if you want a full check). Do not commit code that fails typecheck.
Stack / tooling notes
- TypeScript
strict, plusnoUnusedLocals/noUnusedParameters. Remove unused params/vars or prefix with_rather than leaving them. - WGSL shaders live in
src/engine/shaders/*.wgsland are imported with Vite's?rawsuffix (import src from './x.wgsl?raw'). Keep all GPU code in.wgslfiles — do not inline large shader strings in.ts. @webgpu/typesprovides globalGPUDevice, etc. Do not import them; they are ambient viatypesintsconfig.json.- Target a modern WebGPU browser (recent Chrome/Edge/ChromeOS/Safari preview).
index.htmlships a#fallbacknotice shown whennavigator.gpuis missing. opentype.jsis a runtime dep (font parsing for the Slug text generator);@types/opentype.jsis a dev dep. The Slug text engine is dormant — see the "Text rendering" section. The deps are kept so the dormant files still typecheck; they are not wired into the render loop.
Architecture
src/
├── main.ts # boot: create App, fall back on no WebGPU
├── app.ts # rAF loop, input→uniforms, scene state, DOM overlay wiring
├── engine/
│ ├── renderer.ts # device/context/pipeline, single procedural pass
│ ├── shaders/
│ │ └── fullscreen.wgsl # procedural background only, one fragment shader
│ └── text/ # DORMANT Slug text engine (kept, not wired in)
│ ├── slug-generator.ts # CPU: opentype.js → curve/band texture data (Slug port)
│ ├── text-layout.ts # CPU: text string → instance buffer data per glyph
│ ├── text-renderer.ts # GPU: instanced quad pipeline + textures + bind groups
│ └── text.wgsl # WGSL Slug shader (ray-traces quadratic Béziers per frag)
├── ui/
│ ├── input.ts # window-level pointer position capture (mouse uniform)
│ └── overlay.ts # DOM overlay: scene toggling, resume/project HTML builders
└── data/
├── resume.ts # ResumeData type + RESUME object (name, contact, work, edu, skills)
└── projects/
├── index.ts # ProjectPage type + PROJECTS registry (add new projects here)
├── radiance-caching.ts
├── point-cloud.ts
├── evol-engine.ts
├── auv-simulation.ts
└── improv-gfx.ts
Layout: DOM-owned
UI layout is owned by CSS in index.html (flexbox + viewport units). There is
no shared pixel-space layout contract between the DOM and the shader anymore: the
shader draws only the fullscreen background, so it needs only resolution, time,
scene, and mouse — no per-element bounds.
When you add a new UI element: add it to the DOM overlay in index.html, style it
with CSS, and wire any click/hover in overlay.ts. You do not need to touch
renderer.ts, fullscreen.wgsl, or uniforms unless the background itself must
react to the new element.
Hybrid DOM-overlay principle
The DOM overlay (#overlay in index.html, z-index: 1 over the canvas)
owns all visible UI: the nav bar, scene title/subtitle/body, and the cycle button.
The canvas underneath draws the generative background only. The overlay is
pointer-events: none with interactive children set to pointer-events: auto, so
pointermove still reaches the window for the shader's mouse uniform while clicks
land on the right element. Do not add UI shapes inside the fragment shader — if
you need a new visual element, prefer a DOM element with CSS.
Data layer
Content lives in src/data/ as typed TypeScript objects. No runtime parsing, no
fetches — data is imported directly and rendered into HTML at first visit.
Resume (src/data/resume.ts):
- Exports
ResumeDatainterface andRESUMEobject. - Fields:
name,contact[],location,summary,work[],education[],skills[]. overlay.tshasbuildResumeHTML(data)that generates the full resume DOM from the typed object. To update resume content, editresume.ts.
Projects (src/data/projects/):
- Each project is its own
.tsfile exporting aProjectPage:{ id, name, tagline, date, tech[], content }wherecontentis free-form HTML. index.tsexports theProjectPagetype and thePROJECTSregistry array.- To add a project: create
src/data/projects/my-project.ts, import it inindex.ts, add to thePROJECTSarray. It auto-appears on the Projects page. - To remove a project: remove its import and array entry from
index.ts. - Clicking a project card opens a detail view; a back button returns to the list.
Navigation is handled by event delegation on
#projects-sectioninoverlay.ts. - The list resets on every nav entry (if you're viewing a detail and switch tabs, coming back shows the list again).
To add a new data-driven section, follow the Projects/Resume pattern:
- Create
src/data/whatever.tswith a typed interface + data export. - Add a
#whatever-sectiondiv toindex.htmlwith the same CSS pattern (opacity transition,.visibleclass with fade-in animation). - In
overlay.ts: query the div, add awhateverBuiltflag, build the HTML from data on first visit, toggle.visible/.hiddenclasses insetScene(). - Extend
colorBase/speedBasearrays inapp.tsand update cycle modulo.
Coordinate conventions
- Layout space = drawing-buffer pixels (CSS pixels ×
dpr, capped at 2). - Shader convention: origin bottom-left, y up.
app.tsflips input y:mouse.y = res.y - clientY * dpr. Rendererkeepsresolutionin actual device pixels.
WGSL / shader conventions
- One oversized triangle, 3 hardcoded vertices in the vertex shader; no vertex buffers.
- Uniforms are a single
var<uniform> u: Uniformsat group 0, binding 0. - Update
UNIFORM_FLOATSinrenderer.tswhenever you add a uniform field, and keep the field order in TSrender(state)identical to the WGSL struct declaration. - Keep the shader readable: section it with
// ---------- name ----------comments.
SDF reference helpers already in the shader
sdRoundBox(p, halfExtent, radius)— rounded rect (kept for future background motifs)hash,noise,fbm— value-noise fbm for the generative backgroundpalette(s)— hue shift keyed offscene
To add more shapes, add sd* helpers next to the existing ones.
Currently rendered
- Background (shader): domain-warped fbm flow field, palette indexed by
scene, vignette + grain.scenecycles 0→1→2→3→4 on each button click or nav click; each scene shiftspalette.colorBaseandspeedBasearrays inapp.tsare indexed by scene and must be extended when adding a new scene. - Nav bar (DOM): top-center row of 5
<button>labels (HERO / ABOUT / PROJECTS / CONTACT / RESUME). Clicking a label switches to that scene. The active scene is highlighted via the.activeclass (set byoverlay.setScene); hover handled by CSS. - Scene content (DOM): per-scene title, subtitle, and body text from the
SCENESarray inapp.ts. Used for Hero (0), About (1), and Contact (3). Hidden viaopacity: 0transition for Projects (2) and Resume (4). - Projects section (DOM):
#projects-section— shown for scene 2. Renders project cards fromsrc/data/projects/. Clicking a card opens a detail view with a back button (event delegation inoverlay.ts). Returns to list on every nav entry. Hides the cycle button. - Resume section (DOM):
#resume-section— shown for scene 4. Rendered fromRESUMEdata insrc/data/resume.tsviabuildResumeHTML(). Hides the cycle button. - Cycle button (DOM): centered
<#cycle-button>cycles(scene + 1) % 5. Hidden on Projects (2) and Resume (4) — set viastyle.display = 'none'insetScene().
Scene → section mapping
| Scene | Nav label | Content source | Cycle btn |
|---|---|---|---|
| 0 | HERO | SCENES[0] → #scene-content |
visible |
| 1 | ABOUT | SCENES[1] → #scene-content |
visible |
| 2 | PROJECTS | PROJECTS[] → #projects-section |
hidden |
| 3 | CONTACT | SCENES[3] → #scene-content |
visible |
| 4 | RESUME | RESUME → #resume-section |
hidden |
Section transitions
Special sections (#projects-section, #resume-section) use opacity crossfades
instead of display: none/block:
- Default state:
opacity: 0; pointer-events: none; transition: opacity 0.35s - Visible state (
.visibleclass):opacity: 1; pointer-events: auto; animation: fadeIn 0.45s - The fade-in animation lives on the
.visibleclass so it re-triggers every visit, not just the first render. #scene-contentuses the same pattern with a.hiddenclass (opacity: 0).
Roadmap (in suggested order)
SDF text✅ — Slug algorithm ported; renders crisp in-canvas text labels (HERO / ABOUT / PROJECTS / CONTACT) that change on button click. Now dormant in favor of the DOM overlay; kept for reference.Nav + multiple buttons— extendlayout.tswith a list of element bounds; wire hover/click per element; animate section transitions in the shader. ✅ Nav bar with 4 clickable labels, active/hover underlines, per-scene text content. Now DOM-owned (nolayout.ts); underlines are CSS.- Scroll input — wheel/touch drag → uniform
scroll; drive the single-scrolling scene in the shader. - Interactive 3D space — a second pipeline (raymarched or rasterized 3D) composited behind the UI; WASD/click navigation; project nodes placed in space.
- Content authoring — projects/about/contact data keyed to sections.
Do / don't
- Do run
npm run typecheckafter every change. - Do add UI to the DOM overlay in
index.htmland wire it inoverlay.ts. - Do keep
y up / bottom-leftin mind when wiring input. - Do prefix intentionally-unused params with
_(strict unused checks are on). - Do add an
sd*helper in the shader rather than inlining math. - Don't add UI shapes inside the fragment shader. Prefer a DOM element + CSS.
- Don't inline shader strings in
.ts; use*.wgsl+?raw. - Don't commit changes unless explicitly asked. (opencode default.)
- Don't add code comments unless requested.
WebGPU gotchas
- Always configure the canvas context after device acquisition and on resize; the
texture size comes from
canvas.width/height(device pixels), not CSS pixels. - Cap DPR at 2 for perf on high-density displays (see
DPR_CAPinapp.ts). - Guard
device.lostand setconfigured = falseso a re-init path is possible. - Bind group layouts come from
'auto'pipeline layout; when adding a second pass, create explicit bind group layouts to share resources (atlas texture, sampler).
Text rendering (Slug algorithm) — DORMANT
Text is rendered entirely inside WebGPU using a port of the Slug algorithm (Eric Lengyel's GPU text method, originally C++/commercial). We ported it from manthrax/JSlug (a Three.js/WebGL JS port) to plain TS + WGSL. The key difference from atlas-based SDF text: no glyph atlas — each glyph's TrueType quadratic Bézier curves are ray-traced directly in the fragment shader for resolution-independent, perfectly sharp text.
Status: Dormant. The files under
src/engine/text/and theopentype.jsdeps are retained so they still typecheck, but they are not wired into the render loop. All visible text is now handled by the DOM overlay. The notes below are kept for reference in case the Slug engine is reactivated later.
Pipeline: two passes, one command encoder
- Pass 1 — procedural (
fullscreen.wgsl): the fullscreen-triangle fragment shader draws the background + SDF UI into the canvas texture (storeOp: 'store'). - Pass 2 — text (
text.wgsl): loads the canvas texture (loadOp: 'load'), draws instanced glyph quads with alpha-blended Slug coverage over the procedural background. Skipped entirely if no text or glyph count is 0.
The text pass uses an explicit bind group layout (not 'auto') because it needs
two textures (curvesTexture, bandsTexture) in addition to its uniform buffer.
CPU side: three files, three responsibilities
slug-generator.ts opentype.js Font → SlugData (curve/band arrays + metrics)
text-layout.ts text string + SlugData → TextLayoutResult (instance buffer data)
text-renderer.ts creates GPU textures + pipeline + buffers, renders the text pass
-
SlugGenerator.generate(font)— iterates every glyph, extracts quadratic Bézier curves from the path (convertingL/Zcommands to degenerate quadratics with midpoint control points), packs curves into aFloat32Array(RGBA32F texture data), spatially bins curves into horizontal and vertical bands stored in aUint32Array(RG32UI texture data), and collects per-codepoint metadata (width, height, advanceWidth, bearings, band dimensions, texture coords). Default: ASCII 32–126 only; passfullRange: trueorwhitelistfor more. -
layoutText(text, slugData, options)— iterates codepoints, measures line width, applies justification, and emits per-glyph instance buffer data:scaleBias(vec4f): half-extent + center of the glyph quad in pixel spaceglyphBandScale(vec4f): glyph width/height in font units + band scale factorsbandMaxTexCoords(vec4u): band count - 1 + band texture base coordinates
-
TextRenderer— oninit(device, format, slugData): creates the curves texture (rgba32float), bands texture (rg32uint), the render pipeline (6-vert quad, instanced, alpha-blended), and 4 vertex buffers (quad + 3 instance).updateText(layout)uploads instance data;renderPass(...)adds the text pass to an existing command encoder.
WGSL shader notes (text.wgsl)
- Uses
textureLoad(nottexelFetch),@interpolate(flat)for per-instance data. - WGSL has no fragment derivatives (
dFdx/dFdy), sopixelsPerEmis passed as a flat-interpolatedvec2fcomputed in the vertex shader from the quad's pixel half-extent (2.0 * aScaleBias.xy). - Text coordinates are in pixel space (origin bottom-left, y up), same as the
procedural pass. The vertex shader maps pixel coords → clip coords via
px / resolution * 2.0 - 1.0. - The
trace_ray_curve_hfunction is the heart of Slug: solves the quadratic for the ray-curve intersection, then computes coverage from the intersection's x coordinate scaled bypixelsPerEmfor anti-aliasing.
Adding text labels (Slug — dormant)
The Slug engine is currently not wired in. To reactivate GPU text, you would
also need to restore the text pass in renderer.ts (the initText / updateText
methods and the second render pass were removed when text moved to the DOM overlay).
// 1. Generate slug data (once, on font load)
const gen = new SlugGenerator()
const slugData = await gen.generateFromUrl('/SomeFont.ttf')
// 2. Init the text renderer (once, after device init)
renderer.initText(slugData)
// 3. Layout text (re-call when text or position changes)
const layout = layoutText('Hello', slugData, {
fontScale: 0.3 * dpr, // ~0.3 of font units per pixel
startX: resX * 0.5, // pixel-space, origin bottom-left
startY: resY * 0.5,
justify: 'center'
})
renderer.updateText(layout)
// 4. The render loop picks it up automatically each frame.
Font files go in public/ and are fetched at runtime. Uses opentype.js (runtime
dep) + @types/opentype.js (dev dep).
Deployment (Docker + nginx + webhook CI/CD)
The stack runs in Docker Compose with two containers on a shared internal network:
| Container | Role | Port exposed |
|---|---|---|
webgpu-portfolio-web |
App: nginx serving the built dist/ | internal:80 |
webgpu-portfolio-nginx |
Reverse proxy in front of web | host:80 |
nginx/reverse.conf— reverse proxy config, routesdjosen.one→web:80nginx.default.conf— internal nginx config in the app container (SPA, gzip, caching)docker-compose.yml— full stack, health checks, auto-restart.dockerignore— excludes node_modules, dist, git, and CI files from the build context
CI/CD webhook
A Python stdlib webhook server (webhook/server.py) listens on port 9000, verifies
Gitea's X-Gitea-Signature HMAC-SHA256, and on push events runs:
git pull origin master
docker compose up -d --build --remove-orphans
Minimal downtime (~1–2 s) because only the web container is rebuilt; nginx-proxy
stays running and buffers connections during the restart.
Setup on the server (first time)
# 1. Clone the repo
git clone https://git.djosen.one/djoser/webgpu-portfolio /home/djoser/webgpu-portfolio
cd /home/djoser/webgpu-portfolio
# 2. Create .env with a webhook secret
openssl rand -hex 32 > /tmp/secret
echo "WEBHOOK_SECRET=$(cat /tmp/secret)" > .env
echo "REPO_DIR=/home/djoser/webgpu-portfolio" >> .env
# 3. Start the stack
docker compose up -d
# 4. Install & start the webhook service
sudo cp webhook/webhook.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now webhook-webhook.service
# 5. Configure Gitea webhook
# - Go to repo Settings → Webhooks → Add Webhook (Gitea)
# - Target URL: http://<server-ip>:9000
# - Secret: <content of /tmp/secret>
# - Events: Push events
# - Active: ✓
Manual deploy (no webhook)
bash scripts/deploy.sh
Verify everything is working
# Stack health
docker compose ps
curl -s http://localhost:80/health
# Webhook health
curl -s http://localhost:9000/health
# Test webhook (simulate a push)
curl -X POST http://localhost:9000/ \
-H "X-Gitea-Event: push" \
-H "X-Gitea-Signature: ..." \
-d '{"ref":"refs/heads/master"}'