diff --git a/changes/fix-lens-scroll.md b/changes/fix-lens-scroll.md new file mode 100644 index 000000000..3349ac3c1 --- /dev/null +++ b/changes/fix-lens-scroll.md @@ -0,0 +1,4 @@ +Fixed + +- Liquid Glass scrolls smoothly: the lens pauses while the page scrolls and returns 150 ms after it stops, keeping the blur and colour. +- Settings > Appearance can turn the pause off for Liquid Glass's full refraction while scrolling. diff --git a/e2e/glass.spec.ts b/e2e/glass.spec.ts index c93954064..7c38f702f 100644 --- a/e2e/glass.spec.ts +++ b/e2e/glass.spec.ts @@ -183,6 +183,69 @@ test.describe('glass lens', () => { await expect(page.locator('.rp-popover').first()).toBeVisible(); expect((await surface(page, '.rp-popover')).filter).toContain('url("#doona-lens-sm")'); }); + // Scrolling pauses the refraction, which Chromium redraws on every frame, and keeps the blur and colour. + test('pauses the lens while the page scrolls', async ({page}) => { + await page.goto('/#/overview'); + await expect(page.locator('html')).toHaveAttribute('data-lens', ''); + const card = `.rp-content ${pageCard}`; + const lensed = (await surface(page, card, '::before')).filter; + expect(lensed).toContain('url("#doona-lens")'); + // Read in the scroll event's own task, before the 150 ms resume can run. + const paused = await page + .locator(card) + .first() + .evaluate( + element => + new Promise(resolve => { + addEventListener('scroll', () => resolve(getComputedStyle(element, '::before').backdropFilter), {once: true}); + scrollBy(0, 200); + }) + ); + expect(paused).toBe(lensed.replace(/url\([^)]*\)\s*/g, '')); + await expect(page.locator('html')).not.toHaveAttribute('data-lens-paused'); + expect((await surface(page, card, '::before')).filter).toBe(lensed); + }); + test('keeps refraction while scrolling when the stored pause is off', async ({page}) => { + await page.addInitScript(() => localStorage.setItem('doona-lens-pause', 'off')); + await page.goto('/#/overview'); + await expect(page.locator('html')).toHaveAttribute('data-lens', ''); + const card = `.rp-content ${pageCard}`; + const lensed = (await surface(page, card, '::before')).filter; + expect(lensed).toContain('url("#doona-lens")'); + const scrolling = await page + .locator(card) + .first() + .evaluate( + element => + new Promise<{paused: boolean; filter: string}>(resolve => { + addEventListener( + 'scroll', + () => resolve({paused: document.documentElement.hasAttribute('data-lens-paused'), filter: getComputedStyle(element, '::before').backdropFilter}), + {once: true} + ); + scrollBy(0, 200); + }) + ); + expect(scrolling.paused).toBe(false); + expect(scrolling.filter).toBe(lensed); + await expect(page.locator('html')).toHaveAttribute('data-lens-steady', ''); + await expect(page.locator('html')).not.toHaveAttribute('data-lens-paused'); + }); + test('turns the scroll pause off and on in Settings', async ({page}) => { + await page.goto('/#/settings?tab=appearance'); + const root = page.locator('html'); + await expect(root).toHaveAttribute('data-lens', ''); + const toggle = page.getByRole('switch', {name: 'Pause refraction while scrolling', exact: true}); + await expect(toggle).toBeChecked(); + await expect(root).not.toHaveAttribute('data-lens-steady'); + await toggle.press('Space'); + await expect(root).toHaveAttribute('data-lens-steady', ''); + await page.reload(); + await expect(toggle).not.toBeChecked(); + await expect(root).toHaveAttribute('data-lens-steady', ''); + await toggle.press('Space'); + await expect(root).not.toHaveAttribute('data-lens-steady'); + }); // At 32px the lens shows only as a bright rim, so controls on the wallpaper keep the cards' fill with a plain blur. test('keeps controls on the wallpaper to a plain blur', async ({page}) => { await page.goto('/#/logs'); diff --git a/package.json b/package.json index 8e5b09178..cc2485450 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ }, "sizeBudget": { "startupLogin": 238300, - "startupActivity": 341850, + "startupActivity": 342050, "startupCss": 30500, "locale": 38000, "fontCss": 36000, diff --git a/src/api/storage.test.ts b/src/api/storage.test.ts index 1e2ccd45c..5d94db263 100644 --- a/src/api/storage.test.ts +++ b/src/api/storage.test.ts @@ -13,6 +13,7 @@ it('keeps the keys browsers already hold', () => { 'doona-wordmark', 'doona-mirror', 'doona-country-flags', + 'doona-lens-pause', 'doona-sparklines', 'doona-flag-overrides', 'doona-toast-placement', diff --git a/src/api/storage.ts b/src/api/storage.ts index 4247a1037..3dba965fd 100644 --- a/src/api/storage.ts +++ b/src/api/storage.ts @@ -11,6 +11,7 @@ export const storageKeys = { wordmark: 'doona-wordmark', mirror: 'doona-mirror', countryFlags: 'doona-country-flags', + lensPause: 'doona-lens-pause', sparklines: 'doona-sparklines', flagOverrides: 'doona-flag-overrides', toastPlacement: 'doona-toast-placement', diff --git a/src/features/settings/Settings.tsx b/src/features/settings/Settings.tsx index 77038b43c..64571163f 100644 --- a/src/features/settings/Settings.tsx +++ b/src/features/settings/Settings.tsx @@ -1,4 +1,4 @@ -import {Fragment, useId, type ReactNode} from 'react'; +import {Fragment, lazy, Suspense, useId, type ReactNode} from 'react'; import {LANGS, useT, type Lang} from '../../i18n'; import { ActionHelp, @@ -19,7 +19,6 @@ import { } from '../../ui/ui'; import {SelectBoxGroup} from '../../ui/SelectBoxGroup'; import {preloadGlass, swatchSections} from '../../shell/swatches'; -import {BlurSetting, WallpaperSettings} from './WallpaperSettings'; import type {DateFormat, PaletteId, Scheme, TimeFormat, ToastPlacement, Wordmark} from '../../shell/preferences'; import {useSettingsPage} from './useSettingsPage'; import {useSignOut} from './useSignOut'; @@ -37,6 +36,7 @@ import {cardTab, settingsCard, settingsCards, settingsTab, settingsTabs, type Se const glassIntent = (event: React.SyntheticEvent) => { if (event.target instanceof Element && event.target.closest('[data-key^="glass/"]')) preloadGlass(); }; +const GlassSettings = lazy(() => import('./WallpaperSettings')); const cards = {backend: settingsCard('backend'), appearance: settingsCard('appearance'), about: settingsCard('about')}; export function Settings({query}: PageProps) { @@ -292,9 +292,11 @@ export function Settings({query}: PageProps) { help={ap.shownPalette === 'glass/glass' ? t('palette.glassNote') : undefined} /> - {ap.palette.startsWith('glass/') && } - {/* Tinted draws no blur, so it has nothing to scale. */} - {ap.palette.startsWith('glass/') && ap.palette !== 'glass/tinted' && } + {ap.palette.startsWith('glass/') && ( + + + + )}
{t('settings.countryFlags')} diff --git a/src/features/settings/WallpaperSettings.tsx b/src/features/settings/WallpaperSettings.tsx index 68e83d2e0..c17d46402 100644 --- a/src/features/settings/WallpaperSettings.tsx +++ b/src/features/settings/WallpaperSettings.tsx @@ -5,7 +5,7 @@ import {FileButton} from '../../ui/FileButton'; import {Slider} from '../../ui/Slider'; import {WallpaperThumb} from '../../ui/WallpaperThumb'; import {DEFAULT_DIM, MAX_DIM, setWallpaper, useWallpaper, wallpaperState} from '../../shell/wallpaper'; -import {MAX_BLUR} from '../../shell/preferences'; +import {MAX_BLUR, type PaletteId} from '../../shell/preferences'; import {prepareWallpaper, WallpaperError, type WallpaperProblem} from './wallpaperImage'; const problems: Record = { @@ -102,3 +102,41 @@ export function BlurSetting({value, onChange}: {value: number; onChange: (value:
); } + +function LensPauseSetting({value, onChange}: {value: boolean; onChange: (value: boolean) => void}) { + const t = useT(); + const helpId = useId(); + return ( +
+ + {t('settings.lensPause')} + + + {t('settings.lensPauseHelp')} + +
+ ); +} + +export default function GlassSettings({ + palette, + blur, + onBlurChange, + lensPause, + onLensPauseChange +}: { + palette: PaletteId; + blur: number; + onBlurChange: (value: number) => void; + lensPause: boolean; + onLensPauseChange: (value: boolean) => void; +}) { + return ( + <> + + {/* Tinted draws no blur, so it has nothing to scale. */} + {palette !== 'glass/tinted' && } + {palette === 'glass/glass' && } + + ); +} diff --git a/src/features/settings/nav.ts b/src/features/settings/nav.ts index e62e2b6cf..a7989c2f5 100644 --- a/src/features/settings/nav.ts +++ b/src/features/settings/nav.ts @@ -59,6 +59,7 @@ type SettingsFieldId = | 'mirrored' | 'wallpaper' | 'blur' + | 'lensPause' | 'probeMethod' | 'probeFamily' | 'probeWarmth' @@ -93,6 +94,7 @@ export const settingsFields: ReadonlyArray = [ {id: 'mirrored', card: 'appearance', labelKey: 'settings.mirror', aliases: ['rtl']}, {id: 'wallpaper', card: 'appearance', labelKey: 'settings.wallpaper', aliases: ['background', 'image']}, {id: 'blur', card: 'appearance', labelKey: 'settings.blur'}, + {id: 'lensPause', card: 'appearance', labelKey: 'settings.lensPause'}, {id: 'probeMethod', card: 'probes', labelKey: 'settings.probeMethod'}, {id: 'probeFamily', card: 'probes', labelKey: 'settings.probeFamily', aliases: ['ipv4', 'ipv6']}, {id: 'probeWarmth', card: 'probes', labelKey: 'settings.probeWarmth'}, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 60f0d29bb..b03f3abd6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1794,6 +1794,8 @@ "settings.installed": "Installed", "settings.invalidResponse": "The response has no API version information. Check the backend URL.", "settings.invalidUrl": "Enter http(s)://host[:port][/prefix] without credentials, a query, or a fragment, or enter mock.", + "settings.lensPause": "Pause refraction while scrolling", + "settings.lensPauseHelp": "Keeps scrolling smooth. Edges bend again once the page stops.", "settings.logBuffer": "Log records kept", "settings.logLevel": "Log level", "settings.mirror": "Mirrored layout", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 22686495c..65a486475 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1593,6 +1593,8 @@ "settings.installed": "已安装", "settings.invalidResponse": "响应缺少 API 版本信息:请检查后端网址。", "settings.invalidUrl": "请输入 http(s)://主机[:端口][/前缀] 或 mock。网址不能包含账号密码、查询参数或片段。", + "settings.lensPause": "滚动时暂停折射", + "settings.lensPauseHelp": "滚动更流畅,页面停下后边缘恢复折射。", "settings.logBuffer": "日志保留条数", "settings.logLevel": "日志级别", "settings.mirror": "镜像布局", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 9f1bc2fe8..28df2c98d 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1593,6 +1593,8 @@ "settings.installed": "已安裝", "settings.invalidResponse": "回應缺少 API 版本資訊:請確認後端網址。", "settings.invalidUrl": "請輸入 http(s)://主機[:連接埠][/前綴] 或 mock。網址不可含帳號密碼、查詢參數或片段。", + "settings.lensPause": "捲動時暫停折射", + "settings.lensPauseHelp": "捲動更流暢,頁面停下後邊緣恢復折射。", "settings.logBuffer": "日誌保留筆數", "settings.logLevel": "日誌等級", "settings.mirror": "鏡像版面", diff --git a/src/shell/preferences.test.ts b/src/shell/preferences.test.ts index 739033bb5..739a14a6d 100644 --- a/src/shell/preferences.test.ts +++ b/src/shell/preferences.test.ts @@ -189,11 +189,13 @@ it.each([null, '', 'last', 'login', 'unknown', 'rules?tab=dns'])('defaults an in it.each([ ['countryFlags', 'doona-country-flags'], + ['lensPause', 'doona-lens-pause'], ['sparklines', 'doona-sparklines'] ] as const)('defaults %s on and preserves an explicit off preference', (key, stored) => { expect(readSettings(storageFrom())[key]).toBe(true); expect(readSettings(storageFrom([[stored, 'on']]))[key]).toBe(true); expect(readSettings(storageFrom([[stored, 'true']]))[key]).toBe(true); + expect(readSettings(storageFrom([[stored, 'off']]))[key]).toBe(false); const storage = storageFrom(); writeSetting(key, 'off', storage); expect(readSettings(storage)[key]).toBe(false); diff --git a/src/shell/preferences.ts b/src/shell/preferences.ts index f6b9ec801..fc6fef195 100644 --- a/src/shell/preferences.ts +++ b/src/shell/preferences.ts @@ -34,6 +34,7 @@ export type Settings = { wordmark: Wordmark; mirrored: boolean; countryFlags: boolean; + lensPause: boolean; sparklines: boolean; flagOverrides: FlagOverrides; toastPlacement: ToastPlacement; @@ -52,6 +53,7 @@ export function writeSetting( | 'wordmark' | 'mirror' | 'countryFlags' + | 'lensPause' | 'sparklines' | 'flagOverrides' | 'toastPlacement' @@ -92,6 +94,7 @@ export function readSettings(storage?: StoragePort): Settings { mirrored: read(storageKeys.mirror) === 'on', startPage: startPage !== null && isRoutePath(startPage) ? startPage : defaultRoute, countryFlags: read(storageKeys.countryFlags) !== 'off', + lensPause: read(storageKeys.lensPause) !== 'off', sparklines: read(storageKeys.sparklines) !== 'off', flagOverrides: readFlagOverrides(storage), toastPlacement: TOAST_PLACEMENTS.find(item => item === placement) ?? 'bottom' @@ -117,9 +120,11 @@ type Appearance = { pickWordmark: (value: Wordmark) => void; mirrored: boolean; countryFlags: boolean; + lensPause: boolean; flagOverrides: FlagOverrides; pickMirrored: (value: boolean) => void; pickCountryFlags: (value: boolean) => void; + pickLensPause: (value: boolean) => void; sparklines: boolean; pickSparklines: (value: boolean) => void; pickFlag: (name: string, value: string) => void; diff --git a/src/shell/useAppearance.ts b/src/shell/useAppearance.ts index 2536d3526..5a3d74e59 100644 --- a/src/shell/useAppearance.ts +++ b/src/shell/useAppearance.ts @@ -70,6 +70,7 @@ export function useAppearance(stored: Settings) { const [mirrored, setMirrored] = useState(stored.mirrored); const [flagOverrides, setFlagOverrides] = useState(stored.flagOverrides); const [countryFlags, setCountryFlags] = useState(stored.countryFlags); + const [lensPause, setLensPause] = useState(stored.lensPause); const [sparklines, setSparklines] = useState(stored.sparklines); const [toastPlacement, setToastPlacement] = useState(stored.toastPlacement); const [startPage, setStartPage] = useState(stored.startPage); @@ -78,6 +79,11 @@ export function useAppearance(stored: Settings) { const sysDark = useMediaQuery('(prefers-color-scheme: dark)'); const dark = scheme === 'dark' || (scheme === 'system' && sysDark); useLayoutEffect(() => applyAppearance(dark, palette, wordmark, blur), [dark, palette, wordmark, blur]); + useLayoutEffect(() => { + const root = document.documentElement; + root.toggleAttribute('data-lens-steady', !lensPause); + if (!lensPause) root.removeAttribute('data-lens-paused'); + }, [lensPause]); const pickScheme = useCallback((next: Scheme) => { withCrossfade(() => setScheme(next)); writeSetting('scheme', next); @@ -125,6 +131,10 @@ export function useAppearance(stored: Settings) { setCountryFlags(next); writeSetting('countryFlags', next ? 'on' : 'off'); }, []); + const pickLensPause = useCallback((next: boolean) => { + setLensPause(next); + writeSetting('lensPause', next ? 'on' : 'off'); + }, []); const pickSparklines = useCallback((next: boolean) => { setSparklines(next); writeSetting('sparklines', next ? 'on' : 'off'); @@ -176,6 +186,8 @@ export function useAppearance(stored: Settings) { pickFlag, countryFlags, pickCountryFlags, + lensPause, + pickLensPause, sparklines, pickSparklines, toastPlacement, @@ -205,6 +217,8 @@ export function useAppearance(stored: Settings) { pickFlag, countryFlags, pickCountryFlags, + lensPause, + pickLensPause, sparklines, pickSparklines, toastPlacement, diff --git a/src/ui/lens.ts b/src/ui/lens.ts index 8ec9f3329..ae74c32da 100644 --- a/src/ui/lens.ts +++ b/src/ui/lens.ts @@ -135,6 +135,29 @@ export function installLens(): boolean { }) .join(''); document.body.append(svg); + pauseWhileScrolling(); installed = true; return true; } + +// Chromium redraws every lens on the CPU each frame the page moves under it, about 11 ms a frame on a desktop page of +// cards, so the root carries data-lens-paused while the page scrolls and for 150 ms after, and the stylesheet drops the +// refraction meanwhile. +function pauseWhileScrolling() { + const root = document.documentElement; + let timer: number | undefined; + const resume = () => { + timer = undefined; + root.removeAttribute('data-lens-paused'); + }; + addEventListener( + 'scroll', + () => { + if (root.hasAttribute('data-lens-steady')) return; + if (!root.hasAttribute('data-lens-paused')) root.setAttribute('data-lens-paused', ''); + if (timer !== undefined) clearTimeout(timer); + timer = window.setTimeout(resume, 150); + }, + {passive: true} + ); +} diff --git a/src/ui/styles/palettes/glass.css b/src/ui/styles/palettes/glass.css index 145aad1a0..86107b82f 100644 --- a/src/ui/styles/palettes/glass.css +++ b/src/ui/styles/palettes/glass.css @@ -285,6 +285,11 @@ :root[data-flavour='glass'][data-scheme='dark'][data-lens] { --rp-float-fill: rgba(36, 36, 40, 0.62); } +/* While the page scrolls, src/ui/lens.ts pauses the lens: the same blur and colour without the refraction. */ +:root[data-flavour='glass'][data-lens][data-lens-paused] { + --rp-panel-filter: var(--rp-flow-table-filter); + --rp-lens-sm: blur(calc(6px * var(--rp-blur-scale))) saturate(1.8) brightness(1.04); +} /* Reduce Transparency, Increase Contrast and forced colours turn every material into the solid recipe with a clear edge. The selector matches the flavour blocks above in specificity and follows them. */ @media (prefers-reduced-transparency: reduce), (prefers-contrast: more), (forced-colors: active) {