The Tell I Wrote in My Own Commit Message
I titled the commit "bolder koi markings (still read faint)."
Read that again. I shipped a commit whose entire job was to make the koi on this site more visible — and then, in the same breath, parenthetically admitted that they still looked faint. I knew something was wrong. I wrote it down. And then I shipped it anyway, because I had misdiagnosed the wrongness so completely that the note I left myself was already part of the trap.
The koi were not faint. They were not drawing at all.
That distinction is the whole story, and the reason I couldn't see it is the most ordinary thing in the world: the rest of the pond was perfect. The water rippled. The moon hung in its overlay. The stones sat on the bed, the petals drifted across the surface — every element of the scene rendered exactly as designed. Only the fish were missing. And when nine-tenths of a scene looks right, your brain does not reach for "an entire mesh is failing to render." It reaches for "the fish are a little dim." Absence wearing the costume of attenuation.
What I Thought The Problem Was
So I went down the brightness rabbit hole.
The commit (70f8fa7) was a real, earnest pass at the koi pattern. I widened the dorsal mask so the red markings would cover more of the flank. I tightened the smoothstep so the patch edges read crisp and near-solid instead of bleeding into a soft pink gradient. And — the part I was most sure would fix it — I pushed the patch colour 28% brighter than the palette base, so the mix couldn't dilute it back toward cream:
// patch colour pushed brighter/more saturated than the palette base
vec3 patch = uPatternColor * 1.28;
base = mix(base, patch, clamp(mark * uPatternStrength * 1.35, 0.0, 1.0));I tuned the threshold. I bumped the pattern strength. I nudged the saturation. Every one of those changes was a perfectly reasonable thing to do if the koi were drawing and just looked weak — and not one of them could possibly matter, because the fragment shader those lines belonged to never compiled. I was repainting a wall that didn't exist. A faint marking and a missing marking are pixel-for-pixel identical when your starting assumption is that the mesh is on screen at all.
Why The Build Lied To Me
Here is the part that kept me honest-but-wrong for a commit and a half: every check I had was green.
tsc passed. next build passed. No red squiggle in the editor, no type error, no warning, no failed assertion. By every signal a TypeScript project gives you, the code was fine. And it was fine — as TypeScript.
A GLSL shader is a string. It lives in the JS bundle as a template literal, a quoted blob of text that the TypeScript compiler treats exactly the way it treats any other string: it ships it, untouched, without ever looking inside. tsc does not parse GLSL. It has no idea that the string contains a program, let alone an invalid one. As far as the build is concerned, my shader was a perfectly valid string, because it was.
The actual compiler for that string is the GPU driver, and it runs at a moment no build step can reach: at runtime, in the browser, the first time the material is used to draw something. That is when my fragment shader was handed to glCompileShader — and that is when it failed, because I had named a local variable patch. patch is a reserved word in GLSL, claimed for tessellation control. You cannot use it as an identifier. The shader failed VALIDATE_STATUS, and the consequence of a fragment shader that won't validate is not an exception, not a thrown error, not a dialog. It is nothing. The draw call simply produces no pixels. The mesh is there, the geometry is uploaded, the material is bound — and the GPU quietly declines to paint it.
The lesson generalizes past shaders: any check that finishes at build time is structurally blind to anything that compiles at runtime. Your green checkmark is a statement about your types. It is not a statement about your render.
The Probe That Could See It
If the failure produces no pixels and no exception, where does it speak? Exactly one place: the browser console, where the WebGL implementation prints its compile warning — the line number, the offending token, the word reserved. That message had been there the whole time. I just wasn't looking at a console; I was looking at a pond and squinting at the fish.
So I stopped squinting and wrote a probe that does the looking for me. It opens the page headless, captures every console message and page error, waits for the scene to initialize, and then filters the captured output for the signatures of a shader-compile failure:
const errors = [];
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', (e) => errors.push('PAGEERROR: ' + e.message));
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(`${BASE}/pond-v3`, { waitUntil: 'networkidle', timeout: 60000 });
await page.waitForTimeout(8000);
await page.screenshot({ path: 'verify-pw/koi-debug/koi-fixed-scene.png' });
const shaderErrs = errors.filter((e) =>
/shader|compile|reserved|VALIDATE_STATUS|not compiled|0:\d+/i.test(e));
console.log('--- shader/compile errors:', shaderErrs.length);
shaderErrs.forEach((e) => console.log(' !', e.slice(0, 200)));The point of that last block is that it converts an invisible, runtime-only failure into a single number: shader/compile errors: N. Before the fix, N was nonzero, and the messages it printed named patch and reserved outright. The bug stopped being a vibe — "the fish look dim, I think?" — and became a count I could watch go to zero. The full probe is on GitHub, along with the toolkit it lives in.
The One-Word Fix
The fix (commit dc27c89) was to rename one variable. patch became patchColor, and I left a comment so future-me never spends another commit here:
// patch colour pushed brighter/more saturated than the palette base so
// the soft mix can't dilute it back to faint pink. NB: do not name this
// 'patch' — that is a reserved GLSL word (tessellation) and the fragment
// shader fails to compile, rendering the koi invisible.
vec3 patchColor = uPatternColor * 1.28;
base = mix(base, patchColor, clamp(mark * uPatternStrength * 1.35, 0.0, 1.0));I re-ran the probe. shader/compile errors: 0. And the koi drew — every one of them, with the bold kohaku markings I had spent the previous commit "brightening" into a shader that never ran. The brightness was never the problem. The brightness was beautiful. It had just been compiled to a string and thrown on the floor.
(There was an immediate sequel: now that the fish were actually rendering, they swam too fast, and the next commit had to halve the tail-beat. A good problem to have. You can only discover your fish are hyperactive once they exist.)
What I Actually Learned
Three things I'm keeping.
A green build is a claim about your types, not your render. tsc and next build verified that my shader was a valid string. They cannot verify that it's a valid shader, because that compilation happens later, in a process they don't run, on a machine they don't control. Anything that compiles at runtime — GLSL, dynamically-eval'd code, a regex built from user input — lives in a blind spot your build will never cover, no matter how strict.
Before you tune how something looks, prove that it's drawing. Faint, dim, subtle, washed-out — those are all words for "present but weak," and I reached for every one of them while the truth was "absent." Attenuation and absence are visually identical, so the first question for anything that looks underwhelming is not how do I make it stronger but is it rendering at all? I burned a commit because I skipped that question.
The cheapest detector for a runtime-only failure is a script that reads the console. It doesn't need to understand the scene. It needs to open the page, capture what the page says about itself, and filter for the signature of the failure you care about — so the output is a number you can drive to zero, not an impression you have to argue with. That probe took less time to write than I'd already spent adjusting saturation.
The koi swimming on the homepage of this site are the same fish I got wrong twice — once invisible because they weren't drawing, once hyperactive because they drew far too fast. They look right now. It only took learning to read the console instead of the pond.