fix(live): correct generation shader capture + halftone on dark/textured surfaces (#171)

* fix(live): correct the generation shader's capture + halftone on dark and textured surfaces

The live-mode "ink-wash" loading shader rendered correctly on light
elements but broke on dark and textured ones. Root causes and fixes:

- Ground the halftone on the element's own background tone (new u_paper
  uniform) instead of a fixed cream paper, so dark elements stop flashing
  bright as the roller passes.
- Drive dot size by each cell's contrast from that ground, not absolute
  darkness, so content (text, buttons) becomes the dots on light and dark
  alike instead of inverting on dark elements.
- Cap the dot radius so a solid dark region stays separated dots rather
  than flooding into a gold bar.
- Parse computed colors by rasterizing through a canvas, so oklch()/color()
  tokens resolve instead of falling back to white.
- Two-stage dissolve (flatten to ground, then dots emerge) so the raw
  element never bleeds through the band's soft core/trail.
- Carry the capture's alpha through the shader so rounded corners and
  transparent regions show the live backdrop instead of rendering black.
- When an element is transparent up to the root but its backdrop comes from
  an ancestor's image or a covering layer (e.g. a hero art div), capture
  that ancestor and crop to the element. Fixes the homepage hero heading
  capturing on white, and embeds the real backdrop in the model upload too.
  The halftone ground is sampled from just outside the element so it tracks
  the true backdrop rather than a muddy average of the content.

Adds /shader-lab, a standalone harness that runs the real capture + shader
pipeline against a matrix of background shapes (light, dark, gradient,
image, glass, rounded, and a homepage-hero replica) with raw vs
capture+shader side by side. The capture/shader code is copied from
live-browser.js and kept in sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(live): clear the cached color-parse canvas before each fill

Cursor Bugbot (PR #171): cssColorToRgb01 reuses a cached 2D context, so a
semi-transparent input (alpha 0<a<1, which isTransparentColor lets through)
blended source-over with the previous call's pixel, making the result depend
on call history. clearRect before the fill makes each call independent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-05-28 19:16:25 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent d61c953055
commit 6ef995f8a4
16 changed files with 3327 additions and 308 deletions
+179 -22
View File
@@ -5209,9 +5209,11 @@
return '#ffffff';
}
// Capture the element (with current annotations baked in) and return a PNG
// Blob. Shared between the Go flow (uploads it to the server) and the
// debug toggle (displays it as an overlay for side-by-side comparison).
// Capture the element (with current annotations baked in) and return
// { blob, paper }: the PNG Blob, plus the representative backdrop tone for the
// shader's halftone ground (so capture, upload, and shader all agree on what
// sits behind the element). Shared between the Go flow (uploads the blob) and
// the shader-resume path.
async function captureElementToBlob(el, snapshot, rect) {
try { if (document.fonts?.ready) await document.fonts.ready; } catch {}
const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
@@ -5229,12 +5231,46 @@
try {
const ms = await loadModernScreenshot();
const fontCssText = await collectFontCssText();
const backgroundColor = resolveCanvasBackground(el);
return await ms.domToBlob(el, {
const opts = {
scale: Math.min(window.devicePixelRatio || 1, 2),
font: fontCssText ? { cssText: fontCssText } : undefined,
...(backgroundColor ? { backgroundColor } : {}),
});
};
const bg = resolveCanvasBackground(el);
// Fast path: the element paints its own background, or an opaque ancestor
// color was found. modern-screenshot bakes that color; paper matches it.
if (bg !== '#ffffff') {
const blob = await ms.domToBlob(el, { ...opts, ...(bg ? { backgroundColor: bg } : {}) });
return { blob, paper: bg ? cssColorToRgb01(bg) : resolvePaperRgb(el) };
}
// Transparent up to the root. The visible backdrop may still come from an
// ancestor's background-image or a covering positioned layer (e.g. a hero
// art div) that the color walk can't see. Capture that ancestor and crop
// to the element so the real backdrop is embedded — correct for both the
// shader and the screenshot sent to the model. Fall back to white only
// when nothing is actually painted behind the element.
const backdrop = findBackdropAncestor(el);
if (!backdrop) {
const blob = await ms.domToBlob(el, { ...opts, backgroundColor: '#ffffff' });
return { blob, paper: SHADER_PAPER_FALLBACK };
}
const ancestorCanvas = await ms.domToCanvas(backdrop, opts);
const S = opts.scale;
const er = el.getBoundingClientRect();
const ar = backdrop.getBoundingClientRect();
const sx = (er.left - ar.left) * S, sy = (er.top - ar.top) * S;
const sw = er.width * S, sh = er.height * S;
const crop = document.createElement('canvas');
crop.width = Math.max(1, Math.round(sw));
crop.height = Math.max(1, Math.round(sh));
const cctx = crop.getContext('2d', { willReadFrequently: true });
cctx.drawImage(ancestorCanvas, sx, sy, sw, sh, 0, 0, crop.width, crop.height);
// Ground = backdrop sampled around the element, falling back to the crop
// mean only if the surround is fully transparent.
const actx = ancestorCanvas.getContext('2d', { willReadFrequently: true });
const paper = sampleSurroundingRgb(actx, sx, sy, sw, sh, ancestorCanvas.width, ancestorCanvas.height)
|| averageRgb01(cctx, crop.width, crop.height);
const blob = await new Promise((res) => crop.toBlob(res, 'image/png'));
return { blob, paper };
} finally {
if (annotNode) annotNode.remove();
if (savedPosition !== null) el.style.position = savedPosition;
@@ -5244,15 +5280,16 @@
async function captureAndEmit(el, basePayload, snapshot, rect) {
let screenshotPath;
let blob;
let paper;
try {
blob = await captureElementToBlob(el, snapshot, rect);
({ blob, paper } = await captureElementToBlob(el, snapshot, rect));
} catch (err) {
console.warn('[impeccable] capture failed, proceeding without screenshot:', err);
}
// Light up the shader overlay the moment capture is ready — no reason to
// wait for the upload to complete before the user sees something alive.
if (blob && state === 'GENERATING') {
showShaderOverlay(el, blob, rect);
showShaderOverlay(el, blob, rect, paper);
}
// Only upload + forward the screenshot when annotations (comments/strokes)
// are present. Without annotations the image is pure visual anchoring —
@@ -5300,6 +5337,7 @@ uniform sampler2D u_texture;
uniform float u_time;
uniform vec2 u_resolution;
uniform vec3 u_accent;
uniform vec3 u_paper;
varying vec2 v_uv;
// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at
@@ -5327,23 +5365,139 @@ void main() {
vec2 cellUv = fract(gridUv) - 0.5;
vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution;
vec3 cellImg = texture2D(u_texture, sampleCenter).rgb;
float luma = dot(cellImg, vec3(0.299, 0.587, 0.114));
// Darker cells → bigger kinpaku dots (classic risograph halftone curve).
float radius = sqrt(clamp(1.0 - luma, 0.0, 1.0)) * 0.56;
// Dot size tracks how much the cell DIFFERS from the element's own ground
// (u_paper), not absolute darkness. So the content — text, buttons, anything
// that deviates from the background — always becomes the dots, on light AND
// dark surfaces. A plain darkness curve inverts on dark elements: the dark
// background fills with ink and the lighter content punches holes instead.
// Capped below the cell half-width so dense content stays separated dots.
float contrast = clamp(length(cellImg - u_paper) / 1.732, 0.0, 1.0);
float radius = min(sqrt(contrast) * 0.6, 0.38);
float dotMask = smoothstep(radius + 0.06, radius, length(cellUv));
vec3 paper = vec3(0.975, 0.965, 0.955);
vec3 dotLayer = mix(paper, u_accent, dotMask);
// Blend the halftone layer in where the roller is passing; leave the
// element pristine elsewhere.
vec3 base = texture2D(u_texture, uv).rgb;
gl_FragColor = vec4(mix(base, dotLayer, band), 1.0);
// Two-stage dissolve as the roller passes, so the element is rebuilt purely
// from dot size (its own halftone) and never bleeds through as raw pixels
// behind the dots:
// 1. cover — the element flattens to the uniform paper ground first.
// 2. dotAmt — kinpaku dots then emerge, sized by each cell's luma.
// A plain mix(base, halftone, band) instead left the raw element visible
// through the band's soft core/trail. The paper ground is u_paper (the
// element's own bg tone) rather than a fixed white, so the dissolve reads the
// same over light and dark surfaces.
vec4 tex = texture2D(u_texture, uv);
vec3 base = tex.rgb;
float cover = smoothstep(0.0, 0.35, band);
float dotAmt = dotMask * smoothstep(0.15, 0.6, band);
vec3 ground = mix(base, u_paper, cover);
// Carry the capture's own alpha through, so a rounded corner or any genuinely
// transparent region stays transparent (the live backdrop shows through the
// canvas) instead of rendering as solid black.
gl_FragColor = vec4(mix(ground, u_accent, dotAmt), tex.a);
}`;
// Kinpaku gold converted to approximate sRGB 0-1 (matches oklch(84% 0.19 80.46))
const SHADER_ACCENT = [1.0, 0.78, 0.31];
// Fallback ground when an element and all its ancestors are transparent —
// matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
// The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
// (which returns null when the element paints its own bg), this always returns
// a usable color: the element's own background if any, else the nearest opaque
// ancestor, else the paper fallback.
// Rasterize any CSS color (oklch, color(), named, hex, rgb) through a 1x1
// canvas and read back the sRGB pixel. String-parsing computed colors is a
// trap: Chrome returns backgroundColor as oklch()/color() for oklch inputs,
// which a hex/rgb regex misses — every site token would fall back to white.
let colorParseCtx = null;
function cssColorToRgb01(str) {
if (!colorParseCtx) {
colorParseCtx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
}
// Clear first: the ctx is cached across calls, so a semi-transparent color
// would otherwise blend (source-over) with the previous call's leftover
// pixel, making the result depend on call history.
colorParseCtx.clearRect(0, 0, 1, 1);
colorParseCtx.fillStyle = '#000'; // invalid input leaves this default
colorParseCtx.fillStyle = str;
colorParseCtx.fillRect(0, 0, 1, 1);
const d = colorParseCtx.getImageData(0, 0, 1, 1).data;
return [d[0] / 255, d[1] / 255, d[2] / 255];
}
function resolvePaperRgb(el) {
let node = el;
while (node) {
const bg = getComputedStyle(node).backgroundColor;
if (!isTransparentColor(bg)) return cssColorToRgb01(bg);
node = node.parentElement;
}
return SHADER_PAPER_FALLBACK;
}
// When an element is transparent up to the root, its visible backdrop can
// still come from an ancestor's background-image or a covering positioned
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) —
// neither of which the ancestor background-COLOR walk can see. Return the
// nearest such ancestor so we can capture it and crop, embedding the real
// backdrop. Returns null when nothing is actually painted behind the element
// (genuinely transparent → white is correct).
function paintsBackdrop(node) {
const s = getComputedStyle(node);
if (s.backgroundImage && s.backgroundImage !== 'none') return true;
const nr = node.getBoundingClientRect();
for (const child of node.children) {
const ccs = getComputedStyle(child);
if (ccs.position !== 'absolute' && ccs.position !== 'fixed') continue;
const paints = !isTransparentColor(ccs.backgroundColor)
|| (ccs.backgroundImage && ccs.backgroundImage !== 'none');
if (!paints) continue;
const cr = child.getBoundingClientRect();
if (cr.width >= nr.width * 0.9 && cr.height >= nr.height * 0.9) return true;
}
return false;
}
function findBackdropAncestor(el) {
let node = el.parentElement;
while (node && node !== node.ownerDocument.documentElement) {
if (paintsBackdrop(node)) return node;
node = node.parentElement;
}
return null;
}
// Mean sRGB (0-1) of a canvas region, used as the halftone ground when the
// backdrop was captured from an ancestor rather than read from a CSS color.
function averageRgb01(ctx, w, h) {
const data = ctx.getImageData(0, 0, w, h).data;
let r = 0, g = 0, b = 0, n = 0;
// Stride a few pixels for speed; exact average is unnecessary for a ground.
for (let i = 0; i < data.length; i += 16) { r += data[i]; g += data[i + 1]; b += data[i + 2]; n++; }
return n ? [r / n / 255, g / n / 255, b / n / 255] : SHADER_PAPER_FALLBACK;
}
// Average the backdrop sampled just OUTSIDE an element's rect within a larger
// canvas. The ground tone for the dissolve must be the real backdrop, not the
// mean of the element's own crop — averaging the crop folds in the element's
// content (e.g. bright heading text), pulling the ground toward muddy gray.
function sampleSurroundingRgb(ctx, sx, sy, sw, sh, W, H) {
const pad = Math.max(2, Math.round(Math.min(sw, sh) * 0.12));
const fx = [0.2, 0.5, 0.8].map((f) => sx + sw * f);
const fy = [0.2, 0.5, 0.8].map((f) => sy + sh * f);
const pts = [];
for (const x of fx) { pts.push([x, sy - pad], [x, sy + sh + pad]); }
for (const y of fy) { pts.push([sx - pad, y], [sx + sw + pad, y]); }
let r = 0, g = 0, b = 0, n = 0;
for (const [px, py] of pts) {
const cx = Math.max(0, Math.min(W - 1, Math.round(px)));
const cy = Math.max(0, Math.min(H - 1, Math.round(py)));
const d = ctx.getImageData(cx, cy, 1, 1).data;
if (d[3] === 0) continue; // outside the ancestor's paint
r += d[0]; g += d[1]; b += d[2]; n++;
}
return n ? [r / n / 255, g / n / 255, b / n / 255] : null;
}
function compileShader(gl, type, source) {
const sh = gl.createShader(type);
gl.shaderSource(sh, source);
@@ -5376,7 +5530,7 @@ void main() {
shaderState = null;
}
async function showShaderOverlay(el, blob, rect) {
async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay();
if (!blob || !el) return;
const canvas = document.createElement('canvas');
@@ -5474,7 +5628,9 @@ void main() {
const uTime = gl.getUniformLocation(program, 'u_time');
const uRes = gl.getUniformLocation(program, 'u_resolution');
const uAccent = gl.getUniformLocation(program, 'u_accent');
const uPaper = gl.getUniformLocation(program, 'u_paper');
const uTex = gl.getUniformLocation(program, 'u_texture');
const paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
@@ -5490,6 +5646,7 @@ void main() {
gl.uniform1f(uTime, t);
gl.uniform2f(uRes, canvas.width, canvas.height);
gl.uniform3f(uAccent, SHADER_ACCENT[0], SHADER_ACCENT[1], SHADER_ACCENT[2]);
gl.uniform3f(uPaper, paperRgb[0], paperRgb[1], paperRgb[2]);
gl.drawArrays(gl.TRIANGLES, 0, 6);
shaderState.rafId = requestAnimationFrame(frame);
}
@@ -5792,9 +5949,9 @@ void main() {
try {
const rect = shaderTarget.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const blob = await captureElementToBlob(shaderTarget, null, rect);
const { blob, paper } = await captureElementToBlob(shaderTarget, null, rect);
if (blob && state === 'GENERATING') {
showShaderOverlay(shaderTarget, blob, rect);
showShaderOverlay(shaderTarget, blob, rect, paper);
}
} catch (err) {
console.warn('[impeccable] shader resume failed:', err);