Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changes/fix-lens-scroll.md
Original file line number Diff line number Diff line change
@@ -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.
63 changes: 63 additions & 0 deletions e2e/glass.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(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');
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
},
"sizeBudget": {
"startupLogin": 238300,
"startupActivity": 341850,
"startupActivity": 342050,
"startupCss": 30500,
"locale": 38000,
"fontCss": 36000,
Expand Down
1 change: 1 addition & 0 deletions src/api/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/api/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
12 changes: 7 additions & 5 deletions src/features/settings/Settings.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';
Expand All @@ -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) {
Expand Down Expand Up @@ -292,9 +292,11 @@ export function Settings({query}: PageProps) {
help={ap.shownPalette === 'glass/glass' ? t('palette.glassNote') : undefined}
/>
</div>
{ap.palette.startsWith('glass/') && <WallpaperSettings />}
{/* Tinted draws no blur, so it has nothing to scale. */}
{ap.palette.startsWith('glass/') && ap.palette !== 'glass/tinted' && <BlurSetting value={ap.blur} onChange={ap.pickBlur} />}
{ap.palette.startsWith('glass/') && (
<Suspense fallback={null}>
<GlassSettings palette={ap.palette} blur={ap.blur} onBlurChange={ap.pickBlur} lensPause={ap.lensPause} onLensPauseChange={ap.pickLensPause} />
</Suspense>
)}
<div className="rp-field" data-setting="countryFlags">
<Switch isSelected={ap.countryFlags} onChange={ap.pickCountryFlags} aria-describedby={countryFlagsHelpId}>
{t('settings.countryFlags')}
Expand Down
40 changes: 39 additions & 1 deletion src/features/settings/WallpaperSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<WallpaperProblem, Key> = {
Expand Down Expand Up @@ -102,3 +102,41 @@ export function BlurSetting({value, onChange}: {value: number; onChange: (value:
</div>
);
}

function LensPauseSetting({value, onChange}: {value: boolean; onChange: (value: boolean) => void}) {
const t = useT();
const helpId = useId();
return (
<div className="rp-field" data-setting="lensPause">
<Switch isSelected={value} onChange={onChange} aria-describedby={helpId}>
{t('settings.lensPause')}
</Switch>
<span id={helpId} className="rp-label">
{t('settings.lensPauseHelp')}
</span>
</div>
);
}

export default function GlassSettings({
palette,
blur,
onBlurChange,
lensPause,
onLensPauseChange
}: {
palette: PaletteId;
blur: number;
onBlurChange: (value: number) => void;
lensPause: boolean;
onLensPauseChange: (value: boolean) => void;
}) {
return (
<>
<WallpaperSettings />
{/* Tinted draws no blur, so it has nothing to scale. */}
{palette !== 'glass/tinted' && <BlurSetting value={blur} onChange={onBlurChange} />}
{palette === 'glass/glass' && <LensPauseSetting value={lensPause} onChange={onLensPauseChange} />}
</>
);
}
2 changes: 2 additions & 0 deletions src/features/settings/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ type SettingsFieldId =
| 'mirrored'
| 'wallpaper'
| 'blur'
| 'lensPause'
| 'probeMethod'
| 'probeFamily'
| 'probeWarmth'
Expand Down Expand Up @@ -93,6 +94,7 @@ export const settingsFields: ReadonlyArray<SettingsField> = [
{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'},
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1593,6 +1593,8 @@
"settings.installed": "已安装",
"settings.invalidResponse": "响应缺少 API 版本信息:请检查后端网址。",
"settings.invalidUrl": "请输入 http(s)://主机[:端口][/前缀] 或 mock。网址不能包含账号密码、查询参数或片段。",
"settings.lensPause": "滚动时暂停折射",
"settings.lensPauseHelp": "滚动更流畅,页面停下后边缘恢复折射。",
"settings.logBuffer": "日志保留条数",
"settings.logLevel": "日志级别",
"settings.mirror": "镜像布局",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -1593,6 +1593,8 @@
"settings.installed": "已安裝",
"settings.invalidResponse": "回應缺少 API 版本資訊:請確認後端網址。",
"settings.invalidUrl": "請輸入 http(s)://主機[:連接埠][/前綴] 或 mock。網址不可含帳號密碼、查詢參數或片段。",
"settings.lensPause": "捲動時暫停折射",
"settings.lensPauseHelp": "捲動更流暢,頁面停下後邊緣恢復折射。",
"settings.logBuffer": "日誌保留筆數",
"settings.logLevel": "日誌等級",
"settings.mirror": "鏡像版面",
Expand Down
2 changes: 2 additions & 0 deletions src/shell/preferences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions src/shell/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export type Settings = {
wordmark: Wordmark;
mirrored: boolean;
countryFlags: boolean;
lensPause: boolean;
sparklines: boolean;
flagOverrides: FlagOverrides;
toastPlacement: ToastPlacement;
Expand All @@ -52,6 +53,7 @@ export function writeSetting(
| 'wordmark'
| 'mirror'
| 'countryFlags'
| 'lensPause'
| 'sparklines'
| 'flagOverrides'
| 'toastPlacement'
Expand Down Expand Up @@ -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'
Expand All @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions src/shell/useAppearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ToastPlacement>(stored.toastPlacement);
const [startPage, setStartPage] = useState<RoutePath>(stored.startPage);
Expand All @@ -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);
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -176,6 +186,8 @@ export function useAppearance(stored: Settings) {
pickFlag,
countryFlags,
pickCountryFlags,
lensPause,
pickLensPause,
sparklines,
pickSparklines,
toastPlacement,
Expand Down Expand Up @@ -205,6 +217,8 @@ export function useAppearance(stored: Settings) {
pickFlag,
countryFlags,
pickCountryFlags,
lensPause,
pickLensPause,
sparklines,
pickSparklines,
toastPlacement,
Expand Down
23 changes: 23 additions & 0 deletions src/ui/lens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
);
}
5 changes: 5 additions & 0 deletions src/ui/styles/palettes/glass.css
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading