diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index 7a2c863d..7b08a4bf 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -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 = ({ // 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( ``, )}")`, [dotSpacing, dotSize, c.border.medium]); @@ -414,19 +415,10 @@ const DashboardCanvas: React.FC = ({ inset: 0, pointerEvents: 'none', backgroundColor: washUnderlay, - backgroundImage: washUrl, - backgroundSize: '100% 100%', - }} - /> - )} - {grain > 0 && ( - )} diff --git a/frontend/src/shared/styles/useGrainTileUrl.ts b/frontend/src/shared/styles/useGrainTileUrl.ts new file mode 100644 index 00000000..be53d6f7 --- /dev/null +++ b/frontend/src/shared/styles/useGrainTileUrl.ts @@ -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(); +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(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; +}