[eric] canvas: cut the evictable full-window texture 98MB to 43MB, so the wash has far less left to drop as a band

This commit is contained in:
ciregenz
2026-08-07 15:23:43 -07:00
parent f018eefdb9
commit ec0d706a80
5 changed files with 138 additions and 21 deletions
@@ -32,7 +32,7 @@ import { findBrowserByWebContentsId } from '@/shared/browserRegistry';
import { byPreviewRecency } from '@/shared/previewOrder';
import { useClaudeTokens, useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext';
import SpacesStrip from '@/app/pages/Dashboard/desktop/SpacesStrip';
import { washOpaqueBackgroundUrl, washUnderlayColor, effectiveWashStops } from '@/shared/styles/washBackground';
import { washBackgroundLayers, washUnderlayColor, effectiveWashStops } from '@/shared/styles/washBackground';
import { useGrainTileUrl } from '@/shared/styles/useGrainTileUrl';
import { ErrorSlime } from '@/app/components/feedback/ErrorSlime';
@@ -343,6 +343,14 @@ const AppShell: React.FC = () => {
// unpinned fullscreen keeps the hover-peek overlay.
const [fsSidebarPinned, setFsSidebarPinned] = useState(false);
const sidebarAway = (sidebarCollapsed || (fsActive && !fsSidebarPinned)) && isDashboardViewActive;
// The dashboard canvas paints the identical wash and grain over everything but a 6px frame of
// shell, so painting them here too just buys a second full-window texture for the compositor to
// evict. Skip it while the canvas is up; on every other route the shell is the only painter.
const shellWashLayers = React.useMemo(
() => (fsWashStops
? washBackgroundLayers(fsWashStops, themeWashOpacity, c.bg.page, isDashboardViewActive ? null : shellGrainUrl)
: null),
[fsWashStops, themeWashOpacity, c.bg.page, shellGrainUrl, isDashboardViewActive]);
// When the sidebar docks away, the canvas runs flush to the window's left edge, so the floating
// dashboard header would sit right under the macOS traffic lights. Publish an inset the header reads
// (only on macOS, where the lights exist) so it clears them; the sidebar carries its own clearance.
@@ -353,6 +361,15 @@ const AppShell: React.FC = () => {
else root.style.removeProperty('--osw-header-inset');
return () => { root.style.removeProperty('--osw-header-inset'); };
}, [sidebarAway]);
// Hand the boot paint back. index.html puts the wash gradient on <html> so a reload never flashes
// white, but nothing ever took it off: it stayed for the whole session as a full-window,
// background-attachment:fixed root layer that the shell already paints over. That is a permanently
// resident evictable texture bought for the first frame only, and evicting it is what shows the
// hard-edged band. The shell covers the viewport by the time this runs, so a flat colour is all the
// backdrop that is left to want.
useEffect(() => {
document.documentElement.style.background = c.bg.page;
}, [c.bg.page]);
// Global text-size ratio (Settings > Interface). Scaling the root font-size scales every rem-based
// size in one shot, so type grows or shrinks together with no layout breakage. Clamped to a sane band
// so a corrupt value can never wreck the whole UI.
@@ -417,11 +434,11 @@ const AppShell: React.FC = () => {
// sliver of shell peeking past the viewport reads as continuous texture, never a tint/grain seam.
...(fsWashStops ? {
backgroundColor: washUnderlayColor(fsWashStops, themeWashOpacity, c.bg.page),
backgroundImage: shellGrainUrl
? `${shellGrainUrl}, ${washOpaqueBackgroundUrl(fsWashStops, themeWashOpacity, c.bg.page)}`
: washOpaqueBackgroundUrl(fsWashStops, themeWashOpacity, c.bg.page),
backgroundSize: shellGrainUrl ? 'auto, 100% 100%' : '100% 100%',
backgroundRepeat: shellGrainUrl ? 'repeat, no-repeat' : 'no-repeat',
...(shellWashLayers ? {
backgroundImage: shellWashLayers.image,
backgroundSize: shellWashLayers.size,
backgroundRepeat: shellWashLayers.repeat,
} : {}),
} : {}),
}}>
{/* Sidebar retired: dashboards switch via the macOS-Spaces top strip; a slim band below the
@@ -18,10 +18,13 @@ 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 { washOpaqueBackgroundUrl, washUnderlayColor, effectiveWashStops } from '@/shared/styles/washBackground';
import { washBackgroundLayers, 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;
// 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
// it can be (a hardcoded 256 made it 3.5x bigger than needed, all of it evictable texture) and a
// future max-zoom bump can't silently uncover an edge.
const GRID_BLEED_PX = 24 * MAX_ZOOM;
import type { AgentSession } from '@/shared/state/agentsSlice';
import type {
CardPosition,
@@ -32,7 +35,7 @@ import type {
} from '@/shared/state/dashboardLayoutSlice';
import type { Output } from '@/shared/state/outputsSlice';
import type { CardType, useDashboardSelection } from '../hooks/state/useDashboardSelection';
import type { useCanvasControls } from '../hooks/interaction/useCanvasControls';
import { MAX_ZOOM, type useCanvasControls } from '../hooks/interaction/useCanvasControls';
import { useWebviewSuspend } from '../hooks/interaction/useWebviewSuspend';
import { deleteSelectedCards } from '../hooks/interaction/deleteSelectedCards';
import { getLastInteractedBrowser } from '@/shared/browserFocus';
@@ -174,8 +177,8 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
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 washUrl = React.useMemo(() => washOpaqueBackgroundUrl(washStops, washOpacity, c.bg.page), [washStops, washOpacity, c.bg.page]);
const grainTileUrl = useGrainTileUrl(grain);
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>`,
)}")`, [dotSpacing, dotSize, c.border.medium]);
@@ -427,11 +430,12 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
backgroundColor: washUnderlay,
// Wash + grain paint HERE rather than on a child: two stacked full-viewport layers meant two
// rasters the compositor could evict independently, and a dropped one exposed the flat tint
// as a hard-edged band. One element, one raster, one fewer thing to lose.
...(washStops && washStops.length > 0 ? {
backgroundImage: grainTileUrl ? `${grainTileUrl}, ${washUrl}` : washUrl,
backgroundSize: grainTileUrl ? 'auto, 100% 100%' : '100% 100%',
backgroundRepeat: grainTileUrl ? 'repeat, no-repeat' : 'no-repeat',
// as a hard-edged band. One element, one raster, one fewer thing to lose. A uniform wash
// drops the image entirely, because backgroundColor above already IS that colour.
...(washLayers ? {
backgroundImage: washLayers.image,
backgroundSize: washLayers.size,
backgroundRepeat: washLayers.repeat,
} : {}),
cursor: canvas.isPanning
? 'grabbing'
@@ -14,7 +14,7 @@ const MIN_ZOOM = 0.15;
// measured 100% -> 88% -> 79% -> 61% -> 36% -> 18% over one ordinary session, at which point no word
// on the canvas is readable. A hand-driven zoom can still go all the way to MIN_ZOOM.
const MAX_ZOOM = 3.0;
export const MAX_ZOOM = 3.0;
const ZOOM_IN_FACTOR = 1.1;
const ZOOM_OUT_FACTOR = 1 / ZOOM_IN_FACTOR;
const FIT_PADDING = 200;
@@ -0,0 +1,60 @@
/**
* Run: node --test frontend/src/shared/styles/washBackground.test.ts
*
* The wash is the app's biggest evictable GPU texture, and every case here is about NOT allocating
* one we don't need. Chromium can drop a texture's tiles under memory pressure (many webviews, an
* external display) and paints the element's background-color in their place, which is the
* hard-edged rectangle of flat tint users report. A background-color is a compositor solid-colour
* quad and can never be evicted, so when the wash is one flat colour the image must not exist at all.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { washIsUniform, washBackgroundLayers, washUnderlayColor, washOpaqueBackgroundUrl } from './washBackground.ts';
const PAGE = '#F5F4ED';
test('a single accent is uniform, so it needs no image', () => {
assert.equal(washIsUniform(['#B7CDEA']), true);
assert.equal(washBackgroundLayers(['#B7CDEA'], 0.17, PAGE, null), null);
});
test('repeated identical stops are uniform too (the boot-paint shape)', () => {
assert.equal(washIsUniform(['#B7CDEA', '#B7CDEA']), true);
assert.equal(washIsUniform(['#b7cdea', '#B7CDEA']), true, 'hex case must not decide this');
});
test('a real multi-stop gradient is NOT uniform and still paints', () => {
const stops = ['#B7CDEA', '#EFE0D2', '#E7BDD1'];
assert.equal(washIsUniform(stops), false);
const layers = washBackgroundLayers(stops, 0.17, PAGE, null);
assert.ok(layers && layers.image.includes('linear-gradient'));
assert.equal(layers!.size, '100% 100%');
});
test('for a uniform wash the tint IS the colour, so dropping the image changes no pixel', () => {
// The whole safety argument for skipping the image rests on these two being the same colour, so
// compare the numbers rather than the spelling (#eaedec vs rgba(234, 237, 236, 1)).
for (const accent of ['#B7CDEA', '#E7BDD1', '#3D3D3A', '#FFFFFF']) {
const tint = washUnderlayColor([accent], 0.17, PAGE);
const rgb = washOpaqueBackgroundUrl([accent], 0.17, PAGE).match(/\d+/g)!.slice(1, 4).map(Number);
const hex = [1, 3, 5].map((i) => parseInt(tint.slice(i, i + 2), 16));
assert.deepEqual(rgb, hex, `${accent}: image paints ${rgb}, background-color is ${hex}`);
}
});
test('grain alone still paints when the wash is uniform', () => {
const layers = washBackgroundLayers(['#B7CDEA'], 0.17, PAGE, 'url(grain)');
assert.deepEqual(layers, { image: 'url(grain)', size: 'auto', repeat: 'repeat' });
});
test('grain stacks above the gradient, in that order', () => {
const layers = washBackgroundLayers(['#B7CDEA', '#E7BDD1'], 0.17, PAGE, 'url(grain)');
assert.ok(layers!.image.startsWith('url(grain), '), 'grain must be the top layer');
assert.equal(layers!.size, 'auto, 100% 100%');
assert.equal(layers!.repeat, 'repeat, no-repeat');
});
test('no stops and no grain means no background image at all', () => {
assert.equal(washBackgroundLayers([], 0.17, PAGE, null), null);
assert.equal(washIsUniform([]), true);
});
+40 -4
View File
@@ -1,7 +1,5 @@
// Theme wash as an SVG IMAGE, not a CSS linear-gradient: Chromium caches a decoded image as a GPU
// texture, while a window-sized procedural gradient re-rasterizes on resize and, under GPU memory
// pressure (webviews, external monitors), those rasters get DROPPED and paint as a half/blank
// rectangle (the same class as the 1.5.9 dot-grid white-patch bug; see DashboardCanvas's grid note).
// The theme wash. Anything painted here is a full-window layer, so it is the app's single biggest
// piece of evictable GPU texture: keep it as cheap as the theme allows (see washIsUniform).
export function washBackgroundUrl(stops: string[], washOpacity: number): string {
const alpha = Math.max(0, Math.min(1, washOpacity));
// A native CSS gradient, not an SVG data-URL. The data-URL version was a decoded IMAGE resource:
@@ -35,6 +33,44 @@ export function washOpaqueBackgroundUrl(stops: string[], washOpacity: number, pa
return washBackgroundUrl(blended, 1);
}
/**
* True when the wash is one flat colour, so painting it as an image would be pure waste.
*
* A single-accent theme (the common case) resolves to `linear-gradient(115deg, C 100%)` while the
* element's background-color is already exactly C, measured delta 0/255. That redundant image still
* costs a full-window texture, and a texture is the only thing Chromium can EVICT: dropping its
* tiles is what paints the hard-edged rectangle of flat tint people report. A background-color is a
* compositor solid-colour quad, which can never be evicted, so skipping the image doesn't just save
* memory, it makes the band unrepresentable for these themes.
*/
export function washIsUniform(stops: string[]): boolean {
return stops.length < 2 || stops.every((s) => s.toLowerCase() === stops[0].toLowerCase());
}
export interface WashLayers {
image: string;
size: string;
repeat: string;
}
/**
* The background layers a full-window wash surface should paint, or null for "colour is enough".
*
* Both painters (the shell and the canvas viewport) need the identical stack, and getting it wrong
* is what brings the band back, so it is derived once here rather than re-spelled at each site.
*/
export function washBackgroundLayers(
stops: string[], washOpacity: number, pageBg: string, grainUrl: string | null,
): WashLayers | null {
const wash = stops.length > 0 && !washIsUniform(stops)
? washOpaqueBackgroundUrl(stops, washOpacity, pageBg)
: '';
if (!wash && !grainUrl) return null;
if (!wash) return { image: grainUrl as string, size: 'auto', repeat: 'repeat' };
if (!grainUrl) return { image: wash, size: '100% 100%', repeat: 'no-repeat' };
return { image: `${grainUrl}, ${wash}`, size: 'auto, 100% 100%', repeat: 'repeat, no-repeat' };
}
// 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));