From 4c6540679d143555be29311a967efcb7e17762b0 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:45:21 +0200 Subject: [PATCH] perf(workspace): paint the content area's rounded corner instead of clipping it (#1857) * perf(workspace): paint the content area's rounded corner instead of clipping it The `border-radius: 16px 0 0 0` on the scrolling content area made Blink clip every composited effect inside it (the rail chevrons' and hero controls' backdrop filters, running transform animations, isolated groups) through a mask layer synthesized per effect: its shader path for rounded clips needs four equal radii on macOS and a translation-only transform between the clip and the effect, and the fallback mask is the size of the whole clipped area, about 24 MB of tile memory at 2x on a 16" display. Twelve of them put the dashboard at 537-655 MB of tile memory against Chromium's 512 MB budget ("tile memory limits exceeded", blank tiles while scrolling). The scroller now sits in a frame that carries the radius and the shadow without clipping; the scroller clips to a rectangle and is a stacking context; a 16px corner piece on the frame paints the notch in the body colour. Dashboard tile memory (1728x1000 window at 2x, Xtream mock): idle 537 -> 242 MB, slide switch 598 -> 303 MB, scrolling 655 -> 165 MB; movie detail 110 -> 89 MB, series detail 187 -> 143 MB. The new Electron E2E asserts that no element-less drawing layer spans half the content area (eight did before). Co-Authored-By: Claude Fable 5.1 * test(e2e): identify synthesized clip masks by missing owner node and reasons A content layer's owner node is also missing when its first paint chunk belongs to an anonymous box (the rail track and the content scroller flipped between runs), so the dashboard compositing check now requires both: no owner node and no compositing reason, which only a synthesized mask has. It also waits for Blink's layer debug info to be filled after enabling the LayerTree domain, and asserts that the backdrop-filtered controls really are composited layers, so the mask check cannot pass vacuously. Verified under `--disable-gpu` as well: the old scroller radius still produces seven masks there, the fix none. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- .../workspace-content-corner-tile-memory.md | 9 + .../src/dashboard-compositing.e2e.ts | 157 ++++++++++++++++++ docs/architecture/iptvnator-ui-guidelines.md | 4 + docs/architecture/workspace-shell.md | 20 +++ .../workspace-shell.component.html | 27 +-- .../workspace-shell.component.scss | 52 +++++- 6 files changed, 255 insertions(+), 14 deletions(-) create mode 100644 .changes/workspace-content-corner-tile-memory.md create mode 100644 apps/electron-backend-e2e/src/dashboard-compositing.e2e.ts diff --git a/.changes/workspace-content-corner-tile-memory.md b/.changes/workspace-content-corner-tile-memory.md new file mode 100644 index 000000000..4c345e249 --- /dev/null +++ b/.changes/workspace-content-corner-tile-memory.md @@ -0,0 +1,9 @@ +--- +type: perf +area: workspace +--- + +On Retina displays the dashboard and the movie and series pages no longer run +out of GPU tile memory: the content area's rounded corner is now painted +instead of clipped, so scrolling no longer leaves blank patches behind the +rails and heroes. diff --git a/apps/electron-backend-e2e/src/dashboard-compositing.e2e.ts b/apps/electron-backend-e2e/src/dashboard-compositing.e2e.ts new file mode 100644 index 000000000..156b51319 --- /dev/null +++ b/apps/electron-backend-e2e/src/dashboard-compositing.e2e.ts @@ -0,0 +1,157 @@ +import type { CDPSession, Page } from '@playwright/test'; +import { + addXtreamPortal, + closeElectronApp, + expect, + goToDashboard, + launchElectronApp, + resetMockServers, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; + +type CompositedLayer = { + backendNodeId?: number; + drawsContent: boolean; + height: number; + layerId: string; + width: number; +}; + +type CompositedLayerWithReasons = CompositedLayer & { + /** `LayerTree.compositingReasons` ids, e.g. `BackdropFilter`. */ + reasons: string[]; +}; + +/** + * The composited layers of the page with their compositing reasons. Blink + * fills the layer debug info (reasons, owner nodes) in its first + * layerization after the domain is enabled, so the snapshot is retried + * until a layer reports a reason. + */ +async function compositedLayers( + cdp: CDPSession, + page: Page +): Promise { + let layers: CompositedLayer[] = []; + const onChange = (event: { layers?: CompositedLayer[] }) => { + layers = event.layers ?? []; + }; + cdp.on('LayerTree.layerTreeDidChange', onChange); + let withReasons: CompositedLayerWithReasons[] = []; + const snapshot = async () => { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)) + ) + ); + const result: CompositedLayerWithReasons[] = []; + for (const layer of layers) { + let reasons: string[] = []; + try { + const answer = (await cdp.send('LayerTree.compositingReasons', { + layerId: layer.layerId, + })) as { compositingReasonIds?: string[] }; + reasons = answer.compositingReasonIds ?? []; + } catch { + // The layer went away between the snapshot and the query. + } + result.push({ ...layer, reasons }); + } + return result; + }; + try { + await cdp.send('LayerTree.enable'); + await expect + .poll(async () => { + withReasons = await snapshot(); + return withReasons.filter((layer) => layer.reasons.length > 0) + .length; + }) + .toBeGreaterThan(0); + await cdp.send('LayerTree.disable'); + } finally { + cdp.off('LayerTree.layerTreeDidChange', onChange); + } + return withReasons; +} + +test.describe('Dashboard compositing', () => { + // A `border-radius` on the content scroller made Blink clip every + // composited effect inside it (the rail chevrons' and hero controls' + // backdrop filters, running animations) through a synthesized mask layer + // the size of the whole content area: ~24 MB of tile memory each at 2x, + // a dozen on the dashboard, which took the renderer past Chromium's tile + // budget ("tile memory limits exceeded") and left blank tiles while + // scrolling. The corner is painted instead. A mask layer is synthesized, + // not painted for an element: it has no owner node and no compositing + // reason, unlike every content layer (a content layer's owner node can + // also be missing when its first paint chunk belongs to an anonymous + // box, so the node alone does not identify a mask). Blink synthesizes + // them while layerizing, before any GPU work: under `--disable-gpu` (the + // Linux CI launch, software compositing and raster) the controls are + // still composited and the old radius still produced seven masks, so the + // check guards the same path everywhere. + test('does not synthesize content-area-sized clip masks for the composited rail and hero controls', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + + try { + await addXtreamPortal(page); + await waitForXtreamWorkspaceReady(page); + await goToDashboard(page); + await expect(page.getByTestId('dashboard-hero')).toBeVisible({ + timeout: 20_000, + }); + await expect( + page.getByTestId('dashboard-xtream-recently-added-rail') + ).toBeVisible({ timeout: 20_000 }); + // The composited effects this guards: every rail has two + // backdrop-filtered chevrons, the hero its controls. + expect(await page.locator('.rail__chev').count()).toBeGreaterThan( + 0 + ); + await expect(page.locator('.hero__controls')).toBeVisible(); + + const content = await page + .locator('main.workspace-content') + .evaluate((element) => ({ + width: element.clientWidth * window.devicePixelRatio, + height: element.clientHeight * window.devicePixelRatio, + })); + const cdp = await app.electronApp.context().newCDPSession(page); + const layers = await compositedLayers(cdp, page); + await cdp.detach(); + + // The effects really are composited here: without this the + // mask check below would pass on a page with nothing to clip. + expect( + layers.filter((layer) => + layer.reasons.includes('BackdropFilter') + ).length + ).toBeGreaterThan(0); + + const contentArea = content.width * content.height; + const clipMasks = layers.filter( + (layer) => + layer.drawsContent && + !layer.backendNodeId && + layer.reasons.length === 0 && + layer.width * layer.height >= contentArea / 2 + ); + expect( + clipMasks.map( + (layer) => + `${layer.layerId}: ${layer.width}x${layer.height}` + ) + ).toEqual([]); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index ed0c64e77..ce5bdf13f 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -1017,6 +1017,10 @@ Avoid these: - making entire panes scroll when only the list should scroll - using dark translucent fills unchanged in light theme - solving cramped sidebars with smaller fonts instead of shorter labels +- rounding a scroller's corners (`border-radius` with `overflow`) when it holds + composited effects such as `backdrop-filter`: each effect gets a clip mask + the size of the scroller; paint the corner instead (see the content surface + corner in the [workspace shell contract](workspace-shell.md#content-surface-corner)) ## Definition Of Done For UI Changes diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 829e6e5f9..88045070a 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -107,6 +107,26 @@ The shell is intentionally split into four persistent regions: 4. Optional footer: 1. External playback session bar when a docked session is visible. +### Content surface corner + +`main.workspace-content` (the scroller) sits in `.workspace-content-frame`. +The frame carries the surface's 16px top-left radius and its shadow and never +clips; the scroller clips to a rectangle and is a stacking context; a 16px +corner piece on the frame paints the notch in the body colour. The radius used +to sit on the scroller, which made Blink clip every composited effect inside it +(the rail chevrons' and hero controls' `backdrop-filter`, running transform +animations, isolated groups) through a mask layer synthesized per effect: its +shader path for rounded clips needs four equal radii on macOS and a +translation-only transform between the clip and the effect, and the fallback +mask is the size of the whole clipped area, about 24 MB of tile memory at 2x +on a 16" display. A dozen of them took the dashboard past Chromium's 512 MB +tile budget (`tile memory limits exceeded`, blank tiles while scrolling). +`apps/electron-backend-e2e/src/dashboard-compositing.e2e.ts` guards it: no +drawing layer without an owner node and without a compositing reason (a +synthesized mask, unlike any content layer) may span half the content area, +and the backdrop-filtered controls must really be composited, so the check +cannot pass on a page with nothing to clip. + `WorkspaceShellComponent` binds only to `WorkspaceShellFacade`. The facade is kept as a thin template-facing API and delegates ownership to component-scoped services: diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html index 4506a2c18..3b61bd472 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html @@ -114,17 +114,22 @@ /> } - -
- -
+ +
+ +
+ +
+
@if (facade.showExternalPlaybackBar()) { diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.scss b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.scss index b95fa4dd2..1f7fa21a2 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.scss @@ -74,13 +74,55 @@ app-workspace-shell-context-sidebar { background: var(--app-content-bg, var(--mat-sys-surface)); } +// The content surface's rounded top-left corner is painted, not clipped. +// +// A `border-radius` on the scroller itself clips every composited layer inside +// it (backdrop filters, running transform animations, isolated groups) through +// a mask layer Blink synthesizes per composited effect: its shader path for +// rounded clips needs four equal radii on macOS and a translation-only +// transform between the clip and the effect, and the fallback mask is the size +// of the whole clipped area (about 24 MB of tile memory at 2x on a 16" display, +// one per rail chevron and hero control). Twelve of them put the dashboard over +// Chromium's 512 MB tile budget ("tile memory limits exceeded", blank tiles +// while scrolling). The frame keeps the radius for the shadow only and never +// clips; the scroller clips to a rectangle; the frame's corner piece covers the +// 16px notch in the body colour. The scroller is a stacking context, so nothing +// scrolled under the corner can paint over the piece. +.workspace-content-frame { + --workspace-content-radius: 16px; + + position: relative; + display: grid; + min-width: 0; + min-height: 0; + border-radius: var(--workspace-content-radius) 0 0 0; + box-shadow: -4px 8px 20px var(--app-content-shadow, rgba(0, 0, 0, 0.18)); + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + z-index: 1; + width: var(--workspace-content-radius); + height: var(--workspace-content-radius); + border-top-left-radius: var(--workspace-content-radius); + // The body colour outside the quarter circle: a transparent rounded + // box's spread shadow, clipped to the box. + box-shadow: 0 0 0 var(--workspace-content-radius) + var(--app-rail-bg, var(--mat-sys-surface-container-low)); + clip-path: inset(0); + pointer-events: none; + } +} + .workspace-content { + position: relative; + z-index: 0; min-width: 0; min-height: 0; overflow: auto; background: var(--app-content-bg, var(--mat-sys-surface)); - border-radius: 16px 0 0 0; - box-shadow: -4px 8px 20px var(--app-content-shadow, rgba(0, 0, 0, 0.18)); } @media (max-width: 780px) { @@ -134,9 +176,13 @@ app-workspace-shell-context-sidebar { background: rgba(0, 0, 0, 0.4); } - .workspace-content { + .workspace-content-frame { border-radius: 0; box-shadow: none; + + &::before { + display: none; + } } .workspace-now-playing {