From db4199e91a189704dc5832bf11220ce6d9085e8a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 19 May 2026 23:12:37 -0700 Subject: [PATCH] Enforce styled scrollbars in renderer --- .github/workflows/pr.yml | 3 + config/scripts/check-styled-scrollbars.mjs | 157 ++++++++++++++++++ .../scripts/check-styled-scrollbars.test.mjs | 37 +++++ package.json | 3 +- src/renderer/src/assets/main.css | 2 +- .../src/components/GitHubItemDialog.tsx | 12 +- src/renderer/src/components/TaskPage.tsx | 4 +- .../automations/AutomationEditorDialog.tsx | 2 +- .../browser-pane/GrabConfirmationSheet.tsx | 2 +- .../crash-report/CrashReportDialog.tsx | 2 +- .../components/editor/CombinedDiffViewer.tsx | 2 +- .../src/components/editor/ImageDiffViewer.tsx | 2 +- .../src/components/editor/ImageViewer.tsx | 6 +- .../src/components/editor/IpynbViewer.tsx | 4 +- .../github-project/ProjectPicker.tsx | 4 +- .../components/github-project/ProjectRow.tsx | 2 +- .../slug-dialog/SlugDialogBody.tsx | 2 +- .../right-sidebar/SourceControl.tsx | 2 +- .../settings/ManageSessionsSection.tsx | 2 +- .../src/components/sidebar/AddRepoSteps.tsx | 2 +- .../sidebar/OrcaYamlTrustDialog.tsx | 2 +- .../components/sidebar/WorktreeCardMeta.tsx | 2 +- .../components/sidebar/WorktreeMetaDialog.tsx | 2 +- .../sparse/SparseCheckoutPresetSelect.tsx | 2 +- .../src/components/status-bar/StatusBar.tsx | 4 +- .../src/components/ui/context-menu.tsx | 2 +- .../src/components/ui/dropdown-menu.tsx | 2 +- src/renderer/src/components/ui/select.tsx | 2 +- 28 files changed, 236 insertions(+), 34 deletions(-) create mode 100644 config/scripts/check-styled-scrollbars.mjs create mode 100644 config/scripts/check-styled-scrollbars.test.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index cfee0fe14cc..c2647d83188 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -51,6 +51,9 @@ jobs: - name: Lint run: pnpm exec oxlint --format github + - name: Check styled scrollbars + run: pnpm check:styled-scrollbars + # Why: project-owned type declarations must live in .ts so tsc # actually checks them. TypeScript's skipLibCheck: true (inherited # from @electron-toolkit/tsconfig) silently widens unresolved names diff --git a/config/scripts/check-styled-scrollbars.mjs b/config/scripts/check-styled-scrollbars.mjs new file mode 100644 index 00000000000..fe5d59f13f0 --- /dev/null +++ b/config/scripts/check-styled-scrollbars.mjs @@ -0,0 +1,157 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import process from 'node:process' +import ts from 'typescript' + +const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']) +const SKIP_PATH_PARTS = new Set(['node_modules', 'dist', 'out', '.git', '__snapshots__']) +const STYLED_SCROLLBAR_CLASSES = new Set([ + 'scrollbar-sleek', + 'scrollbar-editor', + 'scrollbar-none', + 'worktree-sidebar-scrollbar' +]) +// Why: vertical scrolling is where Orca's native scrollbar drift keeps showing +// up in cards, dialogs, and menus; horizontal code/table overflow is handled separately. +const VERTICAL_SCROLL_CLASSES = new Set([ + 'overflow-auto', + 'overflow-scroll', + 'overflow-y-auto', + 'overflow-y-scroll' +]) + +export function normalizePath(root, filePath) { + return path.relative(root, filePath).split(path.sep).join('/') +} + +function isSkippedFile(root, filePath) { + const relative = normalizePath(root, filePath) + if (relative.includes('.test.') || relative.includes('.spec.')) { + return true + } + return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part)) +} + +async function collectSourceFiles(root, dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }) + const files = [] + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (!SKIP_PATH_PARTS.has(entry.name)) { + files.push(...(await collectSourceFiles(root, fullPath))) + } + continue + } + if (!entry.isFile() || isSkippedFile(root, fullPath)) { + continue + } + if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) { + files.push(fullPath) + } + } + + return files +} + +export function plainClassName(token) { + const withoutImportant = token.startsWith('!') ? token.slice(1) : token + const variantSeparator = withoutImportant.lastIndexOf(':') + return variantSeparator === -1 ? withoutImportant : withoutImportant.slice(variantSeparator + 1) +} + +function hasVerticalScrollClass(text) { + return text.split(/\s+/).some((token) => VERTICAL_SCROLL_CLASSES.has(plainClassName(token))) +} + +function hasStyledScrollbarClass(text) { + return text.split(/\s+/).some((token) => STYLED_SCROLLBAR_CLASSES.has(plainClassName(token))) +} + +function lineAndColumnForPosition(sourceText, position) { + let line = 1 + let lineStart = 0 + for (let index = 0; index < position; index += 1) { + if (sourceText.charCodeAt(index) === 10) { + line += 1 + lineStart = index + 1 + } + } + return { line, column: position - lineStart + 1 } +} + +function stringFragments(node) { + if (ts.isStringLiteralLike(node)) { + return [node.text] + } + if (!ts.isTemplateExpression(node)) { + return [] + } + return [node.head.text, ...node.templateSpans.map((span) => span.literal.text)] +} + +export function reportUnstyledScrollbars(filePath, sourceText) { + const sourceFile = ts.createSourceFile( + filePath, + sourceText, + ts.ScriptTarget.Latest, + true, + filePath.endsWith('.tsx') || filePath.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS + ) + const reports = [] + + function visit(node) { + const fragments = stringFragments(node) + if (fragments.some(hasVerticalScrollClass) && !fragments.some(hasStyledScrollbarClass)) { + const { line, column } = lineAndColumnForPosition(sourceText, node.getStart(sourceFile)) + reports.push({ filePath, line, column, text: fragments.join('${...}').trim() }) + } + ts.forEachChild(node, visit) + } + + visit(sourceFile) + return reports +} + +export async function collectUnstyledScrollbarReports(root = process.cwd()) { + const scanRoot = path.join(root, 'src', 'renderer', 'src') + const files = await collectSourceFiles(root, scanRoot) + const reports = [] + + for (const filePath of files) { + const sourceText = await fs.readFile(filePath, 'utf8') + reports.push(...reportUnstyledScrollbars(filePath, sourceText)) + } + + return reports +} + +export function formatReports(root, reports) { + return reports + .map( + (report) => + `${normalizePath(root, report.filePath)}:${report.line}:${report.column} ${report.text.replace(/\s+/g, ' ')}` + ) + .join('\n') +} + +export async function main(root = process.cwd()) { + const reports = await collectUnstyledScrollbarReports(root) + if (reports.length === 0) { + return 0 + } + + console.error('Renderer vertical scroll containers must use an Orca scrollbar style.') + console.error( + 'Add scrollbar-sleek, scrollbar-editor, scrollbar-none, or use the shadcn ScrollArea wrapper.' + ) + console.error('') + console.error(formatReports(root, reports)) + return 1 +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exit(await main()) +} diff --git a/config/scripts/check-styled-scrollbars.test.mjs b/config/scripts/check-styled-scrollbars.test.mjs new file mode 100644 index 00000000000..0fcd4aeb0c9 --- /dev/null +++ b/config/scripts/check-styled-scrollbars.test.mjs @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { plainClassName, reportUnstyledScrollbars } from './check-styled-scrollbars.mjs' + +describe('check-styled-scrollbars', () => { + it('reports renderer vertical scroll containers without an Orca scrollbar style', () => { + const reports = reportUnstyledScrollbars( + 'Example.tsx', + 'export function Example() { return
}' + ) + + expect(reports).toHaveLength(1) + expect(reports[0].text).toContain('overflow-y-auto') + }) + + it('accepts styled vertical scroll containers', () => { + const reports = reportUnstyledScrollbars( + 'Example.tsx', + 'export function Example() { return
}' + ) + + expect(reports).toHaveLength(0) + }) + + it('does not require a vertical scrollbar style for horizontal-only overflow', () => { + const reports = reportUnstyledScrollbars( + 'Example.tsx', + 'export function Example() { return
 }'
+    )
+
+    expect(reports).toHaveLength(0)
+  })
+
+  it('normalizes Tailwind variants and important prefixes before matching', () => {
+    expect(plainClassName('md:overflow-y-auto')).toBe('overflow-y-auto')
+    expect(plainClassName('!scrollbar-editor')).toBe('scrollbar-editor')
+  })
+})
diff --git a/package.json b/package.json
index 89476e36c80..56b38c44c62 100644
--- a/package.json
+++ b/package.json
@@ -11,9 +11,10 @@
   "main": "./out/main/index.js",
   "scripts": {
     "format": "oxfmt --write .",
-    "lint": "oxlint",
+    "lint": "oxlint && node config/scripts/check-styled-scrollbars.mjs",
     "prepare": "husky",
     "test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts",
+    "check:styled-scrollbars": "node config/scripts/check-styled-scrollbars.mjs",
     "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs",
     "verify:macos-entitlements": "node config/scripts/verify-macos-entitlements.mjs",
     "vendor:feature-wall-assets": "node config/scripts/vendor-feature-wall-assets.mjs",
diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css
index f8a63f66406..f1838decf13 100644
--- a/src/renderer/src/assets/main.css
+++ b/src/renderer/src/assets/main.css
@@ -318,7 +318,7 @@
 /* Keep the sidebar gutter reserved; only the thumb fades in on hover. */
 .worktree-sidebar-scrollbar {
   /* Why: the scrollbar itself owns the gutter; padding is only the card-to-gutter gap. */
-  padding-right: 6px;
+  padding-right: 2px;
   scrollbar-gutter: stable;
   scrollbar-color: transparent transparent;
 }
diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx
index ce366dc51a4..0e20ac053eb 100644
--- a/src/renderer/src/components/GitHubItemDialog.tsx
+++ b/src/renderer/src/components/GitHubItemDialog.tsx
@@ -3749,7 +3749,7 @@ function ChecksTab({
                 
Annotations
-
+
{details!.annotations.map((annotation, index) => (
{annotation.rawDetails && ( -
+                        
                           {annotation.rawDetails}
                         
)} @@ -3793,7 +3793,7 @@ function ChecksTab({
Jobs
-
+
{details!.jobs.map((job, index) => (
{compactHeader} -
{sorted.map(renderCheckRow)}
+
+ {sorted.map(renderCheckRow)} +
) } @@ -4017,7 +4019,7 @@ function MentionTextarea({ return (
{showSuggestions && ( -
+
{suggestions.map((option, index) => (

Cmd/Ctrl+Enter to submit.

@@ -5523,7 +5523,7 @@ export default function TaskPage(): React.JSX.Element { placeholder="What's going on?" rows={6} disabled={newLinearIssueSubmitting} - className="w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 resize-none max-h-60 overflow-y-auto" + className="w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 resize-none max-h-60 overflow-y-auto scrollbar-sleek" />

Cmd/Ctrl+Enter to submit.

diff --git a/src/renderer/src/components/automations/AutomationEditorDialog.tsx b/src/renderer/src/components/automations/AutomationEditorDialog.tsx index 76915590b3f..6599ef1f91d 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialog.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialog.tsx @@ -193,7 +193,7 @@ export function AutomationEditorDialog({
-
+
{draft.scheduleWarning ? (
{draft.scheduleWarning} diff --git a/src/renderer/src/components/browser-pane/GrabConfirmationSheet.tsx b/src/renderer/src/components/browser-pane/GrabConfirmationSheet.tsx index a18ad4b80a3..9e6236f5e17 100644 --- a/src/renderer/src/components/browser-pane/GrabConfirmationSheet.tsx +++ b/src/renderer/src/components/browser-pane/GrabConfirmationSheet.tsx @@ -208,7 +208,7 @@ export default function GrabConfirmationSheet({

HTML

-
+              
                 
               
diff --git a/src/renderer/src/components/crash-report/CrashReportDialog.tsx b/src/renderer/src/components/crash-report/CrashReportDialog.tsx index 11482068f4b..ae034292e7e 100644 --- a/src/renderer/src/components/crash-report/CrashReportDialog.tsx +++ b/src/renderer/src/components/crash-report/CrashReportDialog.tsx @@ -194,7 +194,7 @@ export function CrashReportDialog(): React.JSX.Element { />
Diagnostic text
-
+              
                 {diagnosticText}
               
diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index 8ee2ca0f6fe..2b0ea798169 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -1283,7 +1283,7 @@ function DiffNotesPreviewPopover({
-
+
{comments.map((comment) => (
diff --git a/src/renderer/src/components/editor/ImageDiffViewer.tsx b/src/renderer/src/components/editor/ImageDiffViewer.tsx index 9b6b4d8495b..fe6bbd34f96 100644 --- a/src/renderer/src/components/editor/ImageDiffViewer.tsx +++ b/src/renderer/src/components/editor/ImageDiffViewer.tsx @@ -89,7 +89,7 @@ export default function ImageDiffViewer({ 'grid min-h-0 gap-3 p-3', isIntrinsicLayout ? 'h-auto' : 'h-full', sideBySide ? 'grid-cols-2' : 'grid-cols-1', - !sideBySide && !isIntrinsicLayout && 'overflow-y-auto' + !sideBySide && !isIntrinsicLayout && 'overflow-y-auto scrollbar-editor' )} style={gridRowStyle} > diff --git a/src/renderer/src/components/editor/ImageViewer.tsx b/src/renderer/src/components/editor/ImageViewer.tsx index e8b9a55a971..d4bf370cc7b 100644 --- a/src/renderer/src/components/editor/ImageViewer.tsx +++ b/src/renderer/src/components/editor/ImageViewer.tsx @@ -106,7 +106,9 @@ export default function ImageViewer({
setIsPopupOpen(true)} title="Open image in popup" @@ -193,7 +195,7 @@ export default function ImageViewer({ Close
-
+
@@ -395,7 +395,7 @@ function OutputItem({ item }: { item: IpynbOutputItem }): React.JSX.Element | nu return null } return ( -
+
{item.mime}
) diff --git a/src/renderer/src/components/github-project/ProjectPicker.tsx b/src/renderer/src/components/github-project/ProjectPicker.tsx index 29f2d1cf3d8..b3f22e5d52e 100644 --- a/src/renderer/src/components/github-project/ProjectPicker.tsx +++ b/src/renderer/src/components/github-project/ProjectPicker.tsx @@ -388,7 +388,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React {!browseError && partialFailures.length > 0 ? ( ) : null} -
+
{projectSettings.pinned.length > 0 ? (
{projectSettings.pinned.map((p) => { @@ -619,7 +619,7 @@ function ViewPickStep({ Choose a view
-
+
{loading ? (
Loading views… diff --git a/src/renderer/src/components/github-project/ProjectRow.tsx b/src/renderer/src/components/github-project/ProjectRow.tsx index e76fa769362..ae1134a251a 100644 --- a/src/renderer/src/components/github-project/ProjectRow.tsx +++ b/src/renderer/src/components/github-project/ProjectRow.tsx @@ -152,7 +152,7 @@ export default function ProjectRow({ {draftBody} diff --git a/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx b/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx index 5878ab970ef..6165d42ffd0 100644 --- a/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx @@ -232,7 +232,7 @@ export function SlugDialogBody({
-
+
{loading && !details ? (
Loading… diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 4f0d0b2b39e..4c44e0fbee4 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -3778,7 +3778,7 @@ export function CommitArea({ Commit Failed {commitFailureSummary} -
+            
               {commitError}
             
diff --git a/src/renderer/src/components/settings/ManageSessionsSection.tsx b/src/renderer/src/components/settings/ManageSessionsSection.tsx index 8efeafa903d..cee5bdd6856 100644 --- a/src/renderer/src/components/settings/ManageSessionsSection.tsx +++ b/src/renderer/src/components/settings/ManageSessionsSection.tsx @@ -410,7 +410,7 @@ export function ManageSessionsSection(): React.JSX.Element { No sessions.
) : ( -
+
{sessions.map((session) => { diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.tsx b/src/renderer/src/components/sidebar/AddRepoSteps.tsx index 45c8f4868e1..902434c7358 100644 --- a/src/renderer/src/components/sidebar/AddRepoSteps.tsx +++ b/src/renderer/src/components/sidebar/AddRepoSteps.tsx @@ -251,7 +251,7 @@ export function RemoteStep({ ) : ( -
+
{sshTargets.map((target) => ( {previouslyApproved ? `New ${scriptKind} script` : `${scriptKind} script`}
-
+            
               {scriptContent}
             
diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx index df0e69074bd..9eff4ab6c1f 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx @@ -271,7 +271,7 @@ export function WorktreeCardDetailsHover({ side="right" align="start" sideOffset={8} - className="w-80 max-h-[28rem] overflow-y-auto p-3 text-xs" + className="w-80 max-h-[28rem] overflow-y-auto p-3 text-xs scrollbar-sleek" onClick={(event) => event.stopPropagation()} >
diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx index b400ad0109b..48a966c3063 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx @@ -340,7 +340,7 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { onKeyDown={handleCommentKeyDown} placeholder="Notes about this worktree..." rows={3} - className="w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-2 text-xs shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 resize-none max-h-60 overflow-y-auto" + className="w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-2 text-xs shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 resize-none max-h-60 overflow-y-auto scrollbar-sleek" />

Supports **markdown** — bold, lists, `code`, links. Press Enter or{' '} diff --git a/src/renderer/src/components/sparse/SparseCheckoutPresetSelect.tsx b/src/renderer/src/components/sparse/SparseCheckoutPresetSelect.tsx index 6ff88ae45cf..853bc8165c1 100644 --- a/src/renderer/src/components/sparse/SparseCheckoutPresetSelect.tsx +++ b/src/renderer/src/components/sparse/SparseCheckoutPresetSelect.tsx @@ -214,7 +214,7 @@ export default function SparseCheckoutPresetSelect({ event.preventDefault()} > {draft ? ( diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 9508e1c39b4..2ce9553f527 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -167,7 +167,7 @@ function ClaudeSwitcherMenu({

Switch to
-
+
{availableSwitchTargets.length === 0 ? (
No other accounts
) : null} @@ -557,7 +557,7 @@ function CodexSwitcherMenu({
Switch to
-
+
{availableSwitchTargets.length === 0 ? (
No other accounts
) : null} diff --git a/src/renderer/src/components/ui/context-menu.tsx b/src/renderer/src/components/ui/context-menu.tsx index 862ff643d0e..d37a6308002 100644 --- a/src/renderer/src/components/ui/context-menu.tsx +++ b/src/renderer/src/components/ui/context-menu.tsx @@ -81,7 +81,7 @@ function ContextMenuContent({