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
2 changes: 1 addition & 1 deletion apps/web/src/components/RightPanelTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,7 @@ function RightPanelEmptyState(props: {
aria-label="Open a surface"
data-surface-launcher-keys={availableActions.map((action) => action.shortcut).join("")}
className={cn(
"flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 outline-none",
"scrollbar-gutter-both flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 outline-none",
// The panel topbar sits above this container; matching bottom padding
// keeps the list centered against the full panel, not the leftover.
"pb-(--workspace-topbar-height)",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/device/DevicePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ export function DevicePanel(props: {
}
/>
) : (
<div className="flex size-full flex-col overflow-y-auto px-5 py-8 text-sm text-muted-foreground">
<div className="scrollbar-gutter-both flex size-full flex-col overflow-y-auto px-5 py-8 text-sm text-muted-foreground">
<div
className={cn(
"mx-auto flex w-full max-w-xl flex-col gap-6",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/files/AttachmentFilePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ export function AttachmentFilePreview(props: {
/>
</div>
) : kind === "image" ? (
// oxlint-disable-next-line t3code/require-centered-scroll-gutter -- The image is capped at max-h-full max-w-full, so this never scrolls.
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4">
<img
src={url}
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ function WorkspaceImagePreview(props: {
}

return assetUrl._tag === "Success" && imageUrl !== null ? (
// oxlint-disable-next-line t3code/require-centered-scroll-gutter -- The image is capped at max-h-full max-w-full, so this never scrolls.
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4">
<MediaActions source={actionsSource}>
<img
Expand Down Expand Up @@ -1367,7 +1368,10 @@ export default function FilePreviewPanel({
workspaceMutationId={workspaceMutationId}
/>
) : relativePath && file.error && file.data === null ? (
<div role="alert" className="flex min-h-0 flex-1 flex-col overflow-auto">
<div
role="alert"
className="scrollbar-gutter-both flex min-h-0 flex-1 flex-col overflow-auto"
>
<div className="my-auto flex shrink-0 flex-col gap-3 px-6 py-6 text-center text-xs leading-relaxed">
<p className="text-destructive">
{file.readError ? filePreviewReadErrorMessage(file.readError) : file.error}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/preview/PreviewEmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function PreviewEmptyState({
}

return (
<div className="flex h-full min-h-0 overflow-y-auto px-5 py-8">
<div className="scrollbar-gutter-both flex h-full min-h-0 overflow-y-auto px-5 py-8">
<div className="mx-auto flex w-full max-w-xl flex-col gap-6">
{recents.length > 0 ? (
<div className="flex flex-col gap-3">
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/preview/PreviewUnreachable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function PreviewUnreachable({ url, code, description, onReload }: Props)
const errorLabel = description.length > 0 ? description : `ERR_${Math.abs(code) || "FAILED"}`;

return (
<div className="relative flex h-full min-h-0 w-full overflow-y-auto bg-background">
<div className="scrollbar-gutter-both relative flex h-full min-h-0 w-full overflow-y-auto bg-background">
<div className="mx-auto flex w-full max-w-xl flex-1 flex-col px-8 py-12 sm:py-16">
<ErrorIcon className="mb-6 size-12 text-muted-foreground/70" />
<h1 className="mb-3 text-2xl font-semibold leading-tight text-foreground">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@ export function PullRequestTimelineTab({
};

return (
<div className="h-full overflow-y-auto px-4 py-5">
<div className="scrollbar-gutter-both h-full overflow-y-auto px-4 py-5">
<div className="mx-auto max-w-3xl">
<div className="relative">
<span aria-hidden className="absolute bottom-5 left-[15px] top-1 w-px bg-border/45" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function PullRequestsUnavailableState({
gitHubUrl?: string;
}) {
return (
<Empty className="min-h-0 justify-center-safe overflow-y-auto [&>*]:shrink-0">
<Empty className="scrollbar-gutter-both min-h-0 justify-center-safe overflow-y-auto [&>*]:shrink-0">
<EmptyMedia variant="icon">
<PullRequestGlyph.pullRequest />
</EmptyMedia>
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/settings/ProviderSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,10 @@ function ProviderSettingsPlaceholder({
) : null}
<SettingsGroup
divided={false}
className={cn(providerCardHeightClassName, "flex overflow-x-hidden overflow-y-auto")}
className={cn(
providerCardHeightClassName,
"scrollbar-gutter-both flex overflow-x-hidden overflow-y-auto",
)}
>
<Empty>
<EmptyMedia variant="icon">{icon}</EmptyMedia>
Expand Down
2 changes: 2 additions & 0 deletions oxlint-plugin-t3code/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import noTestInLoop from "./rules/no-test-in-loop.ts";
import noRpcPermissionBypass from "./rules/no-rpc-permission-bypass.ts";
import noUnscopedHas from "./rules/no-unscoped-has.ts";
import preferCatchTags from "./rules/prefer-catch-tags.ts";
import requireCenteredScrollGutter from "./rules/require-centered-scroll-gutter.ts";
import requireSuppressionReason from "./rules/require-suppression-reason.ts";

export default definePlugin({
Expand All @@ -31,6 +32,7 @@ export default definePlugin({
"no-rpc-permission-bypass": noRpcPermissionBypass,
"no-unscoped-has": noUnscopedHas,
"prefer-catch-tags": preferCatchTags,
"require-centered-scroll-gutter": requireCenteredScrollGutter,
"require-suppression-reason": requireSuppressionReason,
},
});
152 changes: 152 additions & 0 deletions oxlint-plugin-t3code/rules/require-centered-scroll-gutter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { defineRule, type ESTree } from "@oxlint/plugins";
import * as Option from "effect/Option";

import { unwrapExpression } from "../utils.ts";

const SCROLLS_VERTICALLY = /(?:^|:)overflow-(?:y-)?auto$/u;
// Either utility keeps the content box the same width whether or not the scrollbar shows. Both
// exemptions must be unprefixed: a variant such as lg: leaves every other state unprotected.
const RESERVES_GUTTER =
/^(?:scrollbar-gutter-(?:stable|both)|\[scrollbar-gutter:stable(?:_both-edges)?\])$/u;
const HIDES_SCROLLBAR = /^\[scrollbar-width:none\]$/u;
const CENTERED_BY_MARGIN = /(?:^|:)mx?-auto$/u;
const COLUMN = /(?:^|:)flex-col(?:-reverse)?$/u;
const ALWAYS_COLUMN = /^flex-col(?:-reverse)?$/u;
const ROW = /(?:^|:)flex-row(?:-reverse)?$/u;
const CENTERS_COLUMN_ITEMS = /(?:^|:)(?:items-center(?:-safe)?|place-items-center)$/u;
const CENTERS_ROW_ITEMS =
/(?:^|:)(?:justify-center(?:-safe)?|\[justify-content:safe_center\]|place-items-center|place-content-center)$/u;

interface ClassNames {
/** Classes that apply in at least one render. */
readonly possible: Set<string>;
/** Classes that apply in every render, outside any conditional branch. */
readonly always: Set<string>;
}

/**
* The class names a className value can produce: string literals, template text, and the
* branches and arguments of conditionals and cn()-style calls. Conditions themselves are skipped.
*/
function collectClassNames(node: unknown, classNames: ClassNames, always: boolean) {
const expression = unwrapExpression(node);
if (Option.isNone(expression)) return;
const value = expression.value;

const addAll = (text: string | null) => {
for (const className of text?.split(/\s+/u) ?? []) {
if (!className) continue;
classNames.possible.add(className);
if (always) classNames.always.add(className);
}
};

switch (value.type) {
case "Literal":
if (typeof value.value === "string") addAll(value.value);
return;
case "TemplateLiteral":
value.quasis.forEach((quasi, index) => {
// A token touching an interpolation is only part of a class name, such as `${prefix}x`.
const tokens = (quasi.value.cooked ?? "").split(/\s+/u);
if (index > 0) tokens.shift();
if (!quasi.tail) tokens.pop();
addAll(tokens.join(" "));
});
for (const nested of value.expressions) collectClassNames(nested, classNames, always);
return;
case "JSXExpressionContainer":
collectClassNames(value.expression, classNames, always);
return;
case "ConditionalExpression":
collectClassNames(value.consequent, classNames, false);
collectClassNames(value.alternate, classNames, false);
return;
case "LogicalExpression":
collectClassNames(value.left, classNames, false);
collectClassNames(value.right, classNames, false);
return;
case "ArrayExpression":
for (const element of value.elements) collectClassNames(element, classNames, always);
return;
case "CallExpression":
for (const argument of value.arguments) collectClassNames(argument, classNames, always);
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

function classNamesOf(element: ESTree.JSXElement): ClassNames {
const classNames: ClassNames = { possible: new Set(), always: new Set() };
for (const attribute of element.openingElement.attributes) {
if (
attribute.type === "JSXAttribute" &&
attribute.name.type === "JSXIdentifier" &&
attribute.name.name === "className"
) {
collectClassNames(attribute.value, classNames, true);
}
}
return classNames;
}

const hasClass = (classNames: ReadonlySet<string>, pattern: RegExp) =>
[...classNames].some((className) => pattern.test(className));

/**
* Reports a native vertical scroller that centers its content without reserving the scrollbar
* lane. A classic scrollbar narrows the scroller when it appears, so centered content jumps by
* half its width whenever the content grows past the fold.
*/
export default defineRule({
meta: {
type: "problem",
docs: {
description:
"Require scrollbar-gutter-both on overflow-auto scrollers that center their content, so it does not shift when the scrollbar appears.",
},
},
create(context) {
const reported = new Set<ESTree.JSXElement>();

const report = (scroller: ESTree.JSXElement, { always }: ClassNames) => {
if (reported.has(scroller)) return;
if (hasClass(always, RESERVES_GUTTER) || hasClass(always, HIDES_SCROLLBAR)) return;
reported.add(scroller);
context.report({
node: scroller.openingElement,
message:
"This scroller centers its content, which shifts sideways when the scrollbar appears. Add scrollbar-gutter-both.",
});
};

return {
JSXElement(node) {
const classNames = classNamesOf(node);
const { possible } = classNames;
if (possible.size === 0) return;

if (hasClass(possible, SCROLLS_VERTICALLY)) {
// A responsive or conditional direction can render as either axis, so check both.
const centersColumn =
hasClass(possible, COLUMN) && hasClass(possible, CENTERS_COLUMN_ITEMS);
const centersRow =
(!hasClass(classNames.always, ALWAYS_COLUMN) || hasClass(possible, ROW)) &&
hasClass(possible, CENTERS_ROW_ITEMS);
if (centersColumn || centersRow) report(node, classNames);
}

if (!hasClass(possible, CENTERED_BY_MARGIN)) return;
// The nearest enclosing scroller is the one whose scrollbar moves this element.
for (let ancestor: ESTree.Node | null = node.parent; ancestor; ancestor = ancestor.parent) {
// An element passed as a prop renders wherever that component puts it.
if (ancestor.type === "JSXAttribute") return;
if (ancestor.type !== "JSXElement") continue;
const ancestorClassNames = classNamesOf(ancestor);
if (!hasClass(ancestorClassNames.possible, SCROLLS_VERTICALLY)) continue;
report(ancestor, ancestorClassNames);
return;
}
},
};
},
});
1 change: 1 addition & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ export default defineConfig({
"t3code/no-unscoped-has": "error",
"t3code/namespace-node-imports": "error",
"t3code/prefer-catch-tags": "error",
"t3code/require-centered-scroll-gutter": "error",
"t3code/require-suppression-reason": "error",
},
overrides: [
Expand Down
Loading