diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json
index 8df3976675..173d0d749b 100644
--- a/desktop/frontend/package.json
+++ b/desktop/frontend/package.json
@@ -26,7 +26,7 @@
"test:motion-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/approval-animation.mjs",
"test:theme-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/theme-surface-contract.mjs",
"test:diagnostics": "tsx src/__tests__/diagnostics-settings.test.tsx",
- "test:transcript": "tsx src/__tests__/transcript-geometry-replay.test.ts && tsx src/__tests__/transcript-virtuoso-index.test.ts && tsx src/__tests__/transcript-row-geometry.test.ts && tsx src/__tests__/transcript-live-turn-stability.test.tsx && tsx src/__tests__/transcript-geometry-environment.test.ts && tsx src/__tests__/transcript-measured-sizes.test.ts && tsx src/__tests__/transcript-state-snapshot.test.ts && tsx src/__tests__/transcript-layout-recovery.test.ts && tsx src/__tests__/transcript-reader-extent-stability.test.ts && tsx src/__tests__/transcript-reader-extent-race.test.tsx && tsx src/__tests__/transcript-recovery-race.test.tsx && tsx src/__tests__/transcript-same-tab-tail-race.test.tsx && tsx src/__tests__/transcript-history-prepend-race.test.tsx && tsx src/__tests__/transcript-anchor-compensation-race.test.tsx && tsx src/__tests__/transcript-scroll-release.test.ts && tsx src/__tests__/transcript-tail-clamp-race.test.ts && tsx src/__tests__/transcript-scroll-writer.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/frontend-diagnostics.test.ts && tsx src/__tests__/project-tree-diagnostics.test.ts && tsx src/__tests__/transcript-native-scrollbar.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/reasoning-scroll-follow.test.tsx && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/question-jump-bar.test.tsx && tsx src/__tests__/transcript-question-nav.test.ts && tsx src/__tests__/transcript-question-nav-integration.test.ts && tsx src/__tests__/markdown-table-virtual.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-logical-selection.test.ts && tsx src/__tests__/transcript-selection-overlay.test.tsx && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/transcript-selection-rendering.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-virtualization.test.tsx && tsx src/__tests__/transcript-question-jump.test.tsx",
+ "test:transcript": "tsx src/__tests__/transcript-geometry-replay.test.ts && tsx src/__tests__/transcript-virtuoso-index.test.ts && tsx src/__tests__/transcript-row-geometry.test.ts && tsx src/__tests__/transcript-live-turn-stability.test.tsx && tsx src/__tests__/transcript-geometry-environment.test.ts && tsx src/__tests__/transcript-measured-sizes.test.ts && tsx src/__tests__/transcript-state-snapshot.test.ts && tsx src/__tests__/transcript-layout-recovery.test.ts && tsx src/__tests__/transcript-reader-extent-stability.test.ts && tsx src/__tests__/transcript-reader-extent-race.test.tsx && tsx src/__tests__/transcript-reader-visual-guard-race.test.tsx && tsx src/__tests__/transcript-recovery-race.test.tsx && tsx src/__tests__/transcript-same-tab-tail-race.test.tsx && tsx src/__tests__/transcript-history-prepend-race.test.tsx && tsx src/__tests__/transcript-anchor-compensation-race.test.tsx && tsx src/__tests__/transcript-scroll-release.test.ts && tsx src/__tests__/transcript-tail-clamp-race.test.ts && tsx src/__tests__/transcript-scroll-writer.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/frontend-diagnostics.test.ts && tsx src/__tests__/project-tree-diagnostics.test.ts && tsx src/__tests__/transcript-native-scrollbar.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/reasoning-scroll-follow.test.tsx && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/question-jump-bar.test.tsx && tsx src/__tests__/transcript-question-nav.test.ts && tsx src/__tests__/transcript-question-nav-integration.test.ts && tsx src/__tests__/markdown-table-virtual.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-logical-selection.test.ts && tsx src/__tests__/transcript-selection-overlay.test.tsx && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/transcript-selection-rendering.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-virtualization.test.tsx && tsx src/__tests__/transcript-question-jump.test.tsx",
"test:transcript-browser": "node bench/transcript-selection.mjs && node bench/transcript-scroll-stability.mjs && node bench/composer-transcript-stability.mjs",
"test:transcript-reader-browser": "node bench/transcript-reader-transaction.mjs",
"pretest": "pnpm test:terminal && pnpm test:task-monitor && pnpm test:composer && tsx src/__tests__/context-center-contract.test.ts && tsx src/__tests__/provider-model-cache.test.ts && tsx src/__tests__/format-tokens.test.ts && pnpm test:usage-stats && pnpm test:settings-responsive && pnpm test:composer-menu-viewport && pnpm test:diagnostics && pnpm test:transcript",
diff --git a/desktop/frontend/scripts/check-bundle-budget.mjs b/desktop/frontend/scripts/check-bundle-budget.mjs
index 0f1a6384a2..3f7350337d 100644
--- a/desktop/frontend/scripts/check-bundle-budget.mjs
+++ b/desktop/frontend/scripts/check-bundle-budget.mjs
@@ -182,7 +182,10 @@ console.log("\nbundle budgets");
// bring the merged stable path to 462.587 KiB. Windows' embedded build metadata
// lands just above the rounded 462.6 KiB boundary; retain one cross-platform
// decimal step without widening any chunk or raw gate.
-const initialJSBudgetKiB = 462.7;
+// Reading the applied item-list transform (instead of the remembered offset)
+// keeps the reader/anchor visual guards from compounding under reduced-motion
+// WebView2; the merged path measures 462.827 KiB. Retain one decimal step.
+const initialJSBudgetKiB = 462.9;
assertBudget("initial JavaScript gzip", initialJSGzip, initialJSBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk gzip", largestInitialJS, 280 * 1024);
// Render-blocking CSS is intentionally absent: styles.css loads deferred via
@@ -328,6 +331,8 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
// The passive reader-anchor lease for delayed WebView2 range commits measures
// 2469.347 KiB raw (+0.222 KiB, +0.009%). Retain only the next one-decimal
// ceiling; gzip and largest-chunk budgets remain unchanged.
-const rawInitialBudgetKiB = 2_469.4;
+// Reading the applied item-list transform for the reader/anchor visual guards
+// adds 0.5 KiB raw on top; the merged path measures 2469.815 KiB.
+const rawInitialBudgetKiB = 2_469.9;
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
diff --git a/desktop/frontend/scripts/check-motion-ci-contract.mjs b/desktop/frontend/scripts/check-motion-ci-contract.mjs
index 9e7219115a..f518fb4fdd 100644
--- a/desktop/frontend/scripts/check-motion-ci-contract.mjs
+++ b/desktop/frontend/scripts/check-motion-ci-contract.mjs
@@ -148,6 +148,7 @@ if (!packageJSON.scripts?.["test:motion-browser"]?.includes("approval-animation.
const transcriptScript = packageJSON.scripts?.["test:transcript"] ?? "";
for (const required of [
"transcript-virtuoso-index.test.ts",
+ "transcript-reader-visual-guard-race.test.tsx",
"transcript-scroll-release.test.ts",
"nested-scroll-handoff.test.ts",
"creation-transcript-scrollbar.test.ts",
@@ -175,6 +176,23 @@ for (const required of ["transcript-selection.mjs", "transcript-scroll-stability
}
}
+// A near-zero `transition: all` still starts from the old value, so same-frame
+// geometry reads miss transform/padding writes and the transcript guards
+// compound. The global reduced-motion reset must remove transitions outright.
+const stylesSource = readFileSync(resolve(repoRoot, "desktop/frontend/src/styles.css"), "utf8");
+const globalReducedMotion = stylesSource.match(
+ /@media \(prefers-reduced-motion: reduce\) \{\s*\*,\s*\*::before,\s*\*::after \{([^}]*)\}/,
+);
+if (!globalReducedMotion) {
+ throw new Error("motion-ci-contract: styles.css must keep the global universal prefers-reduced-motion reset");
+}
+if (!globalReducedMotion[1].includes("transition: none !important")) {
+ throw new Error("motion-ci-contract: the global reduced-motion reset must use `transition: none !important`");
+}
+if (/transition-duration/.test(globalReducedMotion[1])) {
+ throw new Error("motion-ci-contract: the global reduced-motion reset must not shorten transitions (same-frame geometry reads would lag)");
+}
+
const transcriptCommand = "pnpm --dir frontend test:transcript";
const desktopLinuxJob = jobBody("desktop", "desktop-macos");
const transcriptRuns = desktopLinuxJob.match(/pnpm --dir frontend test:transcript(?:\s|$)/g)?.length ?? 0;
diff --git a/desktop/frontend/src/__tests__/transcript-reader-extent-stability.test.ts b/desktop/frontend/src/__tests__/transcript-reader-extent-stability.test.ts
index 92c534c324..713bf6acca 100644
--- a/desktop/frontend/src/__tests__/transcript-reader-extent-stability.test.ts
+++ b/desktop/frontend/src/__tests__/transcript-reader-extent-stability.test.ts
@@ -11,6 +11,7 @@ import {
transcriptReaderIdleDeadlineReached,
transcriptReaderTransactionCanReuse,
transcriptReaderExtentCanCorrect,
+ transcriptTransformTranslateY,
} from "../lib/transcriptReaderExtentStability";
console.log("\ntranscript reader extent stability");
@@ -163,4 +164,16 @@ for (const event of observingEvents) {
`${event} leaves rebound observation active`);
}
+assert.equal(transcriptTransformTranslateY("none"), 0, "an unset transform applies no visual offset");
+assert.equal(transcriptTransformTranslateY(""), 0, "an empty computed transform applies no visual offset");
+assert.equal(transcriptTransformTranslateY("matrix(1, 0, 0, 1, 0, 7088.5)"), 7088.5, "a 2D matrix exposes its translateY");
+assert.equal(transcriptTransformTranslateY("matrix(1, 0, 0, 1, 0, -12)"), -12, "a negative translateY survives parsing");
+assert.equal(
+ transcriptTransformTranslateY("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 96, 0, 1)"),
+ 96,
+ "a 3D matrix exposes its translateY component",
+);
+assert.equal(transcriptTransformTranslateY("translateY(12px)"), undefined, "an unserialized transform yields no measurement");
+assert.equal(transcriptTransformTranslateY("rotate(45deg)"), undefined, "an unrelated transform yields no measurement");
+
console.log("transcript reader extent stability tests passed");
diff --git a/desktop/frontend/src/__tests__/transcript-reader-visual-guard-race.test.tsx b/desktop/frontend/src/__tests__/transcript-reader-visual-guard-race.test.tsx
new file mode 100644
index 0000000000..2e4a153ccf
--- /dev/null
+++ b/desktop/frontend/src/__tests__/transcript-reader-visual-guard-race.test.tsx
@@ -0,0 +1,199 @@
+// Run: tsx src/__tests__/transcript-reader-visual-guard-race.test.tsx
+//
+// Visual-guard races split out of transcript-reader-extent-race.test.tsx
+// (800-line test-file ceiling). Under prefers-reduced-motion, Windows/WebView2
+// lets the guard transform lag behind its same-frame write, and another guard
+// owner can drop the shared attribute. The reader guard must derive the
+// physical drift from the transform the browser actually applied, never
+// compounding 681 → 1362 → 2043. Same JSDOM + fake rAF harness with a stubbed
+// VirtuosoHandle as the extent race file.
+
+import { JSDOM } from "jsdom";
+import React, { act } from "react";
+import { createRoot } from "react-dom/client";
+import type { VirtuosoHandle } from "react-virtuoso";
+import type { TranscriptScrollWriteRecord } from "../lib/transcriptScrollProbe";
+import { useTranscriptScrollArbiter } from "../lib/useTranscriptScrollArbiter";
+
+let passed = 0;
+let failed = 0;
+
+function check(condition: unknown, label: string) {
+ if (condition) {
+ process.stdout.write(` PASS ${label}\n`);
+ passed += 1;
+ } else {
+ process.stdout.write(` FAIL ${label}\n`);
+ failed += 1;
+ }
+}
+
+console.log("\ntranscript reader visual guard races");
+
+const dom = new JSDOM('
', {
+ pretendToBeVisual: true,
+ url: "http://localhost/",
+});
+(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+globalThis.window = dom.window as unknown as Window & typeof globalThis;
+globalThis.document = dom.window.document;
+globalThis.HTMLElement = dom.window.HTMLElement;
+globalThis.Element = dom.window.Element;
+globalThis.Node = dom.window.Node;
+
+let nextFrame = 1;
+const frames = new Map();
+const requestFrame = (callback: FrameRequestCallback) => {
+ const id = nextFrame;
+ nextFrame += 1;
+ frames.set(id, callback);
+ return id;
+};
+const cancelFrame = (id: number) => void frames.delete(id);
+globalThis.requestAnimationFrame = requestFrame;
+globalThis.cancelAnimationFrame = cancelFrame;
+dom.window.requestAnimationFrame = requestFrame;
+dom.window.cancelAnimationFrame = cancelFrame;
+
+async function flushFrames() {
+ const pending = [...frames.values()];
+ frames.clear();
+ await act(async () => pending.forEach((callback) => callback(performance.now())));
+}
+
+const scrollWrites: TranscriptScrollWriteRecord[] = [];
+dom.window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => { scrollWrites.push(write); };
+
+const rectAt = (top: number) => ({
+ top,
+ bottom: top + 100,
+ height: 100,
+ left: 0,
+ right: 800,
+ width: 800,
+ x: 0,
+ y: top,
+ toJSON: () => ({}),
+});
+const scrollElement = dom.window.document.getElementById("scroll") as HTMLDivElement;
+const rowElement = scrollElement.querySelector(".transcript__row")!;
+rowElement.dataset.index = "0";
+scrollElement.getBoundingClientRect = () => rectAt(0);
+rowElement.getBoundingClientRect = () => rectAt(12);
+Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 725 });
+let scrollExtent = 23_806;
+Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, get: () => scrollExtent });
+Object.defineProperty(scrollElement, "scrollTop", { configurable: true, writable: true, value: 22_608 });
+
+let scrollByCalls = 0;
+let lastScrollByTop = 0;
+const virtuosoHandle = {
+ scrollBy: (options?: { top?: number }) => {
+ scrollByCalls += 1;
+ lastScrollByTop = options?.top ?? 0;
+ scrollElement.scrollTop += lastScrollByTop;
+ },
+ scrollTo: (options?: { top?: number }) => {
+ scrollElement.scrollTop = options?.top ?? scrollElement.scrollTop;
+ },
+ scrollToIndex: () => {},
+ getState: () => {},
+} as unknown as VirtuosoHandle;
+
+let arbiter: ReturnType | undefined;
+function Probe() {
+ arbiter = useTranscriptScrollArbiter();
+ return null;
+}
+
+const root = createRoot(dom.window.document.getElementById("root")!);
+await act(async () => root.render());
+await act(async () => {
+ (arbiter!.virtuosoRef as { current: VirtuosoHandle | null }).current = virtuosoHandle;
+ arbiter!.scrollerRef(scrollElement);
+});
+
+const visualOffsetOf = () => Number.parseFloat(
+ scrollElement.style.getPropertyValue("--transcript-reader-visual-offset"),
+) || 0;
+const itemList = dom.window.document.createElement("div");
+itemList.dataset.testid = "virtuoso-item-list";
+itemList.style.transform = "none";
+scrollElement.append(itemList);
+
+// A downward wheel gesture whose same-scrollTop estimate growth displaces the
+// anchor row by 681px on screen. The row rect deliberately ignores the guard
+// CSS variable: the transform has not been applied by the browser yet.
+const startDisplacedTransaction = async () => {
+ await act(async () => arbiter?.reset());
+ scrollExtent = 23_806;
+ scrollElement.scrollTop = 22_608;
+ rowElement.getBoundingClientRect = () => rectAt(12);
+ await act(async () => arbiter?.deliverScroll());
+ await act(async () => arbiter?.releaseTailFollow());
+ await act(async () => arbiter?.onWheelIntent({
+ ctrlKey: false,
+ deltaMode: 0,
+ deltaX: 0,
+ deltaY: 24,
+ target: scrollElement,
+ } as React.WheelEvent));
+ scrollByCalls = 0;
+ scrollWrites.length = 0;
+ scrollExtent += 681;
+ rowElement.getBoundingClientRect = () => rectAt(693 - (scrollElement.scrollTop - 22_608));
+};
+
+await startDisplacedTransaction();
+await act(async () => arbiter?.deliverScroll());
+check(Math.abs(visualOffsetOf() + 681) <= 1,
+ `an unapplied guard is written once from the physical drift (${visualOffsetOf()}px)`);
+await act(async () => arbiter?.deliverScroll());
+await act(async () => arbiter?.deliverScroll());
+check(Math.abs(visualOffsetOf() + 681) <= 1,
+ `repeated observations before the transform lands do not compound the guard (${visualOffsetOf()}px)`);
+await flushFrames();
+check(scrollByCalls === 1 && Math.abs(lastScrollByTop - 681) <= 1,
+ `the correction targets the physical anchor, not a compounded guard (${lastScrollByTop}px)`);
+check(scrollElement.dataset.transcriptReaderVisualGuard === undefined,
+ "the unapplied guard releases after the anchor is physically restored");
+check(
+ scrollWrites.filter((write) => write.owner === "reader-stability" && write.kind === "scrollBy").length === 1,
+ "the unapplied-guard correction stays inside the reader writer lane",
+);
+
+// The applied transform is the truth even when the remembered offset is gone:
+// a mounted item list carrying the guard transform must still be subtracted.
+const syncItemListTransform = () => {
+ const applied = scrollElement.dataset.transcriptReaderVisualGuard === "true" ? visualOffsetOf() : 0;
+ itemList.style.transform = applied === 0 ? "none" : `matrix(1, 0, 0, 1, 0, ${applied})`;
+};
+await startDisplacedTransaction();
+rowElement.getBoundingClientRect = () => rectAt(693 - (scrollElement.scrollTop - 22_608) + (
+ Number.parseFloat(itemList.style.transform.split(",")[5]) || 0
+));
+await act(async () => arbiter?.deliverScroll());
+syncItemListTransform();
+check(Math.abs(visualOffsetOf() + 681) <= 1,
+ `an applied guard is written from the physical drift (${visualOffsetOf()}px)`);
+await act(async () => arbiter?.deliverScroll());
+syncItemListTransform();
+check(Math.abs(visualOffsetOf() + 681) <= 1,
+ `an applied guard stays put across observations (${visualOffsetOf()}px)`);
+await flushFrames();
+syncItemListTransform();
+check(scrollByCalls === 1 && Math.abs(lastScrollByTop - 681) <= 1,
+ `the correction subtracts the applied transform (${lastScrollByTop}px)`);
+await flushFrames();
+syncItemListTransform();
+check(scrollElement.dataset.transcriptReaderVisualGuard === undefined,
+ "the applied guard releases once the correction lands");
+
+await act(async () => root.unmount());
+dom.window.close();
+
+if (failed > 0) {
+ console.error(`\n${failed} transcript reader visual guard race test(s) failed; ${passed} passed.`);
+ process.exit(1);
+}
+console.log(`\n${passed} transcript reader visual guard race tests passed.`);
diff --git a/desktop/frontend/src/lib/transcriptAnchorCompensation.ts b/desktop/frontend/src/lib/transcriptAnchorCompensation.ts
index 278d5198c2..c4b7de2525 100644
--- a/desktop/frontend/src/lib/transcriptAnchorCompensation.ts
+++ b/desktop/frontend/src/lib/transcriptAnchorCompensation.ts
@@ -8,7 +8,7 @@ import {
captureVisibleTranscriptLayoutAnchor,
type TranscriptLayoutAnchor,
} from "./transcriptVirtuosoRecovery";
-import { MIN_REVERSE_JUMP_PX } from "./transcriptReaderExtentStability";
+import { MIN_REVERSE_JUMP_PX, transcriptAppliedVisualOffset } from "./transcriptReaderExtentStability";
// Fractional row metrics can shift by 1-2px while Virtuoso's estimate tree is
// converging during ordinary traversal. Treat that as layout noise so a
@@ -100,7 +100,7 @@ export function createTranscriptAnchorCompensation({
const row = anchorRow(compensation.anchor.rowKey, element);
if (!row) return null;
const rendered = row.getBoundingClientRect().top - element.getBoundingClientRect().top - compensation.anchor.offset;
- return rendered - compensation.visualOffset;
+ return rendered - transcriptAppliedVisualOffset(element, compensation.visualOffset);
};
const guardLargeDrift = (compensation: ActiveAnchorCompensation, element: HTMLDivElement) => {
diff --git a/desktop/frontend/src/lib/transcriptReaderExtentStability.ts b/desktop/frontend/src/lib/transcriptReaderExtentStability.ts
index 8fd43ac6cd..0abc087603 100644
--- a/desktop/frontend/src/lib/transcriptReaderExtentStability.ts
+++ b/desktop/frontend/src/lib/transcriptReaderExtentStability.ts
@@ -20,6 +20,28 @@ export function transcriptReaderDirection(deltaY: number): -1 | 1 | undefined {
export function transcriptReaderTransactionCanReuse(direction: -1 | 1, deltaY: number): boolean {
return transcriptReaderDirection(deltaY) === direction;
}
+
+export function transcriptTransformTranslateY(transform: string): number | undefined {
+ if (transform === "" || transform === "none") return 0;
+ const match = /^matrix(3d)?\((.*)\)$/.exec(transform);
+ if (!match) return undefined;
+ const value = Number(match[2].split(",")[match[1] ? 13 : 5]);
+ return Number.isFinite(value) ? value : undefined;
+}
+
+/**
+ * The visual-guard offset the browser has actually applied to the item list.
+ * A guard written by a previous observation may not be reflected in row
+ * geometry yet (or any more): a same-frame read under a reduced-motion
+ * transition, or another owner clearing the shared attribute. Deriving the
+ * physical drift from the remembered offset would compound the guard on every
+ * observation, so measure the applied transform; `remembered` is the fallback.
+ */
+export function transcriptAppliedVisualOffset(element: HTMLElement, remembered: number): number {
+ const list = element.querySelector('[data-testid="virtuoso-item-list"]');
+ const view = element.ownerDocument.defaultView;
+ return (list && view && transcriptTransformTranslateY(view.getComputedStyle(list).transform)) ?? remembered;
+}
const REVERSE_JUMP_VIEWPORT_RATIO = 0.5;
const EXTENT_REBOUND_VIEWPORT_RATIO = 0.5;
diff --git a/desktop/frontend/src/lib/useTranscriptReaderExtentStability.ts b/desktop/frontend/src/lib/useTranscriptReaderExtentStability.ts
index 715b37ccab..e59814ad81 100644
--- a/desktop/frontend/src/lib/useTranscriptReaderExtentStability.ts
+++ b/desktop/frontend/src/lib/useTranscriptReaderExtentStability.ts
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import type { TranscriptScrollMode } from "./transcriptScrollArbiter";
-import { MIN_REVERSE_JUMP_PX, TRANSCRIPT_READER_IDLE_MS, TRANSCRIPT_READER_SETTLE_MS, transcriptReaderDirection } from "./transcriptReaderExtentStability";
+import { MIN_REVERSE_JUMP_PX, TRANSCRIPT_READER_IDLE_MS, TRANSCRIPT_READER_SETTLE_MS, transcriptAppliedVisualOffset, transcriptReaderDirection } from "./transcriptReaderExtentStability";
import { nativeTranscriptBottomTop, nativeTranscriptDistanceFromBottom, TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX } from "./transcriptScrollGeometry";
import { recordTranscriptScrollDiagnostic, type TranscriptScrollWriteRecord } from "./transcriptScrollProbe";
import { transcriptElementViewportIsBlank } from "./transcriptVirtuosoRecovery";
@@ -227,9 +227,11 @@ export function useTranscriptReaderExtentStability({
const renderedAnchorDrift = anchorRow && transaction.anchor
? anchorRow.getBoundingClientRect().top - viewport.top - transaction.anchor.offset
: 0;
- // The row rect includes the current visual guard. Remove it so a stable
- // guard does not look like a fresh displacement on every observation.
- const physicalAnchorDrift = renderedAnchorDrift - transaction.visualOffset;
+ // The row rect includes whatever visual guard the browser has applied so
+ // far. Remove that, not the remembered offset, so a guard that has not
+ // landed yet cannot look like a fresh displacement and compound itself.
+ const appliedVisualOffset = transcriptAppliedVisualOffset(element, transaction.visualOffset);
+ const physicalAnchorDrift = renderedAnchorDrift - appliedVisualOffset;
const reverseAnchorDisplacement = transaction.direction * physicalAnchorDrift;
// Extent collapse needs the half-viewport transient threshold, but the
// user-visible screen anchor has the stricter 96px acceptance contract.
@@ -264,9 +266,6 @@ export function useTranscriptReaderExtentStability({
);
}
if (anchorRow && transaction.anchor) {
- // DOM geometry includes the transform already applied by a previous
- // observation. Subtract it before deriving the next absolute guard so
- // repeated scroll events cannot compound the visual compensation.
transaction.visualOffset = -physicalAnchorDrift;
element.dataset.transcriptReaderVisualGuard = "true";
element.style.setProperty("--transcript-reader-visual-offset", `${transaction.visualOffset}px`);
@@ -449,10 +448,10 @@ export function useTranscriptReaderExtentStability({
}
const viewportTop = element.getBoundingClientRect().top;
const targetTop = anchorRow && transaction.anchor
- // getBoundingClientRect includes the temporary list transform. Use
+ // getBoundingClientRect includes the applied list transform. Use
// the unguarded row position to derive the physical scrollTop that
// can replace that transform without a visual jump.
- ? element.scrollTop + anchorRow.getBoundingClientRect().top - transaction.visualOffset - viewportTop - transaction.anchor.offset
+ ? element.scrollTop + anchorRow.getBoundingClientRect().top - transcriptAppliedVisualOffset(element, transaction.visualOffset) - viewportTop - transaction.anchor.offset
: transaction.expectedTop;
const correction = Math.max(0, Math.min(nativeTranscriptBottomTop(element), targetTop)) - element.scrollTop;
if ((transaction.mountAnchorWritten ? Math.abs(correction) : transaction.direction * correction) > 1) {
diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css
index 950760b8f7..09c3e5859f 100644
--- a/desktop/frontend/src/styles.css
+++ b/desktop/frontend/src/styles.css
@@ -244,8 +244,8 @@
/* motion — shared timing + easing tokens. Durations mirror the values
* already used across the app (0.12 / 0.18 / 0.34 / 0.42s); easings reuse the
* existing curves. The global prefers-reduced-motion block at EOF collapses
- * every animation/transition to ~0ms, so these tokens are safe to reuse
- * freely without per-rule reduce overrides. */
+ * every animation to ~0ms and removes transitions, so these tokens are safe
+ * to reuse freely without per-rule reduce overrides. */
--dur-fast: 120ms; /* color/border hovers, tooltips */
--dur-base: 180ms; /* popovers, menus, small enters */
--dur-slow: 340ms; /* drawers, modals, panel slides */
@@ -27449,15 +27449,17 @@ body > .mermaid-diagram--fullscreen {
}
/* Respect the OS reduced-motion preference: squash all animations and force
-/* Respect the OS reduced-motion preference: squash all animations and force
- instant scrolling so the UI feels immediate rather than web-like. */
+ instant scrolling so the UI feels immediate rather than web-like. Transitions
+ go to `none`, not 0.01ms: a near-zero `transition: all` still starts from the
+ old value, so transform/padding writes stay invisible to same-frame geometry
+ reads and the transcript scroll guards compound their compensation. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
- transition-duration: 0.01ms !important;
+ transition: none !important;
scroll-behavior: auto !important;
}
}