Migrate Vite SPA to Hugo (hugo-coder, dark) with WebGPU background

- Hugo static site at root: config, content (home/about/resume/blog + 5 projects),
  data/resume.yaml, custom layouts (gpu background, resume renderer, projects list),
  dark theme with canvas behind content
- gpu-bg/: extracted WebGPU/WebGL2 spiral engine as a Vite sub-package; slim bootstrap
  drops router/overlay/text renderer; fixed uniforms (scene0/dim0); self-contained engine.js
- archive/: retired Vite SPA preserved (router, overlay, data, engine/text orphaned)
- 3-stage Dockerfile (node -> hugo extended/dart-sass -> nginx), static nginx config
- DEPLOYMENT.md handover + README rewrite; pre-hugo rollback tag
This commit is contained in:
2026-08-21 03:39:42 +02:00
parent 3ad5cf55e3
commit 9ea85db5ce
74 changed files with 3170 additions and 75 deletions

View File

@@ -0,0 +1,551 @@
# WebGL Fallback Renderer Implementation Plan
> **For Hermes:** Use `opencode` CLI to implement this plan task-by-task. Each task is self-contained and should be verified before moving to the next.
**Goal:** Add a WebGL2 fallback renderer so devices without WebGPU still get an animated procedural background behind the DOM overlay, instead of a static error message.
**Architecture:** A new `GlRenderer` class mirrors the existing `Renderer` interface (init, render, resize, ready). `app.ts` tries WebGPU first; if that fails, it falls back to WebGL2. Only if both fail does the `#fallback` static message appear. The DOM overlay (nav, content, projects, resume) runs identically regardless of which GPU backend is active — no overlay code changes.
**Tech Stack:** TypeScript, WebGL2 (GLSL 300 ES), UBO for uniform passing, Vite `?raw` imports for shader source.
---
## Current state recap
| File | Role | Change needed? |
|------|------|:---:|
| `src/engine/renderer.ts` | WebGPU-only renderer; checks `navigator.gpu`, returns false if unavailable | Extract interface |
| `src/engine/shaders/fullscreen.wgsl` | WGSL fullscreen-triangle fragment shader (spiral + dim) | None (reference for GLSL port) |
| `src/app.ts` | `App.start()` calls `renderer.init()`, returns false on failure → site dead | Try GL on GPU fail |
| `src/main.ts` | Shows `#fallback` div + hides canvas when `app.start()` returns false | Adjust: keep canvas visible for GL path; only show fallback when both fail |
| `index.html` | `#fallback` div (`display:none`), `#gpu` canvas, `#overlay` div | Remove or update fallback message text |
---
## Task 1: Define shared renderer interface
**Objective:** Extract a structural interface so both `Renderer` and `GlRenderer` are interchangeable in `app.ts`.
**Files:**
- Create: `src/engine/types.ts`
**Step 1: Create the interface file**
```ts
// src/engine/types.ts
import type { UiState } from './renderer'
export interface RendererLike {
readonly ready: boolean
init(canvas: HTMLCanvasElement): Promise<boolean>
render(state: UiState): void
resize(): void
}
```
Note: `UiState` is already exported from `renderer.ts`. Keep the import there; this avoids circular deps because `types.ts` only imports a type.
**Step 2: Verify**
```bash
npm run typecheck
```
Expected: PASS (no usages yet, just the file existing).
---
## Task 2: Make `Renderer` conform to the interface
**Objective:** Ensure `Renderer` already satisfies `RendererLike` (it does — just verify typecheck passes when assigned).
**Files:**
- Modify: `src/engine/renderer.ts`
**Step 1: Import the interface and add a type assertion test**
No code changes needed to `Renderer` itself — it already has `ready`, `init`, `render`, `resize`. Just verify:
```bash
npm run typecheck
```
Expected: PASS.
---
## Task 3: Create GLSL fullscreen shader
**Objective:** Port the WGSL spiral shader to GLSL 300 ES (WebGL2). Same visual output: spiral arms, mouse distortion, scene-based palette, dim factor.
**Files:**
- Create: `src/engine/shaders/fullscreen.glsl`
**Shader content (vertex + fragment in one file, split on marker comments):**
```glsl
//---VERTEX---
#version 300 es
const vec2 POSITIONS[3] = vec2[](
vec2(-1.0, -1.0),
vec2(-1.0, 3.0),
vec2( 3.0, -1.0)
);
out vec2 vUv;
void main() {
vec2 pos = POSITIONS[gl_VertexID];
gl_Position = vec4(pos, 0.0, 1.0);
vUv = pos;
}
//---FRAGMENT---
#version 300 es
precision highp float;
layout(std140) uniform Uniforms {
vec2 resolution;
float time;
float scene;
vec2 mouse;
// 8 bytes padding (vec2 → vec4 alignment in std140)
vec4 date;
vec4 sampleRate;
vec4 stemMultipliers[8];
};
in vec2 vUv;
out vec4 outColor;
void main() {
vec2 fragCoord = (vUv * 0.5 + 0.5) * resolution;
vec2 uv = (fragCoord - 0.5 * resolution) / resolution.y;
float slowTime = time * 0.2;
vec3 bgGrey = vec3(0.11, 0.11, 0.12);
vec3 cardGrey = vec3(0.30, 0.32, 0.36);
vec3 coreWhite = vec3(0.90, 0.95, 0.90);
// palette keyed by scene
vec3 scene0 = vec3(0.20, 0.75, 0.20); // HERO – emerald
vec3 scene1 = vec3(0.85, 0.55, 0.20); // ABOUT – amber
vec3 scene2 = vec3(0.25, 0.60, 0.95); // PROJECTS – cobalt
vec3 scene3 = vec3(0.90, 0.35, 0.55); // CONTACT – rose
int sFloor = int(clamp(floor(scene), 0.0, 3.0));
int sCeil = int(clamp(ceil(scene), 0.0, 3.0));
vec3 c0;
if (sFloor == 0) c0 = scene0;
else if (sFloor == 1) c0 = scene1;
else if (sFloor == 2) c0 = scene2;
else c0 = scene3;
vec3 c1;
if (sCeil == 0) c1 = scene0;
else if (sCeil == 1) c1 = scene1;
else if (sCeil == 2) c1 = scene2;
else c1 = scene3;
float tBlend = scene - floor(scene);
float smoothT = tBlend * tBlend * (3.0 - 2.0 * tBlend);
vec3 sceneAccent = mix(c0, c1, smoothT);
// mouse-reactive distortion
vec2 mousePos = (mouse - 0.5 * resolution) / resolution.y;
float distMouse = length(uv - mousePos);
float influence = exp(-distMouse * 7.0);
uv += (mousePos - uv) * influence * 0.12;
float angle = atan(uv.y, uv.x);
float radius = length(uv);
float baseSpiral = angle + (radius * 8.0) - slowTime;
float ripple = sin(radius * 15.0 - (time * 0.5)) * 0.08;
float distortedSpiral = baseSpiral + ripple;
float armCount = 2.0;
float rChannel = smoothstep(0.05, 0.55, abs(sin((distortedSpiral + 0.04) * armCount)));
float gChannel = smoothstep(0.05, 0.55, abs(sin(distortedSpiral * armCount)));
float bChannel = smoothstep(0.05, 0.55, abs(sin((distortedSpiral - 0.04) * armCount)));
vec3 patternChannels = vec3(rChannel, gChannel, bChannel);
vec3 coreNeon = vec3(0.02 / (patternChannels + 0.025));
vec3 spiralColor = mix(cardGrey, sceneAccent, patternChannels.g);
vec3 primarySpiral = coreNeon * spiralColor;
float ringWave = sin(radius * 12.0 - (time * 1.5));
float sharpRing = smoothstep(0.85, 0.95, ringWave);
float ringMask = sharpRing * smoothstep(0.7, 0.1, radius);
vec3 secondaryRings = cardGrey * ringMask * 0.4;
float vignette = smoothstep(0.95, 0.4, radius);
vec3 finalColor = bgGrey + secondaryRings;
finalColor = mix(finalColor, primarySpiral, 0.85);
float centerLight = 0.015 / (radius + 0.02);
finalColor += centerLight * coreWhite;
// soft cursor glow
finalColor += exp(-distMouse * 4.0) * 0.03 * sceneAccent;
// dim when not on the home page (date.x controls it)
float dim = date.x;
finalColor = mix(finalColor, vec3(0.0), dim);
outColor = vec4(finalColor * vignette, 1.0);
}
```
**Key porting notes:**
- `select(a, b, cond)` → `if/else` chains (clearer than mix-with-bool-cast in GLSL)
- `array<vec4<f32>, 8>` → `vec4 stemMultipliers[8]`
- `@builtin(position)` → `gl_Position`
- `@location(0)` → `out vec2 vUv` / `in vec2 vUv`
- Uniform buffer layout with `std140` matches the Float32Array layout exactly (vec2 + float + float = 16 bytes, then 8 bytes padding, then vec4 — same as the TS array d[6],d[7] being zero)
- `atan2(y, x)` → GLSL `atan(y, x)` (same signature)
**Step 2: Verify the file exists**
```bash
wc -l src/engine/shaders/fullscreen.glsl
```
Expected: ~108 lines.
---
## Task 4: Create `GlRenderer` class
**Objective:** Implement the WebGL2 renderer that mirrors `Renderer`'s lifecycle.
**Files:**
- Create: `src/engine/gl-renderer.ts`
**Step 1: Write the GlRenderer class**
```ts
import glslSource from './shaders/fullscreen.glsl?raw'
import type { UiState } from './renderer'
import type { RendererLike } from './types'
const UNIFORM_FLOATS = 48
const UNIFORM_BYTES = UNIFORM_FLOATS * 4
function compileShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader | null {
const shader = gl.createShader(type)
if (!shader) return null
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('GL shader compile error:', gl.getShaderInfoLog(shader))
gl.deleteShader(shader)
return null
}
return shader
}
function linkProgram(gl: WebGL2RenderingContext, vs: WebGLShader, fs: WebGLShader): WebGLProgram | null {
const program = gl.createProgram()
if (!program) return null
gl.attachShader(program, vs)
gl.attachShader(program, fs)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('GL program link error:', gl.getProgramInfoLog(program))
gl.deleteProgram(program)
return null
}
return program
}
function splitShaderSource(source: string): { vs: string; fs: string } {
const parts = source.split('//---FRAGMENT---')
if (parts.length !== 2) throw new Error('GLSL source must contain //---VERTEX--- and //---FRAGMENT--- markers')
const vs = parts[0].replace('//---VERTEX---', '').trim()
const fs = parts[1].trim()
return { vs, fs }
}
export class GlRenderer implements RendererLike {
private gl: WebGL2RenderingContext | null = null
private program: WebGLProgram | null = null
private ubo: WebGLBuffer | null = null
private uniformData = new Float32Array(UNIFORM_FLOATS)
private _ready = false
get ready(): boolean {
return this._ready
}
async init(canvas: HTMLCanvasElement): Promise<boolean> {
const gl = canvas.getContext('webgl2', {
alpha: false,
antialias: false,
depth: false,
stencil: false,
preserveDrawingBuffer: false,
})
if (!gl) return false
this.gl = gl
const { vs: vsSrc, fs: fsSrc } = splitShaderSource(glslSource)
const vs = compileShader(gl, gl.VERTEX_SHADER, vsSrc)
if (!vs) return false
const fs = compileShader(gl, gl.FRAGMENT_SHADER, fsSrc)
if (!fs) {
gl.deleteShader(vs)
return false
}
const program = linkProgram(gl, vs, fs)
gl.deleteShader(vs)
gl.deleteShader(fs)
if (!program) return false
this.program = program
// Create UBO
const ubo = gl.createBuffer()
if (!ubo) return false
gl.bindBuffer(gl.UNIFORM_BUFFER, ubo)
gl.bufferData(gl.UNIFORM_BUFFER, UNIFORM_BYTES, gl.DYNAMIC_DRAW)
const blockIndex = gl.getUniformBlockIndex(program, 'Uniforms')
if (blockIndex === gl.INVALID_INDEX) {
console.error('Uniform block "Uniforms" not found in GLSL program')
return false
}
gl.uniformBlockBinding(program, blockIndex, 0)
gl.bindBufferBase(gl.UNIFORM_BUFFER, 0, ubo)
this.ubo = ubo
this._ready = true
return true
}
resize(): void {
if (!this.gl) return
this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height)
}
render(state: UiState): void {
const gl = this.gl
if (!gl || !this.ubo || !this._ready) return
const d = this.uniformData
d[0] = state.resolution[0]
d[1] = state.resolution[1]
d[2] = state.time
d[3] = state.scene
d[4] = state.mouse[0]
d[5] = state.mouse[1]
// d[6], d[7]: padding — left as zero
d[8] = state.dim
// d[12]-d[15]: sampleRate — left as zero
d.set(state.stemMultipliers, 16)
gl.bindBuffer(gl.UNIFORM_BUFFER, this.ubo)
gl.bufferSubData(gl.UNIFORM_BUFFER, 0, this.uniformData)
gl.useProgram(this.program)
gl.drawArrays(gl.TRIANGLES, 0, 3)
}
}
```
**Step 2: Verify**
```bash
npm run typecheck
```
Expected: PASS. The GLSL import via `?raw` is a string; the `RendererLike` import is a type; all WebGL2 types are in the standard DOM lib.
---
## Task 5: Wire fallback logic in `app.ts`
**Objective:** `App.start()` tries WebGPU → falls back to WebGL → only returns false if both fail.
**Files:**
- Modify: `src/app.ts`
**Changes:**
1. Add imports at top:
```ts
import { Renderer, UiState } from './engine/renderer'
import { GlRenderer } from './engine/gl-renderer'
import type { RendererLike } from './engine/types'
```
2. Change renderer field type:
```ts
private renderer: RendererLike = new Renderer()
```
3. In `start()`, change the init block:
```ts
let ok = await this.renderer.init(this.canvas)
if (!ok) {
this.renderer = new GlRenderer()
ok = await this.renderer.init(this.canvas)
}
if (!ok) return false
```
That's it. The rest of `app.ts` (resize, loop, render calls) works unchanged because both renderers satisfy `RendererLike`.
**Step 2: Verify**
```bash
npm run typecheck
```
Expected: PASS.
---
## Task 6: Adjust `main.ts` fallback behavior
**Objective:** Only show the static `#fallback` div when BOTH WebGPU and WebGL are unavailable. When WebGL succeeds, the canvas is already visible and rendering — no action needed.
**Files:**
- Modify: `src/main.ts`
**Current code (lines 1-16):**
```ts
import { App } from './app'
const canvas = document.getElementById('gpu') as HTMLCanvasElement
const fallback = document.getElementById('fallback') as HTMLElement
async function boot(): Promise<void> {
const app = new App(canvas)
const ok = await app.start()
if (!ok) {
fallback.style.display = 'flex'
canvas.style.display = 'none'
return
}
}
void boot()
```
**New code:** No structural change needed — the logic is already correct. When `app.start()` returns false (both GPU and GL failed), the fallback div shows. When it returns true (either backend works), the canvas is visible and rendering. No changes required.
**Step 2: Update the fallback message in `index.html`**
Change the `#fallback` content in `index.html` to mention WebGL too:
```html
<div id="fallback">
<div>
<h2>Graphics not available</h2>
<p>Your browser doesn't support WebGPU or WebGL. Open this page in a recent Chrome, Edge, or Firefox.</p>
</div>
</div>
```
**Step 3: Verify**
```bash
npm run typecheck
```
Expected: PASS (no TS changes, just HTML).
---
## Task 7: Build and final check
**Objective:** Full build to verify everything compiles and bundles correctly.
**Files:** None (verification only)
**Step 1: Build**
```bash
npm run build
```
Expected: PASS. The `?raw` import for `.glsl` should be handled by Vite just like `.wgsl`.
If Vite complains about `.glsl` extension, add to `vite.config.ts`:
```ts
export default defineConfig({
server: { open: true },
build: { target: 'esnext' },
assetsInclude: ['**/*.glsl'],
})
```
**Step 2: Check dist size**
```bash
du -sh dist/
```
The GLSL source (~3KB raw) will be inlined as a string in the JS bundle. Acceptable overhead.
---
## Task 8: Manual smoke test (dev server)
**Objective:** Verify the site works in both paths.
**Files:** None (verification only)
**Step 1: Start dev server**
```bash
npm run dev
```
**Step 2: Test WebGPU path**
Open in Chrome/Edge with WebGPU enabled → should see the normal WebGPU spiral + DOM overlay.
**Step 3: Test WebGL fallback path**
Open in Firefox (no WebGPU) → should see the WebGL spiral + fully functional DOM overlay.
Alternatively, temporarily disable WebGPU in Chrome (`chrome://flags/#enable-unsafe-webgpu` → Disabled) and reload.
**Step 4: Verify DOM overlay on both paths**
- Click through nav tabs (Home, About, Projects, Resume)
- Open a project detail, click back
- Scroll resume
- Confirm mouse movement affects the background (cursor glow)
---
## Summary of changes
| File | Action | Lines |
|------|--------|-------|
| `src/engine/types.ts` | Create | ~6 |
| `src/engine/shaders/fullscreen.glsl` | Create | ~108 |
| `src/engine/gl-renderer.ts` | Create | ~110 |
| `src/app.ts` | Modify: add GL fallback init | +8, ~4 changed |
| `src/main.ts` | No changes needed | 0 |
| `index.html` | Update fallback message text | ~2 lines changed |
| `vite.config.ts` | Possibly add `assetsInclude` for `.glsl` | +1 line |
Total: ~230 new lines, ~6 lines modified. No DOM overlay code touched. No existing shader touched.
---
## Risks and mitigations
1. **Vite doesn't handle `.glsl` with `?raw`** — Mitigation: add `assetsInclude: ['**/*.glsl']` to vite config. Vite treats included assets as raw strings by default when imported with `?raw`.
2. **GLSL precision differences** — The WGSL shader uses `f32` (IEEE 754 binary32). WebGL fragment shaders default to `mediump` which may not have enough precision for the spiral math. Mitigation: use `precision highp float;` at the top of the fragment shader. All modern mobile and desktop GPUs support `highp` in fragment shaders.
3. **UBO layout mismatch** — If the GLSL compiler pads the struct differently than expected, the uniform data will be misaligned. Mitigation: explicitly use `layout(std140)` which guarantees the exact layout we've matched. The TS array layout was designed to match std140 already (d[6],d[7] = 8 bytes of padding before `date`).
4. **`noUnusedLocals` / `noUnusedParameters`** — If any GL renderer methods or params trigger TS unused warnings. Mitigation: all method implementations use their params; the `_ready` naming convention is already established in the project.
5. **Context loss handling** — The WebGL2 context can be lost. Mitigation: WebGL2 has `webglcontextlost` / `webglcontextrestored` events. For now, the current codebase doesn't handle WebGPU device loss recovery (beyond logging), so feature parity is sufficient. Can add recovery in a follow-up.

View File

@@ -0,0 +1,307 @@
# Hugo Transition Plan — WebGPU Portfolio
> **For Hermes:** implement this plan via `opencode` CLI, task-by-task, small verifiable chunks wrapped with `timeout`. Verify each task (typecheck/build/hugo/curl) before moving on. After any timeout, inspect `git status` before continuing.
**Created:** 2026-08-16
**Goal:** Replace the Vite/WebGPU single-page app with a Hugo static site (hugo-coder theme, dark), keeping the WebGPU spiral visuals as a full-page homepage background behind the theme. Everything else — content, nav, routing, resume, projects, blog — becomes Hugo.
---
## 1. Locked decisions (recap)
| # | Decision | Choice |
|---|----------|--------|
| 1 | Engine scope | Minimal background shader only (spiral + mouse + accent/dim). Drop text-renderer, router, DOM overlay, TS data files |
| 2 | Engine build | Keep Vite + TS as a sub-package; emit a static JS bundle into Hugo `static/` |
| 3 | Repo layout | In-place monorepo; Hugo at root, engine under `gpu-bg/` |
| 4 | Homepage canvas | Full-page background, dark theme overall |
| 5 | Identity | "Djosen" as site title + displayed author (full alias); resume keeps real name "Ahmed Mohamed" |
| 6 | Resume | Structured `data/resume.yaml` + custom Hugo layout |
| 7 | Projects | `categories` taxonomy (programming/hardware/visuals/music), tech → `tags` |
| 8 | Menu + blog | Home, About, Projects, Resume, Blog — blog enabled |
| 9 | Accent | hugo-coder default blue accent; background stays emerald (two-tone, revisit after render) |
| 10 | baseURL | `https://djosen.one/` |
| 11 | Theme install | git submodule at `themes/hugo-coder` |
| 12 | Old SPA code | Archive (tag + `archive/` folder), do not delete |
| 13 | CI/CD | Deploy workflow lives server-side; produce a handover doc to adjust it |
---
## 2. Target architecture
```
webgpu/ (repo root = Hugo site)
├── config.toml # hugo-coder config (TOML)
├── content/
│ ├── _index.md # homepage welcome text
│ ├── about.md
│ ├── resume.md # layout = "resume", renders data/resume.yaml
│ ├── projects/
│ │ ├── radiance-caching.md
│ │ ├── point-cloud.md
│ │ ├── evol-engine.md
│ │ ├── auv-simulation.md
│ │ └── improv-gfx.md
│ └── posts/
│ └── _index.md # blog landing (empty starter)
├── data/
│ └── resume.yaml
├── layouts/
│ ├── index.html # homepage override → includes gpu-background partial
│ ├── partials/
│ │ └── gpu-background.html # <canvas> + <script src=/gpu/engine.js>
│ ├── resume/
│ │ └── single.html # data-driven resume renderer
│ └── projects/
│ └── list.html # shows period + description + tags (if needed)
├── static/
│ ├── css/custom.css # canvas-behind-content + dark glass styling
│ └── gpu/ # engine build output (gitignored)
│ └── engine.js (+ hashed assets)
├── gpu-bg/ # engine sub-package (Vite + TS)
│ ├── package.json
│ ├── tsconfig.json
│ ├── vite.config.ts
│ ├── index.html # local dev harness only
│ └── src/
│ ├── main.ts # slim bootstrap (replaces app.ts + main.ts)
│ ├── renderer.ts # WebGPU (copied, unmodified)
│ ├── gl-renderer.ts # WebGL2 fallback (copied, unmodified)
│ ├── types.ts # RendererLike (copied)
│ ├── input.ts # pointer tracking (copied)
│ └── shaders/
│ ├── fullscreen.wgsl # UNCHANGED
│ └── fullscreen.glsl # UNCHANGED
├── archive/ # old SPA (per decision 12)
│ ├── index.html
│ ├── src/ # app.ts, router.ts, ui/overlay.ts, data/, engine/text/*
│ ├── package.json / package-lock.json / tsconfig.json / vite.config.ts
├── themes/hugo-coder/ # git submodule
├── Dockerfile # 3-stage: node → hugo(ext) → nginx
├── docker-compose.yml # UNCHANGED
├── nginx.default.conf # drop SPA fallback → plain static
├── DEPLOYMENT.md # handover (section 9 below)
├── .gitmodules
└── .gitignore # + public/, static/gpu/, resources/
```
---
## 3. Engine extraction — what survives vs. is archived
**Survives into `gpu-bg/` (copied unmodified):**
- `engine/renderer.ts` (WebGPU) — `UiState` interface lives here
- `engine/gl-renderer.ts` (WebGL2 fallback)
- `engine/types.ts` (`RendererLike`)
- `engine/shaders/fullscreen.wgsl` + `fullscreen.glsl` — **shader math stays byte-identical**
- `ui/input.ts` (pointer tracking; tiny, feeds `state.mouse`)
**New file — slim bootstrap `gpu-bg/src/main.ts` (replaces app.ts + main.ts):**
- Look up `#gpu` canvas; if absent, no-op (partial only included on homepage).
- `init()` WebGPU → fallback WebGL2; only if both fail, hide canvas (no separate #fallback UI — Hugo content still shows).
- `resize()` + DPR cap 2 (reuse logic from app.ts).
- `requestAnimationFrame` loop: feed `resolution`, `time`, `mouse`, and **fixed uniforms**: `scene = 0`, `dim = 0.0`, zeroed `stemMultipliers` (32 floats).
- No router, no overlay, no scene switching. The `scene`/`dim`/`stemMultipliers` plumbing is collapsed to constants at the bootstrap boundary — the renderer and shader remain untouched.
**Archived into `archive/` (deleted from active tree):**
- `app.ts`, `main.ts` (superseded by slim bootstrap)
- `router.ts` (Hugo does routing)
- `ui/overlay.ts` (Hugo renders content)
- `data/**` (all content → Hugo; see section 4)
- `engine/text/**` (text-renderer, text-layout, slug-generator — confirmed orphaned: only self-references, nothing imports them)
- root `index.html`, `package.json`, `package-lock.json`, `tsconfig.json`, `vite.config.ts` (move to `archive/`; new engine gets its own under `gpu-bg/`)
**Note on shaders:** the WGSL/GLSL scene-palette `if/else` chain will now always resolve to `scene0` (emerald). Do NOT simplify the shader during this transition — it risks introducing bugs in working math. Flag as an optional later cleanup.
---
## 4. Content migration map
| Source | Destination | Notes |
|--------|-------------|-------|
| `pages.ts` home entry + `scenes/hero.ts` | `content/_index.md` + `params.info` | tagline → `params.info = "Creative Developer | Game Developer | Software Engineer"`; welcome body → `_index.md` |
| `scenes/about.ts` | `content/about.md` | Convert HTML `<p>/<ul>/<li>/<a>` → Markdown. Content: aliases, TUM masters, graphics/XR/games/AI background, Forza/Turn10, Siemens AI, hobbies list, emails + discord |
| `scenes/contact.ts` | — (drop) | It's a placeholder (`hello@example.com`); real contact lives in about + social config |
| `scenes/projects.ts` (intro) | `content/projects/_index.md` | Keep the "not an exhaustive list…" intro text |
| `projects/index.ts` categories | taxonomy config | `categories` = programming, hardware, visuals, music (empty ones become invisible terms until populated) |
| `projects/radiance-caching.ts` | `content/projects/radiance-caching.md` | frontmatter below |
| `projects/point-cloud.ts` | `content/projects/point-cloud.md` | " |
| `projects/evol-engine.ts` | `content/projects/evol-engine.md` | " |
| `projects/auv-simulation.ts` | `content/projects/auv-simulation.md` | " |
| `projects/improv-gfx.ts` | `content/projects/improv-gfx.md` | " |
| `resume.ts` | `data/resume.yaml` + `layouts/resume/single.html` | full structure preserved |
| `scenes/resume.ts` (title/subtitle) | `content/resume.md` frontmatter | title "Resume" |
**Project frontmatter shape:**
```toml
+++
title = "<name>"
description = "<tagline>"
date = "<parseable date>" # for Hugo ordering
period = "<human range>" # e.g. "12/2025 — 06/2026"; source dates are ranges/years
tags = ["<tech...>"] # tech[] → tags
categories = ["programming"] # all 5 currently live in "programming"
+++
<markdown body from content>
```
**Date handling:** source `date` values are ranges (`12/2025 — 06/2026`) or bare years (`2024`, `2021`). Hugo needs a real date for list ordering — set `date` to the range end (or a sane parseable date) and preserve the human string in a custom `period` frontmatter field, displayed by the projects list template. `tech` → `tags`, category → `categories`.
**Resume name divergence (intentional):** site identity = "Djosen" (decision 5), but `data/resume.yaml` keeps `name: "Ahmed Mohamed"` — a CV must carry the real name. Flag if you want otherwise.
---
## 5. Theme setup (hugo-coder, dark)
- `git submodule add https://github.com/luizdepra/hugo-coder themes/hugo-coder`
- **Requires Hugo extended** (theme compiles SCSS via Hugo Pipes).
- `config.toml` key points:
- `baseURL = "https://djosen.one/"`
- `title = "Djosen"`, `theme = "hugo-coder"`
- `params.author = "Djosen"`, `params.info = "<tagline>"`, `params.description`, `params.keywords`
- `params.colorScheme = "dark"` (pin dark; user wants dark overall)
- `params.avatarURL = ""` (no avatar for now — leave off; add later)
- `params.social`: github (`github.com/inedited`), linkedin (`linkedin.com/in/inedited/`), email (`ineditedofficial@gmail.com`)
- `menu.main`: Home `/`, About `/about`, Projects `/projects`, Resume `/resume`, Blog `/posts`
**Custom layouts (the only real theme surgery):**
1. `layouts/partials/gpu-background.html` — `<canvas id="gpu" aria-hidden="true"></canvas>` + `<script src="{{ "gpu/engine.js" | relURL }}" defer></script>`.
2. `layouts/index.html` — override homepage: include the partial; canvas is `position: fixed; inset: 0; z-index: -1`; content container gets transparent/dark glass background so the spiral shows through with readable text.
3. `static/css/custom.css` — the canvas + glass styling above; loaded via the theme's head hook (`layouts/partials/head.html` override, or hugo-coder's `custom_css` param if present — **verify exact hook during implementation**).
4. `layouts/resume/single.html` — render `site.Data.resume` (summary, work, education, skills) matching the current card/pill visual but in hugo-coder's palette.
5. `layouts/projects/list.html` — only if the default list doesn't show `period` + `description` + `tags` acceptably.
---
## 6. Build orchestration
**Engine (`gpu-bg/`):** `vite build` → a single self-contained bundle (WGSL/GLSL inlined via `?raw`) emitted to `gpu-bg/dist/`, which the Hugo build copies into `static/gpu/`. Configure `vite.config.ts` build `outDir` + `assetsInlineLimit` so shaders inline; `assetsInclude: ['**/*.glsl', '**/*.wgsl']`.
**Dev workflow:**
- `cd gpu-bg && npm run build` (engine), then `hugo server` from root (watches content + the built bundle in `static/`).
- Optional single `npm`/`Makefile` orchestration script: `build:engine && hugo server`.
**Dockerfile (3-stage):**
```dockerfile
FROM node:22-alpine AS engine
WORKDIR /gpu
COPY gpu-bg/package.json gpu-bg/package-lock.json* ./
RUN npm ci
COPY gpu-bg/ ./
RUN npm run build
FROM gohugoio/hugo:ext-alpine AS site # pin a version tag
WORKDIR /site
COPY . .
COPY --from=engine /gpu/dist ./static/gpu/
RUN hugo --minify
FROM nginx:alpine-slim AS serve
COPY --from=site /site/public /usr/share/nginx/html
COPY nginx.default.conf /etc/nginx/conf.d/default.conf
RUN nginx -t
EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]
```
**Critical:** the build context must contain the initialized `themes/hugo-coder` submodule. Any clone/deploy step must use `--recursive` or run `git submodule update --init --recursive` before `docker build`.
**nginx.default.conf changes:** replace the SPA fallback (`try_files $uri $uri/ /index.html`) with plain static `try_files $uri $uri/ =404` (Hugo emits real `.html` per page; 404s are now legitimate). Keep security headers + gzip. Adjust immutable-cache rule to Hugo's output dirs (`/css/`, `/js/`, `/gpu/`) with fingerprint-aware caching (Hugo fingerprints via filename hash).
---
## 7. Task breakdown (for opencode)
Each task = one opencode run, self-contained, with an explicit verify step.
0. **Safety tag** — `git tag pre-hugo` at current HEAD; confirm clean tree.
1. **Toolchain** — install/extend Hugo (extended) locally; `git submodule add` hugo-coder; init submodule.
2. **Archive SPA** — move `src/`, `index.html`, root `package.json`/`package-lock.json`/`tsconfig.json`/`vite.config.ts` → `archive/`; update `.gitignore` (`public/`, `static/gpu/`, `resources/`, `gpu-bg/dist/`, `gpu-bg/node_modules/`).
3. **Engine sub-package** — create `gpu-bg/` with copied renderer/gl-renderer/types/input/shaders + slim `main.ts` + `package.json`/`tsconfig.json`/`vite.config.ts`. Verify: `npm run typecheck` in `gpu-bg/`.
4. **Engine build** — `npm run build`; verify `dist/` bundle exists and shaders are inlined.
5. **Hugo scaffold** — `config.toml`; `content/_index.md`, `about.md`, `resume.md`, `projects/_index.md`, `posts/_index.md`; `data/resume.yaml`. Verify: `hugo` builds, homepage/about render.
6. **Projects migration** — 5 × `content/projects/*.md` with frontmatter. Verify: `hugo` builds; `/projects` + each project URL render.
7. **Custom layouts** — gpu-background partial, index.html override + custom.css, resume layout, projects list tweak. Verify: `hugo` builds; homepage contains `<canvas id="gpu">` + `/gpu/engine.js` link.
8. **Docker + nginx** — rewrite Dockerfile (3-stage), update nginx.default.conf, `.gitignore`. Verify: `docker build` succeeds; `docker compose up -d`; `curl -sf http://127.0.0.1:8080/`, `/about`, `/projects`, `/resume`, `/posts`, `/gpu/engine.js` all 200; a bogus path → 404.
9. **Docs** — write `DEPLOYMENT.md` (section 9 content) to repo; update `README.md` dev/Docker sections.
10. **Final verification** — `npm run typecheck` + `npm run build` (engine), `hugo`, `docker compose` smoke test, and a manual browser check of WebGPU render + WebGL fallback + dark theme + canvas-behind-content.
---
## 8. Verification checklist (acceptance)
- `hugo` build exits 0; `public/` populated with real `.html` per page (no SPA fallback).
- Homepage renders the WebGPU spiral behind hugo-coder content; text readable (dark theme).
- WebGL2 fallback works when WebGPU is unavailable (manual: disable WebGPU in a browser).
- `/about`, `/projects`, `/projects/<slug>`, `/resume`, `/posts`, `/` all return 200; unknown path → 404.
- `/gpu/engine.js` returns 200 (hashed, cacheable).
- Resume renders from `data/resume.yaml` with real name + full work/education/skills.
- Projects list groups under the `programming` category; tags shown.
- Docker image builds from a recursive clone; container healthcheck passes.
- `git status` clean of build artifacts (public/, static/gpu/, gpu-bg/dist/, node_modules/ all ignored).
---
## 9. DEPLOYMENT HANDOVER (copy verbatim into repo as `DEPLOYMENT.md`)
> How the site now works, and how to adjust the server-side deploy workflow (Gitea Actions + webhook) for the new build.
### 9.1 How it works now (unchanged parts)
Serving topology is **unchanged**. The host's system nginx (Certbot-managed TLS at `djosen.one`) still proxies `location /` → `127.0.0.1:8080`, where the Docker `web` container listens. Nothing in the system nginx (`nginx/system-default.conf` / `nginx/reverse.conf`) or Certbot setup needs to change.
What changes is **what's inside the container**: instead of a Vite SPA (single `index.html` + JS), the container now serves a Hugo static site (`public/` — one real `.html` per page).
### 9.2 What changed in the build
- **Build chain is now two steps:** (1) `gpu-bg/` Vite build → static JS bundle, (2) `hugo --minify` → `public/`. The Dockerfile does both in stages (`node` → `hugo` extended → `nginx`).
- **Hugo extended is required** (the theme compiles SCSS). The Docker stage uses a Hugo-extended image (pinned tag).
- **The hugo-coder theme is a git submodule** at `themes/hugo-coder`.
- **nginx in the container no longer does SPA fallback** — plain static serving with fingerprint-aware cache rules.
### 9.3 How to adjust the server-side deploy workflow
The deploy workflow (Gitea Actions `deploy.yml` / webhook script) currently clones and `docker build`s. Two adjustments are mandatory:
1. **Clone with submodules.** The hugo-coder submodule must be present before `docker build`. Change the checkout step to recursive:
- Gitea Actions: `checkout@v3` with `submodules: recursive` (or add `git submodule update --init --recursive`).
- Plain webhook/script: `git clone --recursive ...` or `git pull && git submodule update --init --recursive`.
Without this, `hugo` fails with "theme not found" and the build breaks.
2. **No other secrets/hosts change.** Registry creds, SSH/webhook target, and the `docker compose up -d` deploy command are all the same. The image just gets bigger (Hugo extended + node build layers are discarded; final nginx stage stays small).
### 9.4 How to test the deployment
**Local (before pushing):**
```bash
git submodule update --init --recursive
cd gpu-bg && npm install && npm run build && cd ..
hugo # builds public/
docker compose build # 3-stage build
docker compose up -d # maps 127.0.0.1:8080:8080
```
**Smoke test (after `up -d`):**
```bash
curl -sf http://127.0.0.1:8080/ | grep -q 'id="gpu"' && echo "canvas OK"
curl -sf http://127.0.0.1:8080/gpu/engine.js -o /dev/null && echo "engine bundle OK"
curl -sf http://127.0.0.1:8080/about/ -o /dev/null && echo "about OK"
curl -sf http://127.0.0.1:8080/projects/ -o /dev/null && echo "projects OK"
curl -sf http://127.0.0.1:8080/resume/ -o /dev/null && echo "resume OK"
curl -sf http://127.0.0.1:8080/posts/ -o /dev/null && echo "posts OK"
curl -s http://127.0.0.1:8080/nonexistent -o /dev/null -w "%{http_code}\n" # expect 404
```
**Browser check (manual, WebGPU is a real-GPU feature):**
- Homepage: spiral animates behind content; dark theme; text readable.
- Disable WebGPU (or use a non-WebGPU browser) → WebGL2 fallback still animates.
- No-WebGL browser → static dark page (content still readable; canvas simply doesn't render).
**After deploy (remote):**
- `curl -s https://djosen.one/health` → `ok` (system nginx health endpoint, unchanged).
- Verify `/`, `/about/`, `/projects/`, `/resume/`, `/posts/` over HTTPS.
- Confirm the spiral renders in a real browser (the one thing `curl` can't check).
### 9.5 Rollback
The previous site is preserved as the git tag `pre-hugo` and the `archive/` folder. To roll back: `git checkout pre-hugo` and redeploy with the old single-stage `Dockerfile` (also recoverable from git history). No system nginx / DNS changes were made, so rollback is purely a container-image swap.