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(/]*role="switch"[^>]*>/)?.[0] ?? '' + // The useEffect that fetches consent does not run in renderToStaticMarkup // (no DOM, no state flushes), so the rendered toggle always reads the // `consent === null` branch of the effect. Env-var blocked-state markup @@ -189,9 +192,7 @@ describe('PrivacyPane — markup respects blocked state', () => { }) }) ) - // Negative assertion: the disabled attribute should not appear on the - // switch. renderToStaticMarkup elides `disabled={false}` entirely. - expect(markup).not.toMatch(/role="switch"[^>]*disabled/) + expect(switchTag(markup)).not.toContain(' disabled=""') }) it('leaves the toggle enabled for an existing user awaiting the banner (flip dismisses the banner)', () => { @@ -206,7 +207,7 @@ describe('PrivacyPane — markup respects blocked state', () => { }) }) ) - expect(markup).not.toMatch(/role="switch"[^>]*disabled/) + expect(switchTag(markup)).not.toContain(' disabled=""') }) it('leaves the toggle enabled once the existing-user banner is resolved', () => { @@ -218,6 +219,6 @@ describe('PrivacyPane — markup respects blocked state', () => { }) }) ) - expect(markup).not.toMatch(/role="switch"[^>]*disabled/) + expect(switchTag(markup)).not.toContain(' disabled=""') }) }) diff --git a/src/renderer/src/components/settings/PrivacyPane.tsx b/src/renderer/src/components/settings/PrivacyPane.tsx index 8a096f8c2c1..f36765e4ffc 100644 --- a/src/renderer/src/components/settings/PrivacyPane.tsx +++ b/src/renderer/src/components/settings/PrivacyPane.tsx @@ -4,6 +4,7 @@ import { useMountedRef } from '@/hooks/useMountedRef' import type { GlobalSettings } from '../../../../shared/types' import type { TelemetryConsentState } from '../../../../shared/telemetry-consent-types' import { Label } from '../ui/label' +import { Switch } from '../ui/switch' import { PRIVACY_URL, getConsentState, setOptIn as telemetrySetOptIn } from '../../lib/telemetry' import { useAppStore } from '../../store' import { PrivacyDiagnosticsSection } from './PrivacyDiagnosticsSection' @@ -111,26 +112,16 @@ export function PrivacyPane({ settings }: PrivacyPaneProps): React.JSX.Element { .

- + onCheckedChange={() => void handleToggle()} + /> {blocked ? : null} diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index 27bcf7acc12..703dbbfbb21 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -8,6 +8,7 @@ import { Input } from '../ui/input' import { Label } from '../ui/label' import { Popover, PopoverAnchor, PopoverContent } from '../ui/popover' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' +import { Switch } from '../ui/switch' import { Check, ChevronsUpDown, CircleX } from 'lucide-react' import { normalizeColor, type TerminalThemeOption } from '@/lib/terminal-theme' import { MAX_THEME_RESULTS } from './SettingsConstants' @@ -36,26 +37,13 @@ export function SettingsSwitch({ disabled }: SettingsSwitchProps): React.JSX.Element { return ( - + onCheckedChange={onChange} + /> ) } diff --git a/src/renderer/src/components/settings/TerminalWindowSection.tsx b/src/renderer/src/components/settings/TerminalWindowSection.tsx index 3ca33d25c39..ffc02ffe44e 100644 --- a/src/renderer/src/components/settings/TerminalWindowSection.tsx +++ b/src/renderer/src/components/settings/TerminalWindowSection.tsx @@ -3,6 +3,7 @@ import { RotateCw } from 'lucide-react' import type { GlobalSettings } from '../../../../shared/types' import { Button } from '../ui/button' import { Label } from '../ui/label' +import { Switch } from '../ui/switch' import { ColorField, NumberField } from './SettingsFormControls' import { SearchableSetting } from './SearchableSetting' import { clampNumber } from '@/lib/terminal-theme' @@ -119,24 +120,10 @@ export function TerminalWindowSection({ )}

- + updateSettings({ windowBackgroundBlur: checked })} + /> {blurPendingRestart ? ( @@ -253,28 +240,14 @@ export function TerminalWindowSection({ )} - + /> - + onCheckedChange={() => void onToggleVoiceDictation()} + className="disabled:cursor-wait disabled:opacity-70" + /> diff --git a/src/renderer/src/components/settings/WslCliRegistration.tsx b/src/renderer/src/components/settings/WslCliRegistration.tsx index 586d306846e..eaa32e638b0 100644 --- a/src/renderer/src/components/settings/WslCliRegistration.tsx +++ b/src/renderer/src/components/settings/WslCliRegistration.tsx @@ -14,6 +14,7 @@ import { DialogTitle } from '../ui/dialog' import { Label } from '../ui/label' +import { Switch } from '../ui/switch' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { translate } from '@/i18n/i18n' @@ -187,21 +188,12 @@ export function WslCliRegistration({ - + onCheckedChange={() => setDialogOpen(true)} + className="disabled:opacity-60" + /> diff --git a/src/renderer/src/components/sidebar/FilterToggleRow.tsx b/src/renderer/src/components/sidebar/FilterToggleRow.tsx index bcb47550649..fc1b4a7dec0 100644 --- a/src/renderer/src/components/sidebar/FilterToggleRow.tsx +++ b/src/renderer/src/components/sidebar/FilterToggleRow.tsx @@ -1,5 +1,6 @@ import React from 'react' import { DropdownMenuShortcut } from '@/components/ui/dropdown-menu' +import { SwitchIndicator } from '@/components/ui/switch' import { cn } from '@/lib/utils' /** @@ -51,20 +52,11 @@ export function FilterToggleRow({
{shortcutLabel ? {shortcutLabel} : null} - - - + ) 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 { )}

- + onCheckedChange={handleSetEnabled} + /> ) @@ -222,19 +218,14 @@ export function ClaudeUsagePane(): React.JSX.Element { - + 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 { )}

- + onCheckedChange={handleSetEnabled} + /> ) @@ -224,19 +220,14 @@ export function CodexUsagePane(): React.JSX.Element { - + 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 { )}

- + onCheckedChange={handleSetEnabled} + /> ) @@ -235,19 +231,14 @@ export function OpenCodeUsagePane(): React.JSX.Element { - + 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 ( +