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 (
- + 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 }))} />
- setCreateNetworkForm(f => ({ ...f, attachable: v }))} + onChange={v => setCreateNetworkForm(f => ({ ...f, attachable: v }))} />
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)} />
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 }:
- +
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,

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" />
@@ -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 }) {

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)} /> 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 ( + + ); + }, +); +TogglePill.displayName = 'TogglePill'; + +export { TogglePill }; +export type { TogglePillProps };