@fr0mpy/component-system 2.1.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +86 -27
  2. package/bin/cli.js +155 -50
  3. package/bin/validate-compliance.js +447 -0
  4. package/bin/validate-config.js +175 -0
  5. package/bin/validate-harness.js +215 -0
  6. package/index.js +15 -0
  7. package/package.json +7 -4
  8. package/templates/agents/component-auditor.md +77 -0
  9. package/templates/agents/design-token-validator.md +72 -0
  10. package/templates/agents/harness-scaffolder.md +146 -0
  11. package/templates/agents/playwright-tester.md +437 -0
  12. package/templates/agents/style-inspector.md +81 -0
  13. package/templates/commands/component-harness.md +104 -10
  14. package/templates/commands/retheme.md +142 -0
  15. package/templates/commands/save-theme.md +50 -0
  16. package/templates/commands/setup-styling.md +386 -57
  17. package/templates/component-recipes/accordion.md +9 -9
  18. package/templates/component-recipes/alert.md +36 -23
  19. package/templates/component-recipes/avatar.md +14 -12
  20. package/templates/component-recipes/badge.md +9 -6
  21. package/templates/component-recipes/breadcrumb.md +4 -4
  22. package/templates/component-recipes/button.md +2 -2
  23. package/templates/component-recipes/checkbox.md +5 -5
  24. package/templates/component-recipes/collapsible.md +13 -13
  25. package/templates/component-recipes/combobox.md +2 -2
  26. package/templates/component-recipes/context-menu.md +11 -11
  27. package/templates/component-recipes/dialog.md +16 -16
  28. package/templates/component-recipes/drawer.md +18 -18
  29. package/templates/component-recipes/dropdown-menu.md +12 -12
  30. package/templates/component-recipes/hover-card.md +11 -11
  31. package/templates/component-recipes/label.md +4 -4
  32. package/templates/component-recipes/modal.md +9 -9
  33. package/templates/component-recipes/navigation-menu.md +19 -19
  34. package/templates/component-recipes/popover.md +10 -10
  35. package/templates/component-recipes/progress.md +10 -8
  36. package/templates/component-recipes/radio.md +6 -6
  37. package/templates/component-recipes/select.md +5 -5
  38. package/templates/component-recipes/separator.md +2 -2
  39. package/templates/component-recipes/slider.md +2 -2
  40. package/templates/component-recipes/switch.md +6 -6
  41. package/templates/component-recipes/table.md +1 -1
  42. package/templates/component-recipes/tabs.md +6 -6
  43. package/templates/component-recipes/toast.md +56 -42
  44. package/templates/component-recipes/toggle-group.md +4 -4
  45. package/templates/component-recipes/tooltip.md +5 -5
  46. package/templates/mcp/mcp.json +8 -0
  47. package/templates/playwright/playwright.config.ts +31 -0
  48. package/templates/playwright/tests/components.spec.ts +104 -0
  49. package/templates/skills/react-patterns.md +141 -0
  50. package/templates/skills/styling.md +141 -52
  51. package/templates/hooks/triggers.d/styling.json +0 -23
@@ -14,14 +14,14 @@ peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center {tokens.radius}
14
14
  border-2 border-transparent shadow-sm transition-colors
15
15
  focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2
16
16
  disabled:cursor-not-allowed disabled:opacity-50
17
- data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted
17
+ data-checked:bg-primary data-unchecked:bg-muted
18
18
  ```
19
19
 
20
20
  ### Thumb
21
21
  ```
22
22
  pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0
23
23
  transition-transform
24
- data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0
24
+ data-checked:translate-x-4 data-unchecked:translate-x-0
25
25
  ```
26
26
 
27
27
  ### Sizes
@@ -51,7 +51,7 @@ interface SwitchProps {
51
51
  ```
52
52
 
53
53
  ## Do
54
- - Use Radix Switch for accessibility
54
+ - Use Base UI Switch for accessibility
55
55
  - Include focus ring
56
56
  - Animate thumb movement smoothly
57
57
  - Use semantic colors (primary for checked)
@@ -64,7 +64,7 @@ interface SwitchProps {
64
64
 
65
65
  ## Example
66
66
  ```tsx
67
- import * as SwitchPrimitive from '@radix-ui/react-switch'
67
+ import { Switch as SwitchPrimitive } from '@base-ui/react/switch'
68
68
  import { cn } from '@/lib/utils'
69
69
 
70
70
  const Switch = ({ className, ...props }) => (
@@ -74,7 +74,7 @@ const Switch = ({ className, ...props }) => (
74
74
  'border-2 border-transparent shadow-sm transition-colors',
75
75
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
76
76
  'disabled:cursor-not-allowed disabled:opacity-50',
77
- 'data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted',
77
+ 'data-checked:bg-primary data-unchecked:bg-muted',
78
78
  className
79
79
  )}
80
80
  {...props}
@@ -82,7 +82,7 @@ const Switch = ({ className, ...props }) => (
82
82
  <SwitchPrimitive.Thumb
83
83
  className={cn(
84
84
  'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0',
85
- 'transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
85
+ 'transition-transform data-checked:translate-x-4 data-unchecked:translate-x-0'
86
86
  )}
87
87
  />
88
88
  </SwitchPrimitive.Root>
@@ -32,7 +32,7 @@ w-full caption-bottom text-sm
32
32
  ```
33
33
  border-b border-border transition-colors
34
34
  hover:bg-muted/50
35
- data-[state=selected]:bg-muted
35
+ data-selected:bg-muted
36
36
  ```
37
37
 
38
38
  ### Head Cell (th)
@@ -20,7 +20,7 @@ text-sm font-medium ring-offset-background
20
20
  transition-all
21
21
  focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2
22
22
  disabled:pointer-events-none disabled:opacity-50
23
- data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:{tokens.shadow}
23
+ data-selected:bg-background data-selected:text-foreground data-selected:{tokens.shadow}
24
24
  ```
25
25
 
26
26
  ### Tab Content
@@ -36,7 +36,7 @@ border-b border-border
36
36
 
37
37
  Tab Trigger:
38
38
  border-b-2 border-transparent pb-3 pt-2
39
- data-[state=active]:border-primary data-[state=active]:text-foreground
39
+ data-selected:border-primary data-selected:text-foreground
40
40
  ```
41
41
 
42
42
  ### Alternative: Pills Style
@@ -46,7 +46,7 @@ flex gap-2
46
46
 
47
47
  Tab Trigger:
48
48
  rounded-full px-4 py-2
49
- data-[state=active]:bg-primary data-[state=active]:text-primary-foreground
49
+ data-selected:bg-primary data-selected:text-primary-foreground
50
50
  ```
51
51
 
52
52
  ## Props Interface
@@ -78,7 +78,7 @@ interface TabsContentProps {
78
78
  ```
79
79
 
80
80
  ## Do
81
- - Use Radix Tabs for accessibility
81
+ - Use Base UI Tabs for accessibility
82
82
  - Support keyboard navigation (arrow keys)
83
83
  - Include focus ring for triggers
84
84
  - Use subtle background for active state
@@ -91,7 +91,7 @@ interface TabsContentProps {
91
91
 
92
92
  ## Example
93
93
  ```tsx
94
- import * as TabsPrimitive from '@radix-ui/react-tabs'
94
+ import { Tabs as TabsPrimitive } from '@base-ui/react/tabs'
95
95
  import { cn } from '@/lib/utils'
96
96
 
97
97
  const Tabs = TabsPrimitive.Root
@@ -113,7 +113,7 @@ const TabsTrigger = ({ className, ...props }) => (
113
113
  'text-sm font-medium ring-offset-background transition-all',
114
114
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
115
115
  'disabled:pointer-events-none disabled:opacity-50',
116
- 'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
116
+ 'data-selected:bg-background data-selected:text-foreground data-selected:shadow-sm',
117
117
  className
118
118
  )}
119
119
  {...props}
@@ -6,6 +6,17 @@
6
6
  - Auto-dismiss with progress indicator (optional)
7
7
  - Support variants: default, success, error, warning
8
8
 
9
+ ## IMPORTANT: Color Token Usage
10
+
11
+ **NEVER use hardcoded Tailwind colors.** Use semantic tokens only:
12
+
13
+ | Semantic Token | Purpose |
14
+ |----------------|---------|
15
+ | `bg-success`, `text-success-foreground` | Success notifications |
16
+ | `bg-warning`, `text-warning-foreground` | Warning notifications |
17
+ | `bg-destructive`, `text-destructive-foreground` | Error notifications |
18
+ | `bg-background`, `text-foreground` | Default notifications |
19
+
9
20
  ## Tailwind Classes
10
21
 
11
22
  ### Viewport (container for all toasts)
@@ -17,26 +28,28 @@ sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]
17
28
  ### Toast
18
29
  ```
19
30
  group pointer-events-auto relative flex w-full items-center justify-between space-x-2
20
- overflow-hidden {tokens.radius} border border-border p-4 pr-6 {tokens.shadow}
31
+ overflow-hidden rounded-[USE_CONFIG_RADIUS] border border-border p-4 pr-6 shadow-elevation-sm
21
32
  transition-all
22
33
  data-[swipe=cancel]:translate-x-0
23
- data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)]
24
- data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)]
34
+ data-[swipe=end]:translate-x-[var(--toast-swipe-end-x)]
35
+ data-[swipe=move]:translate-x-[var(--toast-swipe-move-x)]
25
36
  data-[swipe=move]:transition-none
26
- data-[state=open]:animate-in data-[state=closed]:animate-out
37
+ data-open:animate-in data-closed:animate-out
27
38
  data-[swipe=end]:animate-out
28
- data-[state=closed]:fade-out-80
29
- data-[state=closed]:slide-out-to-right-full
30
- data-[state=open]:slide-in-from-top-full
31
- data-[state=open]:sm:slide-in-from-bottom-full
39
+ data-closed:fade-out-80
40
+ data-closed:slide-out-to-right-full
41
+ data-open:slide-in-from-top-full
42
+ data-open:sm:slide-in-from-bottom-full
32
43
  ```
33
44
 
34
- ### Variants
45
+ **Note:** Replace `rounded-[USE_CONFIG_RADIUS]` with value from `.claude/styling-config.json` → `tokens.radius`
46
+
47
+ ### Variants (Using Semantic Tokens ONLY)
35
48
  ```
36
- default: bg-background text-foreground
37
- success: bg-green-500 text-white border-green-600
49
+ default: bg-background text-foreground border-border
50
+ success: bg-success text-success-foreground border-success
38
51
  error: bg-destructive text-destructive-foreground border-destructive
39
- warning: bg-yellow-500 text-white border-yellow-600
52
+ warning: bg-warning text-warning-foreground border-warning
40
53
  ```
41
54
 
42
55
  ### Title
@@ -51,24 +64,19 @@ text-sm opacity-90
51
64
 
52
65
  ### Action Button
53
66
  ```
54
- inline-flex h-8 shrink-0 items-center justify-center {tokens.radius}
55
- border border-border bg-transparent px-3 text-sm font-medium
67
+ inline-flex h-8 shrink-0 items-center justify-center rounded-[USE_CONFIG_RADIUS]
68
+ border border-border bg-transparent px-3 text-sm font-medium cursor-pointer
56
69
  transition-colors hover:bg-secondary
57
- focus:outline-none focus:ring-1 focus:ring-ring
70
+ focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary
58
71
  disabled:pointer-events-none disabled:opacity-50
59
- group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30
60
- group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground
61
- group-[.destructive]:focus:ring-destructive
62
72
  ```
63
73
 
64
74
  ### Close Button
65
75
  ```
66
- absolute right-1 top-1 rounded-md p-1 text-foreground/50
76
+ absolute right-1 top-1 rounded-md p-1 text-foreground/50 cursor-pointer
67
77
  opacity-0 transition-opacity hover:text-foreground
68
- focus:opacity-100 focus:outline-none focus:ring-1
78
+ focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1
69
79
  group-hover:opacity-100
70
- group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50
71
- group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600
72
80
  ```
73
81
 
74
82
  ## Props Interface
@@ -98,21 +106,25 @@ function useToast(): {
98
106
  ```
99
107
 
100
108
  ## Do
109
+ - Use semantic color tokens ONLY (bg-success, bg-warning, bg-destructive)
110
+ - Read `.claude/styling-config.json` for radius value
111
+ - Include `cursor-pointer` on action and close buttons
112
+ - Use `focus-visible:` not `focus:` for keyboard accessibility
101
113
  - Use a toast provider at app root
102
114
  - Support swipe-to-dismiss on mobile
103
- - Include close button
104
- - Use appropriate variant for context
105
115
  - Limit visible toasts (3-5 max)
106
116
 
107
117
  ## Don't
108
- - Hardcode colors
118
+ - Use hardcoded colors (bg-green-500, bg-yellow-500, #XXXXXX)
119
+ - Use non-semantic Tailwind colors
120
+ - Use `focus:` instead of `focus-visible:`
121
+ - Forget `cursor-pointer` on interactive elements
109
122
  - Skip auto-dismiss (except for errors)
110
- - Use for critical information (use Alert)
111
- - Stack too many toasts
123
+ - Use for critical information (use Alert instead)
112
124
 
113
125
  ## Example
114
126
  ```tsx
115
- import * as ToastPrimitives from '@radix-ui/react-toast'
127
+ import { Toast as ToastPrimitives } from '@base-ui/react/toast'
116
128
  import { X } from 'lucide-react'
117
129
  import { cn } from '@/lib/utils'
118
130
 
@@ -129,27 +141,29 @@ const ToastViewport = ({ className, ...props }) => (
129
141
  />
130
142
  )
131
143
 
144
+ // Using semantic tokens - NO hardcoded colors!
132
145
  const toastVariants = {
133
146
  default: 'bg-background text-foreground border-border',
134
- success: 'bg-green-500 text-white border-green-600',
147
+ success: 'bg-success text-success-foreground border-success',
135
148
  error: 'bg-destructive text-destructive-foreground border-destructive',
136
- warning: 'bg-yellow-500 text-white border-yellow-600',
149
+ warning: 'bg-warning text-warning-foreground border-warning',
137
150
  }
138
151
 
139
152
  const Toast = ({ className, variant = 'default', ...props }) => (
140
153
  <ToastPrimitives.Root
141
154
  className={cn(
142
155
  'group pointer-events-auto relative flex w-full items-center justify-between space-x-2',
143
- 'overflow-hidden rounded-lg border p-4 pr-6 shadow-lg transition-all',
156
+ // Replace rounded-lg with your config's tokens.radius
157
+ 'overflow-hidden rounded-lg border p-4 pr-6 shadow-elevation-sm transition-all',
144
158
  'data-[swipe=cancel]:translate-x-0',
145
- 'data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)]',
146
- 'data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)]',
159
+ 'data-[swipe=end]:translate-x-[var(--toast-swipe-end-x)]',
160
+ 'data-[swipe=move]:translate-x-[var(--toast-swipe-move-x)]',
147
161
  'data-[swipe=move]:transition-none',
148
- 'data-[state=open]:animate-in data-[state=closed]:animate-out',
149
- 'data-[swipe=end]:animate-out data-[state=closed]:fade-out-80',
150
- 'data-[state=closed]:slide-out-to-right-full',
151
- 'data-[state=open]:slide-in-from-top-full',
152
- 'data-[state=open]:sm:slide-in-from-bottom-full',
162
+ 'data-open:animate-in data-closed:animate-out',
163
+ 'data-[swipe=end]:animate-out data-closed:fade-out-80',
164
+ 'data-closed:slide-out-to-right-full',
165
+ 'data-open:slide-in-from-top-full',
166
+ 'data-open:sm:slide-in-from-bottom-full',
153
167
  toastVariants[variant],
154
168
  className
155
169
  )}
@@ -160,9 +174,9 @@ const Toast = ({ className, variant = 'default', ...props }) => (
160
174
  const ToastAction = ({ className, ...props }) => (
161
175
  <ToastPrimitives.Action
162
176
  className={cn(
163
- 'inline-flex h-8 shrink-0 items-center justify-center rounded-md',
177
+ 'inline-flex h-8 shrink-0 items-center justify-center rounded-md cursor-pointer',
164
178
  'border bg-transparent px-3 text-sm font-medium',
165
- 'hover:bg-secondary focus:outline-none focus:ring-1',
179
+ 'hover:bg-secondary focus-visible:outline-none focus-visible:ring-1',
166
180
  className
167
181
  )}
168
182
  {...props}
@@ -172,9 +186,9 @@ const ToastAction = ({ className, ...props }) => (
172
186
  const ToastClose = ({ className, ...props }) => (
173
187
  <ToastPrimitives.Close
174
188
  className={cn(
175
- 'absolute right-1 top-1 rounded-md p-1 text-foreground/50',
189
+ 'absolute right-1 top-1 rounded-md p-1 text-foreground/50 cursor-pointer',
176
190
  'opacity-0 transition-opacity hover:text-foreground',
177
- 'focus:opacity-100 focus:outline-none group-hover:opacity-100',
191
+ 'focus-visible:opacity-100 focus-visible:outline-none group-hover:opacity-100',
178
192
  className
179
193
  )}
180
194
  {...props}
@@ -90,7 +90,7 @@ interface ToggleGroupMultipleProps {
90
90
  ```
91
91
 
92
92
  ## Do
93
- - Use Radix ToggleGroup primitive
93
+ - Use Base UI ToggleGroup primitive
94
94
  - Clearly indicate selected state
95
95
  - Support keyboard navigation
96
96
  - Include proper ARIA attributes
@@ -104,7 +104,7 @@ interface ToggleGroupMultipleProps {
104
104
 
105
105
  ## Example
106
106
  ```tsx
107
- import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group'
107
+ import { ToggleGroup as ToggleGroupPrimitive } from '@base-ui/react/toggle-group'
108
108
  import { cn } from '@/lib/utils'
109
109
 
110
110
  const ToggleGroup = ({ className, variant = 'default', size = 'md', ...props }) => (
@@ -134,12 +134,12 @@ const ToggleGroupItem = ({ className, variant = 'default', size = 'md', ...props
134
134
  'disabled:pointer-events-none disabled:opacity-50',
135
135
  variant === 'default' && [
136
136
  'text-muted-foreground hover:bg-background/50 hover:text-foreground',
137
- 'data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm',
137
+ 'data-pressed:bg-background data-pressed:text-foreground data-pressed:shadow-sm',
138
138
  ],
139
139
  variant === 'outline' && [
140
140
  'border-r border-border last:border-r-0',
141
141
  'text-muted-foreground hover:bg-muted hover:text-foreground',
142
- 'data-[state=on]:bg-muted data-[state=on]:text-foreground',
142
+ 'data-pressed:bg-muted data-pressed:text-foreground',
143
143
  ],
144
144
  toggleGroupItemSizes[size],
145
145
  className
@@ -12,7 +12,7 @@
12
12
  ```
13
13
  z-50 overflow-hidden {tokens.radius} bg-foreground px-3 py-1.5 text-xs text-background
14
14
  animate-in fade-in-0 zoom-in-95
15
- data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95
15
+ data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95
16
16
  data-[side=bottom]:slide-in-from-top-2
17
17
  data-[side=left]:slide-in-from-right-2
18
18
  data-[side=right]:slide-in-from-left-2
@@ -47,7 +47,7 @@ interface TooltipProps {
47
47
  - Skip delay on hover when moving between tooltips
48
48
 
49
49
  ## Do
50
- - Use Radix Tooltip for accessibility
50
+ - Use Base UI Tooltip for accessibility
51
51
  - Include enter/exit animations
52
52
  - Support arrow pointing to trigger
53
53
  - Use inverted colors (dark bg, light text) for contrast
@@ -60,7 +60,7 @@ interface TooltipProps {
60
60
 
61
61
  ## Example
62
62
  ```tsx
63
- import * as TooltipPrimitive from '@radix-ui/react-tooltip'
63
+ import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'
64
64
  import { cn } from '@/lib/utils'
65
65
 
66
66
  const TooltipProvider = TooltipPrimitive.Provider
@@ -75,7 +75,7 @@ const TooltipContent = ({ className, sideOffset = 4, ...props }) => (
75
75
  className={cn(
76
76
  'z-50 overflow-hidden rounded-md bg-foreground px-3 py-1.5 text-xs text-background',
77
77
  'animate-in fade-in-0 zoom-in-95',
78
- 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
78
+ 'data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
79
79
  'data-[side=bottom]:slide-in-from-top-2',
80
80
  'data-[side=left]:slide-in-from-right-2',
81
81
  'data-[side=right]:slide-in-from-left-2',
@@ -97,7 +97,7 @@ const TooltipArrow = ({ className, ...props }) => (
97
97
  const SimpleTooltip = ({ content, side = 'top', delayDuration = 200, children }) => (
98
98
  <TooltipProvider>
99
99
  <Tooltip delayDuration={delayDuration}>
100
- <TooltipTrigger asChild>{children}</TooltipTrigger>
100
+ <TooltipTrigger render={children}>{children}</TooltipTrigger>
101
101
  <TooltipContent side={side}>
102
102
  {content}
103
103
  <TooltipArrow />
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "playwright": {
4
+ "command": "npx",
5
+ "args": ["@playwright/mcp@latest"]
6
+ }
7
+ }
8
+ }
@@ -0,0 +1,31 @@
1
+ import { defineConfig, devices } from '@playwright/test';
2
+
3
+ export default defineConfig({
4
+ testDir: './tests',
5
+ fullyParallel: false,
6
+ forbidOnly: !!process.env.CI,
7
+ retries: process.env.CI ? 2 : 0,
8
+ workers: 1,
9
+ reporter: [
10
+ ['list'],
11
+ ['html', { outputFolder: 'test-results/reports', open: 'never' }],
12
+ ],
13
+ outputDir: 'test-results/screenshots',
14
+ use: {
15
+ baseURL: 'http://localhost:5173',
16
+ screenshot: 'only-on-failure',
17
+ trace: 'on-first-retry',
18
+ },
19
+ projects: [
20
+ {
21
+ name: 'chromium',
22
+ use: { ...devices['Desktop Chrome'] },
23
+ },
24
+ ],
25
+ webServer: {
26
+ command: 'npm run dev',
27
+ port: 5173,
28
+ reuseExistingServer: true,
29
+ timeout: 30000,
30
+ },
31
+ });
@@ -0,0 +1,104 @@
1
+ import { test, expect } from '@playwright/test';
2
+ import AxeBuilder from '@axe-core/playwright';
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+
6
+ // Discover components from Gallery.tsx
7
+ const galleryPath = path.resolve(__dirname, '..', 'Gallery.tsx');
8
+ const galleryContent = fs.readFileSync(galleryPath, 'utf-8');
9
+ const componentNames = [...galleryContent.matchAll(/name:\s*"([^"]+)"/g)].map(
10
+ (m) => m[1]
11
+ );
12
+
13
+ test.describe('Component Harness', () => {
14
+ test('gallery loads', async ({ page }) => {
15
+ await page.goto('/');
16
+ await expect(page.locator('nav')).toBeVisible();
17
+ await expect(page.locator('main')).toBeVisible();
18
+ });
19
+
20
+ for (const name of componentNames) {
21
+ test.describe(name, () => {
22
+ test.beforeEach(async ({ page }) => {
23
+ await page.goto('/');
24
+ // Click the sidebar button matching the component name
25
+ const sidebarButton = page.locator('nav button', {
26
+ hasText: new RegExp(`^${name}$`, 'i'),
27
+ });
28
+ await sidebarButton.click();
29
+ // Wait for preview area to update
30
+ await page.waitForTimeout(300);
31
+ });
32
+
33
+ test('renders without console errors', async ({ page }) => {
34
+ const errors: string[] = [];
35
+ page.on('console', (msg) => {
36
+ if (msg.type() === 'error') errors.push(msg.text());
37
+ });
38
+
39
+ // Re-navigate to capture console from fresh load
40
+ await page.goto('/');
41
+ const sidebarButton = page.locator('nav button', {
42
+ hasText: new RegExp(`^${name}$`, 'i'),
43
+ });
44
+ await sidebarButton.click();
45
+ await page.waitForTimeout(500);
46
+
47
+ // Verify preview area has content
48
+ const previewArea = page.locator('main');
49
+ await expect(previewArea).not.toBeEmpty();
50
+
51
+ // Check no console errors
52
+ expect(errors).toHaveLength(0);
53
+ });
54
+
55
+ test('screenshot', async ({ page }) => {
56
+ const previewArea = page.locator('main');
57
+ await previewArea.screenshot({
58
+ path: `test-results/screenshots/${name}.png`,
59
+ });
60
+ });
61
+
62
+ test('accessibility', async ({ page }) => {
63
+ const results = await new AxeBuilder({ page })
64
+ .include('main')
65
+ .disableRules([
66
+ 'color-contrast', // Often fails in component previews due to isolated context
67
+ ])
68
+ .analyze();
69
+
70
+ const violations = results.violations.map((v) => ({
71
+ id: v.id,
72
+ impact: v.impact,
73
+ description: v.description,
74
+ nodes: v.nodes.length,
75
+ }));
76
+
77
+ // Fail on serious or critical violations
78
+ const critical = violations.filter(
79
+ (v) => v.impact === 'critical' || v.impact === 'serious'
80
+ );
81
+ expect(
82
+ critical,
83
+ `${name} has ${critical.length} serious a11y violation(s): ${JSON.stringify(critical, null, 2)}`
84
+ ).toHaveLength(0);
85
+ });
86
+
87
+ test('responsive - mobile', async ({ page }) => {
88
+ await page.setViewportSize({ width: 375, height: 667 });
89
+ await page.goto('/');
90
+ const sidebarButton = page.locator('nav button', {
91
+ hasText: new RegExp(`^${name}$`, 'i'),
92
+ });
93
+ await sidebarButton.click();
94
+ await page.waitForTimeout(300);
95
+
96
+ // Check for horizontal overflow
97
+ const hasOverflow = await page.evaluate(() => {
98
+ return document.documentElement.scrollWidth > document.documentElement.clientWidth;
99
+ });
100
+ expect(hasOverflow, `${name} overflows at mobile width (375px)`).toBe(false);
101
+ });
102
+ });
103
+ }
104
+ });
@@ -0,0 +1,141 @@
1
+ ---
2
+ description: USE WHEN creating, modifying, or reviewing React components, hooks, state management, or effect patterns. Enforces Rules of Hooks and correct state/effect usage.
3
+ ---
4
+
5
+ # React Patterns
6
+
7
+ Apply when creating, modifying, or reviewing React components.
8
+
9
+ ## Rules of Hooks (CRITICAL)
10
+
11
+ Hooks must be called in the **same order** every render. Violating this crashes the app.
12
+
13
+ ### Never Do
14
+
15
+ ```tsx
16
+ // ❌ Hook in callback/render prop
17
+ const demos = [{
18
+ render: () => {
19
+ const [state, setState] = useState() // CRASHES
20
+ return <Component />
21
+ }
22
+ }]
23
+
24
+ // ❌ Hook after conditional return
25
+ if (loading) return <Spinner />
26
+ const [data, setData] = useState() // CRASHES
27
+
28
+ // ❌ Hook in condition
29
+ if (userId) {
30
+ const [user, setUser] = useState() // CRASHES
31
+ }
32
+
33
+ // ❌ Hook in loop
34
+ items.map(item => {
35
+ const [selected, setSelected] = useState() // CRASHES
36
+ })
37
+ ```
38
+
39
+ ### Always Do
40
+
41
+ ```tsx
42
+ // ✅ Extract to separate component
43
+ const CheckboxDemo = () => {
44
+ const [checked, setChecked] = useState(false)
45
+ return <Checkbox checked={checked} onChange={setChecked} />
46
+ }
47
+
48
+ // ✅ Hooks before any returns
49
+ const [data, setData] = useState()
50
+ const [loading, setLoading] = useState(true)
51
+ if (loading) return <Spinner />
52
+
53
+ // ✅ Condition inside hook, not hook inside condition
54
+ useEffect(() => {
55
+ if (userId) fetchUser(userId)
56
+ }, [userId])
57
+ ```
58
+
59
+ ## State Updates
60
+
61
+ ```tsx
62
+ // ❌ Stale state - only increments once
63
+ setCount(count + 1)
64
+ setCount(count + 1)
65
+
66
+ // ✅ Functional update - increments twice
67
+ setCount(prev => prev + 1)
68
+ setCount(prev => prev + 1)
69
+ ```
70
+
71
+ ## Dependencies
72
+
73
+ ```tsx
74
+ // ❌ Missing dependency - stale closure
75
+ useEffect(() => {
76
+ doSomething(value)
77
+ }, [])
78
+
79
+ // ✅ All dependencies listed
80
+ useEffect(() => {
81
+ doSomething(value)
82
+ }, [value])
83
+ ```
84
+
85
+ ## Component Galleries
86
+
87
+ When building component showcases:
88
+
89
+ | Pattern | Wrong | Right |
90
+ |---------|-------|-------|
91
+ | Stateful demo | Hook in render prop | Separate `*Demo` component |
92
+ | Context consumer | Render alone | Wrap in provider |
93
+ | Controlled input | Internal state | Props from wrapper |
94
+
95
+ ```tsx
96
+ // ❌ Wrong - hook in render function
97
+ const components = [{
98
+ name: "Checkbox",
99
+ render: () => {
100
+ const [checked, setChecked] = useState(false)
101
+ return <Checkbox checked={checked} />
102
+ }
103
+ }]
104
+
105
+ // ✅ Right - separate component
106
+ const CheckboxDemo = () => {
107
+ const [checked, setChecked] = useState(false)
108
+ return <Checkbox checked={checked} onChange={setChecked} />
109
+ }
110
+
111
+ const components = [{
112
+ name: "Checkbox",
113
+ render: () => <CheckboxDemo />
114
+ }]
115
+ ```
116
+
117
+ ## Cleanup
118
+
119
+ ```tsx
120
+ // ❌ Memory leak
121
+ useEffect(() => {
122
+ const id = setInterval(tick, 1000)
123
+ }, [])
124
+
125
+ // ✅ Cleanup on unmount
126
+ useEffect(() => {
127
+ const id = setInterval(tick, 1000)
128
+ return () => clearInterval(id)
129
+ }, [])
130
+ ```
131
+
132
+ ## Checklist
133
+
134
+ Before delivering React code:
135
+
136
+ - [ ] No hooks in callbacks, loops, conditions, or render props
137
+ - [ ] No hooks after conditional returns
138
+ - [ ] All `useEffect`/`useCallback`/`useMemo` deps included
139
+ - [ ] Functional state updates when depending on previous state
140
+ - [ ] Context consumers wrapped in their providers
141
+ - [ ] Effects return cleanup functions where needed