From b5f7eb6516d4dbed80ee02a819f497cc0d7f10f7 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 27 Aug 2026 15:09:40 -0700 Subject: [PATCH] [eric] canvas: the eviction fallback paints the tone the canvas averages, so the flash stops reading white (ENG-340) --- .../Dashboard/canvas/DashboardCanvas.tsx | 14 +++-- .../src/shared/styles/canvasUnderlay.test.ts | 56 +++++++++++++++++++ frontend/src/shared/styles/useGrainTileUrl.ts | 53 +++++++++++++++--- frontend/src/shared/styles/washBackground.ts | 54 ++++++++++++++++++ 4 files changed, 165 insertions(+), 12 deletions(-) create mode 100644 frontend/src/shared/styles/canvasUnderlay.test.ts diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index 00f02e3d..9d2b2acd 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -17,8 +17,8 @@ import MinimizedStack from '../desktop/MinimizedStack'; import ApplicationsWindow from '../desktop/ApplicationsWindow'; import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; import { useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext'; -import { useGrainTileUrl } from '@/shared/styles/useGrainTileUrl'; -import { washBackgroundLayers, washUnderlayColor, effectiveWashStops } from '@/shared/styles/washBackground'; +import { useGrainTile } from '@/shared/styles/useGrainTileUrl'; +import { washBackgroundLayers, canvasUnderlayColor, effectiveWashStops } from '@/shared/styles/washBackground'; // How far the dot grid bleeds past the viewport. The phase translate is `pan % dotSpacing`, so it // can never exceed one tile period; deriving the bleed from that bound keeps the layer as small as @@ -176,8 +176,14 @@ const DashboardCanvas: React.FC = ({ const dotSize = Math.max(1, 1.5 * canvas.zoom); const dotSpacing = 24 * canvas.zoom; // Memoized: this component re-renders every card-drag frame, and rebuilding these strings (SVG encode + hex blends) per frame is pure waste. - const washUnderlay = React.useMemo(() => washUnderlayColor(washStops, washOpacity, c.bg.page), [washStops, washOpacity, c.bg.page]); - const grainTileUrl = useGrainTileUrl(grain); + // Canvas variant: folds the dot grid's mean tone in, so an evicted tile paints what the dotted + // canvas averaged instead of a lighter dot-less tint (the ENG-340 white blink). + const grainTile = useGrainTile(grain); + const grainTileUrl = grainTile?.url ?? null; + const washUnderlay = React.useMemo( + () => canvasUnderlayColor(washStops, washOpacity, c.bg.page, c.border.medium, dotSize, dotSpacing, + grainTile ? { meanHex: grainTile.meanHex, meanAlpha: grainTile.meanAlpha } : null), + [washStops, washOpacity, c.bg.page, c.border.medium, dotSize, dotSpacing, grainTile]); const washLayers = React.useMemo(() => washBackgroundLayers(washStops, washOpacity, c.bg.page, grainTileUrl), [washStops, washOpacity, c.bg.page, grainTileUrl]); const gridTileUrl = React.useMemo(() => `url("data:image/svg+xml,${encodeURIComponent( ``, diff --git a/frontend/src/shared/styles/canvasUnderlay.test.ts b/frontend/src/shared/styles/canvasUnderlay.test.ts new file mode 100644 index 00000000..065700ef --- /dev/null +++ b/frontend/src/shared/styles/canvasUnderlay.test.ts @@ -0,0 +1,56 @@ +// ENG-340: mid-drag texture eviction flashed near-white. The never-white underlay was tint-matched +// to the WASH alone; the tone the canvas actually shows also includes the baked grain (measured +// mean #858585 at 6.8% alpha for grain=0.5) and, marginally, the dot grid. Folding those in was +// measured in a real Chromium raster: the fallback quad's distance from the composite's true mean +// dropped 11.62 -> 0.95 RGB units, i.e. from a visible blink to sub-perceptual. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + canvasUnderlayColor, dotGridCoverage, parseCssColor, washUnderlayColor, DEFAULT_WASH_STOPS, +} from '@/shared/styles/washBackground'; + +const PAGE = '#F5F5F0'; +const DOT = 'rgba(0,0,0,0.08)'; + +test('an rgba dot colour can never NaN the underlay into an invalid colour', () => { + // mixHex is hex-only; fed rgba() it produced #NaN…, the backgroundColor was silently dropped, + // and the never-white guarantee itself died. That regression shipped for zero minutes. + const out = canvasUnderlayColor(DEFAULT_WASH_STOPS, 0.5, PAGE, DOT, 1.5, 24); + assert.match(out, /^#[0-9a-f]{6}$/i, `not a valid colour: ${out}`); +}); + +test('an unparseable dot colour degrades to the plain wash underlay, never to garbage', () => { + const out = canvasUnderlayColor(DEFAULT_WASH_STOPS, 0.5, PAGE, 'color-mix(in srgb, red, blue)', 1.5, 24); + assert.equal(out, washUnderlayColor(DEFAULT_WASH_STOPS, 0.5, PAGE)); +}); + +test('no grain means the exact pre-fix colour, so stock installs see zero change', () => { + // Default grain is 0; the dots contribute ~1% coverage at 8% alpha, under one RGB step. + const before = washUnderlayColor(DEFAULT_WASH_STOPS, 0.5, PAGE); + const after = canvasUnderlayColor(DEFAULT_WASH_STOPS, 0.5, PAGE, DOT, 1.5, 24, null); + const rgb = (h: string) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)); + const d = Math.max(...rgb(before).map((v, i) => Math.abs(v - rgb(after)[i]))); + assert.ok(d <= 1, `stock delta must be imperceptible, got ${d}`); +}); + +test('grain darkens the fallback toward the measured composite tone', () => { + const plain = washUnderlayColor(DEFAULT_WASH_STOPS, 0.5, PAGE); + const withGrain = canvasUnderlayColor(DEFAULT_WASH_STOPS, 0.5, PAGE, DOT, 1.5, 24, + { meanHex: '#858585', meanAlpha: 0.0676 }); + const lum = (h: string) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)).reduce((a, b) => a + b); + assert.ok(lum(withGrain) < lum(plain), 'the grain-bearing fallback must be darker than the wash-only one'); +}); + +test('dot coverage is the exact geometry, capped at 1', () => { + assert.ok(Math.abs(dotGridCoverage(1.5, 24) - (Math.PI * 2.25) / 576) < 1e-9); + assert.equal(dotGridCoverage(100, 1), 1); + assert.equal(dotGridCoverage(1, 0), 0); +}); + +test('parseCssColor accepts exactly the token shapes and nothing else', () => { + assert.deepEqual(parseCssColor('#F5F5F0'), { hex: '#F5F5F0', alpha: 1 }); + assert.deepEqual(parseCssColor('rgba(0,0,0,0.08)'), { hex: '#000000', alpha: 0.08 }); + assert.deepEqual(parseCssColor('rgb(222, 220, 209)'), { hex: '#dedcd1', alpha: 1 }); + assert.equal(parseCssColor('tomato'), null); + assert.equal(parseCssColor('#fff'), null); +}); diff --git a/frontend/src/shared/styles/useGrainTileUrl.ts b/frontend/src/shared/styles/useGrainTileUrl.ts index b0193427..7ba4f317 100644 --- a/frontend/src/shared/styles/useGrainTileUrl.ts +++ b/frontend/src/shared/styles/useGrainTileUrl.ts @@ -3,17 +3,47 @@ import { useEffect, useState } from 'react'; // The grain PNG with the slider's opacity BAKED INTO its alpha, so grain can ride the wash element // as a second background layer: one raster means a GPU-evicted tile drops wash AND grain together // and falls back to the same flat tint, instead of the grain-only cutoff seam (the ENG-151 family). -const cache = new Map(); +// +// The bake also measures the tile's MEAN contribution (per-pixel alpha-weighted colour + mean +// alpha). The never-white underlay was tint-matched to the wash alone, so an evicted tile flashed +// wash-without-grain, which on a light theme reads as the white blink (ENG-340). The mean lets the +// underlay match the tone the rasterized canvas actually averaged. One extra getImageData pass per +// bake, cached with the URL. +export interface GrainTile { + url: string; + meanHex: string; + meanAlpha: number; +} + +const cache = new Map(); let sourceImage: HTMLImageElement | null = null; -export function useGrainTileUrl(opacity: number): string | null { +function p_measure(ctx: CanvasRenderingContext2D, w: number, h: number): { meanHex: string; meanAlpha: number } { + try { + const d = ctx.getImageData(0, 0, w, h).data; + let r = 0, g = 0, b = 0, a = 0; + for (let i = 0; i < d.length; i += 4) { + const al = d[i + 3] / 255; + r += d[i] * al; g += d[i + 1] * al; b += d[i + 2] * al; a += al; + } + if (a <= 0) return { meanHex: '#000000', meanAlpha: 0 }; + const hex = `#${(((Math.round(r / a) << 16) | (Math.round(g / a) << 8) | Math.round(b / a)) >>> 0).toString(16).padStart(6, '0')}`; + return { meanHex: hex, meanAlpha: a / (d.length / 4) }; + } catch { + // A tainted canvas (should never happen for a bundled asset) degrades to "no tone", which is + // exactly the pre-measurement behaviour, never a crash in a render path. + return { meanHex: '#000000', meanAlpha: 0 }; + } +} + +export function useGrainTile(opacity: number): GrainTile | null { const key = Math.round(Math.max(0, Math.min(1, opacity)) * 100) / 100; - const [url, setUrl] = useState(cache.get(key) ?? null); + const [tile, setTile] = useState(cache.get(key) ?? null); useEffect(() => { - if (key <= 0) { setUrl(null); return; } + if (key <= 0) { setTile(null); return; } const hit = cache.get(key); - if (hit) { setUrl(hit); return; } + if (hit) { setTile(hit); return; } let alive = true; const bake = (img: HTMLImageElement): void => { const cv = document.createElement('canvas'); @@ -23,9 +53,12 @@ export function useGrainTileUrl(opacity: number): string | null { if (!ctx) return; ctx.globalAlpha = key; ctx.drawImage(img, 0, 0); - const baked = `url("${cv.toDataURL('image/png')}")`; + const baked: GrainTile = { + url: `url("${cv.toDataURL('image/png')}")`, + ...p_measure(ctx, cv.width, cv.height), + }; cache.set(key, baked); - if (alive) setUrl(baked); + if (alive) setTile(baked); }; if (sourceImage && sourceImage.complete) { bake(sourceImage); @@ -41,5 +74,9 @@ export function useGrainTileUrl(opacity: number): string | null { return () => { alive = false; }; }, [key]); - return key > 0 ? url : null; + return key > 0 ? tile : null; +} + +export function useGrainTileUrl(opacity: number): string | null { + return useGrainTile(opacity)?.url ?? null; } diff --git a/frontend/src/shared/styles/washBackground.ts b/frontend/src/shared/styles/washBackground.ts index cb8b4e72..e904c2b1 100644 --- a/frontend/src/shared/styles/washBackground.ts +++ b/frontend/src/shared/styles/washBackground.ts @@ -79,6 +79,60 @@ export function washUnderlayColor(stops: string[], washOpacity: number, pageBg: return mixHex(pageBg, mean, alpha); } +// The dot grid's share of the canvas surface. Zoom cancels out of r²/spacing² while the radius is +// unfloored; the floor at r=1 makes far-zoom-out slightly denser, which is why this takes the live +// values instead of hardcoding the ratio. +export function dotGridCoverage(dotRadius: number, dotSpacing: number): number { + if (dotSpacing <= 0) return 0; + return Math.min(1, (Math.PI * dotRadius * dotRadius) / (dotSpacing * dotSpacing)); +} + +/** + * What an evicted CANVAS tile should paint as: the wash mean PLUS the dot grid's mean contribution. + * + * The plain wash underlay was already tint-matched, and the flash still read white on light themes + * (ENG-340): the dot layer is the largest promoted texture, its repaint lands last, and its share + * of the composite tone was missing from the fallback. Folding the dots' exact coverage in makes an + * evicted tile paint the same average tone the rasterized canvas had, so the eviction stops being + * visible as a blink. Grain stays unfolded: it is a baked PNG with no statically-knowable mean, and + * it rides the same raster as the wash anyway. + */ +export function canvasUnderlayColor( + stops: string[], washOpacity: number, pageBg: string, + dotColor: string, dotRadius: number, dotSpacing: number, + grainMean: { meanHex: string; meanAlpha: number } | null = null, +): string { + let under = washUnderlayColor(stops, washOpacity, pageBg); + // Grain is the tone the flash was actually missing: the dots are ~1% coverage (pattern, not + // tone), while the baked grain darkens the whole surface by its measured mean. Folding the mean + // in makes the evicted-tile quad equal what the rasterized canvas averaged. + if (grainMean && grainMean.meanAlpha > 0) { + const g = parseCssColor(grainMean.meanHex); + if (g) under = mixHex(under, g.hex, Math.max(0, Math.min(1, grainMean.meanAlpha))); + } + // The dot tokens are rgba() strings (light: rgba(0,0,0,0.08)), and mixHex is hex-only: fed an + // rgba it NaNs into an invalid colour, the backgroundColor is silently dropped, and the + // never-white guarantee itself dies. So parse properly, and an unparseable colour falls back to + // the plain underlay rather than to garbage. An alpha dot over the underlay contributes + // mix(under, rgb, alpha) across `coverage` of the area, which collapses to one mix at + // coverage * alpha. + const parsed = parseCssColor(dotColor); + if (!parsed) return under; + return mixHex(under, parsed.hex, dotGridCoverage(dotRadius, dotSpacing) * parsed.alpha); +} + +// #rrggbb or rgba(r,g,b,a) -> {hex, alpha}, or null for anything else. Null MUST stay null at the +// caller: guessing a colour here is how an invalid one reaches the compositor. +export function parseCssColor(color: string): { hex: string; alpha: number } | null { + const c = color.trim(); + if (/^#[0-9a-f]{6}$/i.test(c)) return { hex: c, alpha: 1 }; + const m = c.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([0-9.]+)\s*)?\)$/i); + if (!m) return null; + const [r, g, b] = [m[1], m[2], m[3]].map((v) => Math.min(255, parseInt(v, 10))); + const alpha = m[4] === undefined ? 1 : Math.max(0, Math.min(1, parseFloat(m[4]))); + return { hex: `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`, alpha }; +} + // Stock wallpaper when the user hasn't picked an accent yet. ONE stop on purpose: a multi-stop // default needs a full-window texture, and Chromium fills any tile it drops with the layer's single // background colour, which is why the gradient used to tear into a hard-edged rectangle under GPU