@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.
- package/README.md +86 -27
- package/bin/cli.js +155 -50
- package/bin/validate-compliance.js +447 -0
- package/bin/validate-config.js +175 -0
- package/bin/validate-harness.js +215 -0
- package/index.js +15 -0
- package/package.json +7 -4
- package/templates/agents/component-auditor.md +77 -0
- package/templates/agents/design-token-validator.md +72 -0
- package/templates/agents/harness-scaffolder.md +146 -0
- package/templates/agents/playwright-tester.md +437 -0
- package/templates/agents/style-inspector.md +81 -0
- package/templates/commands/component-harness.md +104 -10
- package/templates/commands/retheme.md +142 -0
- package/templates/commands/save-theme.md +50 -0
- package/templates/commands/setup-styling.md +386 -57
- package/templates/component-recipes/accordion.md +9 -9
- package/templates/component-recipes/alert.md +36 -23
- package/templates/component-recipes/avatar.md +14 -12
- package/templates/component-recipes/badge.md +9 -6
- package/templates/component-recipes/breadcrumb.md +4 -4
- package/templates/component-recipes/button.md +2 -2
- package/templates/component-recipes/checkbox.md +5 -5
- package/templates/component-recipes/collapsible.md +13 -13
- package/templates/component-recipes/combobox.md +2 -2
- package/templates/component-recipes/context-menu.md +11 -11
- package/templates/component-recipes/dialog.md +16 -16
- package/templates/component-recipes/drawer.md +18 -18
- package/templates/component-recipes/dropdown-menu.md +12 -12
- package/templates/component-recipes/hover-card.md +11 -11
- package/templates/component-recipes/label.md +4 -4
- package/templates/component-recipes/modal.md +9 -9
- package/templates/component-recipes/navigation-menu.md +19 -19
- package/templates/component-recipes/popover.md +10 -10
- package/templates/component-recipes/progress.md +10 -8
- package/templates/component-recipes/radio.md +6 -6
- package/templates/component-recipes/select.md +5 -5
- package/templates/component-recipes/separator.md +2 -2
- package/templates/component-recipes/slider.md +2 -2
- package/templates/component-recipes/switch.md +6 -6
- package/templates/component-recipes/table.md +1 -1
- package/templates/component-recipes/tabs.md +6 -6
- package/templates/component-recipes/toast.md +56 -42
- package/templates/component-recipes/toggle-group.md +4 -4
- package/templates/component-recipes/tooltip.md +5 -5
- package/templates/mcp/mcp.json +8 -0
- package/templates/playwright/playwright.config.ts +31 -0
- package/templates/playwright/tests/components.spec.ts +104 -0
- package/templates/skills/react-patterns.md +141 -0
- package/templates/skills/styling.md +141 -52
- 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-
|
|
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-
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
85
|
+
'transition-transform data-checked:translate-x-4 data-unchecked:translate-x-0'
|
|
86
86
|
)}
|
|
87
87
|
/>
|
|
88
88
|
</SwitchPrimitive.Root>
|
|
@@ -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-
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
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-
|
|
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
|
|
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(--
|
|
24
|
-
data-[swipe=move]:translate-x-[var(--
|
|
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-
|
|
37
|
+
data-open:animate-in data-closed:animate-out
|
|
27
38
|
data-[swipe=end]:animate-out
|
|
28
|
-
data-
|
|
29
|
-
data-
|
|
30
|
-
data-
|
|
31
|
-
data-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
|
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-
|
|
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
|
-
-
|
|
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
|
|
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-
|
|
147
|
+
success: 'bg-success text-success-foreground border-success',
|
|
135
148
|
error: 'bg-destructive text-destructive-foreground border-destructive',
|
|
136
|
-
warning: 'bg-
|
|
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
|
-
|
|
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(--
|
|
146
|
-
'data-[swipe=move]:translate-x-[var(--
|
|
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-
|
|
149
|
-
'data-[swipe=end]:animate-out data-
|
|
150
|
-
'data-
|
|
151
|
-
'data-
|
|
152
|
-
'data-
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
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-
|
|
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
|
|
100
|
+
<TooltipTrigger render={children}>{children}</TooltipTrigger>
|
|
101
101
|
<TooltipContent side={side}>
|
|
102
102
|
{content}
|
|
103
103
|
<TooltipArrow />
|
|
@@ -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
|