* fix(cli): install all 7 skills via uipro init, not just the orchestrator `uipro init` rendered only the orchestrator (ui-ux-pro-max) and never delivered the 6 sibling skills (banner-design, brand, design, design-system, slides, ui-styling), so users got 1 of 7 skills (#362). - sync-assets.mjs: bundle the 6 sub-skills into cli/assets/skills/ as static copies (source of truth: .claude/skills/), with sync + check coverage. Excludes ui-styling/canvas-fonts (~5.8MB of TTF) and __pycache__/.pyc cruft — a skill registers from its SKILL.md, not its fonts — so the bundle adds ~0.9MB, not ~6.6MB. - template.ts: after rendering the orchestrator, install each bundled sub-skill as a sibling. The skills parent is derived from the platform's skillPath (skills/ for most, prompts/ for copilot, steering/ for kiro) rather than hardcoded. - uninstall.ts: remove the sub-skills too. Verified: check:assets in sync, tsc passes, and a per-platform install harness delivers all 7 skills to the correct parent dir with no fonts. Closes #362 * fix(cli): filter excluded files from target side of check:assets check:assets filtered sourceFiles with isExcludedAssetFile but not targetFiles, so a stray cli/assets/scripts/__pycache__/*.pyc (generated by a local Python run) was reported as an "extra asset file" and failed the gate. Apply the same predicate to targetFiles in both the dirsToSync and sub-skill loops. Verified: check:assets now passes with __pycache__/*.pyc present in the target tree; typecheck passes. * fix(cli): uninstall from each platform's real skills dir, not hardcoded skills/ removeSkillDir() hardcoded <folder>/skills/<name>, but the installer places skills under each platform config's skillPath parent — copilot in .github/prompts/, kiro in .kiro/steering/. So uninstall left those platforms' skills (orchestrator + sub-skills) behind. Derive the install parent from loadPlatformConfig(aiType).folderStructure (same source the installer uses), and keep the legacy <folder>/skills/ cleanup (incl. .shared/) for older installs. Deduped via a Set. Verified: typecheck passes; an install+uninstall harness removes all 7 skills with zero leftovers for claude (.claude/skills), copilot (.github/prompts) and kiro (.kiro/steering). * fix(cli): re-sync bundled sub-skills after #385 stripped ckm- names #385 merged to main and removed the ckm- prefix from the six .claude/skills/*/SKILL.md name fields. This branch's bundled copies under cli/assets/skills/ still carried the old ckm- names, so after the PR merges with main the source no longer matched the bundle and the check-asset-sync CI gate failed (stale asset file: skills/*/SKILL.md). Merge main and regenerate the bundle so cli/assets/skills matches the current .claude/skills source of truth. check:assets and typecheck pass.
9.7 KiB
shadcn/ui Accessibility Patterns
ARIA patterns, keyboard navigation, screen reader support, and accessible component usage.
Foundation: Radix UI Primitives
shadcn/ui built on Radix UI primitives - unstyled, accessible components following WAI-ARIA design patterns.
Benefits:
- Keyboard navigation built-in
- Screen reader announcements
- Focus management
- ARIA attributes automatically applied
- Tested against accessibility standards
Keyboard Navigation
Focus Management
Focus visible states:
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
Accessible Button
</Button>
Skip to content:
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2">
Skip to content
</a>
<main id="main-content">
{/* Content */}
</main>
Dialog/Modal Navigation
Dialogs trap focus automatically via Radix Dialog primitive:
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
{/* Focus trapped here */}
<input /> {/* Auto-focused */}
<Button>Action</Button>
{/* Esc to close, Tab to navigate */}
</DialogContent>
</Dialog>
Features:
- Focus trapped within dialog
- Esc key closes
- Tab cycles through focusable elements
- Focus returns to trigger on close
Dropdown/Menu Navigation
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
<DropdownMenu>
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem>Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
Keyboard shortcuts:
Space/Enter: Open menuArrow Up/Down: Navigate itemsEsc: Close menuTab: Close and move focus
Command Palette Navigation
import { Command } from "@/components/ui/command"
<Command>
<CommandInput placeholder="Search..." />
<CommandList>
<CommandGroup heading="Suggestions">
<CommandItem>Calendar</CommandItem>
<CommandItem>Search</CommandItem>
</CommandGroup>
</CommandList>
</Command>
Features:
- Type to filter
- Arrow keys to navigate
- Enter to select
- Esc to close
Screen Reader Support
Semantic HTML
Use proper HTML elements:
// Good: Semantic HTML
<button>Click me</button>
<nav><a href="/">Home</a></nav>
// Avoid: Div soup
<div onClick={handler}>Click me</div>
ARIA Labels
Label interactive elements:
<Button aria-label="Close dialog">
<X className="h-4 w-4" />
</Button>
<Input aria-label="Email address" type="email" />
Describe elements:
<Button aria-describedby="delete-description">
Delete Account
</Button>
<p id="delete-description" className="sr-only">
This action permanently deletes your account and cannot be undone
</p>
Screen Reader Only Text
Use sr-only class for screen reader only content:
<Button>
<Trash className="h-4 w-4" />
<span className="sr-only">Delete item</span>
</Button>
// CSS for sr-only
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
Live Regions
Announce dynamic content:
<div aria-live="polite" aria-atomic="true">
{message}
</div>
// For urgent updates
<div aria-live="assertive">
{error}
</div>
Toast component includes live region:
const { toast } = useToast()
toast({
title: "Success",
description: "Profile updated"
})
// Announced to screen readers automatically
Form Accessibility
Labels and Descriptions
Always label inputs:
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
<div>
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" />
</div>
Add descriptions:
import { FormDescription, FormMessage } from "@/components/ui/form"
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Your public display name
</FormDescription>
<FormMessage /> {/* Error messages */}
</FormItem>
Error Handling
Announce errors to screen readers:
<FormField
control={form.control}
name="email"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
{...field}
aria-invalid={!!fieldState.error}
aria-describedby={fieldState.error ? "email-error" : undefined}
/>
</FormControl>
<FormMessage id="email-error" />
</FormItem>
)}
/>
Required Fields
Indicate required fields:
<Label htmlFor="name">
Name <span className="text-destructive">*</span>
<span className="sr-only">(required)</span>
</Label>
<Input id="name" required />
Fieldset and Legend
Group related fields:
<fieldset>
<legend className="text-lg font-semibold mb-4">
Contact Information
</legend>
<div className="space-y-4">
<FormField name="email" />
<FormField name="phone" />
</div>
</fieldset>
Component-Specific Patterns
Accordion
import { Accordion } from "@/components/ui/accordion"
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<AccordionTrigger>
{/* Includes aria-expanded, aria-controls automatically */}
Is it accessible?
</AccordionTrigger>
<AccordionContent>
{/* Hidden when collapsed, announced when expanded */}
Yes. Follows WAI-ARIA design pattern.
</AccordionContent>
</AccordionItem>
</Accordion>
Tabs
import { Tabs } from "@/components/ui/tabs"
<Tabs defaultValue="account">
<TabsList role="tablist">
{/* Arrow keys navigate, Space/Enter activates */}
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">
{/* Hidden unless selected, aria-labelledby links to trigger */}
Account content
</TabsContent>
</Tabs>
Select
import { Select } from "@/components/ui/select"
<Select>
<SelectTrigger aria-label="Choose theme">
<SelectValue placeholder="Theme" />
</SelectTrigger>
<SelectContent>
{/* Keyboard navigable, announced to screen readers */}
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
Checkbox and Radio
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
<div className="flex items-center space-x-2">
<Checkbox id="terms" aria-describedby="terms-description" />
<Label htmlFor="terms">Accept terms</Label>
</div>
<p id="terms-description" className="text-sm text-muted-foreground">
You agree to our Terms of Service and Privacy Policy
</p>
Alert
import { Alert } from "@/components/ui/alert"
<Alert role="alert">
{/* Announced immediately to screen readers */}
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Your session has expired
</AlertDescription>
</Alert>
Color Contrast
Ensure sufficient contrast between text and background.
WCAG Requirements:
- AA: 4.5:1 for normal text, 3:1 for large text
- AAA: 7:1 for normal text, 4.5:1 for large text
Check defaults:
// Good: High contrast
<p className="text-gray-900 dark:text-gray-100">Text</p>
// Avoid: Low contrast
<p className="text-gray-400 dark:text-gray-600">Hard to read</p>
Muted text:
// Use semantic muted foreground
<p className="text-muted-foreground">
Secondary text with accessible contrast
</p>
Focus Indicators
Always provide visible focus indicators:
Default focus ring:
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
Button
</Button>
Custom focus styles:
<a href="#" className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:underline">
Link
</a>
Don't remove focus styles:
// Avoid
<button className="focus:outline-none">Bad</button>
// Use focus-visible instead
<button className="focus-visible:ring-2">Good</button>
Motion and Animation
Respect reduced motion preference:
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
In components:
<div className="transition-all motion-reduce:transition-none">
Respects user preference
</div>
Testing Checklist
- All interactive elements keyboard accessible
- Focus indicators visible
- Screen reader announces all content correctly
- Form errors announced and associated
- Color contrast meets WCAG AA
- Semantic HTML used
- ARIA labels provided for icon-only buttons
- Modal/dialog focus trap works
- Dropdown/select keyboard navigable
- Live regions announce updates
- Respects reduced motion preference
- Works with browser zoom up to 200%
- Tab order logical
- Skip links provided for navigation
Tools
Testing tools:
- Lighthouse accessibility audit
- axe DevTools browser extension
- NVDA/JAWS screen readers
- Keyboard-only navigation testing
- Color contrast checkers (Contrast Ratio, WebAIM)
Automated testing:
npm install -D @axe-core/react
import { useEffect } from 'react'
if (process.env.NODE_ENV === 'development') {
import('@axe-core/react').then((axe) => {
axe.default(React, ReactDOM, 1000)
})
}