fix(ui): align toggle switch handle symmetrically in on state (#9714)

* fix(ui): align toggle switch handle symmetrically in on state

The switch handle (translate-x-4 / 16px) left a 6px gap on the right
in the on state while the off state had only a 2px gap on the left.
Accounting for border-box sizing (1px border each side reduces content
width to 34px), the correct on-position is translate-x-4.5 (18px) so
both sides have a 2px inset.

* docs: add JSDoc to exported functions in changed files for docstring coverage

* fix(ui): align toggle switch handle symmetrically in on state

The `h-5 w-9` switch track is `border-box` with a 1px border, leaving 34px
of content for the 14px `size-3.5` handle. `translate-x-0.5` insets the off
state by 2px, so the on state needs 34 - 14 - 2 = 18px. It used
`translate-x-4` (16px), leaving 4px on the right against 2px on the left.

Adds a boundary test so the offset cannot drift again across the 26
hand-rolled switch call sites, and drops the docstrings the original patch
added to satisfy a coverage bot (the surrounding files carry none).

Original patch by @sei0.

Co-authored-by: Orca <help@stably.ai>

* test(ui): widen the switch-handle alignment guard to every h-5 w-9 track

Scan the renderer with the readdirSync walk and TypeScript AST already used by
no-top-level-translate.test.ts instead of shelling out to `git grep`, and find
each knob inside its own track rather than by scanning source text forward.

Why: gating on `role="switch"` silently skipped ClaudeUsageLoadingState.tsx (a
loading skeleton, so a div with no role) and HiddenExperimentalGroup.tsx, so
regressing the loading-state handle back to translate-x-4 left the guard green.

The guard now also fails when the premise behind 34 - 14 - 2 = 18px stops
holding: a track that loses its 1px border or gains padding, a conditional whose
two branches no longer pair off/on, and a track whose knob moved into a child
component where the scan would otherwise just stop seeing it.

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
Seiyoung Park 2026-08-10 10:34:05 +09:00 committed by GitHub
parent 1e72534709
commit cb6069ceea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 261 additions and 30 deletions

View File

@ -1219,7 +1219,7 @@ export default function NewWorkspaceComposerCard({
<span
className={cn(
'pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform',
createMultiple ? 'translate-x-4' : 'translate-x-0.5'
createMultiple ? 'translate-x-4.5' : 'translate-x-0.5'
)}
/>
</span>

View File

@ -66,7 +66,7 @@ function AutoRenameBranchFromWorkControl(): JSX.Element {
<span
className={cn(
'pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform',
enabled ? 'translate-x-4' : 'translate-x-0.5'
enabled ? 'translate-x-4.5' : 'translate-x-0.5'
)}
/>
</button>

View File

@ -27,7 +27,7 @@ export function AiCommitPrSettingsSwitch({
<span
className={cn(
'pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform',
checked ? 'translate-x-4' : 'translate-x-0.5'
checked ? 'translate-x-4.5' : 'translate-x-0.5'
)}
/>
</button>

View File

@ -38,7 +38,7 @@ export function KeepAwakeCard(props: {
<span
className={cn(
'pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform',
enabled ? 'translate-x-4' : 'translate-x-0.5'
enabled ? 'translate-x-4.5' : 'translate-x-0.5'
)}
/>
</button>

View File

@ -1570,7 +1570,7 @@ export function AccountsPane({
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.geminiCliOAuthEnabled ? 'translate-x-4' : 'translate-x-0.5'
settings.geminiCliOAuthEnabled ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>

View File

@ -50,7 +50,7 @@ export function AgentAwakeSetting({
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.keepComputerAwakeWhileAgentsRun ? 'translate-x-4' : 'translate-x-0.5'
settings.keepComputerAwakeWhileAgentsRun ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>

View File

@ -186,7 +186,7 @@ export function AutoRenameBranchFromWorkSetting({
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.autoRenameBranchFromWork ? 'translate-x-4' : 'translate-x-0.5'
settings.autoRenameBranchFromWork ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>

View File

@ -54,7 +54,7 @@ export function BrowserLinkRoutingSetting({
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow-sm transition-transform ${
settings.openLinksInApp ? 'translate-x-4' : 'translate-x-0.5'
settings.openLinksInApp ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>

View File

@ -21,7 +21,7 @@ export function BrowserUseEnableSwitch({
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow-sm transition-transform ${
enabled ? 'translate-x-4' : 'translate-x-0.5'
enabled ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>

View File

@ -299,7 +299,7 @@ export function CliSection({
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
isEnabled ? 'translate-x-4' : 'translate-x-0.5'
isEnabled ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>

View File

@ -209,7 +209,7 @@ export function CommitMessageAiPane({
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
config.enabled ? 'translate-x-4' : 'translate-x-0.5'
config.enabled ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>

View File

@ -99,7 +99,7 @@ export function ExperimentalPane({
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow-sm transition-transform ${
settings.experimentalPet ? 'translate-x-4' : 'translate-x-0.5'
settings.experimentalPet ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
@ -144,7 +144,7 @@ export function ExperimentalPane({
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow-sm transition-transform ${
settings.experimentalActivity ? 'translate-x-4' : 'translate-x-0.5'
settings.experimentalActivity ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
@ -203,7 +203,7 @@ export function ExperimentalPane({
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow-sm transition-transform ${
settings.experimentalTerminalAttention ? 'translate-x-4' : 'translate-x-0.5'
settings.experimentalTerminalAttention ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>

View File

@ -291,7 +291,7 @@ export function GitPane({
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.refreshLocalBaseRefOnWorktreeCreate ? 'translate-x-4' : 'translate-x-0.5'
settings.refreshLocalBaseRefOnWorktreeCreate ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
@ -385,7 +385,7 @@ export function GitPane({
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.enableGitHubAttribution ? 'translate-x-4' : 'translate-x-0.5'
settings.enableGitHubAttribution ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>

View File

@ -70,7 +70,7 @@ export function InputPane({ settings, updateSettings }: InputPaneProps): React.J
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
enabled ? 'translate-x-4' : 'translate-x-0.5'
enabled ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>

View File

@ -39,7 +39,7 @@ export function NotificationSettingToggle({
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
checked ? 'translate-x-4' : 'translate-x-0.5'
checked ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>

View File

@ -127,7 +127,7 @@ export function PrivacyPane({ settings }: PrivacyPaneProps): React.JSX.Element {
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
toggleChecked ? 'translate-x-4' : 'translate-x-0.5'
toggleChecked ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>

View File

@ -52,7 +52,7 @@ export function SettingsSwitch({
<span
className={cn(
'pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform',
checked ? 'translate-x-4' : 'translate-x-0.5'
checked ? 'translate-x-4.5' : 'translate-x-0.5'
)}
/>
</button>

View File

@ -133,7 +133,7 @@ export function TerminalWindowSection({
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
(settings.windowBackgroundBlur ?? false) ? 'translate-x-4' : 'translate-x-0.5'
(settings.windowBackgroundBlur ?? false) ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
@ -270,7 +270,7 @@ export function TerminalWindowSection({
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
(settings.terminalMouseHideWhileTyping ?? false)
? 'translate-x-4'
? 'translate-x-4.5'
: 'translate-x-0.5'
}`}
/>

View File

@ -51,7 +51,7 @@ export function VoiceDictationSettingsSection({
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
voiceSettings.enabled ? 'translate-x-4' : 'translate-x-0.5'
voiceSettings.enabled ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>

View File

@ -198,7 +198,7 @@ export function WslCliRegistration({
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
isEnabled ? 'translate-x-4' : 'translate-x-0.5'
isEnabled ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>

View File

@ -251,7 +251,7 @@ export function HostRemoveDialog({
<span
className={cn(
'pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform',
deleteWorkspaces ? 'translate-x-4' : 'translate-x-0.5'
deleteWorkspaces ? 'translate-x-4.5' : 'translate-x-0.5'
)}
/>
</span>

View File

@ -21,7 +21,7 @@ export function ClaudeUsageLoadingState({
<div className="flex shrink-0 items-center gap-2 self-start">
<RefreshCw className="size-3.5 animate-spin text-muted-foreground" />
<div className="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent bg-foreground/80">
<span className="pointer-events-none block size-3.5 translate-x-4 rounded-full bg-background shadow-sm" />
<span className="pointer-events-none block size-3.5 translate-x-4.5 rounded-full bg-background shadow-sm" />
</div>
</div>
</div>

View File

@ -233,7 +233,7 @@ export function ClaudeUsagePane(): React.JSX.Element {
onClick={() => handleSetEnabled(false)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors"
>
<span className="pointer-events-none block size-3.5 translate-x-4 rounded-full bg-background shadow-sm transition-transform" />
<span className="pointer-events-none block size-3.5 translate-x-4.5 rounded-full bg-background shadow-sm transition-transform" />
</button>
</div>
</div>

View File

@ -235,7 +235,7 @@ export function CodexUsagePane(): React.JSX.Element {
onClick={() => handleSetEnabled(false)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors"
>
<span className="pointer-events-none block size-3.5 translate-x-4 rounded-full bg-background shadow-sm transition-transform" />
<span className="pointer-events-none block size-3.5 translate-x-4.5 rounded-full bg-background shadow-sm transition-transform" />
</button>
</div>
</div>

View File

@ -246,7 +246,7 @@ export function OpenCodeUsagePane(): React.JSX.Element {
onClick={() => handleSetEnabled(false)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors"
>
<span className="pointer-events-none block size-3.5 translate-x-4 rounded-full bg-background shadow-sm transition-transform" />
<span className="pointer-events-none block size-3.5 translate-x-4.5 rounded-full bg-background shadow-sm transition-transform" />
</button>
</div>
</div>

View File

@ -39,7 +39,7 @@ export function TerminalQuickCommandAppendEnterSwitch({
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
appendEnter ? 'translate-x-4' : 'translate-x-0.5'
appendEnter ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>

View File

@ -0,0 +1,231 @@
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { relative, resolve } from 'node:path'
// TypeScript 7 is a native CLI; AST tests still need the legacy JavaScript API.
import ts from 'typescript-api'
import { describe, expect, it } from 'vitest'
// Why: the h-5 w-9 switch is hand-rolled at every call site instead of living in a
// primitive, so the handle offset drifts. The 1px-bordered track leaves a 34px content
// box for the 14px (`size-3.5`) handle, and `translate-x-0.5` insets the off state by
// 2px, so a symmetric on state is 34 - 14 - 2 = 18px (`translate-x-4.5`).
const RENDERER_ROOT = resolve('src/renderer/src')
const OFF_STATE = 'translate-x-0.5'
const ON_STATE = 'translate-x-4.5'
/** The track geometry the arithmetic above is derived from; other switch sizes are not ours to check. */
const TRACK = ['h-5', 'w-9']
/** The handle's 14px size, in both the shorthand and long-hand spellings in use. */
const HANDLE_SIZE = [['size-3.5'], ['h-3.5', 'w-3.5']]
/** Every current handle is the round knob; this is what separates it from a size-3.5 icon in the same track. */
const HANDLE_SHAPE = 'rounded-full'
/** Any horizontal translate — negative, arbitrary, variant-prefixed or named — so no respelling reads as "no offset". */
const OFFSET = /(?:^|:)-?translate-x-\S+$/
/** Anything that moves the track off the 34px content box the 18px on-state is derived from. */
const BORDER_WIDTH = /^border(?:-[xytrbles])?-(?:\d+|\[)/
const TRACK_PADDING = /^p[xytrbles]?-(?:\d|\[)/
function collectSourceFiles(dir: string, files: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const filePath = resolve(dir, name)
if (statSync(filePath).isDirectory()) {
collectSourceFiles(filePath, files)
} else if (name.endsWith('.tsx') && !name.endsWith('.test.tsx')) {
files.push(filePath)
}
}
return files
}
/** Class tokens anywhere under a node, flattening the ternaries, template holes and `cn()` args a className is built from. */
function tokensIn(node: ts.Node): string[] {
const tokens: string[] = []
const read = (child: ts.Node): void => {
if (ts.isStringLiteral(child) || ts.isTemplateLiteralToken(child)) {
tokens.push(...child.text.split(/\s+/).filter(Boolean))
}
ts.forEachChild(child, read)
}
read(node)
return tokens
}
/** Reading the className attribute's own subtree keeps a child element's classes out of its parent's token set. */
function classNameOf(element: ts.JsxOpeningLikeElement): ts.Node | undefined {
const className = element.attributes.properties.find(
(property): property is ts.JsxAttribute =>
ts.isJsxAttribute(property) && property.name.getText() === 'className'
)
return className?.initializer
}
function hasAll(tokens: string[], required: string[]): boolean {
return required.every((token) => tokens.includes(token))
}
/**
* The offsets each side of a conditional className contributes. A handle that toggles is
* two-state by construction, so this tells a deliberately single-state knob apart from one
* that lost a branch which a flat token list cannot.
*/
function offsetBranches(node: ts.Node): [string[], string[]][] {
const branches: [string[], string[]][] = []
const read = (child: ts.Node): void => {
if (ts.isConditionalExpression(child)) {
const whenTrue = tokensIn(child.whenTrue).filter((token) => OFFSET.test(token))
const whenFalse = tokensIn(child.whenFalse).filter((token) => OFFSET.test(token))
if (whenTrue.length > 0 || whenFalse.length > 0) {
branches.push([whenTrue, whenFalse])
return
}
}
ts.forEachChild(child, read)
}
read(node)
return branches
}
function trackDrift(tokens: string[]): string | undefined {
if (!tokens.includes('border')) {
return 'no 1px border'
}
if (tokens.includes('box-content')) {
return 'box-content'
}
return tokens.find((token) => BORDER_WIDTH.test(token) || TRACK_PADDING.test(token))
}
type Handle = { at: string; offsets: string[] }
type Toggle = { at: string; branches: [string[], string[]] }
type Scan = { handles: Handle[]; toggles: Toggle[]; drifted: string[]; knobless: string[] }
/**
* Handles are found *inside* a track element rather than by scanning source text forward, so
* neither a sibling element nor a same-file icon can be mistaken for the knob.
*/
function collectFile(filePath: string, source: string, scan: Scan): void {
const sourceFile = ts.createSourceFile(
filePath,
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX
)
const at = (node: ts.Node): string => {
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
return `${relative(RENDERER_ROOT, filePath)}:${line + 1}`
}
const readHandles = (node: ts.Node): void => {
if (ts.isJsxOpeningLikeElement(node)) {
const className = classNameOf(node)
const tokens = className ? tokensIn(className) : []
if (
className &&
HANDLE_SIZE.some((size) => hasAll(tokens, size)) &&
tokens.includes(HANDLE_SHAPE)
) {
scan.handles.push({ at: at(node), offsets: tokens.filter((token) => OFFSET.test(token)) })
for (const branches of offsetBranches(className)) {
scan.toggles.push({ at: at(node), branches })
}
}
}
ts.forEachChild(node, readHandles)
}
const visit = (node: ts.Node): void => {
if (ts.isJsxOpeningLikeElement(node)) {
const className = classNameOf(node)
const tokens = className ? tokensIn(className) : []
if (hasAll(tokens, TRACK)) {
const drift = trackDrift(tokens)
if (drift) {
scan.drifted.push(`${at(node)}: ${drift}`)
}
// The knob lives in the track's subtree; a self-closing track has no subtree to walk.
const found = scan.handles.length
if (ts.isJsxOpeningElement(node)) {
readHandles(node.parent)
}
// A track whose knob moved into a child component would otherwise leave the scan silently.
if (scan.handles.length === found) {
scan.knobless.push(at(node))
}
return
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
}
let cached: Scan | undefined
/** Parsing every gated file is the expensive part, so all three assertions share one scan. */
function scanRenderer(): Scan {
if (!cached) {
const scan: Scan = { handles: [], toggles: [], drifted: [], knobless: [] }
for (const filePath of collectSourceFiles(RENDERER_ROOT)) {
const source = readFileSync(filePath, 'utf8')
if (TRACK.every((token) => source.includes(token))) {
collectFile(filePath, source, scan)
}
}
// A track nested inside another h-5 w-9 element would otherwise be collected twice.
const seen = new Set<string>()
scan.handles = scan.handles.filter((handle) => !seen.has(handle.at) && seen.add(handle.at))
cached = scan
}
// A scan that stops finding switches has lost its bounds, not proven the tree clean. The
// floor only has to catch a collapse — `knobless` and `drifted` catch the structural cases —
// so it sits well under the ~34 handles in the tree rather than pinning the current count.
expect(cached.handles.length, 'the switch scan lost its bounds').toBeGreaterThan(25)
return cached
}
describe('toggle switch handle alignment', () => {
it('insets the handle by 2px at both ends of the track', () => {
const offenders = scanRenderer().handles.flatMap((handle) =>
handle.offsets.length === 0
? [`${handle.at}: no translate-x offset`]
: handle.offsets
.filter((offset) => offset !== OFF_STATE && offset !== ON_STATE)
.map((offset) => `${handle.at}: ${offset}`)
)
expect(offenders).toEqual([])
})
it('gives every toggling handle both ends of the pair', () => {
const pairs = (on: string[], off: string[]): boolean =>
on.length === 1 && on[0] === ON_STATE && off.length === 1 && off[0] === OFF_STATE
const offenders = scanRenderer()
.toggles.filter(({ branches: [a, b] }) => !pairs(a, b) && !pairs(b, a))
.map(
({ at, branches }) => `${at}: ${branches.map((side) => side.join(' ') || '—').join(' / ')}`
)
expect(offenders).toEqual([])
})
it('keeps every track on the geometry the 18px on-state is derived from', () => {
expect(scanRenderer().drifted).toEqual([])
})
it('keeps every track a knob the scan can see', () => {
expect(scanRenderer().knobless).toEqual([])
})
})