authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-08 15:21:51-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 15:21:51-03:00
log07a8b9ece40bc20a5f84ce78d12f621518065863
tree0791e82d50db12c66085896b51bc0284c3d61f53
parent61b9790816a8af359ab7e8ba0b52a3481353b18d
signature Signed by PGP key B5690EEEBB952194

fix(web): centered scrollers no longer shift when the scrollbar appears (#17077)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

11 files changed, 170 insertions(+), 7 deletions(-)

apps/web/src/components/RightPanelTabs.tsx+1-1
...@@ -479,7 +479,7 @@ function RightPanelEmptyState(props: {...@@ -479,7 +479,7 @@ function RightPanelEmptyState(props: {
479 aria-label="Open a surface"479 aria-label="Open a surface"
480 data-surface-launcher-keys={availableActions.map((action) => action.shortcut).join("")}480 data-surface-launcher-keys={availableActions.map((action) => action.shortcut).join("")}
481 className={cn(481 className={cn(
482 "flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 outline-none",482 "scrollbar-gutter-both flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 outline-none",
483 // The panel topbar sits above this container; matching bottom padding483 // The panel topbar sits above this container; matching bottom padding
484 // keeps the list centered against the full panel, not the leftover.484 // keeps the list centered against the full panel, not the leftover.
485 "pb-(--workspace-topbar-height)",485 "pb-(--workspace-topbar-height)",
apps/web/src/components/device/DevicePanel.tsx+1-1
...@@ -232,7 +232,7 @@ export function DevicePanel(props: {...@@ -232,7 +232,7 @@ export function DevicePanel(props: {
232 }232 }
233 />233 />
234 ) : (234 ) : (
235 <div className="flex size-full flex-col overflow-y-auto px-5 py-8 text-sm text-muted-foreground">235 <div className="scrollbar-gutter-both flex size-full flex-col overflow-y-auto px-5 py-8 text-sm text-muted-foreground">
236 <div236 <div
237 className={cn(237 className={cn(
238 "mx-auto flex w-full max-w-xl flex-col gap-6",238 "mx-auto flex w-full max-w-xl flex-col gap-6",
apps/web/src/components/files/AttachmentFilePreview.tsx+1
...@@ -265,6 +265,7 @@ export function AttachmentFilePreview(props: {...@@ -265,6 +265,7 @@ export function AttachmentFilePreview(props: {
265 />265 />
266 </div>266 </div>
267 ) : kind === "image" ? (267 ) : kind === "image" ? (
268 // oxlint-disable-next-line t3code/require-centered-scroll-gutter -- The image is capped at max-h-full max-w-full, so this never scrolls.
268 <div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4">269 <div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4">
269 <img270 <img
270 src={url}271 src={url}
apps/web/src/components/files/FilePreviewPanel.tsx+5-1
...@@ -186,6 +186,7 @@ function WorkspaceImagePreview(props: {...@@ -186,6 +186,7 @@ function WorkspaceImagePreview(props: {
186 }186 }
187187
188 return assetUrl._tag === "Success" && imageUrl !== null ? (188 return assetUrl._tag === "Success" && imageUrl !== null ? (
189 // oxlint-disable-next-line t3code/require-centered-scroll-gutter -- The image is capped at max-h-full max-w-full, so this never scrolls.
189 <div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4">190 <div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4">
190 <MediaActions source={actionsSource}>191 <MediaActions source={actionsSource}>
191 <img192 <img
...@@ -1368,7 +1369,10 @@ export default function FilePreviewPanel({...@@ -1368,7 +1369,10 @@ export default function FilePreviewPanel({
1368 workspaceMutationId={workspaceMutationId}1369 workspaceMutationId={workspaceMutationId}
1369 />1370 />
1370 ) : relativePath && file.error && file.data === null ? (1371 ) : relativePath && file.error && file.data === null ? (
1371 <div role="alert" className="flex min-h-0 flex-1 flex-col overflow-auto">1372 <div
1373 role="alert"
1374 className="scrollbar-gutter-both flex min-h-0 flex-1 flex-col overflow-auto"
1375 >
1372 <div className="my-auto flex shrink-0 flex-col gap-3 px-6 py-6 text-center text-xs leading-relaxed">1376 <div className="my-auto flex shrink-0 flex-col gap-3 px-6 py-6 text-center text-xs leading-relaxed">
1373 <p className="text-destructive">1377 <p className="text-destructive">
1374 {file.readError ? filePreviewReadErrorMessage(file.readError) : file.error}1378 {file.readError ? filePreviewReadErrorMessage(file.readError) : file.error}
apps/web/src/components/preview/PreviewEmptyState.tsx+1-1
...@@ -48,7 +48,7 @@ export function PreviewEmptyState({...@@ -48,7 +48,7 @@ export function PreviewEmptyState({
48 }48 }
4949
50 return (50 return (
51 <div className="flex h-full min-h-0 overflow-y-auto px-5 py-8">51 <div className="scrollbar-gutter-both flex h-full min-h-0 overflow-y-auto px-5 py-8">
52 <div className="mx-auto flex w-full max-w-xl flex-col gap-6">52 <div className="mx-auto flex w-full max-w-xl flex-col gap-6">
53 {recents.length > 0 ? (53 {recents.length > 0 ? (
54 <div className="flex flex-col gap-3">54 <div className="flex flex-col gap-3">
apps/web/src/components/preview/PreviewUnreachable.tsx+1-1
...@@ -21,7 +21,7 @@ export function PreviewUnreachable({ url, code, description, onReload }: Props)...@@ -21,7 +21,7 @@ export function PreviewUnreachable({ url, code, description, onReload }: Props)
21 const errorLabel = description.length > 0 ? description : `ERR_${Math.abs(code) || "FAILED"}`;21 const errorLabel = description.length > 0 ? description : `ERR_${Math.abs(code) || "FAILED"}`;
2222
23 return (23 return (
24 <div className="relative flex h-full min-h-0 w-full overflow-y-auto bg-background">24 <div className="scrollbar-gutter-both relative flex h-full min-h-0 w-full overflow-y-auto bg-background">
25 <div className="mx-auto flex w-full max-w-xl flex-1 flex-col px-8 py-12 sm:py-16">25 <div className="mx-auto flex w-full max-w-xl flex-1 flex-col px-8 py-12 sm:py-16">
26 <ErrorIcon className="mb-6 size-12 text-muted-foreground/70" />26 <ErrorIcon className="mb-6 size-12 text-muted-foreground/70" />
27 <h1 className="mb-3 text-2xl font-semibold leading-tight text-foreground">27 <h1 className="mb-3 text-2xl font-semibold leading-tight text-foreground">
apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx+1-1
...@@ -19,7 +19,7 @@ export function PullRequestsUnavailableState({...@@ -19,7 +19,7 @@ export function PullRequestsUnavailableState({
19 gitHubUrl?: string;19 gitHubUrl?: string;
20}) {20}) {
21 return (21 return (
22 <Empty className="min-h-0 justify-center-safe overflow-y-auto [&>*]:shrink-0">22 <Empty className="scrollbar-gutter-both min-h-0 justify-center-safe overflow-y-auto [&>*]:shrink-0">
23 <EmptyMedia variant="icon">23 <EmptyMedia variant="icon">
24 <PullRequestGlyph.pullRequest />24 <PullRequestGlyph.pullRequest />
25 </EmptyMedia>25 </EmptyMedia>
apps/web/src/components/settings/ProviderSettingsPanel.tsx+4-1
...@@ -217,7 +217,10 @@ function ProviderSettingsPlaceholder({...@@ -217,7 +217,10 @@ function ProviderSettingsPlaceholder({
217 ) : null}217 ) : null}
218 <SettingsGroup218 <SettingsGroup
219 divided={false}219 divided={false}
220 className={cn(providerCardHeightClassName, "flex overflow-x-hidden overflow-y-auto")}220 className={cn(
221 providerCardHeightClassName,
222 "scrollbar-gutter-both flex overflow-x-hidden overflow-y-auto",
223 )}
221 >224 >
222 <Empty>225 <Empty>
223 <EmptyMedia variant="icon">{icon}</EmptyMedia>226 <EmptyMedia variant="icon">{icon}</EmptyMedia>
oxlint-plugin-t3code/index.ts+2
...@@ -12,6 +12,7 @@ import noTestInLoop from "./rules/no-test-in-loop.ts";...@@ -12,6 +12,7 @@ import noTestInLoop from "./rules/no-test-in-loop.ts";
12import noRpcPermissionBypass from "./rules/no-rpc-permission-bypass.ts";12import noRpcPermissionBypass from "./rules/no-rpc-permission-bypass.ts";
13import noUnscopedHas from "./rules/no-unscoped-has.ts";13import noUnscopedHas from "./rules/no-unscoped-has.ts";
14import preferCatchTags from "./rules/prefer-catch-tags.ts";14import preferCatchTags from "./rules/prefer-catch-tags.ts";
15import requireCenteredScrollGutter from "./rules/require-centered-scroll-gutter.ts";
15import requireSuppressionReason from "./rules/require-suppression-reason.ts";16import requireSuppressionReason from "./rules/require-suppression-reason.ts";
1617
17export default definePlugin({18export default definePlugin({
...@@ -31,6 +32,7 @@ export default definePlugin({...@@ -31,6 +32,7 @@ export default definePlugin({
31 "no-rpc-permission-bypass": noRpcPermissionBypass,32 "no-rpc-permission-bypass": noRpcPermissionBypass,
32 "no-unscoped-has": noUnscopedHas,33 "no-unscoped-has": noUnscopedHas,
33 "prefer-catch-tags": preferCatchTags,34 "prefer-catch-tags": preferCatchTags,
35 "require-centered-scroll-gutter": requireCenteredScrollGutter,
34 "require-suppression-reason": requireSuppressionReason,36 "require-suppression-reason": requireSuppressionReason,
35 },37 },
36});38});
oxlint-plugin-t3code/rules/require-centered-scroll-gutter.ts created+152
...@@ -0,0 +1,152 @@
1import { defineRule, type ESTree } from "@oxlint/plugins";
2import * as Option from "effect/Option";
3
4import { unwrapExpression } from "../utils.ts";
5
6const SCROLLS_VERTICALLY = /(?:^|:)overflow-(?:y-)?auto$/u;
7// Either utility keeps the content box the same width whether or not the scrollbar shows. Both
8// exemptions must be unprefixed: a variant such as lg: leaves every other state unprotected.
9const RESERVES_GUTTER =
10 /^(?:scrollbar-gutter-(?:stable|both)|\[scrollbar-gutter:stable(?:_both-edges)?\])$/u;
11const HIDES_SCROLLBAR = /^\[scrollbar-width:none\]$/u;
12const CENTERED_BY_MARGIN = /(?:^|:)mx?-auto$/u;
13const COLUMN = /(?:^|:)flex-col(?:-reverse)?$/u;
14const ALWAYS_COLUMN = /^flex-col(?:-reverse)?$/u;
15const ROW = /(?:^|:)flex-row(?:-reverse)?$/u;
16const CENTERS_COLUMN_ITEMS = /(?:^|:)(?:items-center(?:-safe)?|place-items-center)$/u;
17const CENTERS_ROW_ITEMS =
18 /(?:^|:)(?:justify-center(?:-safe)?|\[justify-content:safe_center\]|place-items-center|place-content-center)$/u;
19
20interface ClassNames {
21 /** Classes that apply in at least one render. */
22 readonly possible: Set<string>;
23 /** Classes that apply in every render, outside any conditional branch. */
24 readonly always: Set<string>;
25}
26
27/**
28 * The class names a className value can produce: string literals, template text, and the
29 * branches and arguments of conditionals and cn()-style calls. Conditions themselves are skipped.
30 */
31function collectClassNames(node: unknown, classNames: ClassNames, always: boolean) {
32 const expression = unwrapExpression(node);
33 if (Option.isNone(expression)) return;
34 const value = expression.value;
35
36 const addAll = (text: string | null) => {
37 for (const className of text?.split(/\s+/u) ?? []) {
38 if (!className) continue;
39 classNames.possible.add(className);
40 if (always) classNames.always.add(className);
41 }
42 };
43
44 switch (value.type) {
45 case "Literal":
46 if (typeof value.value === "string") addAll(value.value);
47 return;
48 case "TemplateLiteral":
49 value.quasis.forEach((quasi, index) => {
50 // A token touching an interpolation is only part of a class name, such as `${prefix}x`.
51 const tokens = (quasi.value.cooked ?? "").split(/\s+/u);
52 if (index > 0) tokens.shift();
53 if (!quasi.tail) tokens.pop();
54 addAll(tokens.join(" "));
55 });
56 for (const nested of value.expressions) collectClassNames(nested, classNames, always);
57 return;
58 case "JSXExpressionContainer":
59 collectClassNames(value.expression, classNames, always);
60 return;
61 case "ConditionalExpression":
62 collectClassNames(value.consequent, classNames, false);
63 collectClassNames(value.alternate, classNames, false);
64 return;
65 case "LogicalExpression":
66 collectClassNames(value.left, classNames, false);
67 collectClassNames(value.right, classNames, false);
68 return;
69 case "ArrayExpression":
70 for (const element of value.elements) collectClassNames(element, classNames, always);
71 return;
72 case "CallExpression":
73 for (const argument of value.arguments) collectClassNames(argument, classNames, always);
74 return;
75 }
76}
77
78function classNamesOf(element: ESTree.JSXElement): ClassNames {
79 const classNames: ClassNames = { possible: new Set(), always: new Set() };
80 for (const attribute of element.openingElement.attributes) {
81 if (
82 attribute.type === "JSXAttribute" &&
83 attribute.name.type === "JSXIdentifier" &&
84 attribute.name.name === "className"
85 ) {
86 collectClassNames(attribute.value, classNames, true);
87 }
88 }
89 return classNames;
90}
91
92const hasClass = (classNames: ReadonlySet<string>, pattern: RegExp) =>
93 [...classNames].some((className) => pattern.test(className));
94
95/**
96 * Reports a native vertical scroller that centers its content without reserving the scrollbar
97 * lane. A classic scrollbar narrows the scroller when it appears, so centered content jumps by
98 * half its width whenever the content grows past the fold.
99 */
100export default defineRule({
101 meta: {
102 type: "problem",
103 docs: {
104 description:
105 "Require scrollbar-gutter-both on overflow-auto scrollers that center their content, so it does not shift when the scrollbar appears.",
106 },
107 },
108 create(context) {
109 const reported = new Set<ESTree.JSXElement>();
110
111 const report = (scroller: ESTree.JSXElement, { always }: ClassNames) => {
112 if (reported.has(scroller)) return;
113 if (hasClass(always, RESERVES_GUTTER) || hasClass(always, HIDES_SCROLLBAR)) return;
114 reported.add(scroller);
115 context.report({
116 node: scroller.openingElement,
117 message:
118 "This scroller centers its content, which shifts sideways when the scrollbar appears. Add scrollbar-gutter-both.",
119 });
120 };
121
122 return {
123 JSXElement(node) {
124 const classNames = classNamesOf(node);
125 const { possible } = classNames;
126 if (possible.size === 0) return;
127
128 if (hasClass(possible, SCROLLS_VERTICALLY)) {
129 // A responsive or conditional direction can render as either axis, so check both.
130 const centersColumn =
131 hasClass(possible, COLUMN) && hasClass(possible, CENTERS_COLUMN_ITEMS);
132 const centersRow =
133 (!hasClass(classNames.always, ALWAYS_COLUMN) || hasClass(possible, ROW)) &&
134 hasClass(possible, CENTERS_ROW_ITEMS);
135 if (centersColumn || centersRow) report(node, classNames);
136 }
137
138 if (!hasClass(possible, CENTERED_BY_MARGIN)) return;
139 // The nearest enclosing scroller is the one whose scrollbar moves this element.
140 for (let ancestor: ESTree.Node | null = node.parent; ancestor; ancestor = ancestor.parent) {
141 // An element passed as a prop renders wherever that component puts it.
142 if (ancestor.type === "JSXAttribute") return;
143 if (ancestor.type !== "JSXElement") continue;
144 const ancestorClassNames = classNamesOf(ancestor);
145 if (!hasClass(ancestorClassNames.possible, SCROLLS_VERTICALLY)) continue;
146 report(ancestor, ancestorClassNames);
147 return;
148 }
149 },
150 };
151 },
152});
vite.config.ts+1
...@@ -173,6 +173,7 @@ export default defineConfig({...@@ -173,6 +173,7 @@ export default defineConfig({
173 "t3code/no-unscoped-has": "error",173 "t3code/no-unscoped-has": "error",
174 "t3code/namespace-node-imports": "error",174 "t3code/namespace-node-imports": "error",
175 "t3code/prefer-catch-tags": "error",175 "t3code/prefer-catch-tags": "error",
176 "t3code/require-centered-scroll-gutter": "error",
176 "t3code/require-suppression-reason": "error",177 "t3code/require-suppression-reason": "error",
177 },178 },
178 overrides: [179 overrides: [