shaders · git:20260901.699d9ae · 2026-09-01 · sha256 bcf00d44a76d266c

shaders git:20260901.699d9aeB

Immutable. This exact content is served forever at /api/v1/blob/bcf00d44a76d266c.

---
name: shaders
description: Use when editing, adding, or reviewing a WebGL shader mode in src/components/DitherBackground.tsx (the notebook backdrop, one GLSL program with 17 theme-driven modes) or src/components/settings/TileShader.tsx (Activity tile washes), or when a theme in src/lib/themes.ts gets a new `shader` variant. Renders the real GLSL in a browser harness so the change is seen before it ships.
---

# Shaders — render before you ship

Both shader components are GLSL ES 1.0 inside `FRAG` template literals,
compiled by WebGL1 at runtime. That choice is deliberate (WKWebView on every
macOS, no WebGPU) — keep the WebGL1 constraints: no dynamic array indexing,
constant-bound loops, no `${}` in the literals (the harness refuses them).

Two facts drive the workflow:

- **One program serves every theme.** A GLSL error in the mode you're adding
  kills the backdrop for all 27 themes. Compile is the first gate.
- **Aesthetic misses are confident.** Writing a "bokeh city lights" field
  from the words alone produced an abstract plexus. The only reliable check
  is the rendered pixels next to a reference image.

## The loop

```bash
python3 scripts/shader-harness.py --serve    # http://127.0.0.1:8791/
```

or `preview_start` with the `shaders` launch config, which runs the same
command. The page is written once at start — after each `FRAG` edit rerun
`python3 scripts/shader-harness.py` (no flags) to regenerate it in place,
then reload.

1. **Ask for a reference image** when the request names a style. It carries
   more than the name does.
2. **Edit** the field function in the `.tsx`. New backdrop mode: add the
   `ShaderVariant` union member in `themes.ts`, the `SHADER_MODE` index, a
   `<name>Field(uv, glow)` function, and its branch in `main()`. Keep the
   shared dither, central glow, and transmutation ring — they are what make
   every mode read as the same element.
3. **Regenerate, reload, read status.** `<html data-status>` is `ok` or
   `fail`; the red banner (and `console.error`) carries the GLSL log with
   line numbers relative to the `FRAG` literal.
4. **Look.** The contact sheet (`/`) shows every mode with the theme that
   uses it. `?mode=<name>&theme=<id>` fills the viewport with one;
   `&t=<seconds>` freezes time for a deterministic screenshot;
   `&density=` / `&gain=` are the component's `density` / `intensity`
   props; `?shader=tile&mode=ember|sky|spark` (`&hour=`, `&series=a,b,c`)
   covers the tiles. Screenshot, compare with the reference, iterate.
5. **Check motion too** — drop `t` and watch a few seconds; the field should
   breathe, not scroll. Then check the app itself (`pnpm tauri dev`), since
   ANGLE-on-Metal in WKWebView is what ships.

## Harness internals worth knowing

- It regex-extracts `VERT`/`FRAG`/`SHADER_MODE` from the two components and
  each theme's `id`/`shader`/`background`/`primary` from `themes.ts`, so it
  needs no build step and no dependencies beyond Python 3.
- The sheet renders 22 cells through two shared offscreen WebGL contexts
  (opaque backdrop, alpha tiles) blitted into 2D canvases — browsers evict
  contexts past ~16, which is what a per-cell design hits.
- The Browser pane pauses `requestAnimationFrame` while hidden; front the
  tab before reading pixels or status.