[eric] canvas: grain rides the wash layer with baked alpha, an evicted tile drops both to tint instead of the grain cutoff seam

This commit is contained in:
ciregenz
2026-08-05 15:26:38 -07:00
parent f60653174f
commit 51ee40a918
2 changed files with 49 additions and 14 deletions
@@ -14,7 +14,7 @@ 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 { GRAIN_URL } from '@/shared/styles/grainTexture';
import { useGrainTileUrl } from '@/shared/styles/useGrainTileUrl';
import { washOpaqueBackgroundUrl, washUnderlayColor, effectiveWashStops } from '@/shared/styles/washBackground';
// How far the dot grid bleeds past the viewport; must exceed one tile period (24px * max zoom) so the compositor phase translate can never expose an edge.
@@ -172,6 +172,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
// 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 washUrl = React.useMemo(() => washOpaqueBackgroundUrl(washStops, washOpacity, c.bg.page), [washStops, washOpacity, c.bg.page]);
const grainTileUrl = useGrainTileUrl(grain);
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>`,
)}")`, [dotSpacing, dotSize, c.border.medium]);
@@ -414,19 +415,10 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
inset: 0,
pointerEvents: 'none',
backgroundColor: washUnderlay,
backgroundImage: washUrl,
backgroundSize: '100% 100%',
}}
/>
)}
{grain > 0 && (
<Box
sx={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
opacity: grain,
backgroundImage: GRAIN_URL,
// Grain rides the SAME element (alpha pre-baked): one raster, so an evicted tile drops both and paints the tint, never a grain-only seam.
backgroundImage: grainTileUrl ? `${grainTileUrl}, ${washUrl}` : washUrl,
backgroundSize: grainTileUrl ? 'auto, 100% 100%' : '100% 100%',
backgroundRepeat: grainTileUrl ? 'repeat, no-repeat' : 'no-repeat',
}}
/>
)}
@@ -0,0 +1,43 @@
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>();
let sourceImage: HTMLImageElement | null = null;
export function useGrainTileUrl(opacity: number): string | null {
const key = Math.round(Math.max(0, Math.min(1, opacity)) * 100) / 100;
const [url, setUrl] = useState<string | null>(cache.get(key) ?? null);
useEffect(() => {
if (key <= 0) { setUrl(null); return; }
const hit = cache.get(key);
if (hit) { setUrl(hit); return; }
let alive = true;
const bake = (img: HTMLImageElement): void => {
const cv = document.createElement('canvas');
cv.width = img.naturalWidth;
cv.height = img.naturalHeight;
const ctx = cv.getContext('2d');
if (!ctx) return;
ctx.globalAlpha = key;
ctx.drawImage(img, 0, 0);
const baked = `url("${cv.toDataURL('image/png')}")`;
cache.set(key, baked);
if (alive) setUrl(baked);
};
if (sourceImage && sourceImage.complete) {
bake(sourceImage);
} else {
const img = sourceImage ?? new Image();
sourceImage = img;
img.onload = () => bake(img);
if (!img.src) img.src = './grain-texture.png';
if (img.complete && img.naturalWidth > 0) bake(img);
}
return () => { alive = false; };
}, [key]);
return key > 0 ? url : null;
}