diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 478494916..2447e45f2 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -1219,7 +1219,7 @@ export default function NewWorkspaceComposerCard({ diff --git a/src/renderer/src/components/contextual-tours/ContextualTourControl.tsx b/src/renderer/src/components/contextual-tours/ContextualTourControl.tsx index 7898b5795..0a2186c50 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourControl.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourControl.tsx @@ -66,7 +66,7 @@ function AutoRenameBranchFromWorkControl(): JSX.Element { diff --git a/src/renderer/src/components/feature-wall/AiCommitPrSettingsSwitch.tsx b/src/renderer/src/components/feature-wall/AiCommitPrSettingsSwitch.tsx index 7655cd88e..751722d95 100644 --- a/src/renderer/src/components/feature-wall/AiCommitPrSettingsSwitch.tsx +++ b/src/renderer/src/components/feature-wall/AiCommitPrSettingsSwitch.tsx @@ -27,7 +27,7 @@ export function AiCommitPrSettingsSwitch({ diff --git a/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx b/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx index 6340d2f1b..2a7b57adc 100644 --- a/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx +++ b/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx @@ -38,7 +38,7 @@ export function KeepAwakeCard(props: { diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index d9db52dd9..0a3dd5ae7 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -1570,7 +1570,7 @@ export function AccountsPane({ > diff --git a/src/renderer/src/components/settings/AgentAwakeSetting.tsx b/src/renderer/src/components/settings/AgentAwakeSetting.tsx index 66e56c32a..28cd7cc01 100644 --- a/src/renderer/src/components/settings/AgentAwakeSetting.tsx +++ b/src/renderer/src/components/settings/AgentAwakeSetting.tsx @@ -50,7 +50,7 @@ export function AgentAwakeSetting({ > diff --git a/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx b/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx index c4d520cbd..2cbdf227e 100644 --- a/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx +++ b/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx @@ -186,7 +186,7 @@ export function AutoRenameBranchFromWorkSetting({ > diff --git a/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx b/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx index 6b0885a86..b26c1e2fd 100644 --- a/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx +++ b/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx @@ -54,7 +54,7 @@ export function BrowserLinkRoutingSetting({ > diff --git a/src/renderer/src/components/settings/BrowserUseEnableSwitch.tsx b/src/renderer/src/components/settings/BrowserUseEnableSwitch.tsx index cbb4c3361..28a071b15 100644 --- a/src/renderer/src/components/settings/BrowserUseEnableSwitch.tsx +++ b/src/renderer/src/components/settings/BrowserUseEnableSwitch.tsx @@ -21,7 +21,7 @@ export function BrowserUseEnableSwitch({ > diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index 41984fe20..86a5c89f3 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -299,7 +299,7 @@ export function CliSection({ > diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.tsx index e24fc97ee..13f1b10cf 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.tsx @@ -209,7 +209,7 @@ export function CommitMessageAiPane({ > diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx index 62d8420be..9760591d4 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -99,7 +99,7 @@ export function ExperimentalPane({ > @@ -144,7 +144,7 @@ export function ExperimentalPane({ > @@ -203,7 +203,7 @@ export function ExperimentalPane({ > diff --git a/src/renderer/src/components/settings/GitPane.tsx b/src/renderer/src/components/settings/GitPane.tsx index da1a34c3f..490cef11e 100644 --- a/src/renderer/src/components/settings/GitPane.tsx +++ b/src/renderer/src/components/settings/GitPane.tsx @@ -291,7 +291,7 @@ export function GitPane({ > @@ -385,7 +385,7 @@ export function GitPane({ > diff --git a/src/renderer/src/components/settings/InputPane.tsx b/src/renderer/src/components/settings/InputPane.tsx index 55094ec4a..125e90cc0 100644 --- a/src/renderer/src/components/settings/InputPane.tsx +++ b/src/renderer/src/components/settings/InputPane.tsx @@ -70,7 +70,7 @@ export function InputPane({ settings, updateSettings }: InputPaneProps): React.J > diff --git a/src/renderer/src/components/settings/NotificationSettingToggle.tsx b/src/renderer/src/components/settings/NotificationSettingToggle.tsx index 4de95f53b..935023924 100644 --- a/src/renderer/src/components/settings/NotificationSettingToggle.tsx +++ b/src/renderer/src/components/settings/NotificationSettingToggle.tsx @@ -39,7 +39,7 @@ export function NotificationSettingToggle({ > diff --git a/src/renderer/src/components/settings/PrivacyPane.tsx b/src/renderer/src/components/settings/PrivacyPane.tsx index f2334e867..8a096f8c2 100644 --- a/src/renderer/src/components/settings/PrivacyPane.tsx +++ b/src/renderer/src/components/settings/PrivacyPane.tsx @@ -127,7 +127,7 @@ export function PrivacyPane({ settings }: PrivacyPaneProps): React.JSX.Element { > diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index a2d57a6ac..27bcf7acc 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -52,7 +52,7 @@ export function SettingsSwitch({ diff --git a/src/renderer/src/components/settings/TerminalWindowSection.tsx b/src/renderer/src/components/settings/TerminalWindowSection.tsx index 0cd000602..3ca33d25c 100644 --- a/src/renderer/src/components/settings/TerminalWindowSection.tsx +++ b/src/renderer/src/components/settings/TerminalWindowSection.tsx @@ -133,7 +133,7 @@ export function TerminalWindowSection({ > @@ -270,7 +270,7 @@ export function TerminalWindowSection({ diff --git a/src/renderer/src/components/settings/VoiceDictationSettingsSection.tsx b/src/renderer/src/components/settings/VoiceDictationSettingsSection.tsx index f148e631d..3747781b9 100644 --- a/src/renderer/src/components/settings/VoiceDictationSettingsSection.tsx +++ b/src/renderer/src/components/settings/VoiceDictationSettingsSection.tsx @@ -51,7 +51,7 @@ export function VoiceDictationSettingsSection({ > diff --git a/src/renderer/src/components/settings/WslCliRegistration.tsx b/src/renderer/src/components/settings/WslCliRegistration.tsx index 7d01d96d5..586d30684 100644 --- a/src/renderer/src/components/settings/WslCliRegistration.tsx +++ b/src/renderer/src/components/settings/WslCliRegistration.tsx @@ -198,7 +198,7 @@ export function WslCliRegistration({ > diff --git a/src/renderer/src/components/sidebar/HostRemoveDialog.tsx b/src/renderer/src/components/sidebar/HostRemoveDialog.tsx index 97135f97e..d99f5e860 100644 --- a/src/renderer/src/components/sidebar/HostRemoveDialog.tsx +++ b/src/renderer/src/components/sidebar/HostRemoveDialog.tsx @@ -251,7 +251,7 @@ export function HostRemoveDialog({ diff --git a/src/renderer/src/components/stats/ClaudeUsageLoadingState.tsx b/src/renderer/src/components/stats/ClaudeUsageLoadingState.tsx index e459ab9c6..59c378cc8 100644 --- a/src/renderer/src/components/stats/ClaudeUsageLoadingState.tsx +++ b/src/renderer/src/components/stats/ClaudeUsageLoadingState.tsx @@ -21,7 +21,7 @@ export function ClaudeUsageLoadingState({
- +
diff --git a/src/renderer/src/components/stats/ClaudeUsagePane.tsx b/src/renderer/src/components/stats/ClaudeUsagePane.tsx index 32536628f..dfd83b666 100644 --- a/src/renderer/src/components/stats/ClaudeUsagePane.tsx +++ b/src/renderer/src/components/stats/ClaudeUsagePane.tsx @@ -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" > - + diff --git a/src/renderer/src/components/stats/CodexUsagePane.tsx b/src/renderer/src/components/stats/CodexUsagePane.tsx index 2ebd6913d..4ae5074de 100644 --- a/src/renderer/src/components/stats/CodexUsagePane.tsx +++ b/src/renderer/src/components/stats/CodexUsagePane.tsx @@ -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" > - + diff --git a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx index c09f2a5a1..b70b06593 100644 --- a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx +++ b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx @@ -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" > - + diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx index ad45db4b0..0ddd62224 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx @@ -39,7 +39,7 @@ export function TerminalQuickCommandAppendEnterSwitch({ > diff --git a/src/renderer/src/components/toggle-switch-handle-alignment.test.ts b/src/renderer/src/components/toggle-switch-handle-alignment.test.ts new file mode 100644 index 000000000..b0a5574dd --- /dev/null +++ b/src/renderer/src/components/toggle-switch-handle-alignment.test.ts @@ -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() + 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([]) + }) +})