From 198c0ba311b7b73d4b71fdee6c7b449edaac5599 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sun, 9 Aug 2026 18:55:22 -0700
Subject: [PATCH] refactor(ui): consolidate toggle switches
---
.../components/NewWorkspaceComposerCard.tsx | 16 +-
.../ContextualTourControl.tsx | 23 +-
.../feature-wall/AiCommitPrSettingsSwitch.tsx | 23 +-
.../components/feature-wall/KeepAwakeCard.tsx | 24 +-
.../src/components/settings/AccountsPane.tsx | 21 +-
.../AutoRenameBranchFromWorkSetting.tsx | 21 +-
.../settings/BrowserLinkRoutingSetting.tsx | 21 +-
.../settings/BrowserUseEnableSwitch.tsx | 19 +-
.../src/components/settings/CliSection.tsx | 20 +-
.../settings/CommitMessageAiPane.tsx | 16 +-
.../components/settings/ExperimentalPane.tsx | 64 ++---
.../src/components/settings/GitPane.tsx | 43 +---
.../settings/HiddenExperimentalGroup.tsx | 12 +-
.../src/components/settings/InputPane.tsx | 22 +-
.../settings/NotificationSettingToggle.tsx | 18 +-
.../components/settings/PrivacyPane.test.ts | 11 +-
.../src/components/settings/PrivacyPane.tsx | 19 +-
.../settings/SettingsFormControls.tsx | 22 +-
.../settings/TerminalWindowSection.tsx | 47 +---
.../VoiceDictationSettingsSection.tsx | 20 +-
.../settings/WslCliRegistration.tsx | 20 +-
.../components/sidebar/FilterToggleRow.tsx | 20 +-
.../components/sidebar/HostRemoveDialog.tsx | 16 +-
.../stats/ClaudeUsageLoadingState.tsx | 9 +-
.../src/components/stats/ClaudeUsagePane.tsx | 27 +-
.../src/components/stats/CodexUsagePane.tsx | 27 +-
.../components/stats/OpenCodeUsagePane.tsx | 27 +-
.../TerminalQuickCommandAppendEnterSwitch.tsx | 20 +-
.../toggle-switch-handle-alignment.test.ts | 231 ------------------
.../src/components/ui/switch.test.tsx | 30 +++
src/renderer/src/components/ui/switch.tsx | 67 +++++
31 files changed, 269 insertions(+), 707 deletions(-)
delete mode 100644 src/renderer/src/components/toggle-switch-handle-alignment.test.ts
create mode 100644 src/renderer/src/components/ui/switch.test.tsx
create mode 100644 src/renderer/src/components/ui/switch.tsx
diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx
index 2447e45f21b..bc1691f293a 100644
--- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx
+++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx
@@ -14,6 +14,7 @@ import {
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { SwitchIndicator } from '@/components/ui/switch'
import { SettingsSwitch } from '@/components/settings/SettingsFormControls'
import type RepoCombobox from '@/components/repo/RepoCombobox'
import AgentCombobox from '@/components/agent/AgentCombobox'
@@ -1209,20 +1210,7 @@ export default function NewWorkspaceComposerCard({
onClick={() => onCreateMultipleChange?.(!createMultiple)}
className="group flex w-fit cursor-pointer items-center gap-2 rounded-md text-xs outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
-
-
-
+
{translate('auto.components.NewWorkspaceComposerCard.createMultiple', 'Create more')}
diff --git a/src/renderer/src/components/contextual-tours/ContextualTourControl.tsx b/src/renderer/src/components/contextual-tours/ContextualTourControl.tsx
index 0a2186c5077..3b574aa8f52 100644
--- a/src/renderer/src/components/contextual-tours/ContextualTourControl.tsx
+++ b/src/renderer/src/components/contextual-tours/ContextualTourControl.tsx
@@ -1,5 +1,5 @@
import type { JSX } from 'react'
-import { cn } from '@/lib/utils'
+import { Switch } from '@/components/ui/switch'
import { useAppStore } from '@/store'
import type { ContextualTourStepControl } from '../../../../shared/contextual-tours'
import { CONTEXTUAL_TOUR_ENABLE_AUTO_WORKSPACE_NAME_EVENT } from './contextual-tour-composer-events'
@@ -43,33 +43,20 @@ 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 751722d95ff..767bcf85439 100644
--- a/src/renderer/src/components/feature-wall/AiCommitPrSettingsSwitch.tsx
+++ b/src/renderer/src/components/feature-wall/AiCommitPrSettingsSwitch.tsx
@@ -1,5 +1,5 @@
import type { JSX } from 'react'
-import { cn } from '@/lib/utils'
+import { Switch } from '@/components/ui/switch'
type AiCommitPrSettingsSwitchProps = {
checked: boolean
@@ -12,24 +12,5 @@ export function AiCommitPrSettingsSwitch({
label,
onToggle
}: AiCommitPrSettingsSwitchProps): JSX.Element {
- return (
-
- )
+ return
}
diff --git a/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx b/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx
index 40d25ebf8bb..a201b6f60cd 100644
--- a/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx
+++ b/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx
@@ -1,5 +1,5 @@
import type { JSX } from 'react'
-import { cn } from '@/lib/utils'
+import { Switch } from '@/components/ui/switch'
import { getAgentAwakeDescription, getAgentAwakeTitle } from '../settings/agent-awake-copy'
import type { GlobalSettings } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
@@ -33,23 +33,13 @@ export function KeepAwakeCard(props: {
{getAgentAwakeDescription()}
-
+ checked={enabled}
+ onCheckedChange={(checked) =>
+ updateSettings(computerAwakeSettingsForMode(checked ? 'auto' : 'off'))
+ }
+ />
)
diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx
index 0a3dd5ae786..f2b96a27262 100644
--- a/src/renderer/src/components/settings/AccountsPane.tsx
+++ b/src/renderer/src/components/settings/AccountsPane.tsx
@@ -19,6 +19,7 @@ import { Label } from '../ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
import { Separator } from '../ui/separator'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
+import { Switch } from '../ui/switch'
import {
AlertTriangle,
ExternalLink,
@@ -1555,25 +1556,15 @@ export function AccountsPane({
)}
-
+ />
) : null,
diff --git a/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx b/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx
index 2cbdf227e0d..0981d0eddaf 100644
--- a/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx
+++ b/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx
@@ -18,6 +18,7 @@ import { SourceControlActionVariableChips } from '../source-control/SourceContro
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible'
import { Label } from '../ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
+import { Switch } from '../ui/switch'
import { getAutoRenameBranchAdvancedSearchEntries } from './auto-rename-branch-search'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search'
@@ -172,24 +173,14 @@ export function AutoRenameBranchFromWorkSetting({
)}
-
+ />
diff --git a/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx b/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx
index b26c1e2fd78..4cfc8a445dd 100644
--- a/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx
+++ b/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx
@@ -1,5 +1,6 @@
import type { GlobalSettings } from '../../../../shared/types'
import { Label } from '../ui/label'
+import { Switch } from '../ui/switch'
import { SearchableSetting } from './SearchableSetting'
import { translate } from '@/i18n/i18n'
@@ -39,25 +40,15 @@ export function BrowserLinkRoutingSetting({
{linkRoutingDescription}
-
+ />
)
}
diff --git a/src/renderer/src/components/settings/BrowserUseEnableSwitch.tsx b/src/renderer/src/components/settings/BrowserUseEnableSwitch.tsx
index 28a071b1583..20995d90239 100644
--- a/src/renderer/src/components/settings/BrowserUseEnableSwitch.tsx
+++ b/src/renderer/src/components/settings/BrowserUseEnableSwitch.tsx
@@ -1,4 +1,5 @@
import { translate } from '@/i18n/i18n'
+import { Switch } from '@/components/ui/switch'
export function BrowserUseEnableSwitch({
enabled,
onToggle
@@ -7,23 +8,13 @@ export function BrowserUseEnableSwitch({
onToggle: () => void
}): React.JSX.Element {
return (
-
+ onCheckedChange={onToggle}
+ />
)
}
diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx
index 86a5c89f346..762a28b6b72 100644
--- a/src/renderer/src/components/settings/CliSection.tsx
+++ b/src/renderer/src/components/settings/CliSection.tsx
@@ -20,6 +20,7 @@ import {
import { useMountedRef } from '@/hooks/useMountedRef'
import { Button } from '../ui/button'
import { Label } from '../ui/label'
+import { Switch } from '../ui/switch'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
import { CliRegistrationDialog } from './CliRegistrationDialog'
@@ -288,21 +289,12 @@ export function CliSection({
{!isBrowserManaged ? (
-
+ onCheckedChange={() => setDialogOpen(true)}
+ className="disabled:opacity-60"
+ />
) : null}
diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.tsx
index 13f1b10cf52..93ca5fd6b66 100644
--- a/src/renderer/src/components/settings/CommitMessageAiPane.tsx
+++ b/src/renderer/src/components/settings/CommitMessageAiPane.tsx
@@ -21,6 +21,7 @@ import { getRuntimeGitScope } from '../../runtime/runtime-git-client'
import { useAppStore } from '../../store'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
+import { Switch } from '../ui/switch'
import { SearchableSetting } from './SearchableSetting'
import { SourceControlAiActionRecipeDefaults } from './SourceControlAiActionRecipeDefaults'
import { matchesSettingsSearch } from './settings-search'
@@ -199,20 +200,7 @@ export function CommitMessageAiPane({
)}
-
+
)
}
diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx
index 9760591d477..0a1a46401fa 100644
--- a/src/renderer/src/components/settings/ExperimentalPane.tsx
+++ b/src/renderer/src/components/settings/ExperimentalPane.tsx
@@ -1,5 +1,6 @@
import type { GlobalSettings } from '../../../../shared/types'
import { Label } from '../ui/label'
+import { Switch } from '../ui/switch'
import { useAppStore } from '../../store'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch } from './settings-search'
@@ -86,23 +87,12 @@ export function ExperimentalPane({
)}
-
+ />
) : null}
@@ -129,25 +119,14 @@ export function ExperimentalPane({
)}
-
+ />
) : null}
@@ -188,25 +167,14 @@ export function ExperimentalPane({
)}
-
+ />
) : null}
diff --git a/src/renderer/src/components/settings/GitPane.tsx b/src/renderer/src/components/settings/GitPane.tsx
index 490cef11e36..113cd0d739c 100644
--- a/src/renderer/src/components/settings/GitPane.tsx
+++ b/src/renderer/src/components/settings/GitPane.tsx
@@ -4,6 +4,7 @@ import type { SourceControlAiSettingsPatch } from '../../../../shared/source-con
import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from '../../../../shared/source-control-group-order'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
+import { Switch } from '../ui/switch'
import { useAppStore } from '../../store'
import { getGitPaneSearchEntries } from './git-search'
import { SearchableSetting } from './SearchableSetting'
@@ -275,26 +276,14 @@ export function GitPane({
)}
-
+ />
) : null,
matchesSettingsSearch(searchQuery, {
@@ -371,24 +360,14 @@ export function GitPane({
)}
-
+ />
) : null
].filter(Boolean)
diff --git a/src/renderer/src/components/settings/HiddenExperimentalGroup.tsx b/src/renderer/src/components/settings/HiddenExperimentalGroup.tsx
index bcf702c166a..25541870376 100644
--- a/src/renderer/src/components/settings/HiddenExperimentalGroup.tsx
+++ b/src/renderer/src/components/settings/HiddenExperimentalGroup.tsx
@@ -1,4 +1,5 @@
import { Label } from '../ui/label'
+import { Switch } from '../ui/switch'
import { translate } from '@/i18n/i18n'
// Why: anything in this group is deliberately unfinished or staff-only. The
@@ -38,17 +39,16 @@ export function HiddenExperimentalGroup(): React.JSX.Element {
)}
-
+ thumbClassName="data-[state=unchecked]:bg-orange-200 dark:data-[state=unchecked]:bg-orange-100"
+ />
)
diff --git a/src/renderer/src/components/settings/InputPane.tsx b/src/renderer/src/components/settings/InputPane.tsx
index 125e90cc003..7083b635797 100644
--- a/src/renderer/src/components/settings/InputPane.tsx
+++ b/src/renderer/src/components/settings/InputPane.tsx
@@ -1,5 +1,6 @@
import type { GlobalSettings } from '../../../../shared/types'
import { Label } from '../ui/label'
+import { Switch } from '../ui/switch'
import { SearchableSetting } from './SearchableSetting'
import { isDefaultPrimarySelectionMiddleClickPasteUserAgent } from '@/hooks/usePrimarySelectionPaste'
import { translate } from '@/i18n/i18n'
@@ -55,25 +56,14 @@ 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 93502392487..7b551610114 100644
--- a/src/renderer/src/components/settings/NotificationSettingToggle.tsx
+++ b/src/renderer/src/components/settings/NotificationSettingToggle.tsx
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import { Label } from '../ui/label'
+import { Switch } from '../ui/switch'
export type NotificationSettingToggleProps = {
label: string
@@ -27,22 +28,7 @@ export function NotificationSettingToggle({
{description}
-
+
)
}
diff --git a/src/renderer/src/components/settings/PrivacyPane.test.ts b/src/renderer/src/components/settings/PrivacyPane.test.ts
index 7d1cb214298..751e1b5627a 100644
--- a/src/renderer/src/components/settings/PrivacyPane.test.ts
+++ b/src/renderer/src/components/settings/PrivacyPane.test.ts
@@ -166,6 +166,9 @@ describe('PrivacyPane — computeBlockedReason', () => {
})
describe('PrivacyPane — markup respects blocked state', () => {
+ const switchTag = (markup: string): string =>
+ markup.match(/
)
diff --git a/src/renderer/src/components/sidebar/HostRemoveDialog.tsx b/src/renderer/src/components/sidebar/HostRemoveDialog.tsx
index d99f5e860e9..994cd4e505b 100644
--- a/src/renderer/src/components/sidebar/HostRemoveDialog.tsx
+++ b/src/renderer/src/components/sidebar/HostRemoveDialog.tsx
@@ -11,6 +11,7 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
+import { SwitchIndicator } from '@/components/ui/switch'
import { useMountedRef } from '@/hooks/useMountedRef'
import { useAppStore } from '@/store'
import { getAllWorktreesFromState } from '@/store/selectors'
@@ -241,20 +242,7 @@ export function HostRemoveDialog({
onClick={() => setDeleteWorkspaces((v) => !v)}
className="group mt-0.5 flex shrink-0 cursor-pointer items-center rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
-
-
-
+
{deleteOptionLabel}
diff --git a/src/renderer/src/components/stats/ClaudeUsageLoadingState.tsx b/src/renderer/src/components/stats/ClaudeUsageLoadingState.tsx
index 59c378cc8c3..d6bb0989a76 100644
--- a/src/renderer/src/components/stats/ClaudeUsageLoadingState.tsx
+++ b/src/renderer/src/components/stats/ClaudeUsageLoadingState.tsx
@@ -1,4 +1,5 @@
import { RefreshCw } from 'lucide-react'
+import { SwitchIndicator } from '../ui/switch'
type ClaudeUsageLoadingStateProps = {
title?: string
@@ -20,9 +21,11 @@ export function ClaudeUsageLoadingState({
diff --git a/src/renderer/src/components/stats/ClaudeUsagePane.tsx b/src/renderer/src/components/stats/ClaudeUsagePane.tsx
index dfd83b6663c..8b24f0aca39 100644
--- a/src/renderer/src/components/stats/ClaudeUsagePane.tsx
+++ b/src/renderer/src/components/stats/ClaudeUsagePane.tsx
@@ -13,6 +13,7 @@ import {
import type { ClaudeUsageRange, ClaudeUsageScope } from '../../../../shared/claude-usage-types'
import { useAppStore } from '../../store'
import { Button } from '../ui/button'
+import { Switch } from '../ui/switch'
import {
DropdownMenu,
DropdownMenuContent,
@@ -103,19 +104,14 @@ export function ClaudeUsagePane(): React.JSX.Element {
)}
- handleSetEnabled(true)}
- className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-muted-foreground/30 transition-colors"
- >
-
-
+ onCheckedChange={handleSetEnabled}
+ />
)
@@ -222,19 +218,14 @@ export function ClaudeUsagePane(): React.JSX.Element {
- 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"
- >
-
-
+ onCheckedChange={handleSetEnabled}
+ />
diff --git a/src/renderer/src/components/stats/CodexUsagePane.tsx b/src/renderer/src/components/stats/CodexUsagePane.tsx
index 4ae5074de51..ad6e02c7b56 100644
--- a/src/renderer/src/components/stats/CodexUsagePane.tsx
+++ b/src/renderer/src/components/stats/CodexUsagePane.tsx
@@ -12,6 +12,7 @@ import {
import type { CodexUsageRange, CodexUsageScope } from '../../../../shared/codex-usage-types'
import { useAppStore } from '../../store'
import { Button } from '../ui/button'
+import { Switch } from '../ui/switch'
import {
DropdownMenu,
DropdownMenuContent,
@@ -99,19 +100,14 @@ export function CodexUsagePane(): React.JSX.Element {
)}
- handleSetEnabled(true)}
- className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-muted-foreground/30 transition-colors"
- >
-
-
+ onCheckedChange={handleSetEnabled}
+ />
)
@@ -224,19 +220,14 @@ export function CodexUsagePane(): React.JSX.Element {
- 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"
- >
-
-
+ onCheckedChange={handleSetEnabled}
+ />
diff --git a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx
index b70b065934e..6c11240a866 100644
--- a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx
+++ b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx
@@ -15,6 +15,7 @@ import type {
} from '../../../../shared/opencode-usage-types'
import { useAppStore } from '../../store'
import { Button } from '../ui/button'
+import { Switch } from '../ui/switch'
import {
DropdownMenu,
DropdownMenuContent,
@@ -107,19 +108,14 @@ export function OpenCodeUsagePane(): React.JSX.Element {
)}
- handleSetEnabled(true)}
- className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-muted-foreground/30 transition-colors"
- >
-
-
+ onCheckedChange={handleSetEnabled}
+ />
)
@@ -235,19 +231,14 @@ export function OpenCodeUsagePane(): React.JSX.Element {
- 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"
- >
-
-
+ onCheckedChange={handleSetEnabled}
+ />
diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx
index 0ddd6222465..4450562729e 100644
--- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx
+++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx
@@ -1,4 +1,5 @@
import { translate } from '@/i18n/i18n'
+import { Switch } from '@/components/ui/switch'
type TerminalQuickCommandAppendEnterSwitchProps = {
appendEnter: boolean
onToggle: () => void
@@ -24,25 +25,14 @@ export function TerminalQuickCommandAppendEnterSwitch({
)}
-
-
-
+ onCheckedChange={onToggle}
+ />
)
}
diff --git a/src/renderer/src/components/toggle-switch-handle-alignment.test.ts b/src/renderer/src/components/toggle-switch-handle-alignment.test.ts
deleted file mode 100644
index b0a5574dd27..00000000000
--- a/src/renderer/src/components/toggle-switch-handle-alignment.test.ts
+++ /dev/null
@@ -1,231 +0,0 @@
-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([])
- })
-})
diff --git a/src/renderer/src/components/ui/switch.test.tsx b/src/renderer/src/components/ui/switch.test.tsx
new file mode 100644
index 00000000000..dacaf8b8699
--- /dev/null
+++ b/src/renderer/src/components/ui/switch.test.tsx
@@ -0,0 +1,30 @@
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it } from 'vitest'
+import { Switch, SwitchIndicator } from './switch'
+
+describe('Switch', () => {
+ it('renders the checked state with symmetric track geometry', () => {
+ const html = renderToStaticMarkup()
+
+ expect(html).toContain('role="switch"')
+ expect(html).toContain('aria-checked="true"')
+ expect(html).toContain('p-0.5')
+ expect(html).toContain('data-[state=checked]:translate-x-4')
+ })
+
+ it('renders a visual-only indicator without switch semantics', () => {
+ const html = renderToStaticMarkup()
+
+ expect(html).toContain('aria-hidden="true"')
+ expect(html).toContain('data-state="unchecked"')
+ expect(html).not.toContain('role="switch"')
+ })
+
+ it('renders compact indicator geometry', () => {
+ const html = renderToStaticMarkup()
+
+ expect(html).toContain('h-3.5 w-6')
+ expect(html).toContain('size-2.5')
+ expect(html).toContain('data-[state=checked]:translate-x-2.5')
+ })
+})
diff --git a/src/renderer/src/components/ui/switch.tsx b/src/renderer/src/components/ui/switch.tsx
new file mode 100644
index 00000000000..5213b703e73
--- /dev/null
+++ b/src/renderer/src/components/ui/switch.tsx
@@ -0,0 +1,67 @@
+import * as React from 'react'
+import { Switch as SwitchPrimitive } from 'radix-ui'
+
+import { cn } from '@/lib/utils'
+
+const trackClassName =
+ 'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent p-0.5 transition-colors data-[state=checked]:bg-foreground data-[state=unchecked]:bg-muted-foreground/30'
+const thumbClassName =
+ 'pointer-events-none block size-3.5 translate-x-0 rounded-full bg-background shadow-sm transition-transform data-[state=checked]:translate-x-4'
+const compactTrackClassName = 'h-3.5 w-6 border-0 p-0.5'
+const compactThumbClassName = 'size-2.5 data-[state=checked]:translate-x-2.5'
+
+type SwitchProps = React.ComponentProps & {
+ thumbClassName?: string
+}
+
+function Switch({ className, thumbClassName: thumbClasses, ...props }: SwitchProps) {
+ return (
+
+
+
+ )
+}
+
+type SwitchIndicatorProps = Omit, 'children'> & {
+ checked: boolean
+ size?: 'default' | 'compact'
+ thumbClassName?: string
+}
+
+function SwitchIndicator({
+ checked,
+ className,
+ size = 'default',
+ thumbClassName: thumbClasses,
+ ...props
+}: SwitchIndicatorProps) {
+ const state = checked ? 'checked' : 'unchecked'
+ return (
+
+
+
+ )
+}
+
+export { Switch, SwitchIndicator }