From 88ec71fcaaf7caea21697505ab638c5faeacfa30 Mon Sep 17 00:00:00 2001
From: Anso
Date: Sat, 18 Apr 2026 22:15:22 -0400
Subject: [PATCH] feat(ui): replace Switch with TogglePill per design audit
(#687)
* chore: ignore local design-system references
Adds frontend/DESIGN.md and .design-bundle/ to .gitignore so the
design-system reference and the audit handoff bundle stay local
to each contributor's machine.
* feat(ui): replace Switch with TogglePill per design audit
Adds a mono-uppercase ON/OFF pill that replaces the sliding Switch
across every call site. The pill uses role="switch" plus aria-checked,
tints success-green when on and neutral card-tone when off, and holds
a stable 60px min-width so the label flip does not shift layout. It
reinforces the tracked-mono uppercase pattern already used for kicker
labels and table column headers, and echoes the UP/DN status language
of the sidebar stack list.
- Add TogglePill component (frontend/src/components/ui/toggle-pill.tsx)
- Replace 17 Switch call sites across 10 files (settings sections,
SSO providers, scheduled operations, network topology, resources,
stack auto-heal)
- Remove unused Switch wrapper and radix primitive
- Drop the @radix-ui/react-switch dependency
---
.gitignore | 4 +
frontend/package-lock.json | 1 -
frontend/package.json | 1 -
.../src/components/NetworkTopologyView.tsx | 4 +-
frontend/src/components/ResourcesView.tsx | 10 +-
frontend/src/components/SSOSection.tsx | 10 +-
.../components/ScheduledOperationsView.tsx | 8 +-
.../src/components/StackAutoHealSheet.tsx | 6 +-
.../animate-ui/primitives/radix/switch.tsx | 156 ------------------
.../components/settings/AccountSection.tsx | 6 +-
.../components/settings/DeveloperSection.tsx | 6 +-
.../settings/NotificationRoutingSection.tsx | 8 +-
.../settings/NotificationsSection.tsx | 6 +-
.../components/settings/SecuritySection.tsx | 14 +-
.../components/settings/WebhooksSection.tsx | 4 +-
frontend/src/components/ui/switch.tsx | 33 ----
frontend/src/components/ui/toggle-pill.tsx | 40 +++++
17 files changed, 85 insertions(+), 232 deletions(-)
delete mode 100644 frontend/src/components/animate-ui/primitives/radix/switch.tsx
delete mode 100644 frontend/src/components/ui/switch.tsx
create mode 100644 frontend/src/components/ui/toggle-pill.tsx
diff --git a/.gitignore b/.gitignore
index f53b48ad..efbf61ae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,6 +50,10 @@ MANUAL_STEPS.md
docs/superpowers/
.worktrees/
+# Design system (local-only; authoritative reference for agents + user)
+frontend/DESIGN.md
+.design-bundle/
+
# Playwright MCP
.playwright-mcp/
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 1ae7577a..8568d737 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -24,7 +24,6 @@
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
- "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@xterm/addon-fit": "^0.11.0",
diff --git a/frontend/package.json b/frontend/package.json
index 34af8f4c..21def523 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -28,7 +28,6 @@
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
- "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@xterm/addon-fit": "^0.11.0",
diff --git a/frontend/src/components/NetworkTopologyView.tsx b/frontend/src/components/NetworkTopologyView.tsx
index 335d4962..370f3922 100644
--- a/frontend/src/components/NetworkTopologyView.tsx
+++ b/frontend/src/components/NetworkTopologyView.tsx
@@ -18,7 +18,7 @@ import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { Container, Network, Loader2, RefreshCw } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
-import { Switch } from '@/components/ui/switch';
+import { TogglePill } from '@/components/ui/toggle-pill';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@@ -306,7 +306,7 @@ export default function NetworkTopologyView({ onContainerClick }: NetworkTopolog
return (
-
+
Show system networks
diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx
index 10ee9113..5395d50b 100644
--- a/frontend/src/components/ResourcesView.tsx
+++ b/frontend/src/components/ResourcesView.tsx
@@ -13,7 +13,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Combobox } from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
-import { Switch } from "@/components/ui/switch";
+import { TogglePill } from "@/components/ui/toggle-pill";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
@@ -1311,18 +1311,18 @@ export default function ResourcesView() {
- setCreateNetworkForm(f => ({ ...f, internal: v }))}
+ onChange={v => setCreateNetworkForm(f => ({ ...f, internal: v }))}
/>
Internal (no external access)
- setCreateNetworkForm(f => ({ ...f, attachable: v }))}
+ onChange={v => setCreateNetworkForm(f => ({ ...f, attachable: v }))}
/>
Attachable
diff --git a/frontend/src/components/SSOSection.tsx b/frontend/src/components/SSOSection.tsx
index c43f1896..9f10a03f 100644
--- a/frontend/src/components/SSOSection.tsx
+++ b/frontend/src/components/SSOSection.tsx
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { Input } from '@/components/ui/input';
-import { Switch } from '@/components/ui/switch';
+import { TogglePill } from '@/components/ui/toggle-pill';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Combobox } from '@/components/ui/combobox';
@@ -142,9 +142,9 @@ function ProviderCard({ providerId, type, label, initialConfig, onSave }: {
)}
- update('enabled', checked)}
+ onChange={(checked) => update('enabled', checked)}
onClick={(e) => e.stopPropagation()}
/>
@@ -221,9 +221,9 @@ function ProviderCard({ providerId, type, label, initialConfig, onSave }: {
- update('ldapTlsRejectUnauthorized', checked)}
+ onChange={checked => update('ldapTlsRejectUnauthorized', checked)}
/>
Verify TLS certificate
diff --git a/frontend/src/components/ScheduledOperationsView.tsx b/frontend/src/components/ScheduledOperationsView.tsx
index 5823208d..de1e0c5e 100644
--- a/frontend/src/components/ScheduledOperationsView.tsx
+++ b/frontend/src/components/ScheduledOperationsView.tsx
@@ -8,7 +8,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Di
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { ScrollArea } from '@/components/ui/scroll-area';
-import { Switch } from '@/components/ui/switch';
+import { TogglePill } from '@/components/ui/toggle-pill';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, ChevronRight, Download, CalendarClock, Table2 } from 'lucide-react';
@@ -588,9 +588,9 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:
{formatTimestamp(task.next_run_at)}
- handleToggle(task)}
+ onChange={() => handleToggle(task)}
/>
@@ -742,7 +742,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:
-
+
Enabled
diff --git a/frontend/src/components/StackAutoHealSheet.tsx b/frontend/src/components/StackAutoHealSheet.tsx
index e447c341..c10ccf28 100644
--- a/frontend/src/components/StackAutoHealSheet.tsx
+++ b/frontend/src/components/StackAutoHealSheet.tsx
@@ -9,7 +9,7 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
-import { Switch } from '@/components/ui/switch';
+import { TogglePill } from '@/components/ui/toggle-pill';
import { Combobox } from '@/components/ui/combobox';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Trash2, ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
@@ -131,9 +131,9 @@ function PolicyRow({ policy, onDelete, onToggle, deleting, saving }: PolicyRowPr
)}
- policy.id != null && onToggle(policy.id, checked)}
+ onChange={(checked) => policy.id != null && onToggle(policy.id, checked)}
disabled={saving}
aria-label={`Toggle policy for ${policy.service_name ?? 'all services'}`}
/>
diff --git a/frontend/src/components/animate-ui/primitives/radix/switch.tsx b/frontend/src/components/animate-ui/primitives/radix/switch.tsx
deleted file mode 100644
index c414bf99..00000000
--- a/frontend/src/components/animate-ui/primitives/radix/switch.tsx
+++ /dev/null
@@ -1,156 +0,0 @@
-/* eslint-disable */
-'use client';
-
-import * as React from 'react';
-import { Switch as SwitchPrimitives } from 'radix-ui';
-import {
- motion,
- type TargetAndTransition,
- type VariantLabels,
- type HTMLMotionProps,
- type LegacyAnimationControls,
-} from 'motion/react';
-
-import { getStrictContext } from '@/lib/get-strict-context';
-import { useControlledState } from '@/hooks/use-controlled-state';
-
-type SwitchContextType = {
- isChecked: boolean;
- setIsChecked: (isChecked: boolean) => void;
- isPressed: boolean;
- setIsPressed: (isPressed: boolean) => void;
-};
-
-const [SwitchProvider, useSwitch] =
- getStrictContext('SwitchContext');
-
-type SwitchProps = Omit<
- React.ComponentProps,
- 'asChild'
-> &
- HTMLMotionProps<'button'>;
-
-function Switch(props: SwitchProps) {
- // Destructure Radix-only props so they don't leak onto the motion.button DOM element
- const {
- checked,
- defaultChecked,
- onCheckedChange,
- disabled,
- required,
- name,
- value,
- form,
- ...motionProps
- } = props;
-
- const [isPressed, setIsPressed] = React.useState(false);
- const [isChecked, setIsChecked] = useControlledState({
- value: checked,
- defaultValue: defaultChecked,
- onChange: onCheckedChange,
- });
-
- return (
-
-
- setIsPressed(true)}
- onTapCancel={() => setIsPressed(false)}
- onTap={() => setIsPressed(false)}
- {...motionProps}
- />
-
-
- );
-}
-
-type SwitchThumbProps = Omit<
- React.ComponentProps,
- 'asChild'
-> &
- HTMLMotionProps<'div'> & {
- pressedAnimation?:
- | TargetAndTransition
- | VariantLabels
- | boolean
- | LegacyAnimationControls;
- };
-
-function SwitchThumb({
- pressedAnimation,
- transition = { type: 'spring', stiffness: 300, damping: 25 },
- ...props
-}: SwitchThumbProps) {
- const { isPressed } = useSwitch();
-
- return (
-
-
-
- );
-}
-
-type SwitchIconPosition = 'left' | 'right' | 'thumb';
-
-type SwitchIconProps = HTMLMotionProps<'div'> & {
- position: SwitchIconPosition;
-};
-
-function SwitchIcon({
- position,
- transition = { type: 'spring', bounce: 0 },
- ...props
-}: SwitchIconProps) {
- const { isChecked } = useSwitch();
-
- const isAnimated = React.useMemo(() => {
- if (position === 'right') return !isChecked;
- if (position === 'left') return isChecked;
- if (position === 'thumb') return true;
- return false;
- }, [position, isChecked]);
-
- return (
-
- );
-}
-
-export {
- Switch,
- SwitchThumb,
- SwitchIcon,
- useSwitch,
- type SwitchProps,
- type SwitchThumbProps,
- type SwitchIconProps,
- type SwitchIconPosition,
- type SwitchContextType,
-};
\ No newline at end of file
diff --git a/frontend/src/components/settings/AccountSection.tsx b/frontend/src/components/settings/AccountSection.tsx
index e9fffa54..af4571a1 100644
--- a/frontend/src/components/settings/AccountSection.tsx
+++ b/frontend/src/components/settings/AccountSection.tsx
@@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
-import { Switch } from '@/components/ui/switch';
+import { TogglePill } from '@/components/ui/toggle-pill';
import { Separator } from '@/components/ui/separator';
import { AlertTriangle, RefreshCw, Shield, ShieldCheck } from 'lucide-react';
import { MfaEnrollDialog } from '@/components/mfa/MfaEnrollDialog';
@@ -179,9 +179,9 @@ export function AccountSection({ authData, onAuthDataChange, onPasswordChange, i
SSO logins skip the second factor by default.
-
diff --git a/frontend/src/components/settings/DeveloperSection.tsx b/frontend/src/components/settings/DeveloperSection.tsx
index 31106667..8603f3b2 100644
--- a/frontend/src/components/settings/DeveloperSection.tsx
+++ b/frontend/src/components/settings/DeveloperSection.tsx
@@ -1,7 +1,7 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
-import { Switch } from '@/components/ui/switch';
+import { TogglePill } from '@/components/ui/toggle-pill';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
@@ -64,10 +64,10 @@ export function DeveloperSection({ settings, onSettingChange, onSave, isSaving,
Developer Mode
Enable Real-Time Metrics, Debug Diagnostics & Extended Logs
- onSettingChange('developer_mode', c ? '1' : '0')}
+ onChange={(c) => onSettingChange('developer_mode', c ? '1' : '0')}
/>
diff --git a/frontend/src/components/settings/NotificationRoutingSection.tsx b/frontend/src/components/settings/NotificationRoutingSection.tsx
index 3efce66e..423be659 100644
--- a/frontend/src/components/settings/NotificationRoutingSection.tsx
+++ b/frontend/src/components/settings/NotificationRoutingSection.tsx
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
-import { Switch } from '@/components/ui/switch';
+import { TogglePill } from '@/components/ui/toggle-pill';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Combobox } from '@/components/ui/combobox';
@@ -322,7 +322,7 @@ export function NotificationRoutingSection() {
@@ -373,9 +373,9 @@ export function NotificationRoutingSection() {
)}
-
handleToggleEnabled(route)}
+ onChange={() => handleToggleEnabled(route)}
className="scale-75"
/>
Enable {title}
- handleAgentChange(type, 'enabled', c)}
+ onChange={(c) => handleAgentChange(type, 'enabled', c)}
/>
diff --git a/frontend/src/components/settings/SecuritySection.tsx b/frontend/src/components/settings/SecuritySection.tsx
index 31213d15..ee2cab51 100644
--- a/frontend/src/components/settings/SecuritySection.tsx
+++ b/frontend/src/components/settings/SecuritySection.tsx
@@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
-import { Switch } from '@/components/ui/switch';
+import { TogglePill } from '@/components/ui/toggle-pill';
import { Skeleton } from '@/components/ui/skeleton';
import { Combobox } from '@/components/ui/combobox';
import {
@@ -358,9 +358,9 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
Check daily and install newer Trivy releases automatically.
-
@@ -483,9 +483,9 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
Emit a critical alert when this policy is violated after a deploy.
- setForm({ ...form, block_on_deploy: c })}
+ onChange={(c) => setForm({ ...form, block_on_deploy: c })}
/>
@@ -493,9 +493,9 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
Enabled
Disabled policies are skipped during evaluation.
- setForm({ ...form, enabled: c })}
+ onChange={(c) => setForm({ ...form, enabled: c })}
/>
diff --git a/frontend/src/components/settings/WebhooksSection.tsx b/frontend/src/components/settings/WebhooksSection.tsx
index 7646d995..e01ac51f 100644
--- a/frontend/src/components/settings/WebhooksSection.tsx
+++ b/frontend/src/components/settings/WebhooksSection.tsx
@@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
-import { Switch } from '@/components/ui/switch';
+import { TogglePill } from '@/components/ui/toggle-pill';
import { Skeleton } from '@/components/ui/skeleton';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { toast } from '@/components/ui/toast-store';
@@ -244,7 +244,7 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) {
{wh.stack_name}
-
handleToggle(wh.id!, c)} />
+ handleToggle(wh.id!, c)} />
handleDelete(wh.id!)}>
diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx
deleted file mode 100644
index c07979e7..00000000
--- a/frontend/src/components/ui/switch.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-'use client';
-
-import * as React from 'react';
-import { cn } from '@/lib/utils';
-
-import {
- Switch as AnimateSwitch,
- SwitchThumb,
- type SwitchProps as AnimateSwitchProps,
-} from '@/components/animate-ui/primitives/radix/switch';
-
-type SwitchProps = Omit;
-
-const Switch = React.forwardRef(
- ({ className, ...props }, ref) => (
-
-
-
- )
-);
-Switch.displayName = 'Switch';
-
-export { Switch };
diff --git a/frontend/src/components/ui/toggle-pill.tsx b/frontend/src/components/ui/toggle-pill.tsx
new file mode 100644
index 00000000..9f65a1ff
--- /dev/null
+++ b/frontend/src/components/ui/toggle-pill.tsx
@@ -0,0 +1,40 @@
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+interface TogglePillProps extends Omit, 'onChange'> {
+ checked: boolean;
+ onChange: (next: boolean) => void;
+}
+
+const TogglePill = React.forwardRef(
+ ({ checked, onChange, className, disabled, onClick, ...props }, ref) => {
+ return (
+ {
+ onClick?.(event);
+ if (event.defaultPrevented) return;
+ onChange(!checked);
+ }}
+ className={cn(
+ 'inline-flex items-center justify-center rounded-md border px-2.5 py-1 font-mono text-xs uppercase tracking-[0.18em] transition-colors min-w-[60px] focus-visible:ring-2 focus-visible:ring-brand/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
+ checked
+ ? 'border-success/30 bg-success/10 text-success hover:bg-success/15'
+ : 'border-card-border bg-card text-stat-subtitle hover:text-stat-value',
+ className,
+ )}
+ {...props}
+ >
+ {checked ? 'ON' : 'OFF'}
+
+ );
+ },
+);
+TogglePill.displayName = 'TogglePill';
+
+export { TogglePill };
+export type { TogglePillProps };