diff --git a/desktop/scripts/check-px-text.mjs b/desktop/scripts/check-px-text.mjs index 8182d18cd..b26417a4d 100644 --- a/desktop/scripts/check-px-text.mjs +++ b/desktop/scripts/check-px-text.mjs @@ -23,7 +23,7 @@ const rules = [ // glyph is a fixed display size sized to its avatar box (not readable message // text), so it stays as the lone documented `text-[6rem]` literal. const overrides = new Set([ - "src/features/settings/ui/ProfileSettingsCard.tsx:584", + "src/features/settings/ui/ProfileSettingsCard.tsx:619", "src/features/onboarding/ui/AvatarStep.tsx:89", ]); diff --git a/desktop/src/app/useWebviewZoomShortcuts.ts b/desktop/src/app/useWebviewZoomShortcuts.ts index cda6c0f2e..01e910e66 100644 --- a/desktop/src/app/useWebviewZoomShortcuts.ts +++ b/desktop/src/app/useWebviewZoomShortcuts.ts @@ -3,7 +3,8 @@ import { getCurrentWebview } from "@tauri-apps/api/webview"; import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; -const DEFAULT_ZOOM_FACTOR = 1; +const DEFAULT_ZOOM_FACTOR = 1.1; +const DEFAULT_WEBVIEW_ZOOM_FACTOR = 1; const MIN_ZOOM_FACTOR = 0.75; const MAX_ZOOM_FACTOR = 1.5; const ZOOM_STEP = 0.1; @@ -61,28 +62,49 @@ function getNextZoomFactor(action: ZoomAction, zoomFactor: number) { return Math.max(roundZoomFactor(zoomFactor - ZOOM_STEP), MIN_ZOOM_FACTOR); } -function readStoredZoomFactor() { +type StoredZoomFactor = { + zoomFactor: number; + hasStoredPreference: boolean; +}; + +type ApplyTextScaleOptions = { + persistPreference?: boolean; +}; + +function readStoredZoomFactor(): StoredZoomFactor { const raw = window.localStorage.getItem(TEXT_SCALE_STORAGE_KEY); if (!raw) { - return DEFAULT_ZOOM_FACTOR; + return { zoomFactor: DEFAULT_ZOOM_FACTOR, hasStoredPreference: false }; } const parsed = Number.parseFloat(raw); if (!Number.isFinite(parsed)) { - return DEFAULT_ZOOM_FACTOR; + return { zoomFactor: DEFAULT_ZOOM_FACTOR, hasStoredPreference: false }; } - return Math.min(Math.max(parsed, MIN_ZOOM_FACTOR), MAX_ZOOM_FACTOR); + return { + zoomFactor: Math.min(Math.max(parsed, MIN_ZOOM_FACTOR), MAX_ZOOM_FACTOR), + hasStoredPreference: true, + }; } -function applyTextScale(zoomFactor: number) { - if (zoomFactor === DEFAULT_ZOOM_FACTOR) { +function applyTextScale( + zoomFactor: number, + { + persistPreference = zoomFactor !== DEFAULT_ZOOM_FACTOR, + }: ApplyTextScaleOptions = {}, +) { + if (zoomFactor === DEFAULT_WEBVIEW_ZOOM_FACTOR) { document.documentElement.style.fontSize = ""; + } else { + document.documentElement.style.fontSize = `${BASE_FONT_SIZE_PX * zoomFactor}px`; + } + + if (!persistPreference) { window.localStorage.removeItem(TEXT_SCALE_STORAGE_KEY); return; } - document.documentElement.style.fontSize = `${BASE_FONT_SIZE_PX * zoomFactor}px`; window.localStorage.setItem(TEXT_SCALE_STORAGE_KEY, String(zoomFactor)); } @@ -91,13 +113,16 @@ export function useWebviewZoomShortcuts() { React.useLayoutEffect(() => { const webview = getCurrentWebview(); - const storedZoomFactor = readStoredZoomFactor(); + const { zoomFactor: storedZoomFactor, hasStoredPreference } = + readStoredZoomFactor(); zoomFactorRef.current = storedZoomFactor; - applyTextScale(storedZoomFactor); + applyTextScale(storedZoomFactor, { + persistPreference: hasStoredPreference, + }); // Keep the webview coordinate system stable; only text should scale. - void webview.setZoom(DEFAULT_ZOOM_FACTOR).catch((error) => { + void webview.setZoom(DEFAULT_WEBVIEW_ZOOM_FACTOR).catch((error) => { console.error("Failed to reset webview zoom", error); }); diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs b/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs index dc33da4f8..50e7c93ac 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs +++ b/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs @@ -90,13 +90,13 @@ test("estimateRowHeight: bare URL line adds a preview card", () => { assert.ok(withUrl > withoutUrl + 50, `url ${withUrl} vs ${withoutUrl}`); }); -test("timelineRowReserveStyle: message item yields containIntrinsicSize", () => { +test("timelineRowReserveStyle: message item yields rem containIntrinsicSize", () => { const style = timelineRowReserveStyle({ kind: "message", key: "k", entry: { message: msg({ body: "hi" }), summary: null }, }); - assert.match(String(style.containIntrinsicSize), /^auto \d+px$/); + assert.match(String(style.containIntrinsicSize), /^auto \d+(?:\.\d+)?rem$/); }); test("timelineRowReserveStyle: divider is short fixed height", () => { @@ -105,5 +105,5 @@ test("timelineRowReserveStyle: divider is short fixed height", () => { key: "k", headingTimestamp: 0, }); - assert.equal(style.containIntrinsicSize, "auto 32px"); + assert.equal(style.containIntrinsicSize, "auto 2rem"); }); diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.ts b/desktop/src/features/messages/lib/rowHeightEstimate.ts index c979a8ad1..199be4060 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.ts +++ b/desktop/src/features/messages/lib/rowHeightEstimate.ts @@ -175,5 +175,5 @@ export function timelineRowReserveStyle( : item.kind === "system" ? estimateRowHeight(item.entry.message) : DIVIDER_HEIGHT; - return { containIntrinsicSize: `auto ${height}px` }; + return { containIntrinsicSize: `auto ${height / 16}rem` }; } diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 2590078e8..6a2df1dd3 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -295,8 +295,18 @@ function SystemRow({ onToggleReaction?: TimelineMessageListProps["onToggleReaction"]; profiles?: UserProfileLookup; }) { + // `data-message-id` is load-bearing: useAnchoredScroll's computeAnchor walks + // `[data-message-id]` rows to decide whether the user is anchored mid-history + // or at the bottom. A young channel can be scrollable while its only rows are + // system events (channel_created / member_joined) — without an id here the + // walk finds nothing, falls through to "at-bottom", and a user who scrolled + // up never gets the scroll-to-latest pill and gets yanked to the bottom by + // the next arrival. return ( -
+
(null); + // Count of scroll events produced by our own anchor-holding writes (prepend + // restore, resize re-pin) that `onScroll` must swallow WITHOUT re-capturing + // the anchor. The write is re-pinning the saved anchor row; capturing the + // post-write position would bake in drift the next write is about to correct + // (rows prepended above the viewport realize their `content-visibility` + // estimates a few frames after the restore, shrinking the content above the + // reading row — the browser fires no scroll event for that shrink and native + // scroll anchoring does not compensate above the viewport). + const programmaticScrollEventsRef = React.useRef(0); // Reset everything when the channel changes — the layout effect that runs // immediately after this reset is responsible for either jumping to bottom @@ -197,6 +219,11 @@ export function useAnchoredScroll({ handledTargetIdRef.current = null; forceBottomOnNextAppendRef.current = false; settlingRef.current = false; + if (settleRafIdRef.current !== null) { + cancelAnimationFrame(settleRafIdRef.current); + settleRafIdRef.current = null; + } + programmaticScrollEventsRef.current = 0; if (highlightTimeoutRef.current !== null) { window.clearTimeout(highlightTimeoutRef.current); highlightTimeoutRef.current = null; @@ -205,8 +232,99 @@ export function useAnchoredScroll({ cancelAnimationFrame(mountPinRafIdRef.current); mountPinRafIdRef.current = null; } + // Unmount cleanup: stop the settle loop so a dead component's rAF chain + // doesn't keep scheduling against a detached container. + return () => { + settlingRef.current = false; + if (settleRafIdRef.current !== null) { + cancelAnimationFrame(settleRafIdRef.current); + settleRafIdRef.current = null; + } + }; }, [channelId]); + // Arm the bottom-settle guard and start (or restart) the settle loop. A + // programmatic bottom jump is not atomic: a smooth jump (scroll-to-latest + // pill) computes its animation target from the scroll metrics at call time, + // and `content-visibility` rows realizing their true heights as the + // animation passes them make that target stale — the engine settles ABOVE + // the real floor and then emits no further scroll events, so an event-driven + // guard can never issue the final corrective write (the strand distance + // scales with the root font size; the 1.1 default text zoom pushed it past + // the e2e floor budget). The loop instead samples once per frame: while the + // position is still moving it stays hands-off (fighting a live animation is + // jerky and the engine re-targets anyway); once the position holds still + // short of the true floor, the animation is dead and it issues an instant + // corrective pin. Disarms at the true floor or at the hard time cap. + const armBottomSettle = React.useCallback(() => { + settlingRef.current = true; + if (settleRafIdRef.current !== null) { + cancelAnimationFrame(settleRafIdRef.current); + settleRafIdRef.current = null; + } + const container = scrollContainerRef.current; + const startedAt = performance.now(); + let lastTop = Number.NaN; + let stableFrames = 0; + // Real user input during the settle window means the user has taken over — + // stop chasing the floor immediately instead of yanking them back down. + // Only trusted input events disarm; the stale animation's scroll events + // don't fire these. + const abortOnUserInput = () => { + settlingRef.current = false; + }; + const inputEvents = ["wheel", "touchstart", "keydown"] as const; + for (const type of inputEvents) { + container?.addEventListener(type, abortOnUserInput, { passive: true }); + } + const cleanup = () => { + for (const type of inputEvents) { + container?.removeEventListener(type, abortOnUserInput); + } + }; + const tick = () => { + settleRafIdRef.current = null; + const container = scrollContainerRef.current; + if (!container || !settlingRef.current) { + settlingRef.current = false; + cleanup(); + return; + } + if (performance.now() - startedAt >= SETTLE_MAX_MS) { + settlingRef.current = false; + cleanup(); + return; + } + const top = container.scrollTop; + if (top === lastTop) { + stableFrames += 1; + } else { + stableFrames = 0; + lastTop = top; + } + if (stableFrames >= SETTLE_STABLE_FRAMES) { + // Stability alone isn't enough to disarm — the floor itself must be + // confirmed while the position is holding still. A smooth animation + // can touch the floor transiently and still walk backward on later + // ticks, so disarming on a mid-flight floor reading would hand those + // ticks back to `computeAnchor` as a phantom scroll-up. + if (isAtTrueBottom(container)) { + // Physically at the floor and no longer moving: settled. Later + // growth (image decode, late rows) is the at-bottom ResizeObserver's + // job, not the settle loop's. + settlingRef.current = false; + cleanup(); + return; + } + settleProgrammaticBottomPin(container); + stableFrames = 0; + lastTop = container.scrollTop; + } + settleRafIdRef.current = requestAnimationFrame(tick); + }; + settleRafIdRef.current = requestAnimationFrame(tick); + }, [scrollContainerRef]); + const scrollToBottomImperative = React.useCallback( (behavior: ScrollBehavior = "auto") => { const container = scrollContainerRef.current; @@ -218,13 +336,13 @@ export function useAnchoredScroll({ // gap as a deliberate scroll-up and latch a mid-history message anchor, // which strands future appends above the floor. Arm the settle guard for // every imperative bottom jump so `onScroll` holds the at-bottom anchor - // until it can snap to the true floor. - settlingRef.current = true; + // until the settle loop confirms the true floor. + armBottomSettle(); container.scrollTo({ top: container.scrollHeight, behavior }); setIsAtBottom(true); setNewMessageCount(0); }, - [scrollContainerRef], + [armBottomSettle, scrollContainerRef], ); // Arm a one-shot: the next append snaps to bottom regardless of where the @@ -304,13 +422,23 @@ export function useAnchoredScroll({ // events while `scrollTop` holds at the old floor — opening a transient gap // above the true bottom. `computeAnchor` would read that as a deliberate // scroll-up and latch a message anchor, freezing the view short of bottom. - // While settling, keep the anchor at-bottom and chase the physical floor. + // While settling, hold the at-bottom anchor and swallow the event — the + // rAF settle loop armed by `armBottomSettle` owns all corrective writes + // (an event-driven guard can't finish the job: a stale smooth-scroll + // animation strands the view above the floor and then emits no further + // scroll events, so the final corrective pin must fire outside the event + // stream). if (settlingRef.current) { - if (settleProgrammaticBottomPin(container)) { - settlingRef.current = false; - } else { - return; - } + return; + } + // A scroll event echoing one of our own anchor-holding writes: swallow it + // without re-capturing, so the saved anchor offset keeps describing where + // the reading row BELONGS. Re-capturing here would adopt drift the next + // re-pin is about to correct. User scrolls are unaffected — only writes we + // issued ourselves increment this counter. + if (programmaticScrollEventsRef.current > 0) { + programmaticScrollEventsRef.current -= 1; + return; } anchorRef.current = computeAnchor(container); const atBottom = anchorRef.current.kind === "at-bottom"; @@ -392,7 +520,7 @@ export function useAnchoredScroll({ if (newLatestArrived && forceBottomOnNextAppendRef.current) { forceBottomOnNextAppendRef.current = false; anchorRef.current = { kind: "at-bottom" }; - settlingRef.current = true; + armBottomSettle(); container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); setIsAtBottom(true); setNewMessageCount(0); @@ -425,7 +553,13 @@ export function useAnchoredScroll({ container.getBoundingClientRect().top; const drift = currentTopOffset - anchor.topOffset; if (Math.abs(drift) > 0.5) { + const scrollTopBefore = container.scrollTop; container.scrollBy(0, drift); + // Only count the write if it actually moved (a clamped write emits + // no scroll event, and a stale count would swallow a user scroll). + if (container.scrollTop !== scrollTopBefore) { + programmaticScrollEventsRef.current += 1; + } } } if (!isPrepend) { @@ -437,6 +571,7 @@ export function useAnchoredScroll({ prevFirstMessageIdRef.current = firstMessage?.id; prevMessageCountRef.current = messages.length; }, [ + armBottomSettle, isLoading, messages, onTargetReached, @@ -447,12 +582,18 @@ export function useAnchoredScroll({ ]); // --------------------------------------------------------------------------- - // Content resize: while stuck to the bottom, an in-viewport reflow (image - // decode, embed expand, late font load) that React isn't driving grows - // `scrollHeight` without a `messages` change, so the layout effect doesn't - // fire — re-pin to the new floor here to stay glued. When anchored - // mid-history, native scroll anchoring (overflow-anchor) holds the reading - // row across the reflow, so there's nothing to do. + // Content resize: reflow that React isn't driving (image decode, embed + // expand, late font load, `content-visibility` realizing a row's true height) + // grows the content without a `messages` change, so the layout effect doesn't + // fire. While stuck to the bottom, re-pin to the new floor to stay glued. + // While anchored mid-history, native scroll anchoring (overflow-anchor) holds + // the reading row for in-viewport reflow — but it has no anchor node ABOVE + // the viewport near the top edge, so a just-prepended history page realizing + // its `contain-intrinsic-size` estimates over the next frames drifts the + // reading row (the drift scales with the root font size, so the 1.1 default + // text zoom pushed it past the e2e settle budget). Re-pin the anchored row to + // its saved offset here; when native anchoring already held it, the drift is + // ~0 and this is a no-op. // --------------------------------------------------------------------------- // biome-ignore lint/correctness/useExhaustiveDependencies: channelId is a deliberate re-subscription trigger — the effect body reads only the stable refs, but on a channel switch the keyed scroll container remounts and contentRef.current becomes a fresh node, so the observer must disconnect from the previous channel's detached node and re-observe the live one. React.useEffect(() => { @@ -461,8 +602,27 @@ export function useAnchoredScroll({ const observer = new ResizeObserver(() => { const container = scrollContainerRef.current; if (!container) return; - if (anchorRef.current.kind === "at-bottom") { + const anchor = anchorRef.current; + if (anchor.kind === "at-bottom") { container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); + return; + } + const row = container.querySelector( + `[data-message-id="${CSS.escape(anchor.messageId)}"]`, + ); + if (!row) return; + const drift = + row.getBoundingClientRect().top - + container.getBoundingClientRect().top - + anchor.topOffset; + if (Math.abs(drift) > 0.5) { + const scrollTopBefore = container.scrollTop; + container.scrollBy(0, drift); + // Only count the write if it actually moved (a clamped write emits no + // scroll event, and a stale count would swallow a user scroll). + if (container.scrollTop !== scrollTopBefore) { + programmaticScrollEventsRef.current += 1; + } } }); observer.observe(content); diff --git a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx index 262c39f2a..5a5287783 100644 --- a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx @@ -167,6 +167,8 @@ export function ProfileSettingsCard({ const isEditingProfileMetadataRef = React.useRef(false); const avatarEditorOpenFrameRef = React.useRef(null); const avatarEditorFinishTimeoutRef = React.useRef(null); + const rootSectionRef = React.useRef(null); + const settingsScrollTopBeforeEditRef = React.useRef(null); isEditingProfileMetadataRef.current = isEditingProfileMetadata; React.useEffect(() => { @@ -323,14 +325,35 @@ export function ProfileSettingsCard({ window.clearTimeout(avatarEditorFinishTimeoutRef.current); avatarEditorFinishTimeoutRef.current = null; }, []); + // The avatar editor is much taller than the closed profile card, so the + // settings scroller often gets scrolled down while it is open (reaching the + // Done button, browser focus-scroll, etc.). When the editor collapses on + // close, the scroller clamps to the now-shorter content and the profile card + // lands offset from where the user left it. Snapshot the scroll position on + // open and re-apply it on close so closing the editor returns to the exact + // pre-edit view. + const applySavedSettingsScroll = React.useCallback(() => { + const saved = settingsScrollTopBeforeEditRef.current; + if (saved === null) { + return; + } + const scroller = rootSectionRef.current?.closest( + "[data-settings-scroller]", + ); + if (scroller) { + scroller.scrollTop = saved; + } + }, []); const closeAvatarEditor = React.useCallback(() => { clearAvatarEditorFinishTimeout(); setIsAvatarEditorOpen(false); setIsAvatarEditorFinishing(false); - }, [clearAvatarEditorFinishTimeout]); + applySavedSettingsScroll(); + }, [applySavedSettingsScroll, clearAvatarEditorFinishTimeout]); const completeAvatarEditorClose = React.useCallback(() => { setIsAvatarEditorOpen(false); clearAvatarEditorFinishTimeout(); + applySavedSettingsScroll(); avatarEditorFinishTimeoutRef.current = window.setTimeout( () => { avatarEditorFinishTimeoutRef.current = null; @@ -338,7 +361,11 @@ export function ProfileSettingsCard({ }, shouldReduceMotion ? 0 : AVATAR_EDITOR_TRANSITION_MS, ); - }, [clearAvatarEditorFinishTimeout, shouldReduceMotion]); + }, [ + applySavedSettingsScroll, + clearAvatarEditorFinishTimeout, + shouldReduceMotion, + ]); const reopenAvatarEditorAfterClose = React.useCallback(() => { clearAvatarEditorFinishTimeout(); setShouldRenderAvatarEditor(true); @@ -347,6 +374,10 @@ export function ProfileSettingsCard({ }, [clearAvatarEditorFinishTimeout]); const openAvatarEditor = React.useCallback(() => { + const scroller = rootSectionRef.current?.closest( + "[data-settings-scroller]", + ); + settingsScrollTopBeforeEditRef.current = scroller?.scrollTop ?? null; setShouldRenderAvatarEditor(true); setIsAvatarEditorFinishing(false); clearAvatarEditorFinishTimeout(); @@ -447,7 +478,11 @@ export function ProfileSettingsCard({ }, []); return ( -
+
-
+
window.getComputedStyle(element).borderRadius); diff --git a/desktop/tests/e2e/custom-emoji.spec.ts b/desktop/tests/e2e/custom-emoji.spec.ts index 42c7e707a..488e76bba 100644 --- a/desktop/tests/e2e/custom-emoji.spec.ts +++ b/desktop/tests/e2e/custom-emoji.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; +import { expectedScaledPx } from "../helpers/css"; // Custom-emoji end-to-end guard. // @@ -271,14 +272,34 @@ test("reacting with a custom emoji renders via the loopback media proxy", async }), ) .toBe("0"); + const expectedInlineReactionButtonWidth = Math.round( + await expectedScaledPx(inlineAddReactionButton, 40), + ); + const minExpectedInlineReactionButtonHeight = Math.round( + await expectedScaledPx(inlineAddReactionButton, 28), + ); + await expect + .poll(() => + inlineAddReactionButton.evaluate((button) => { + const rect = button.getBoundingClientRect(); + return { + height: Math.round(rect.height), + width: Math.round(rect.width), + }; + }), + ) + .toEqual({ + height: expect.any(Number), + width: expectedInlineReactionButtonWidth, + }); await expect .poll(() => inlineAddReactionButton.evaluate((button) => { const rect = button.getBoundingClientRect(); - return `${Math.round(rect.width)}x${Math.round(rect.height)}`; + return Math.round(rect.height); }), ) - .toBe("40x28"); + .toBeGreaterThanOrEqual(minExpectedInlineReactionButtonHeight); await expect .poll(() => inlineAddReactionButton.evaluate((button) => { @@ -292,10 +313,24 @@ test("reacting with a custom emoji renders via the loopback media proxy", async .poll(() => inlineAddReactionButton.evaluate((button) => { const rect = button.getBoundingClientRect(); - return `${Math.round(rect.width)}x${Math.round(rect.height)}`; + return { + height: Math.round(rect.height), + width: Math.round(rect.width), + }; + }), + ) + .toEqual({ + height: expect.any(Number), + width: expectedInlineReactionButtonWidth, + }); + await expect + .poll(() => + inlineAddReactionButton.evaluate((button) => { + const rect = button.getBoundingClientRect(); + return Math.round(rect.height); }), ) - .toBe("40x28"); + .toBeGreaterThanOrEqual(minExpectedInlineReactionButtonHeight); // Toggle the reaction back off: click the pill, which fires remove_reaction // -> emits a kind:5 deletion targeting the reaction event. The pill must diff --git a/desktop/tests/e2e/file-attachment.spec.ts b/desktop/tests/e2e/file-attachment.spec.ts index 41eb55e52..ac3148630 100644 --- a/desktop/tests/e2e/file-attachment.spec.ts +++ b/desktop/tests/e2e/file-attachment.spec.ts @@ -47,7 +47,7 @@ test("upload a file and see a FileCard in the timeline", async ({ page }) => { // escapes the webview to the OS browser and hits a corporate CDN page. const card = page.getByTestId("file-card").last(); await expect(card).toBeVisible(); - await expectCornerRadiusPx(card, 16); + await expectCornerRadiusPx(card, 16, { scaleWithRootFont: true }); await expectSmoothCorners(card); await expect(card).toContainText("quarterly-report.pdf"); diff --git a/desktop/tests/e2e/image-attachment-gallery.spec.ts b/desktop/tests/e2e/image-attachment-gallery.spec.ts index df4cfc980..6df4eaacf 100644 --- a/desktop/tests/e2e/image-attachment-gallery.spec.ts +++ b/desktop/tests/e2e/image-attachment-gallery.spec.ts @@ -126,8 +126,10 @@ test("image bundle lightbox navigates as a gallery", async ({ page }) => { const triggers = row.getByTestId("message-image-lightbox-trigger"); await expect(triggers).toHaveCount(3); - await expectCornerRadiusPx(triggers.first(), 16); - await expectCornerRadiusPx(triggers.first().locator("img"), 16); + await expectCornerRadiusPx(triggers.first(), 16, { scaleWithRootFont: true }); + await expectCornerRadiusPx(triggers.first().locator("img"), 16, { + scaleWithRootFont: true, + }); await expectSmoothCorners(triggers.first().locator("img")); await triggers.first().click(); @@ -137,7 +139,7 @@ test("image bundle lightbox navigates as a gallery", async ({ page }) => { const lightboxSurface = page .locator("[data-image-lightbox-frame] > div > div") .first(); - await expectCornerRadiusPx(lightboxSurface, 16); + await expectCornerRadiusPx(lightboxSurface, 16, { scaleWithRootFont: true }); await expectSmoothCorners(lightboxSurface); await expect( page.getByRole("button", { name: "Previous image" }), @@ -174,6 +176,7 @@ test("image bundle lightbox navigates as a gallery", async ({ page }) => { await expectCornerRadiusPx( page.locator("[data-image-lightbox-frame] > div > div").first(), 16, + { scaleWithRootFont: true }, ); expect(Math.abs(closingFrameBox.x - currentThumbnailBox.x)).toBeLessThan(2); diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 1c7202125..85f843ba0 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -1,7 +1,11 @@ import { expect, test, type Locator } from "@playwright/test"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; -import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css"; +import { + expectCornerRadiusPx, + expectSmoothCorners, + expectedScaledPx, +} from "../helpers/css"; import { openSettings } from "../helpers/settings"; async function expectThreadReplyUnobscured(row: Locator) { @@ -179,7 +183,7 @@ test("supported link previews keep the message link visible", async ({ ).toBeVisible(); const previewCard = row.locator('[data-link-preview="github-pull-request"]'); await expect(previewCard).toBeVisible(); - await expectCornerRadiusPx(previewCard, 16); + await expectCornerRadiusPx(previewCard, 16, { scaleWithRootFont: true }); await expectSmoothCorners(previewCard); }); @@ -233,7 +237,9 @@ test("copy a rendered code block and paste it back as code", async ({ const codeBlock = page.locator("[data-code-block]"); await expect(codeBlock).toHaveCount(1); - await expectCornerRadiusPx(codeBlock.locator("pre"), 16); + await expectCornerRadiusPx(codeBlock.locator("pre"), 16, { + scaleWithRootFont: true, + }); await expectSmoothCorners(codeBlock.locator("pre")); const copyButton = page.getByLabel("Copy code block"); @@ -647,7 +653,19 @@ test("opens a single-level thread panel with inline expansion", async ({ return `${Math.round(rect.width)}x${Math.round(rect.height)}`; }), ) - .toBe("24x24"); + .toBe( + `${Math.round( + await expectedScaledPx( + rootSummaryRow.getByTestId("message-thread-summary-participant"), + 24, + ), + )}x${Math.round( + await expectedScaledPx( + rootSummaryRow.getByTestId("message-thread-summary-participant"), + 24, + ), + )}`, + ); const summaryGeometry = await measureThreadSummaryGeometry(rootSummaryRow); expect( Math.abs(summaryGeometry.authorLeft - summaryGeometry.bodyLeft), diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 0ff0e8a79..e6f236731 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -361,11 +361,15 @@ test("swaps the avatar preview and mode tabs while editing", async ({ await waitForAvatarEditorToClose(page); await expect(tabList).toHaveCount(0); + // Closing the editor must return the settings scroller to its pre-edit + // position — ProfileSettingsCard snapshots the scrollTop on open and + // re-applies it on close, so the preview lands back where it started. const restoredPreviewBox = await previewFrame.boundingBox(); if (!restoredPreviewBox) { throw new Error("Profile avatar preview did not restore bounds."); } expect(Math.abs(restoredPreviewBox.y - closedPreviewBox.y)).toBeLessThan(8); + await expect(page.getByTestId("profile-avatar-edit")).toBeVisible(); }); test("highlights the avatar drop target while dragging an image", async ({ @@ -527,7 +531,7 @@ test("renders emoji avatars with a static background layer", async ({ ); await expect(page.getByTestId("profile-avatar-preview-emoji")).toHaveCSS( "font-size", - "96px", + "105.6px", ); }); @@ -1219,15 +1223,15 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => { await dispatchPrimaryShortcut("+", "Equal", true); await expect.poll(getTextScaleState).toEqual({ - fontSize: "17.6px", - storedScale: "1.1", + fontSize: "19.2px", + storedScale: "1.2", webviewZoom: 1, }); await dispatchPrimaryShortcut("-", "Minus"); await expect.poll(getTextScaleState).toEqual({ - fontSize: "16px", + fontSize: "17.6px", storedScale: null, webviewZoom: 1, }); @@ -1236,15 +1240,15 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => { await dispatchPrimaryShortcut("+", "Equal", true); await expect.poll(getTextScaleState).toEqual({ - fontSize: "19.2px", - storedScale: "1.2", + fontSize: "20.8px", + storedScale: "1.3", webviewZoom: 1, }); await dispatchPrimaryShortcut("0", "Digit0"); await expect.poll(getTextScaleState).toEqual({ - fontSize: "16px", + fontSize: "17.6px", storedScale: null, webviewZoom: 1, }); diff --git a/desktop/tests/e2e/video-attachment.spec.ts b/desktop/tests/e2e/video-attachment.spec.ts index c4bc4c70b..ff9a894b5 100644 --- a/desktop/tests/e2e/video-attachment.spec.ts +++ b/desktop/tests/e2e/video-attachment.spec.ts @@ -253,7 +253,7 @@ test("video upload previews use poster frames and inline videos open review mode const inlinePlayer = page.getByTestId("video-player").last(); const inlineSurface = inlinePlayer.locator("[data-smooth-corners]").first(); - await expectCornerRadiusPx(inlineSurface, 16); + await expectCornerRadiusPx(inlineSurface, 16, { scaleWithRootFont: true }); await expectSmoothCorners(inlineSurface); const inlineVideo = inlinePlayer.locator("video"); await inlinePlayer.getByRole("button", { name: "Play video" }).click(); diff --git a/desktop/tests/e2e/virtualization.spec.ts b/desktop/tests/e2e/virtualization.spec.ts index 1270e51dd..58b1e6012 100644 --- a/desktop/tests/e2e/virtualization.spec.ts +++ b/desktop/tests/e2e/virtualization.spec.ts @@ -30,7 +30,18 @@ async function seedChannelSections(page: Page) { // 6px distance constraint, so a single move never starts a drag. This walks the // pointer down, past the activation threshold, onto the target, then releases — // the sequence dnd-kit needs to fire onDragEnd and commit the reorder. +// +// Both handles are scrolled on-screen first: at the 1.1 default text scale the +// sidebar overflows the 720px test viewport, and the click that navigates to +// channel-general auto-scrolls the sidebar to center that row — leaving the +// section headers above the scroller's clip. boundingBox() still reports those +// off-screen positions, so raw mouse coords would land on the workspace rail +// instead of the sortable rows and the drag would never activate. Scrolling the +// handles into view keeps the coords honest; the reorder itself must still +// activate, drop, and commit for the test to pass. async function dragOver(page: Page, source: Locator, target: Locator) { + await source.scrollIntoViewIfNeeded(); + await target.scrollIntoViewIfNeeded(); const from = await source.boundingBox(); const to = await target.boundingBox(); if (!from || !to) throw new Error("drag handles not laid out"); diff --git a/desktop/tests/helpers/css.ts b/desktop/tests/helpers/css.ts index 0985fb788..0f0b586b5 100644 --- a/desktop/tests/helpers/css.ts +++ b/desktop/tests/helpers/css.ts @@ -1,8 +1,25 @@ import { expect, type Locator } from "@playwright/test"; +export async function currentRootFontScale(locator: Locator) { + const rootFontSize = await locator.evaluate(() => + Number.parseFloat( + window.getComputedStyle(document.documentElement).fontSize, + ), + ); + return Number.isFinite(rootFontSize) ? rootFontSize / 16 : 1; +} + +export async function expectedScaledPx( + locator: Locator, + pxAtDefaultScale: number, +) { + return pxAtDefaultScale * (await currentRootFontScale(locator)); +} + export async function expectCornerRadiusPx( locator: Locator, expectedRadiusPx: number, + options: { scaleWithRootFont?: boolean } = {}, ) { const measurement = await locator.evaluate((element) => { const style = window.getComputedStyle(element); @@ -65,13 +82,18 @@ export async function expectCornerRadiusPx( className: element.getAttribute("class") ?? "", radius, rawRadius, + rootFontSize, }; }); + const expected = options.scaleWithRootFont + ? expectedRadiusPx * (measurement.rootFontSize / 16) + : expectedRadiusPx; + expect( measurement.radius, - `Expected ${expectedRadiusPx}px corner radius, got ${measurement.rawRadius} on class "${measurement.className}".`, - ).toBeCloseTo(expectedRadiusPx, 0); + `Expected ${expected}px corner radius, got ${measurement.rawRadius} on class "${measurement.className}".`, + ).toBeCloseTo(expected, 0); } export async function expectSmoothCorners(