Loading...
Loading...
Expert UI/UX designer specializing in user-centered design, accessibility (WCAG 2.2), design systems, and responsive interfaces. Use when designing web/mobile applications, implementing accessible interfaces, creating design systems, or conducting usability testing.
npx skill4agent add martinholovsky/claude-skills-generator ui-ux-expert// tests/components/Button.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Button from '@/components/ui/Button.vue'
describe('Button', () => {
// Accessibility tests
it('has accessible role and label', () => {
const wrapper = mount(Button, {
props: { label: 'Submit' }
})
expect(wrapper.attributes('role')).toBe('button')
expect(wrapper.text()).toContain('Submit')
})
it('supports keyboard activation', async () => {
const wrapper = mount(Button, {
props: { label: 'Click me' }
})
await wrapper.trigger('keydown.enter')
expect(wrapper.emitted('click')).toBeTruthy()
})
it('has visible focus indicator', () => {
const wrapper = mount(Button, {
props: { label: 'Focus me' }
})
// Focus indicator should be defined in CSS
expect(wrapper.classes()).not.toContain('no-outline')
})
it('meets minimum touch target size', () => {
const wrapper = mount(Button, {
props: { label: 'Tap me' }
})
// Component should have min-height/min-width of 44px
expect(wrapper.classes()).toContain('touch-target')
})
// Responsive behavior tests
it('adapts to container width', () => {
const wrapper = mount(Button, {
props: { label: 'Responsive', fullWidth: true }
})
expect(wrapper.classes()).toContain('w-full')
})
// Loading state tests
it('shows loading state correctly', async () => {
const wrapper = mount(Button, {
props: { label: 'Submit', loading: true }
})
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
expect(wrapper.attributes('disabled')).toBeDefined()
})
// Color contrast (visual regression)
it('maintains sufficient color contrast', () => {
const wrapper = mount(Button, {
props: { label: 'Contrast', variant: 'primary' }
})
// Primary buttons should use high-contrast colors
expect(wrapper.classes()).toContain('bg-primary')
})
})<!-- components/ui/Button.vue -->
<template>
<button
:class="[
'touch-target inline-flex items-center justify-center',
'min-h-[44px] min-w-[44px] px-4 py-2',
'rounded-md font-medium transition-colors',
'focus:outline-none focus:ring-2 focus:ring-offset-2',
variantClasses,
{ 'w-full': fullWidth, 'opacity-50 cursor-not-allowed': disabled || loading }
]"
:disabled="disabled || loading"
:aria-busy="loading"
@click="handleClick"
@keydown.enter="handleClick"
>
<span v-if="loading" class="animate-spin mr-2">
<LoadingSpinner />
</span>
<slot>{{ label }}</slot>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
label?: string
variant?: 'primary' | 'secondary' | 'ghost'
fullWidth?: boolean
disabled?: boolean
loading?: boolean
}>()
const emit = defineEmits<{
click: [event: Event]
}>()
const variantClasses = computed(() => {
switch (props.variant) {
case 'primary':
return 'bg-primary text-white hover:bg-primary-dark focus:ring-primary'
case 'secondary':
return 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500'
case 'ghost':
return 'bg-transparent hover:bg-gray-100 focus:ring-gray-500'
default:
return 'bg-primary text-white hover:bg-primary-dark focus:ring-primary'
}
})
function handleClick(event: Event) {
if (!props.disabled && !props.loading) {
emit('click', event)
}
}
</script># Run component tests
npm run test:unit -- --filter Button
# Run accessibility audit
npm run test:a11y
# Run visual regression tests
npm run test:visual
# Build and check for errors
npm run build
# Run Lighthouse audit
npm run lighthouse<img src="/hero-large.jpg" alt="Hero image" />
<img src="/product-1.jpg" alt="Product" />
<img src="/product-2.jpg" alt="Product" /><!-- Critical above-fold image - load immediately -->
<img src="/hero-large.jpg" alt="Hero image" fetchpriority="high" />
<!-- Below-fold images - lazy load -->
<img src="/product-1.jpg" alt="Product" loading="lazy" decoding="async" />
<img src="/product-2.jpg" alt="Product" loading="lazy" decoding="async" /><!-- Vue component with intersection observer -->
<template>
<img
v-if="isVisible"
:src="src"
:alt="alt"
@load="onLoad"
/>
<div v-else ref="placeholder" class="skeleton" />
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'
const props = defineProps(['src', 'alt'])
const placeholder = ref(null)
const isVisible = ref(false)
onMounted(() => {
const { stop } = useIntersectionObserver(
placeholder,
([{ isIntersecting }]) => {
if (isIntersecting) {
isVisible.value = true
stop()
}
},
{ rootMargin: '100px' }
)
})
</script><img src="/photo.jpg" alt="Photo" /><picture>
<!-- Modern format for supporting browsers -->
<source
type="image/avif"
srcset="
/photo-400.avif 400w,
/photo-800.avif 800w,
/photo-1200.avif 1200w
"
sizes="(max-width: 600px) 100vw, 50vw"
/>
<source
type="image/webp"
srcset="
/photo-400.webp 400w,
/photo-800.webp 800w,
/photo-1200.webp 1200w
"
sizes="(max-width: 600px) 100vw, 50vw"
/>
<!-- Fallback -->
<img
src="/photo-800.jpg"
alt="Photo description"
loading="lazy"
decoding="async"
width="800"
height="600"
/>
</picture><link rel="stylesheet" href="/styles.css" /><head>
<!-- Critical CSS inlined -->
<style>
/* Above-fold styles only */
.hero { ... }
.nav { ... }
.cta-button { ... }
</style>
<!-- Non-critical CSS loaded async -->
<link
rel="preload"
href="/styles.css"
as="style"
onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript>
<link rel="stylesheet" href="/styles.css" />
</noscript>
</head><template>
<div v-if="loading" class="spinner" />
<div v-else>{{ content }}</div>
</template><template>
<article class="card">
<template v-if="loading">
<!-- Skeleton matches final content structure -->
<div class="skeleton-image animate-pulse bg-gray-200 h-48 rounded-t" />
<div class="p-4 space-y-3">
<div class="skeleton-title h-6 bg-gray-200 rounded w-3/4 animate-pulse" />
<div class="skeleton-text h-4 bg-gray-200 rounded w-full animate-pulse" />
<div class="skeleton-text h-4 bg-gray-200 rounded w-2/3 animate-pulse" />
</div>
</template>
<template v-else>
<img :src="image" :alt="title" class="h-48 object-cover rounded-t" />
<div class="p-4">
<h3 class="text-lg font-semibold">{{ title }}</h3>
<p class="text-gray-600">{{ description }}</p>
</div>
</template>
</article>
</template>import Dashboard from '@/views/Dashboard.vue'
import Settings from '@/views/Settings.vue'
import Analytics from '@/views/Analytics.vue'
import Admin from '@/views/Admin.vue'// router/index.ts
const routes = [
{
path: '/dashboard',
component: () => import('@/views/Dashboard.vue')
},
{
path: '/settings',
component: () => import('@/views/Settings.vue')
},
{
path: '/analytics',
// Prefetch for likely navigation
component: () => import(/* webpackPrefetch: true */ '@/views/Analytics.vue')
},
{
path: '/admin',
// Only load when needed
component: () => import('@/views/Admin.vue')
}
]
// Lazy load heavy components
const HeavyChart = defineAsyncComponent({
loader: () => import('@/components/HeavyChart.vue'),
loadingComponent: ChartSkeleton,
delay: 200,
timeout: 10000
})<img src="/photo.jpg" alt="Photo" /><!-- Always specify dimensions -->
<img
src="/photo.jpg"
alt="Photo"
width="800"
height="600"
class="aspect-[4/3] object-cover"
/>
<!-- Use aspect-ratio for responsive images -->
<div class="aspect-video">
<img src="/video-thumb.jpg" alt="Video" class="w-full h-full object-cover" />
</div>
<!-- Reserve space for dynamic content -->
<div class="min-h-[200px]">
<AsyncContent />
</div>[Step Indicator]
Step 1 of 3: Basic Info
[Form Fields - Only Essential]
Name: [_______]
Email: [_______]
[Collapsible Section]
> Advanced Options (Optional)
[Hidden by default, expands on click]
[Primary Action]
[Continue →]
Design Principles:
- Show only essential info by default
- Use "Show more" links for optional content
- Indicate progress in multi-step flows
- Allow users to expand sections as neededaria-expanded[Input Field with Validation]
Email Address
[user@example] ⚠️
└─ "Please include '@' in the email address"
(Inline, real-time validation)
[Confirmation Dialog]
┌─────────────────────────────┐
│ Delete Account? │
│ │
│ This action cannot be │
│ undone. All your data will │
│ be permanently deleted. │
│ │
│ Type "DELETE" to confirm: │
│ [_______] │
│ │
│ [Cancel] [Delete Account] │
└─────────────────────────────┘
Best Practices:
- Validate inline, not just on submit
- Use clear, helpful error messages
- Highlight error fields with color + icon
- Place errors near the relevant field
- Provide suggested fixes when possible
- Use confirmation dialogs for destructive actionsaria-invalidaria-describedby[Primary Navigation]
Top-level (5-7 items max)
├─ Dashboard
├─ Projects
├─ Team
├─ Settings
└─ Help
[Breadcrumbs]
Home > Projects > Website Redesign > Design Files
[Sidebar Navigation]
Settings
├─ Profile
├─ Security
├─ Notifications
├─ Billing
└─ Integrations
Principles:
- Limit top-level nav to 7±2 items
- Group related items logically
- Use familiar labels
- Provide multiple navigation paths
- Show current location clearly[Single Column Layout]
Full Name *
[________________________]
Email Address *
[________________________]
Helper text: We'll never share your email
Password *
[________________________] [👁️ Show]
At least 8 characters, including a number
[Label Above Input - Scannable]
[Visual Field Grouping]
Shipping Address
┌─────────────────────────┐
│ Street [____________] │
│ City [____________] │
│ State [▼ Select] │
│ ZIP [_____] │
└─────────────────────────┘
Design Rules:
- One column layout for better scanning
- Labels above inputs, left-aligned
- Mark required fields clearly
- Use appropriate input types
- Show password visibility toggle
- Group related fields visually
- Use smart defaults when possible
- Provide inline validation
- Make CTAs action-orientedforid[Mobile Touch Targets - 44x44px minimum]
❌ Too Small:
[Submit] (30x30px - hard to tap)
✅ Proper Size:
[ Submit ] (48x48px - easy to tap)
[Button Spacing]
Minimum 8px between interactive elements
[Mobile Action Bar]
┌─────────────────────────┐
│ │
│ [Large tap area for │
│ primary action] │
│ │
│ [ Primary Action ] │ 48px height
│ │
└─────────────────────────┘
Guidelines:
- 44x44px minimum (WCAG 2.2)
- 48x48px recommended
- 8px minimum spacing between targets
- Larger targets for primary actions
- Consider thumb zones on mobile
- Test on actual devices[Skeleton Screens - Better than spinners]
┌─────────────────────────┐
│ ▓▓▓▓▓▓▓▓░░░░░░░░░░ │ (Title loading)
│ ░░░░░░░░░░░░░░░░ │ (Description)
│ ▓▓▓▓░░░░ ▓▓▓▓░░░░ │ (Cards loading)
└─────────────────────────┘
[Progress Indicators]
Uploading file... 47%
[████████░░░░░░░░░░]
[Optimistic UI]
User clicks "Like" →
1. Show liked state immediately
2. Send request in background
3. Revert if request fails
[Toast Notifications]
┌─────────────────────────┐
│ ✓ Settings saved │
└─────────────────────────┘
(Auto-dismiss after 3-5s)
Feedback Types:
- Immediate: Button states, toggles
- Short (< 3s): Spinners, animations
- Long (> 3s): Progress bars with %
- Completion: Success messages, toastsaria-live[Typography Scale]
H1: 32px / 2rem (Page title)
H2: 24px / 1.5rem (Section heading)
H3: 20px / 1.25rem (Subsection)
Body: 16px / 1rem (Base text)
Small: 14px / 0.875rem (Helper text)
[Visual Weight]
1. Size (larger = more important)
2. Color (high contrast = emphasis)
3. Weight (bold = important)
4. Spacing (more space = separation)
[Z-Pattern for Scanning]
Logo ─────────────→ CTA
↓
Headline
↓
Content ─────────→ Image
[F-Pattern for Content]
Headline ──────────
Subhead ──────
Content
Content ───
Subhead ─────
Content
Principles:
- One clear primary action per screen
- Use size to indicate importance
- Maintain consistent spacing
- Create clear content sections
- Use color sparingly for emphasisreferences/design-patterns.mdText Contrast:
- Normal text (< 24px): 4.5:1 minimum
- Large text (≥ 24px): 3:1 minimum
- UI components: 3:1 minimum
Examples:
✅ #000000 on #FFFFFF (21:1) - Excellent
✅ #595959 on #FFFFFF (7:1) - Good
✅ #767676 on #FFFFFF (4.6:1) - Passes AA
❌ #959595 on #FFFFFF (3.9:1) - Fails AA
Tools:
- WebAIM Contrast Checker
- Stark plugin for Figma
- Chrome DevTools Accessibility Panel<!-- Semantic HTML -->
<nav>, <main>, <article>, <aside>, <header>, <footer>
<!-- ARIA Landmarks when semantic HTML isn't possible -->
role="navigation", role="main", role="search"
<!-- ARIA Labels -->
<button aria-label="Close dialog">×</button>
<!-- ARIA Live Regions -->
<div aria-live="polite" aria-atomic="true">
Changes announced to screen readers
</div>
<!-- ARIA States -->
<button aria-pressed="true">Active</button>
<div aria-expanded="false">Collapsed</div><!-- Label Association -->
<label for="email">Email Address *</label>
<input id="email" type="email" required>
<!-- Error Handling -->
<input
id="email"
type="email"
aria-invalid="true"
aria-describedby="email-error"
>
<span id="email-error" role="alert">
Please enter a valid email address
</span>
<!-- Fieldset for Radio Groups -->
<fieldset>
<legend>Shipping Method</legend>
<input type="radio" id="standard" name="shipping">
<label for="standard">Standard</label>
</fieldset>references/accessibility-guide.mdLight gray text on white background
#CCCCCC on #FFFFFF (1.6:1 contrast)
Fails WCAG AA - unreadable for many usersUse sufficient contrast ratios:
- Body text: #333333 on #FFFFFF (12.6:1)
- Secondary text: #666666 on #FFFFFF (5.7:1)
- Always test with contrast checker toolsError shown only by red border
[_________] (red border)
No icon, no text - fails for colorblind usersUse multiple indicators:
⚠️ [_________]
└─ "Email address is required"
Combine: Color + Icon + Text[×] Close button: 20x20px
Too small for reliable tapping[ × ] Minimum 44x44px tap area
Even if icon is smaller, padding increases hit area<div onclick="submit()">Submit</div>
Not keyboard accessible, no semantic meaning<button type="submit">Submit</button>
Semantic, keyboard accessible by default<input type="text" placeholder="Enter email">
Screen readers can't identify the field<label for="email">Email Address</label>
<input id="email" type="email" placeholder="user@example.com">- Save button is blue on page 1
- Save button is green on page 2
- Save button position changesCreate design system with consistent:
- Component styles
- Button positions
- Interaction patterns
- Terminology"Error: Invalid input"
Doesn't tell user what's wrong or how to fix it"Password must be at least 8 characters and include a number"
Clear, actionable, helpfulVideo with sound auto-plays on page load
Disorienting for screen reader users- Never auto-play with sound
- Provide play/pause controls
- Show captions by default
- Allow users to control mediaMain navigation with 15+ top-level items
Mega-menu with 100+ links
Overwhelming and hard to scan- Limit top-level nav to 5-7 items
- Use clear hierarchy
- Group related items
- Provide search for large sites[Submit] → Click → Nothing happens → User clicks again
No feedback, user is confused[Submit] → [Submitting...] → [✓ Saved]
Clear feedback at every step// tests/components/Modal.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import Modal from '@/components/ui/Modal.vue'
describe('Modal', () => {
// Accessibility tests
it('has correct ARIA attributes', () => {
const wrapper = mount(Modal, {
props: { isOpen: true, title: 'Test Modal' }
})
expect(wrapper.attributes('role')).toBe('dialog')
expect(wrapper.attributes('aria-modal')).toBe('true')
expect(wrapper.attributes('aria-labelledby')).toBeDefined()
})
it('traps focus within modal', async () => {
const wrapper = mount(Modal, {
props: { isOpen: true, title: 'Focus Trap' },
attachTo: document.body
})
const focusableElements = wrapper.findAll('button, [tabindex="0"]')
expect(focusableElements.length).toBeGreaterThan(0)
})
it('closes on Escape key', async () => {
const wrapper = mount(Modal, {
props: { isOpen: true, title: 'Escape Test' }
})
await wrapper.trigger('keydown.escape')
expect(wrapper.emitted('close')).toBeTruthy()
})
it('announces to screen readers when opened', () => {
const wrapper = mount(Modal, {
props: { isOpen: true, title: 'Announcement' }
})
const liveRegion = wrapper.find('[aria-live]')
expect(liveRegion.exists()).toBe(true)
})
// Touch target tests
it('close button meets touch target size', () => {
const wrapper = mount(Modal, {
props: { isOpen: true, title: 'Touch Target' }
})
const closeButton = wrapper.find('[aria-label="Close"]')
expect(closeButton.classes()).toContain('touch-target')
})
})// tests/visual/button.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Button Visual Tests', () => {
test('button states render correctly', async ({ page }) => {
await page.goto('/storybook/button')
// Default state
await expect(page.locator('.btn-primary')).toHaveScreenshot('button-default.png')
// Hover state
await page.locator('.btn-primary').hover()
await expect(page.locator('.btn-primary')).toHaveScreenshot('button-hover.png')
// Focus state
await page.locator('.btn-primary').focus()
await expect(page.locator('.btn-primary')).toHaveScreenshot('button-focus.png')
// Disabled state
await expect(page.locator('.btn-primary[disabled]')).toHaveScreenshot('button-disabled.png')
})
test('button has sufficient contrast', async ({ page }) => {
await page.goto('/storybook/button')
// Check color contrast using axe
const results = await new AxeBuilder({ page }).analyze()
expect(results.violations).toHaveLength(0)
})
})// tests/a11y/pages.spec.ts
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
test.describe('Accessibility Audits', () => {
test('home page passes accessibility audit', async ({ page }) => {
await page.goto('/')
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.analyze()
expect(results.violations).toHaveLength(0)
})
test('form page has accessible inputs', async ({ page }) => {
await page.goto('/contact')
const results = await new AxeBuilder({ page })
.include('form')
.analyze()
expect(results.violations).toHaveLength(0)
})
test('navigation is keyboard accessible', async ({ page }) => {
await page.goto('/')
// Tab through navigation
await page.keyboard.press('Tab')
const firstNavItem = page.locator('nav a:first-child')
await expect(firstNavItem).toBeFocused()
// Can activate with Enter
await page.keyboard.press('Enter')
await expect(page).toHaveURL(/.*about/)
})
})// tests/performance/core-web-vitals.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Core Web Vitals', () => {
test('LCP is under 2.5 seconds', async ({ page }) => {
await page.goto('/')
const lcp = await page.evaluate(() => {
return new Promise((resolve) => {
new PerformanceObserver((list) => {
const entries = list.getEntries()
resolve(entries[entries.length - 1].startTime)
}).observe({ entryTypes: ['largest-contentful-paint'] })
})
})
expect(lcp).toBeLessThan(2500)
})
test('CLS is under 0.1', async ({ page }) => {
await page.goto('/')
const cls = await page.evaluate(() => {
return new Promise((resolve) => {
let clsValue = 0
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
clsValue += entry.value
}
}
resolve(clsValue)
}).observe({ entryTypes: ['layout-shift'] })
setTimeout(() => resolve(clsValue), 5000)
})
})
expect(cls).toBeLessThan(0.1)
})
})# Run all tests
npm run test:unit
npm run test:a11y
npm run test:visual
npm run test:e2e# Run Lighthouse
npm run lighthouse
# Check bundle size
npm run build -- --reportreferences/design-patterns.mdreferences/accessibility-guide.md