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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-10-09 19:45:21 +02:00
1 parent f6ad98255e
commit 4c6540679d
6 files changed
+255 -14

No files matched your search

@@ -114,17 +114,22 @@
/>
}
<!-- tabindex="-1": programmatic focus target for the drawer-close
fallback when the toggle is gone (navigated to a route without
a context panel); not in the Tab order. -->
<main
#workspaceContent
class="workspace-content"
tabindex="-1"
[attr.inert]="contextDrawer.isOpen() ? '' : null"
>
<router-outlet />
</main>
<!-- The frame carries the content surface's rounded corner and
shadow without clipping; the scroller inside clips to a
rectangle (see the stylesheet for why). -->
<div class="workspace-content-frame">
<!-- tabindex="-1": programmatic focus target for the drawer-close
fallback when the toggle is gone (navigated to a route without
a context panel); not in the Tab order. -->
<main
#workspaceContent
class="workspace-content"
tabindex="-1"
[attr.inert]="contextDrawer.isOpen() ? '' : null"
>
<router-outlet />
</main>
</div>
</div>
@if (facade.showExternalPlaybackBar()) {
@@ -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 {