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
This commit is contained in:
Anso
2026-04-18 22:15:22 -04:00
committed by GitHub
parent bd94ef9e15
commit 88ec71fcaa
17 changed files with 85 additions and 232 deletions
+4
View File
@@ -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/
-1
View File
@@ -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",
-1
View File
@@ -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",
@@ -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 (
<div className="rounded-lg border border-card-border bg-card shadow-card-bevel overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-card-border">
<Switch id="show-system" checked={includeSystem} onCheckedChange={setIncludeSystem} />
<TogglePill id="show-system" checked={includeSystem} onChange={setIncludeSystem} />
<Label htmlFor="show-system" className="text-xs cursor-pointer">
Show system networks
</Label>
+5 -5
View File
@@ -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() {
</div>
<div className="flex items-center gap-6 pt-1">
<div className="flex items-center gap-2">
<Switch
<TogglePill
id="net-internal"
checked={createNetworkForm.internal}
onCheckedChange={v => setCreateNetworkForm(f => ({ ...f, internal: v }))}
onChange={v => setCreateNetworkForm(f => ({ ...f, internal: v }))}
/>
<Label htmlFor="net-internal" className="text-xs cursor-pointer">Internal <span className="text-muted-foreground">(no external access)</span></Label>
</div>
<div className="flex items-center gap-2">
<Switch
<TogglePill
id="net-attachable"
checked={createNetworkForm.attachable}
onCheckedChange={v => setCreateNetworkForm(f => ({ ...f, attachable: v }))}
onChange={v => setCreateNetworkForm(f => ({ ...f, attachable: v }))}
/>
<Label htmlFor="net-attachable" className="text-xs cursor-pointer">Attachable</Label>
</div>
+5 -5
View File
@@ -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 }: {
)}
</div>
<div className="flex items-center gap-2">
<Switch
<TogglePill
checked={!!config.enabled}
onCheckedChange={(checked) => update('enabled', checked)}
onChange={(checked) => update('enabled', checked)}
onClick={(e) => e.stopPropagation()}
/>
</div>
@@ -221,9 +221,9 @@ function ProviderCard({ providerId, type, label, initialConfig, onSave }: {
</div>
</div>
<div className="flex items-center gap-2">
<Switch
<TogglePill
checked={config.ldapTlsRejectUnauthorized !== false}
onCheckedChange={checked => update('ldapTlsRejectUnauthorized', checked)}
onChange={checked => update('ldapTlsRejectUnauthorized', checked)}
/>
<Label className="text-xs text-muted-foreground">Verify TLS certificate</Label>
</div>
@@ -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)}
</TableCell>
<TableCell>
<Switch
<TogglePill
checked={task.enabled === 1}
onCheckedChange={() => handleToggle(task)}
onChange={() => handleToggle(task)}
/>
</TableCell>
<TableCell className="text-right">
@@ -742,7 +742,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:
</div>
<div className="flex items-center gap-2">
<Switch checked={formEnabled} onCheckedChange={setFormEnabled} id="task-enabled" />
<TogglePill checked={formEnabled} onChange={setFormEnabled} id="task-enabled" />
<Label htmlFor="task-enabled">Enabled</Label>
</div>
</div>
@@ -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
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Switch
<TogglePill
checked={policy.enabled === 1}
onCheckedChange={(checked) => 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'}`}
/>
@@ -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<SwitchContextType>('SwitchContext');
type SwitchProps = Omit<
React.ComponentProps<typeof SwitchPrimitives.Root>,
'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 (
<SwitchProvider
value={{ isChecked, setIsChecked, isPressed, setIsPressed }}
>
<SwitchPrimitives.Root
checked={checked}
defaultChecked={defaultChecked}
onCheckedChange={setIsChecked}
disabled={disabled}
required={required}
name={name}
value={value}
form={form}
asChild
>
<motion.button
data-slot="switch"
whileTap="tap"
initial={false}
onTapStart={() => setIsPressed(true)}
onTapCancel={() => setIsPressed(false)}
onTap={() => setIsPressed(false)}
{...motionProps}
/>
</SwitchPrimitives.Root>
</SwitchProvider>
);
}
type SwitchThumbProps = Omit<
React.ComponentProps<typeof SwitchPrimitives.Thumb>,
'asChild'
> &
HTMLMotionProps<'div'> & {
pressedAnimation?:
| TargetAndTransition
| VariantLabels
| boolean
| LegacyAnimationControls;
};
function SwitchThumb({
pressedAnimation,
transition = { type: 'spring', stiffness: 300, damping: 25 },
...props
}: SwitchThumbProps) {
const { isPressed } = useSwitch();
return (
<SwitchPrimitives.Thumb asChild>
<motion.div
data-slot="switch-thumb"
whileTap="tab"
layout
transition={transition}
animate={isPressed ? pressedAnimation : undefined}
{...props}
/>
</SwitchPrimitives.Thumb>
);
}
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 (
<motion.div
data-slot={`switch-${position}-icon`}
animate={isAnimated ? { scale: 1, opacity: 1 } : { scale: 0, opacity: 0 }}
transition={transition}
{...props}
/>
);
}
export {
Switch,
SwitchThumb,
SwitchIcon,
useSwitch,
type SwitchProps,
type SwitchThumbProps,
type SwitchIconProps,
type SwitchIconPosition,
type SwitchContextType,
};
@@ -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.
</div>
</div>
<Switch
<TogglePill
checked={mfa.sso_enforce_mfa}
onCheckedChange={handleBypassToggle}
onChange={handleBypassToggle}
disabled={togglingBypass}
/>
</div>
@@ -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,
<Label htmlFor="developer_mode" className="text-base">Developer Mode</Label>
<p className="text-xs text-muted-foreground">Enable Real-Time Metrics, Debug Diagnostics & Extended Logs</p>
</div>
<Switch
<TogglePill
id="developer_mode"
checked={settings.developer_mode === '1'}
onCheckedChange={(c) => onSettingChange('developer_mode', c ? '1' : '0')}
onChange={(c) => onSettingChange('developer_mode', c ? '1' : '0')}
/>
</div>
@@ -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() {
<div className="space-y-2">
<Label>Enabled</Label>
<div className="pt-2">
<Switch checked={formEnabled} onCheckedChange={setFormEnabled} />
<TogglePill checked={formEnabled} onChange={setFormEnabled} />
</div>
</div>
</div>
@@ -373,9 +373,9 @@ export function NotificationRoutingSection() {
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<Switch
<TogglePill
checked={route.enabled}
onCheckedChange={() => handleToggleEnabled(route)}
onChange={() => handleToggleEnabled(route)}
className="scale-75"
/>
<Button
@@ -4,7 +4,7 @@ import { springs } from '@/lib/motion';
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 { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { useNodes } from '@/context/NodeContext';
@@ -96,10 +96,10 @@ export function NotificationsSection() {
<div className="space-y-4 py-4">
<div className="flex items-center justify-between">
<Label htmlFor={`${type}-enabled`} className="font-medium">Enable {title}</Label>
<Switch
<TogglePill
id={`${type}-enabled`}
checked={agents[type].enabled}
onCheckedChange={(c) => handleAgentChange(type, 'enabled', c)}
onChange={(c) => handleAgentChange(type, 'enabled', c)}
/>
</div>
<div className="space-y-2">
@@ -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.
</p>
</div>
<Switch
<TogglePill
checked={trivy.autoUpdate}
onCheckedChange={handleAutoUpdateToggle}
onChange={handleAutoUpdateToggle}
disabled={trivyBusy !== null}
/>
</div>
@@ -483,9 +483,9 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
Emit a critical alert when this policy is violated after a deploy.
</p>
</div>
<Switch
<TogglePill
checked={form.block_on_deploy}
onCheckedChange={(c) => setForm({ ...form, block_on_deploy: c })}
onChange={(c) => setForm({ ...form, block_on_deploy: c })}
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
@@ -493,9 +493,9 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
<Label className="text-sm">Enabled</Label>
<p className="text-xs text-muted-foreground">Disabled policies are skipped during evaluation.</p>
</div>
<Switch
<TogglePill
checked={form.enabled}
onCheckedChange={(c) => setForm({ ...form, enabled: c })}
onChange={(c) => setForm({ ...form, enabled: c })}
/>
</div>
</div>
@@ -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 }) {
<Badge variant="secondary" className="text-[10px] shrink-0">{wh.stack_name}</Badge>
</div>
<div className="flex items-center gap-2 shrink-0">
<Switch checked={wh.enabled} onCheckedChange={(c) => handleToggle(wh.id!, c)} />
<TogglePill checked={wh.enabled} onChange={(c) => handleToggle(wh.id!, c)} />
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleDelete(wh.id!)}>
<Trash2 className="w-4 h-4 text-muted-foreground" />
</Button>
-33
View File
@@ -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<AnimateSwitchProps, 'children'>;
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
({ className, ...props }, ref) => (
<AnimateSwitch
ref={ref}
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
{...props}
>
<SwitchThumb
className="pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
pressedAnimation={{ scale: 1.15 }}
/>
</AnimateSwitch>
)
);
Switch.displayName = 'Switch';
export { Switch };
@@ -0,0 +1,40 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
interface TogglePillProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'> {
checked: boolean;
onChange: (next: boolean) => void;
}
const TogglePill = React.forwardRef<HTMLButtonElement, TogglePillProps>(
({ checked, onChange, className, disabled, onClick, ...props }, ref) => {
return (
<button
ref={ref}
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={(event) => {
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'}
</button>
);
},
);
TogglePill.displayName = 'TogglePill';
export { TogglePill };
export type { TogglePillProps };