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 9caf42ca2d6c..09bec05e9ef3 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1609,6 +1609,48 @@ body { --desktop-window-right-resize-inset: 6px; } +/* 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(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(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, 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 {