From 6ef995f8a42c665615092a3482b07947507a9b19 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Thu, 28 May 2026 19:16:25 -0700 Subject: [PATCH] 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) * 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 --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../skills/impeccable/scripts/live-browser.js | 201 ++++- .../skills/impeccable/scripts/live-browser.js | 201 ++++- .../skills/impeccable/scripts/live-browser.js | 201 ++++- .../skills/impeccable/scripts/live-browser.js | 201 ++++- .../skills/impeccable/scripts/live-browser.js | 201 ++++- .../skills/impeccable/scripts/live-browser.js | 201 ++++- .../skills/impeccable/scripts/live-browser.js | 201 ++++- .pi/skills/impeccable/scripts/live-browser.js | 201 ++++- .../skills/impeccable/scripts/live-browser.js | 201 ++++- .../skills/impeccable/scripts/live-browser.js | 201 ++++- .../skills/impeccable/scripts/live-browser.js | 201 ++++- .../skills/impeccable/scripts/live-browser.js | 201 ++++- .../skills/impeccable/scripts/live-browser.js | 201 ++++- site/pages/shader-lab/index.astro | 804 ++++++++++++++++++ site/pages/shader-lab/modern-screenshot.js.ts | 17 + skill/scripts/live-browser.js | 201 ++++- 16 files changed, 3327 insertions(+), 308 deletions(-) create mode 100644 site/pages/shader-lab/index.astro create mode 100644 site/pages/shader-lab/modern-screenshot.js.ts diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js index 028363916..f1e525786 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -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); diff --git a/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js index 028363916..f1e525786 100644 --- a/.claude/skills/impeccable/scripts/live-browser.js +++ b/.claude/skills/impeccable/scripts/live-browser.js @@ -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); diff --git a/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js index 028363916..f1e525786 100644 --- a/.cursor/skills/impeccable/scripts/live-browser.js +++ b/.cursor/skills/impeccable/scripts/live-browser.js @@ -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); diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js index 028363916..f1e525786 100644 --- a/.gemini/skills/impeccable/scripts/live-browser.js +++ b/.gemini/skills/impeccable/scripts/live-browser.js @@ -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); diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js index 028363916..f1e525786 100644 --- a/.github/skills/impeccable/scripts/live-browser.js +++ b/.github/skills/impeccable/scripts/live-browser.js @@ -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); diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js index 028363916..f1e525786 100644 --- a/.kiro/skills/impeccable/scripts/live-browser.js +++ b/.kiro/skills/impeccable/scripts/live-browser.js @@ -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); diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js index 028363916..f1e525786 100644 --- a/.opencode/skills/impeccable/scripts/live-browser.js +++ b/.opencode/skills/impeccable/scripts/live-browser.js @@ -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); diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js index 028363916..f1e525786 100644 --- a/.pi/skills/impeccable/scripts/live-browser.js +++ b/.pi/skills/impeccable/scripts/live-browser.js @@ -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); diff --git a/.qoder/skills/impeccable/scripts/live-browser.js b/.qoder/skills/impeccable/scripts/live-browser.js index 028363916..f1e525786 100644 --- a/.qoder/skills/impeccable/scripts/live-browser.js +++ b/.qoder/skills/impeccable/scripts/live-browser.js @@ -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); diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js index 028363916..f1e525786 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser.js +++ b/.rovodev/skills/impeccable/scripts/live-browser.js @@ -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); diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js index 028363916..f1e525786 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser.js @@ -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); diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js index 028363916..f1e525786 100644 --- a/.trae/skills/impeccable/scripts/live-browser.js +++ b/.trae/skills/impeccable/scripts/live-browser.js @@ -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); diff --git a/plugin/skills/impeccable/scripts/live-browser.js b/plugin/skills/impeccable/scripts/live-browser.js index 028363916..f1e525786 100644 --- a/plugin/skills/impeccable/scripts/live-browser.js +++ b/plugin/skills/impeccable/scripts/live-browser.js @@ -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); diff --git a/site/pages/shader-lab/index.astro b/site/pages/shader-lab/index.astro new file mode 100644 index 000000000..afd96d885 --- /dev/null +++ b/site/pages/shader-lab/index.astro @@ -0,0 +1,804 @@ +--- +// Shader Lab: a standalone harness for the live-mode generation shader. +// +// Live mode (skill/scripts/live-browser.js) captures the selected element to a +// PNG via modern-screenshot, then runs an "ink-wash" WebGL shader over that +// texture while the model generates. The capture path is brittle: transparent +// elements can come back with a solid white background. This page reproduces +// the exact pipeline against a matrix of element/background shapes so we can +// see which ones break BEFORE touching the production code. +// +// Left column: the raw element on its page background. +// Right column: a byte-identical copy with the real capture + shader on top, +// plus a readout of what resolveCanvasBackground() decided. +// +// The capture/shader code below is copied verbatim from live-browser.js. If you +// change it there, mirror it here (and vice-versa). This lab is only honest +// while the two stay in sync. +--- + + + + + + + + Shader Lab · live-mode capture + overlay + + + + +
+

Shader Lab

+

+ Reproduces live mode's capture + ink-wash shader pipeline against varied + backgrounds. Left is the raw element; right runs the real + modern-screenshot capture and WebGL overlay on top. The + bg: readout is exactly what resolveCanvasBackground() + handed to the capture. Watch for #ffffff appearing on + elements that should stay transparent or dark. +

+
+ +
+ + + booting… +
+ +
+ +
+ Capture + shader code is copied from + skill/scripts/live-browser.js. Keep them in sync. +
+ + + + + diff --git a/site/pages/shader-lab/modern-screenshot.js.ts b/site/pages/shader-lab/modern-screenshot.js.ts new file mode 100644 index 000000000..1447c1b4a --- /dev/null +++ b/site/pages/shader-lab/modern-screenshot.js.ts @@ -0,0 +1,17 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +// Serve the same vendored modern-screenshot UMD build the live server hands to +// the injected overlay (skill/scripts/live-server.mjs -> /modern-screenshot.js). +// Reading it straight off disk keeps the shader lab in lockstep with what live +// mode actually runs, with no committed copy to drift. +const VENDOR_PATH = path.join(process.cwd(), 'skill', 'scripts', 'modern-screenshot.umd.js'); + +export function GET() { + return new Response(fs.readFileSync(VENDOR_PATH, 'utf-8'), { + headers: { + 'Content-Type': 'application/javascript; charset=utf-8', + 'Cache-Control': 'no-store', + }, + }); +} diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 028363916..f1e525786 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -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);