| author | |
| committer | |
| log | 07a8b9ece40bc20a5f84ce78d12f621518065863 |
| tree | 0791e82d50db12c66085896b51bc0284c3d61f53 |
| parent | 61b9790816a8af359ab7e8ba0b52a3481353b18d |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 479 | aria-label="Open a surface" |
| 480 | 480 | data-surface-launcher-keys={availableActions.map((action) => action.shortcut).join("")} |
| 481 | 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 | 483 | // The panel topbar sits above this container; matching bottom padding |
| 484 | 484 | // keeps the list centered against the full panel, not the leftover. |
| 485 | 485 | "pb-(--workspace-topbar-height)", |
apps/web/src/components/device/DevicePanel.tsx+1-1| ... | ... | @@ -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 | 236 | <div |
| 237 | 237 | className={cn( |
| 238 | 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 | 265 | /> |
| 266 | 266 | </div> |
| 267 | 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 | 269 | <div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4"> |
| 269 | 270 | <img |
| 270 | 271 | src={url} |
apps/web/src/components/files/FilePreviewPanel.tsx+5-1| ... | ... | @@ -186,6 +186,7 @@ function WorkspaceImagePreview(props: { |
| 186 | 186 | } |
| 187 | 187 | |
| 188 | 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 | 190 | <div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4"> |
| 190 | 191 | <MediaActions source={actionsSource}> |
| 191 | 192 | <img |
| ... | ... | @@ -1368,7 +1369,10 @@ export default function FilePreviewPanel({ |
| 1368 | 1369 | workspaceMutationId={workspaceMutationId} |
| 1369 | 1370 | /> |
| 1370 | 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 | 1376 | <div className="my-auto flex shrink-0 flex-col gap-3 px-6 py-6 text-center text-xs leading-relaxed"> |
| 1373 | 1377 | <p className="text-destructive"> |
| 1374 | 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 | 48 | } |
| 49 | 49 | |
| 50 | 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 | 52 | <div className="mx-auto flex w-full max-w-xl flex-col gap-6"> |
| 53 | 53 | {recents.length > 0 ? ( |
| 54 | 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 | 21 | const errorLabel = description.length > 0 ? description : `ERR_${Math.abs(code) || "FAILED"}`; |
| 22 | 22 | |
| 23 | 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 | 25 | <div className="mx-auto flex w-full max-w-xl flex-1 flex-col px-8 py-12 sm:py-16"> |
| 26 | 26 | <ErrorIcon className="mb-6 size-12 text-muted-foreground/70" /> |
| 27 | 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 | 19 | gitHubUrl?: string; |
| 20 | 20 | }) { |
| 21 | 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 | 23 | <EmptyMedia variant="icon"> |
| 24 | 24 | <PullRequestGlyph.pullRequest /> |
| 25 | 25 | </EmptyMedia> |
apps/web/src/components/settings/ProviderSettingsPanel.tsx+4-1| ... | ... | @@ -217,7 +217,10 @@ function ProviderSettingsPlaceholder({ |
| 217 | 217 | ) : null} |
| 218 | 218 | <SettingsGroup |
| 219 | 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 | 225 | <Empty> |
| 223 | 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 | 12 | import noRpcPermissionBypass from "./rules/no-rpc-permission-bypass.ts"; |
| 13 | 13 | import noUnscopedHas from "./rules/no-unscoped-has.ts"; |
| 14 | 14 | import preferCatchTags from "./rules/prefer-catch-tags.ts"; |
| 15 | import requireCenteredScrollGutter from "./rules/require-centered-scroll-gutter.ts"; | |
| 15 | 16 | import requireSuppressionReason from "./rules/require-suppression-reason.ts"; |
| 16 | 17 | |
| 17 | 18 | export default definePlugin({ |
| ... | ... | @@ -31,6 +32,7 @@ export default definePlugin({ |
| 31 | 32 | "no-rpc-permission-bypass": noRpcPermissionBypass, |
| 32 | 33 | "no-unscoped-has": noUnscopedHas, |
| 33 | 34 | "prefer-catch-tags": preferCatchTags, |
| 35 | "require-centered-scroll-gutter": requireCenteredScrollGutter, | |
| 34 | 36 | "require-suppression-reason": requireSuppressionReason, |
| 35 | 37 | }, |
| 36 | 38 | }); |
oxlint-plugin-t3code/rules/require-centered-scroll-gutter.ts created+152| ... | ... | @@ -0,0 +1,152 @@ |
| 1 | import { defineRule, type ESTree } from "@oxlint/plugins"; | |
| 2 | import * as Option from "effect/Option"; | |
| 3 | ||
| 4 | import { unwrapExpression } from "../utils.ts"; | |
| 5 | ||
| 6 | const 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. | |
| 9 | const RESERVES_GUTTER = | |
| 10 | /^(?:scrollbar-gutter-(?:stable|both)|\[scrollbar-gutter:stable(?:_both-edges)?\])$/u; | |
| 11 | const HIDES_SCROLLBAR = /^\[scrollbar-width:none\]$/u; | |
| 12 | const CENTERED_BY_MARGIN = /(?:^|:)mx?-auto$/u; | |
| 13 | const COLUMN = /(?:^|:)flex-col(?:-reverse)?$/u; | |
| 14 | const ALWAYS_COLUMN = /^flex-col(?:-reverse)?$/u; | |
| 15 | const ROW = /(?:^|:)flex-row(?:-reverse)?$/u; | |
| 16 | const CENTERS_COLUMN_ITEMS = /(?:^|:)(?:items-center(?:-safe)?|place-items-center)$/u; | |
| 17 | const CENTERS_ROW_ITEMS = | |
| 18 | /(?:^|:)(?:justify-center(?:-safe)?|\[justify-content:safe_center\]|place-items-center|place-content-center)$/u; | |
| 19 | ||
| 20 | interface 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 | */ | |
| 31 | function 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 | ||
| 78 | function 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 | ||
| 92 | const 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 | */ | |
| 100 | export 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 | 173 | "t3code/no-unscoped-has": "error", |
| 174 | 174 | "t3code/namespace-node-imports": "error", |
| 175 | 175 | "t3code/prefer-catch-tags": "error", |
| 176 | "t3code/require-centered-scroll-gutter": "error", | |
| 176 | 177 | "t3code/require-suppression-reason": "error", |
| 177 | 178 | }, |
| 178 | 179 | overrides: [ |