[eric] canvas: the eviction fallback paints the tone the canvas averages, so the flash stops reading white (ENG-340)

This commit is contained in:
ciregenz
2026-08-27 15:09:40 -07:00
parent ee93672d26
commit b5f7eb6516
4 changed files with 165 additions and 12 deletions
@@ -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<DashboardCanvasProps> = ({
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(
`<svg xmlns='http://www.w3.org/2000/svg' width='${dotSpacing}' height='${dotSpacing}'><circle cx='${dotSpacing / 2}' cy='${dotSpacing / 2}' r='${dotSize}' fill='${c.border.medium}'/></svg>`,
@@ -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);
});
+45 -8
View File
@@ -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<number, string>();
//
// 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<number, GrainTile>();
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<string | null>(cache.get(key) ?? null);
const [tile, setTile] = useState<GrainTile | null>(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;
}
@@ -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