diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index a0b73ead..074b6a45 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -16,7 +16,10 @@ 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 { washBackgroundUrl, effectiveWashStops } from '@/shared/styles/washBackground'; +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. +const GRID_BLEED_PX = 256; import type { AgentSession } from '@/shared/state/agentsSlice'; import type { CardPosition, @@ -346,6 +349,8 @@ const DashboardCanvas: React.FC = ({ position: 'absolute', inset: 0, overflow: 'hidden', + // Last line of the never-white guarantee: if every background layer's raster is gone, the viewport itself still paints tint (solid colors are compositor quads, not evictable textures). + backgroundColor: washUnderlayColor(washStops, washOpacity, c.bg.page), cursor: canvas.isPanning ? 'grabbing' : (canvas.spaceHeld || canvas.cmdHeld) @@ -355,14 +360,15 @@ const DashboardCanvas: React.FC = ({ : 'default', }} > - {/* Gradient wash: the user's theme-pad stops tint the canvas, Arc-window style; intensity + grain come from the theme device; sits under the dot grid. */} + {/* Gradient wash: the user's theme-pad stops tint the canvas, Arc-window style; intensity + grain come from the theme device; sits under the dot grid. Pre-blended opaque + declared backgroundColor so a GPU-evicted tile paints as tint, never raw white/black (the ENG-151 band). */} {washStops && washStops.length > 0 && ( @@ -384,15 +390,17 @@ const DashboardCanvas: React.FC = ({ ref={canvas.gridRef} sx={{ position: 'absolute', - inset: 0, + // Bled past the viewport so the pan phase rides a compositor transform: backgroundPosition writes repainted the WHOLE viewport every pan frame, which both cost the frame budget and fed the GPU pressure that evicts the wash. + inset: `-${GRID_BLEED_PX}px`, pointerEvents: 'none', + willChange: 'transform', + transform: `translate3d(${canvas.panX % dotSpacing}px, ${canvas.panY % dotSpacing}px, 0)`, // Arc fullscreen: the float sits on a clean themed ground, the dot texture is canvas-only. display: anyFullscreen ? 'none' : undefined, backgroundImage: `url("data:image/svg+xml,${encodeURIComponent( ``, )}")`, backgroundSize: `${dotSpacing}px ${dotSpacing}px`, - backgroundPosition: `${canvas.panX % dotSpacing}px ${canvas.panY % dotSpacing}px`, }} /> diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index dde7420d..e098fb11 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -106,7 +106,8 @@ export function useCanvasControls( const grid = gridRef.current; if (grid) { const spacing = 24 * zoom; - grid.style.backgroundPosition = `${panX % spacing}px ${panY % spacing}px`; + // Phase rides a compositor transform (the element is bled past the viewport); backgroundPosition here repainted the whole viewport every pan frame. + grid.style.transform = `translate3d(${panX % spacing}px, ${panY % spacing}px, 0)`; // Dot RADIUS lives in the committed backgroundImage and lags to gesture-end; at 1-4px dots the mid-pinch error is invisible and skipping the per-frame gradient rebuild keeps this handler pure style writes. grid.style.backgroundSize = `${spacing}px ${spacing}px`; } diff --git a/frontend/src/shared/styles/washBackground.ts b/frontend/src/shared/styles/washBackground.ts index 890da23c..a3f3e72f 100644 --- a/frontend/src/shared/styles/washBackground.ts +++ b/frontend/src/shared/styles/washBackground.ts @@ -13,6 +13,28 @@ export function washBackgroundUrl(stops: string[], washOpacity: number): string return `url("data:image/svg+xml,${svg.replace(/#/g, '%23').replace(/'/g, '%27')}")`; } +function mixHex(a: string, b: string, t: number): string { + const pa = parseInt(a.slice(1), 16); + const pb = parseInt(b.slice(1), 16); + const ch = (shift: number): number => Math.round(((pa >> shift) & 0xff) * (1 - t) + ((pb >> shift) & 0xff) * t); + return `#${((ch(16) << 16) | (ch(8) << 8) | ch(0)).toString(16).padStart(6, '0')}`; +} + +// The canvas wash, pre-blended over the page color so the layer is OPAQUE: identical pixels to the translucent version, but Chromium can then paint the declared background-color for any tile it evicted or hasn't rastered yet, instead of raw page white/black. +export function washOpaqueBackgroundUrl(stops: string[], washOpacity: number, pageBg: string): string { + const alpha = Math.max(0, Math.min(1, washOpacity)); + const blended = stops.map((hex) => mixHex(pageBg, hex, alpha)); + return washBackgroundUrl(blended, 1); +} + +// What an evicted/unrastered wash tile should paint as: the wash's mean tint, never raw page color. +export function washUnderlayColor(stops: string[], washOpacity: number, pageBg: string): string { + const alpha = Math.max(0, Math.min(1, washOpacity)); + if (stops.length === 0) return pageBg; + const mean = stops.reduce((acc, hex, i) => (i === 0 ? hex : mixHex(acc, hex, 1 / (i + 1))), stops[0]); + return mixHex(pageBg, mean, alpha); +} + // Stock wallpaper when the user hasn't picked an accent yet: a designed blue-to-cream-to-pink // gradient (contrasting stops), so a fresh install and the onboarding stage never look flat/white. export const DEFAULT_WASH_STOPS = ['#B7CDEA', '#EFE0D2', '#E7BDD1'];