diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 742003d5..753cba04 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -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 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 diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index 2321198a..4a5a3168 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -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 = ({ 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( ``, )}")`, [dotSpacing, dotSize, c.border.medium]); @@ -427,11 +430,12 @@ const DashboardCanvas: React.FC = ({ 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' diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index 2f520563..a722e37d 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -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; diff --git a/frontend/src/shared/styles/washBackground.test.ts b/frontend/src/shared/styles/washBackground.test.ts new file mode 100644 index 00000000..f99895b5 --- /dev/null +++ b/frontend/src/shared/styles/washBackground.test.ts @@ -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); +}); diff --git a/frontend/src/shared/styles/washBackground.ts b/frontend/src/shared/styles/washBackground.ts index 42e07be4..f24eda01 100644 --- a/frontend/src/shared/styles/washBackground.ts +++ b/frontend/src/shared/styles/washBackground.ts @@ -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));