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. +--- + + + +
+ + + +
+ 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.
+