From fb09b76730cde747c3df621b1f8592d1e87610a8 Mon Sep 17 00:00:00 2001 From: B-EtterDigital Date: Fri, 25 Sep 2026 19:24:04 +0700 Subject: [PATCH 1/3] fix(web): stop the sidebar flickering on Linux desktop (#7766) Chromium's GPU compositor on Linux loses scroll areas that carry the stacked fade masks: rows turn invisible while staying clickable, so the sidebar and the folder and thread pickers flicker. Removing the viewport mask stops it, as confirmed in #7766. Tag Linux desktop windows with `electron-linux`, next to the existing `electron-windows` class, and draw hard scroll edges there. Other platforms keep the fade. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/index.css | 8 ++++++++ apps/web/src/lib/utils.test.ts | 15 ++++++++++++++- apps/web/src/lib/utils.ts | 4 ++++ apps/web/src/lib/windowControlsOverlay.ts | 12 +++++++----- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 9caf42ca2d6c..0a0cebaf2c4a 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1609,6 +1609,14 @@ body { --desktop-window-right-resize-inset: 6px; } +/* Chromium's GPU compositor on Linux loses masked scroll viewports: rows go + invisible while staying clickable, so the sidebar flickers (#7766). Scroll + areas keep hard edges there until Chromium handles the stacked fade masks. */ +.electron-linux [data-slot="scroll-area-viewport"] { + -webkit-mask-image: none; + mask-image: none; +} + /* App-chrome grain. Baked into each surface's own background (behind content) rather than a fixed overlay on top: a full-viewport overlay forces the compositor to re-blend every frame any animation produces, diff --git a/apps/web/src/lib/utils.test.ts b/apps/web/src/lib/utils.test.ts index a930856438a3..d33eeca28e76 100644 --- a/apps/web/src/lib/utils.test.ts +++ b/apps/web/src/lib/utils.test.ts @@ -1,5 +1,5 @@ import { describe, assert, it } from "vite-plus/test"; -import { getLocalFileManagerName, isWindowsPlatform } from "./utils"; +import { getLocalFileManagerName, isLinuxPlatform, isWindowsPlatform } from "./utils"; describe("getLocalFileManagerName", () => { it.each([ @@ -22,3 +22,16 @@ describe("isWindowsPlatform", () => { assert.isFalse(isWindowsPlatform("darwin")); }); }); + +describe("isLinuxPlatform", () => { + it("matches Linux platform identifiers", () => { + assert.isTrue(isLinuxPlatform("Linux x86_64")); + assert.isTrue(isLinuxPlatform("Linux aarch64")); + assert.isTrue(isLinuxPlatform("linux")); + }); + + it("does not match macOS or Windows", () => { + assert.isFalse(isLinuxPlatform("MacIntel")); + assert.isFalse(isLinuxPlatform("Win32")); + }); +}); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 43d55682e12d..4ba8553ff7e4 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -20,6 +20,10 @@ export function isWindowsPlatform(platform: string): boolean { return /^win(dows)?/i.test(platform); } +export function isLinuxPlatform(platform: string): boolean { + return /^linux/i.test(platform); +} + export function normalizeSearchText(value: string): string { return value.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase().replace(/\s+/g, " ").trim(); } diff --git a/apps/web/src/lib/windowControlsOverlay.ts b/apps/web/src/lib/windowControlsOverlay.ts index 7c9e8e8553b3..1c6aef4576ec 100644 --- a/apps/web/src/lib/windowControlsOverlay.ts +++ b/apps/web/src/lib/windowControlsOverlay.ts @@ -1,8 +1,9 @@ -import { isWindowsPlatform } from "./utils"; +import { isLinuxPlatform, isWindowsPlatform } from "./utils"; const WCO_CLASS_NAME = "wco"; const ELECTRON_CLASS_NAME = "electron"; const ELECTRON_WINDOWS_CLASS_NAME = "electron-windows"; +const ELECTRON_LINUX_CLASS_NAME = "electron-linux"; interface WindowControlsOverlayLike { readonly visible: boolean; @@ -47,10 +48,11 @@ function getElectronPlatformClassNames( platform: string, ): | readonly [typeof ELECTRON_CLASS_NAME] - | readonly [typeof ELECTRON_CLASS_NAME, typeof ELECTRON_WINDOWS_CLASS_NAME] { - return isWindowsPlatform(platform) - ? [ELECTRON_CLASS_NAME, ELECTRON_WINDOWS_CLASS_NAME] - : [ELECTRON_CLASS_NAME]; + | readonly [typeof ELECTRON_CLASS_NAME, typeof ELECTRON_WINDOWS_CLASS_NAME] + | readonly [typeof ELECTRON_CLASS_NAME, typeof ELECTRON_LINUX_CLASS_NAME] { + if (isWindowsPlatform(platform)) return [ELECTRON_CLASS_NAME, ELECTRON_WINDOWS_CLASS_NAME]; + if (isLinuxPlatform(platform)) return [ELECTRON_CLASS_NAME, ELECTRON_LINUX_CLASS_NAME]; + return [ELECTRON_CLASS_NAME]; } export function syncDocumentElectronPlatformClasses(platform: string): () => void { From c5b15604f58592711dd1d3ad472aa1f0b3813673 Mon Sep 17 00:00:00 2001 From: B-EtterDigital Date: Fri, 25 Sep 2026 22:28:44 +0700 Subject: [PATCH 2/3] fix(web): preserve Linux scroll fades with a single gradient Replace the hard-edge workaround with the single vertical gradient tested by the founder over sustained use without further flicker. Apply it only to scrollFade viewports, with additive compositing. Other platforms keep their existing masks. Song: full format/lint and web types pass; seven platform helper tests pass. Live gradient behavior confirmed by founder; other Linux hardware remains unverified. --- apps/web/src/components/ui/scroll-area.tsx | 1 + apps/web/src/index.css | 25 ++++++++++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx index 7792b9606e41..33354c06f927 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -63,6 +63,7 @@ function ScrollArea({ "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", )} data-slot="scroll-area-viewport" + data-scroll-fade={scrollFade ? "" : undefined} > {children} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 0a0cebaf2c4a..65c95c30a7f4 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1609,12 +1609,25 @@ body { --desktop-window-right-resize-inset: 6px; } -/* Chromium's GPU compositor on Linux loses masked scroll viewports: rows go - invisible while staying clickable, so the sidebar flickers (#7766). Scroll - areas keep hard edges there until Chromium handles the stacked fade masks. */ -.electron-linux [data-slot="scroll-area-viewport"] { - -webkit-mask-image: none; - mask-image: none; +/* A single vertical mask preserves the fade without the stacked masks that + caused Linux desktop scroll areas to flicker (#7766). */ +.electron-linux [data-slot="scroll-area-viewport"][data-scroll-fade] { + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0, + black min(1.5rem, var(--scroll-area-overflow-y-start)), + black calc(100% - min(1.5rem, var(--scroll-area-overflow-y-end))), + transparent 100% + ); + mask-image: linear-gradient( + to bottom, + transparent 0, + black min(1.5rem, var(--scroll-area-overflow-y-start)), + black calc(100% - min(1.5rem, var(--scroll-area-overflow-y-end))), + transparent 100% + ); + -webkit-mask-composite: source-over; + mask-composite: add; } /* App-chrome grain. Baked into each surface's own background (behind From fb7a6ca0f1f20b05a978e3f1fb80f387a96e8c7b Mon Sep 17 00:00:00 2001 From: B-EtterDigital Date: Mon, 28 Sep 2026 01:15:13 +0700 Subject: [PATCH 3/3] fix(web): preserve custom and horizontal Linux scroll fades Respect --fade-size and use a single horizontal gradient for horizontal-only overflow. Both-axis viewports retain the vertical mask. Identical CSS passed formatter/lint and web types in the C0CODE composed native source on Song (370 suites,4524 tests). Browser CSS inspection covered vertical,horizontal,both-axis and custom sizes. This is not a new GPU stress measurement. --- apps/web/src/index.css | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 65c95c30a7f4..09bec05e9ef3 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1615,21 +1615,42 @@ body { -webkit-mask-image: linear-gradient( to bottom, transparent 0, - black min(1.5rem, var(--scroll-area-overflow-y-start)), - black calc(100% - min(1.5rem, var(--scroll-area-overflow-y-end))), + black min(var(--fade-size), var(--scroll-area-overflow-y-start)), + black calc(100% - min(var(--fade-size), var(--scroll-area-overflow-y-end))), transparent 100% ); mask-image: linear-gradient( to bottom, transparent 0, - black min(1.5rem, var(--scroll-area-overflow-y-start)), - black calc(100% - min(1.5rem, var(--scroll-area-overflow-y-end))), + black min(var(--fade-size), var(--scroll-area-overflow-y-start)), + black calc(100% - min(var(--fade-size), var(--scroll-area-overflow-y-end))), transparent 100% ); -webkit-mask-composite: source-over; mask-composite: add; } +/* Horizontal-only viewports keep their edge cues with the same single-mask path. */ +.electron-linux + [data-slot="scroll-area-viewport"][data-scroll-fade][data-has-overflow-x]:not( + [data-has-overflow-y] + ) { + -webkit-mask-image: linear-gradient( + to right, + transparent 0, + black min(var(--fade-size), var(--scroll-area-overflow-x-start)), + black calc(100% - min(var(--fade-size), var(--scroll-area-overflow-x-end))), + transparent 100% + ); + mask-image: linear-gradient( + to right, + transparent 0, + black min(var(--fade-size), var(--scroll-area-overflow-x-start)), + black calc(100% - min(var(--fade-size), var(--scroll-area-overflow-x-end))), + transparent 100% + ); +} + /* App-chrome grain. Baked into each surface's own background (behind content) rather than a fixed overlay on top: a full-viewport overlay forces the compositor to re-blend every frame any animation produces,