ux-toolkit 0.1.0 → 0.4.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 +113 -7
- package/agents/card-reviewer.md +173 -0
- package/agents/comparison-reviewer.md +143 -0
- package/agents/density-reviewer.md +207 -0
- package/agents/detail-page-reviewer.md +143 -0
- package/agents/editor-reviewer.md +165 -0
- package/agents/form-reviewer.md +156 -0
- package/agents/game-ui-reviewer.md +181 -0
- package/agents/list-page-reviewer.md +132 -0
- package/agents/navigation-reviewer.md +145 -0
- package/agents/panel-reviewer.md +182 -0
- package/agents/replay-reviewer.md +174 -0
- package/agents/settings-reviewer.md +166 -0
- package/agents/ux-auditor.md +145 -45
- package/agents/ux-engineer.md +211 -38
- package/dist/cli.js +172 -5
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +172 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +128 -4
- package/dist/index.d.ts +128 -4
- package/dist/index.js +172 -5
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
- package/skills/canvas-grid-patterns/SKILL.md +367 -0
- package/skills/comparison-patterns/SKILL.md +354 -0
- package/skills/data-density-patterns/SKILL.md +493 -0
- package/skills/detail-page-patterns/SKILL.md +522 -0
- package/skills/drag-drop-patterns/SKILL.md +406 -0
- package/skills/editor-workspace-patterns/SKILL.md +552 -0
- package/skills/event-timeline-patterns/SKILL.md +542 -0
- package/skills/form-patterns/SKILL.md +608 -0
- package/skills/info-card-patterns/SKILL.md +531 -0
- package/skills/keyboard-shortcuts-patterns/SKILL.md +365 -0
- package/skills/list-page-patterns/SKILL.md +351 -0
- package/skills/modal-patterns/SKILL.md +750 -0
- package/skills/navigation-patterns/SKILL.md +476 -0
- package/skills/page-structure-patterns/SKILL.md +271 -0
- package/skills/playback-replay-patterns/SKILL.md +695 -0
- package/skills/react-ux-patterns/SKILL.md +434 -0
- package/skills/split-panel-patterns/SKILL.md +609 -0
- package/skills/status-visualization-patterns/SKILL.md +635 -0
- package/skills/toast-notification-patterns/SKILL.md +207 -0
- package/skills/turn-based-ui-patterns/SKILL.md +506 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# Canvas & Grid UI Patterns
|
|
2
|
+
|
|
3
|
+
Patterns for interactive canvas-based UIs including hex grids, tactical maps, game boards, and SVG-based graphics. These patterns apply to any application with spatial/coordinate-based interaction.
|
|
4
|
+
|
|
5
|
+
## When to Use This Skill
|
|
6
|
+
|
|
7
|
+
- Tactical/strategy game maps
|
|
8
|
+
- Hex-based or square-based grids
|
|
9
|
+
- Interactive diagrams with selectable elements
|
|
10
|
+
- Map editors or level designers
|
|
11
|
+
- Any SVG/Canvas-based interactive graphics
|
|
12
|
+
- Floor planners, seating charts
|
|
13
|
+
- Network topology visualizers
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Core Concepts
|
|
18
|
+
|
|
19
|
+
### Coordinate Systems
|
|
20
|
+
|
|
21
|
+
| System | Use Case | Formula |
|
|
22
|
+
|--------|----------|---------|
|
|
23
|
+
| **Axial (q,r)** | Hex grids | Most efficient for hex math |
|
|
24
|
+
| **Cube (x,y,z)** | Hex algorithms | x + y + z = 0 constraint |
|
|
25
|
+
| **Offset (col,row)** | Square grids | Direct mapping to pixels |
|
|
26
|
+
| **Cartesian (x,y)** | Canvas/SVG | Pixel coordinates |
|
|
27
|
+
|
|
28
|
+
### Hex Grid Math Reference
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
// Axial to Pixel (flat-top hex)
|
|
32
|
+
function hexToPixel(hex: {q: number, r: number}, size: number) {
|
|
33
|
+
const x = size * (3/2) * hex.q;
|
|
34
|
+
const y = size * (Math.sqrt(3)/2 * hex.q + Math.sqrt(3) * hex.r);
|
|
35
|
+
return { x, y };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Pixel to Axial (with rounding)
|
|
39
|
+
function pixelToHex(x: number, y: number, size: number) {
|
|
40
|
+
const q = (2/3 * x) / size;
|
|
41
|
+
const r = (-1/3 * x + Math.sqrt(3)/3 * y) / size;
|
|
42
|
+
return roundHex(q, r);
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Audit Checklist
|
|
49
|
+
|
|
50
|
+
### Grid Rendering
|
|
51
|
+
- [ ] [CRITICAL] Grid cells render correctly at all zoom levels
|
|
52
|
+
- [ ] [CRITICAL] Coordinate system consistent throughout
|
|
53
|
+
- [ ] [MAJOR] Grid lines visible but not overpowering
|
|
54
|
+
- [ ] [MAJOR] Cell highlighting on hover
|
|
55
|
+
- [ ] [MINOR] Coordinate labels toggleable
|
|
56
|
+
- [ ] [MINOR] Grid snapping for placed elements
|
|
57
|
+
|
|
58
|
+
### Pan & Zoom Controls
|
|
59
|
+
- [ ] [CRITICAL] Mouse wheel zooms (with configurable sensitivity)
|
|
60
|
+
- [ ] [CRITICAL] Drag to pan (middle-click or alt+drag)
|
|
61
|
+
- [ ] [CRITICAL] Zoom has min/max limits (prevent losing view)
|
|
62
|
+
- [ ] [MAJOR] Zoom controls visible (+ / - / reset buttons)
|
|
63
|
+
- [ ] [MAJOR] Zoom centers on cursor position
|
|
64
|
+
- [ ] [MAJOR] Reset view button returns to default
|
|
65
|
+
- [ ] [MINOR] Pinch-to-zoom on touch devices
|
|
66
|
+
- [ ] [MINOR] Zoom level indicator visible
|
|
67
|
+
- [ ] [MINOR] Smooth zoom animation (not jumpy)
|
|
68
|
+
|
|
69
|
+
### Token/Marker Display
|
|
70
|
+
- [ ] [CRITICAL] Tokens clearly distinguish between types/teams
|
|
71
|
+
- [ ] [CRITICAL] Selected token has obvious visual indicator
|
|
72
|
+
- [ ] [MAJOR] Tokens scale appropriately with zoom
|
|
73
|
+
- [ ] [MAJOR] Token labels readable at normal zoom
|
|
74
|
+
- [ ] [MINOR] Tokens have hover tooltip with details
|
|
75
|
+
- [ ] [MINOR] Destroyed/disabled tokens visually distinct
|
|
76
|
+
|
|
77
|
+
### Selection & Interaction
|
|
78
|
+
- [ ] [CRITICAL] Click on cell triggers selection callback
|
|
79
|
+
- [ ] [CRITICAL] Click on token triggers separate callback
|
|
80
|
+
- [ ] [MAJOR] Selected cell visually highlighted
|
|
81
|
+
- [ ] [MAJOR] Selection persists until explicitly changed
|
|
82
|
+
- [ ] [MAJOR] Multi-select supported (if applicable)
|
|
83
|
+
- [ ] [MINOR] Selection box for area selection
|
|
84
|
+
- [ ] [MINOR] Keyboard navigation between cells (arrow keys)
|
|
85
|
+
|
|
86
|
+
### Range/Area Visualization
|
|
87
|
+
- [ ] [CRITICAL] Movement range uses distinct color (green/blue)
|
|
88
|
+
- [ ] [CRITICAL] Attack range uses distinct color (red/orange)
|
|
89
|
+
- [ ] [MAJOR] Overlapping ranges visually distinguishable
|
|
90
|
+
- [ ] [MAJOR] Range boundaries clear (not bleeding into adjacent cells)
|
|
91
|
+
- [ ] [MINOR] Range cost labels (MP remaining) shown on cells
|
|
92
|
+
- [ ] [MINOR] Invalid/blocked cells clearly marked
|
|
93
|
+
|
|
94
|
+
### Directional Indicators
|
|
95
|
+
- [ ] [MAJOR] Facing/direction shown on tokens (arrow/wedge)
|
|
96
|
+
- [ ] [MAJOR] Direction rotates smoothly with facing changes
|
|
97
|
+
- [ ] [MINOR] Direction indicator scales with zoom
|
|
98
|
+
- [ ] [MINOR] Arc indicators for line-of-sight/fire arcs
|
|
99
|
+
|
|
100
|
+
### Path Visualization
|
|
101
|
+
- [ ] [MAJOR] Movement path shown during preview
|
|
102
|
+
- [ ] [MAJOR] Path highlights intermediate cells
|
|
103
|
+
- [ ] [MINOR] Path shows waypoints/turns
|
|
104
|
+
- [ ] [MINOR] Path cost accumulation displayed
|
|
105
|
+
|
|
106
|
+
### Performance
|
|
107
|
+
- [ ] [CRITICAL] Large grids (100+ cells) render smoothly
|
|
108
|
+
- [ ] [MAJOR] Interaction doesn't lag at normal grid sizes
|
|
109
|
+
- [ ] [MAJOR] No visible flicker on state changes
|
|
110
|
+
- [ ] [MINOR] Virtualized rendering for very large grids
|
|
111
|
+
|
|
112
|
+
### Accessibility
|
|
113
|
+
- [ ] [CRITICAL] Keyboard alternative for all mouse actions
|
|
114
|
+
- [ ] [CRITICAL] Screen reader announces cell contents
|
|
115
|
+
- [ ] [MAJOR] Focus indicator visible on current cell
|
|
116
|
+
- [ ] [MAJOR] High contrast mode support
|
|
117
|
+
- [ ] [MINOR] Color-blind friendly palette options
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Implementation Patterns
|
|
122
|
+
|
|
123
|
+
### React Component Structure
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
interface GridDisplayProps {
|
|
127
|
+
// Data
|
|
128
|
+
cells: GridCell[];
|
|
129
|
+
tokens: Token[];
|
|
130
|
+
|
|
131
|
+
// Selection state
|
|
132
|
+
selectedCell: Coordinate | null;
|
|
133
|
+
selectedToken: string | null;
|
|
134
|
+
|
|
135
|
+
// Overlays
|
|
136
|
+
movementRange?: Coordinate[];
|
|
137
|
+
attackRange?: Coordinate[];
|
|
138
|
+
highlightPath?: Coordinate[];
|
|
139
|
+
|
|
140
|
+
// Callbacks
|
|
141
|
+
onCellClick?: (coord: Coordinate) => void;
|
|
142
|
+
onCellHover?: (coord: Coordinate | null) => void;
|
|
143
|
+
onTokenClick?: (tokenId: string) => void;
|
|
144
|
+
|
|
145
|
+
// Display options
|
|
146
|
+
showCoordinates?: boolean;
|
|
147
|
+
showGrid?: boolean;
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Pan/Zoom State Management
|
|
152
|
+
|
|
153
|
+
```tsx
|
|
154
|
+
const [viewState, setViewState] = useState({
|
|
155
|
+
pan: { x: 0, y: 0 },
|
|
156
|
+
zoom: 1,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const MIN_ZOOM = 0.5;
|
|
160
|
+
const MAX_ZOOM = 3;
|
|
161
|
+
|
|
162
|
+
// Zoom handler
|
|
163
|
+
const handleWheel = (e: WheelEvent) => {
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
|
166
|
+
setViewState(prev => ({
|
|
167
|
+
...prev,
|
|
168
|
+
zoom: Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, prev.zoom * delta))
|
|
169
|
+
}));
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Pan handler (alt+drag or middle mouse)
|
|
173
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
174
|
+
if (isPanning) {
|
|
175
|
+
setViewState(prev => ({
|
|
176
|
+
...prev,
|
|
177
|
+
pan: {
|
|
178
|
+
x: prev.pan.x + e.movementX,
|
|
179
|
+
y: prev.pan.y + e.movementY
|
|
180
|
+
}
|
|
181
|
+
}));
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### SVG ViewBox Calculation
|
|
187
|
+
|
|
188
|
+
```tsx
|
|
189
|
+
const transformedViewBox = useMemo(() => {
|
|
190
|
+
const scale = 1 / zoom;
|
|
191
|
+
const width = baseViewBox.width * scale;
|
|
192
|
+
const height = baseViewBox.height * scale;
|
|
193
|
+
const x = baseViewBox.x - pan.x * scale + (baseViewBox.width - width) / 2;
|
|
194
|
+
const y = baseViewBox.y - pan.y * scale + (baseViewBox.height - height) / 2;
|
|
195
|
+
return `${x} ${y} ${width} ${height}`;
|
|
196
|
+
}, [baseViewBox, zoom, pan]);
|
|
197
|
+
|
|
198
|
+
<svg viewBox={transformedViewBox}>
|
|
199
|
+
{/* Grid and tokens */}
|
|
200
|
+
</svg>
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Token with Facing Indicator
|
|
204
|
+
|
|
205
|
+
```tsx
|
|
206
|
+
function TokenWithFacing({ token, onClick }: TokenProps) {
|
|
207
|
+
const { x, y } = coordToPixel(token.position);
|
|
208
|
+
const rotation = getFacingRotation(token.facing); // 0, 60, 120, 180, 240, 300 for hex
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<g transform={`translate(${x}, ${y})`} onClick={onClick}>
|
|
212
|
+
{/* Selection ring */}
|
|
213
|
+
{token.isSelected && (
|
|
214
|
+
<circle r={TOKEN_SIZE * 0.7} fill="none" stroke="#fbbf24" strokeWidth={3} />
|
|
215
|
+
)}
|
|
216
|
+
|
|
217
|
+
{/* Token body */}
|
|
218
|
+
<circle r={TOKEN_SIZE * 0.5} fill={token.teamColor} stroke="#1e293b" strokeWidth={2} />
|
|
219
|
+
|
|
220
|
+
{/* Facing indicator (arrow) */}
|
|
221
|
+
<g transform={`rotate(${rotation - 90})`}>
|
|
222
|
+
<path d="M0,-20 L8,-8 L0,-12 L-8,-8 Z" fill="white" stroke="#1e293b" />
|
|
223
|
+
</g>
|
|
224
|
+
|
|
225
|
+
{/* Label */}
|
|
226
|
+
<text y={4} textAnchor="middle" fontSize={10} fill="white">
|
|
227
|
+
{token.label}
|
|
228
|
+
</text>
|
|
229
|
+
</g>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Movement Range Overlay
|
|
235
|
+
|
|
236
|
+
```tsx
|
|
237
|
+
function MovementRangeOverlay({ range, onCellClick }: RangeProps) {
|
|
238
|
+
return (
|
|
239
|
+
<g className="movement-range">
|
|
240
|
+
{range.map(({ coord, mpCost, reachable }) => {
|
|
241
|
+
const { x, y } = coordToPixel(coord);
|
|
242
|
+
return (
|
|
243
|
+
<g key={coordKey(coord)} onClick={() => onCellClick(coord)}>
|
|
244
|
+
<path
|
|
245
|
+
d={hexPath(x, y)}
|
|
246
|
+
fill={reachable ? "rgba(34, 197, 94, 0.3)" : "rgba(107, 114, 128, 0.2)"}
|
|
247
|
+
stroke={reachable ? "#22c55e" : "#6b7280"}
|
|
248
|
+
strokeWidth={1}
|
|
249
|
+
/>
|
|
250
|
+
{reachable && (
|
|
251
|
+
<text x={x} y={y + 4} textAnchor="middle" fontSize={9} fill="#166534">
|
|
252
|
+
{mpCost}MP
|
|
253
|
+
</text>
|
|
254
|
+
)}
|
|
255
|
+
</g>
|
|
256
|
+
);
|
|
257
|
+
})}
|
|
258
|
+
</g>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## Color Schemes
|
|
266
|
+
|
|
267
|
+
### Default Grid Colors
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
const GRID_COLORS = {
|
|
271
|
+
// Cell states
|
|
272
|
+
cellDefault: '#1e293b', // Dark slate
|
|
273
|
+
cellHover: '#334155', // Lighter slate
|
|
274
|
+
cellSelected: '#3b82f6', // Blue
|
|
275
|
+
gridLine: '#475569', // Medium slate
|
|
276
|
+
|
|
277
|
+
// Range overlays
|
|
278
|
+
movementRange: 'rgba(34, 197, 94, 0.3)', // Green 30%
|
|
279
|
+
attackRange: 'rgba(239, 68, 68, 0.3)', // Red 30%
|
|
280
|
+
pathHighlight: 'rgba(59, 130, 246, 0.5)', // Blue 50%
|
|
281
|
+
|
|
282
|
+
// Token colors
|
|
283
|
+
playerToken: '#3b82f6', // Blue
|
|
284
|
+
opponentToken: '#ef4444', // Red
|
|
285
|
+
neutralToken: '#6b7280', // Gray
|
|
286
|
+
destroyedToken: '#374151', // Dark gray
|
|
287
|
+
|
|
288
|
+
// Indicators
|
|
289
|
+
selectionRing: '#fbbf24', // Amber
|
|
290
|
+
targetRing: '#f87171', // Light red
|
|
291
|
+
};
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
## Anti-Patterns
|
|
297
|
+
|
|
298
|
+
### DON'T: Unbounded Zoom
|
|
299
|
+
```tsx
|
|
300
|
+
// BAD - User can zoom infinitely
|
|
301
|
+
setZoom(prev => prev * delta);
|
|
302
|
+
|
|
303
|
+
// GOOD - Clamp to reasonable bounds
|
|
304
|
+
setZoom(prev => Math.max(0.5, Math.min(3, prev * delta)));
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### DON'T: Zoom Relative to Center Only
|
|
308
|
+
```tsx
|
|
309
|
+
// BAD - Always zooms to center, disorienting
|
|
310
|
+
const newViewBox = { ...viewBox, width: viewBox.width * scale };
|
|
311
|
+
|
|
312
|
+
// GOOD - Zoom toward cursor position
|
|
313
|
+
const cursorInSvg = screenToSvg(cursorPosition);
|
|
314
|
+
// Adjust pan to keep cursor position stable
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### DON'T: Missing Reset Button
|
|
318
|
+
```tsx
|
|
319
|
+
// BAD - User gets lost, no way to recover
|
|
320
|
+
<ZoomControls onZoomIn={...} onZoomOut={...} />
|
|
321
|
+
|
|
322
|
+
// GOOD - Always provide reset
|
|
323
|
+
<ZoomControls
|
|
324
|
+
onZoomIn={...}
|
|
325
|
+
onZoomOut={...}
|
|
326
|
+
onReset={() => setViewState({ pan: { x: 0, y: 0 }, zoom: 1 })}
|
|
327
|
+
/>
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### DON'T: No Keyboard Alternatives
|
|
331
|
+
```tsx
|
|
332
|
+
// BAD - Mouse-only interaction
|
|
333
|
+
<HexCell onClick={handleClick} />
|
|
334
|
+
|
|
335
|
+
// GOOD - Full keyboard support
|
|
336
|
+
<HexCell
|
|
337
|
+
onClick={handleClick}
|
|
338
|
+
onKeyDown={e => e.key === 'Enter' && handleClick()}
|
|
339
|
+
tabIndex={0}
|
|
340
|
+
role="gridcell"
|
|
341
|
+
aria-label={`Cell ${coord.q}, ${coord.r}`}
|
|
342
|
+
/>
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
## Testing Checklist
|
|
348
|
+
|
|
349
|
+
- [ ] Grid renders correct number of cells for given radius
|
|
350
|
+
- [ ] Zoom stays within min/max bounds
|
|
351
|
+
- [ ] Pan works with middle mouse and alt+drag
|
|
352
|
+
- [ ] Reset view returns to default state
|
|
353
|
+
- [ ] Cell click returns correct coordinates
|
|
354
|
+
- [ ] Token click returns correct token ID
|
|
355
|
+
- [ ] Selection state updates correctly
|
|
356
|
+
- [ ] Range overlays display on correct cells
|
|
357
|
+
- [ ] Path visualization follows expected route
|
|
358
|
+
- [ ] Keyboard navigation moves between cells
|
|
359
|
+
- [ ] Performance acceptable with 100+ cells
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
363
|
+
## Related Skills
|
|
364
|
+
|
|
365
|
+
- `drag-drop-patterns` - For token dragging within grid
|
|
366
|
+
- `keyboard-shortcuts-patterns` - For grid keyboard navigation
|
|
367
|
+
- `mobile-responsive-ux` - For touch gesture support
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: comparison-patterns
|
|
3
|
+
description: Side-by-side comparison, diff highlighting, and multi-item comparison patterns
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Comparison & Diff Patterns
|
|
8
|
+
|
|
9
|
+
Patterns for side-by-side comparison UIs, diff highlighting, synchronized scrolling, and multi-item comparison interfaces.
|
|
10
|
+
|
|
11
|
+
## 1. Comparison Layout
|
|
12
|
+
|
|
13
|
+
**Side-by-Side (2-4 items)**
|
|
14
|
+
```tsx
|
|
15
|
+
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
16
|
+
{items.map(item => (
|
|
17
|
+
<div key={item.id} className="border rounded-lg p-4">
|
|
18
|
+
<ComparisonCard item={item} />
|
|
19
|
+
</div>
|
|
20
|
+
))}
|
|
21
|
+
</div>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Responsive Behavior**: Stack on mobile, 2-col on tablet, 3-4 on desktop. Use `min-w-[280px]` for item cards.
|
|
25
|
+
|
|
26
|
+
## 2. Item Selection
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
interface ComparisonSlot {
|
|
30
|
+
id: string;
|
|
31
|
+
item: Item | null;
|
|
32
|
+
isBaseline?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Empty slot with add button
|
|
36
|
+
<div className="border-2 border-dashed rounded-lg p-8 flex items-center justify-center">
|
|
37
|
+
<button onClick={onAddItem} className="text-sm text-gray-500">
|
|
38
|
+
+ Add Item to Compare
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
// Max items warning
|
|
43
|
+
{slots.length >= MAX_ITEMS && (
|
|
44
|
+
<div className="text-sm text-amber-600">Maximum {MAX_ITEMS} items</div>
|
|
45
|
+
)}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## 3. Diff Highlighting
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
interface DiffValue {
|
|
52
|
+
previous: number | string;
|
|
53
|
+
current: number | string;
|
|
54
|
+
change: 'improved' | 'degraded' | 'neutral';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function DiffCell({ value, baseline }: { value: number; baseline: number }) {
|
|
58
|
+
const diff = value - baseline;
|
|
59
|
+
const isDegraded = diff < 0;
|
|
60
|
+
const isImproved = diff > 0;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className="flex items-center gap-2">
|
|
64
|
+
<span>{value}</span>
|
|
65
|
+
{diff !== 0 && (
|
|
66
|
+
<span className={cn(
|
|
67
|
+
"text-sm font-medium",
|
|
68
|
+
isImproved && "text-green-600",
|
|
69
|
+
isDegraded && "text-red-600"
|
|
70
|
+
)}>
|
|
71
|
+
{isImproved ? '▲' : '▼'} {Math.abs(diff)}
|
|
72
|
+
</span>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Color Coding**: Green = better/improved, Red = worse/degraded, Gray = neutral/unchanged.
|
|
80
|
+
|
|
81
|
+
## 4. Synchronized Scrolling
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
function SyncScrollContainer({ children, enabled }: { enabled: boolean }) {
|
|
85
|
+
const scrollRefs = useRef<HTMLDivElement[]>([]);
|
|
86
|
+
const isScrolling = useRef(false);
|
|
87
|
+
|
|
88
|
+
const handleScroll = (index: number) => (e: React.UIEvent<HTMLDivElement>) => {
|
|
89
|
+
if (!enabled || isScrolling.current) return;
|
|
90
|
+
|
|
91
|
+
isScrolling.current = true;
|
|
92
|
+
const scrollTop = e.currentTarget.scrollTop;
|
|
93
|
+
|
|
94
|
+
scrollRefs.current.forEach((ref, i) => {
|
|
95
|
+
if (i !== index && ref) {
|
|
96
|
+
ref.scrollTop = scrollTop;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
requestAnimationFrame(() => {
|
|
101
|
+
isScrolling.current = false;
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className="flex gap-4">
|
|
107
|
+
{React.Children.map(children, (child, i) => (
|
|
108
|
+
<div
|
|
109
|
+
ref={(el) => scrollRefs.current[i] = el!}
|
|
110
|
+
onScroll={handleScroll(i)}
|
|
111
|
+
className="flex-1 overflow-y-auto"
|
|
112
|
+
>
|
|
113
|
+
{child}
|
|
114
|
+
</div>
|
|
115
|
+
))}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Toggle sync control
|
|
121
|
+
<button onClick={() => setSyncEnabled(!syncEnabled)}>
|
|
122
|
+
{syncEnabled ? '🔒 Synced' : '🔓 Independent'}
|
|
123
|
+
</button>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## 5. Comparison Table
|
|
127
|
+
|
|
128
|
+
```tsx
|
|
129
|
+
interface ComparisonRow {
|
|
130
|
+
attribute: string;
|
|
131
|
+
values: (string | number)[];
|
|
132
|
+
hasDifference: boolean;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function ComparisonTable({ items, rows }: Props) {
|
|
136
|
+
return (
|
|
137
|
+
<table className="w-full">
|
|
138
|
+
<thead className="sticky top-0 bg-white border-b">
|
|
139
|
+
<tr>
|
|
140
|
+
<th className="text-left p-3">Attribute</th>
|
|
141
|
+
{items.map(item => (
|
|
142
|
+
<th key={item.id} className="text-left p-3">{item.name}</th>
|
|
143
|
+
))}
|
|
144
|
+
</tr>
|
|
145
|
+
</thead>
|
|
146
|
+
<tbody>
|
|
147
|
+
{rows.map(row => (
|
|
148
|
+
<tr key={row.attribute} className={cn(
|
|
149
|
+
"border-b",
|
|
150
|
+
row.hasDifference && "bg-yellow-50"
|
|
151
|
+
)}>
|
|
152
|
+
<td className="p-3 font-medium">{row.attribute}</td>
|
|
153
|
+
{row.values.map((value, i) => (
|
|
154
|
+
<td key={i} className="p-3">{value}</td>
|
|
155
|
+
))}
|
|
156
|
+
</tr>
|
|
157
|
+
))}
|
|
158
|
+
</tbody>
|
|
159
|
+
</table>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## 6. Attribute Grouping
|
|
165
|
+
|
|
166
|
+
```tsx
|
|
167
|
+
interface AttributeGroup {
|
|
168
|
+
name: string;
|
|
169
|
+
attributes: string[];
|
|
170
|
+
collapsed: boolean;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function CollapsibleGroup({ group, onToggle }: Props) {
|
|
174
|
+
return (
|
|
175
|
+
<div className="border rounded-lg mb-4">
|
|
176
|
+
<button
|
|
177
|
+
onClick={onToggle}
|
|
178
|
+
className="w-full flex items-center justify-between p-4 hover:bg-gray-50"
|
|
179
|
+
>
|
|
180
|
+
<span className="font-medium">{group.name}</span>
|
|
181
|
+
<span>{group.collapsed ? '▶' : '▼'}</span>
|
|
182
|
+
</button>
|
|
183
|
+
{!group.collapsed && (
|
|
184
|
+
<div className="p-4 border-t">
|
|
185
|
+
{group.attributes.map(attr => (
|
|
186
|
+
<ComparisonRow key={attr} attribute={attr} />
|
|
187
|
+
))}
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## 7. Baseline Selection
|
|
196
|
+
|
|
197
|
+
```tsx
|
|
198
|
+
function BaselineSelector({ items, baselineId, onChange }: Props) {
|
|
199
|
+
return (
|
|
200
|
+
<div className="flex items-center gap-2 mb-4">
|
|
201
|
+
<label className="text-sm font-medium">Baseline:</label>
|
|
202
|
+
<select
|
|
203
|
+
value={baselineId}
|
|
204
|
+
onChange={(e) => onChange(e.target.value)}
|
|
205
|
+
className="border rounded px-3 py-1"
|
|
206
|
+
>
|
|
207
|
+
<option value="">None</option>
|
|
208
|
+
{items.map(item => (
|
|
209
|
+
<option key={item.id} value={item.id}>{item.name}</option>
|
|
210
|
+
))}
|
|
211
|
+
</select>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Show baseline badge on item card
|
|
217
|
+
{isBaseline && (
|
|
218
|
+
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-blue-100 text-blue-800">
|
|
219
|
+
Baseline
|
|
220
|
+
</span>
|
|
221
|
+
)}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## 8. Quick Actions
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
function ComparisonCardActions({ item, isBaseline, onRemove, onSetBaseline }: Props) {
|
|
228
|
+
return (
|
|
229
|
+
<div className="flex gap-2 mt-4 pt-4 border-t">
|
|
230
|
+
<button
|
|
231
|
+
onClick={onRemove}
|
|
232
|
+
className="text-sm text-red-600 hover:text-red-800"
|
|
233
|
+
>
|
|
234
|
+
Remove
|
|
235
|
+
</button>
|
|
236
|
+
{!isBaseline && (
|
|
237
|
+
<button
|
|
238
|
+
onClick={onSetBaseline}
|
|
239
|
+
className="text-sm text-blue-600 hover:text-blue-800"
|
|
240
|
+
>
|
|
241
|
+
Set as Baseline
|
|
242
|
+
</button>
|
|
243
|
+
)}
|
|
244
|
+
<button
|
|
245
|
+
onClick={() => router.push(`/items/${item.id}`)}
|
|
246
|
+
className="text-sm text-gray-600 hover:text-gray-800 ml-auto"
|
|
247
|
+
>
|
|
248
|
+
View Detail →
|
|
249
|
+
</button>
|
|
250
|
+
</div>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## 9. Code Examples
|
|
256
|
+
|
|
257
|
+
**ComparisonGrid Component**
|
|
258
|
+
```tsx
|
|
259
|
+
interface ComparisonGridProps {
|
|
260
|
+
items: Item[];
|
|
261
|
+
baselineId?: string;
|
|
262
|
+
maxItems?: number;
|
|
263
|
+
onRemove: (id: string) => void;
|
|
264
|
+
onAdd: () => void;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function ComparisonGrid({ items, baselineId, maxItems = 4, onRemove, onAdd }: ComparisonGridProps) {
|
|
268
|
+
const canAddMore = items.length < maxItems;
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
<div>
|
|
272
|
+
<div className="flex items-center justify-between mb-4">
|
|
273
|
+
<h2 className="text-lg font-semibold">Comparing {items.length} items</h2>
|
|
274
|
+
<button onClick={() => items.forEach(i => onRemove(i.id))} className="text-sm text-gray-600">
|
|
275
|
+
Clear All
|
|
276
|
+
</button>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
280
|
+
{items.map(item => (
|
|
281
|
+
<ComparisonCard
|
|
282
|
+
key={item.id}
|
|
283
|
+
item={item}
|
|
284
|
+
isBaseline={item.id === baselineId}
|
|
285
|
+
onRemove={() => onRemove(item.id)}
|
|
286
|
+
/>
|
|
287
|
+
))}
|
|
288
|
+
|
|
289
|
+
{canAddMore && (
|
|
290
|
+
<button onClick={onAdd} className="border-2 border-dashed rounded-lg p-8 hover:bg-gray-50">
|
|
291
|
+
+ Add Item
|
|
292
|
+
</button>
|
|
293
|
+
)}
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
## 10. Audit Checklist
|
|
301
|
+
|
|
302
|
+
**Layout & Structure** [MAJOR]
|
|
303
|
+
- [ ] Responsive grid (stack mobile, 2-col tablet, 3-4 desktop)
|
|
304
|
+
- [ ] Minimum card width prevents over-cramping
|
|
305
|
+
- [ ] Empty slots show clear "add item" affordance
|
|
306
|
+
- [ ] Max items limit enforced (typically 4)
|
|
307
|
+
|
|
308
|
+
**Diff Visualization** [CRITICAL]
|
|
309
|
+
- [ ] Color-coded changes (green=better, red=worse)
|
|
310
|
+
- [ ] Direction indicators (▲/▼) for numeric changes
|
|
311
|
+
- [ ] Baseline comparison clearly labeled
|
|
312
|
+
- [ ] Neutral/unchanged values visually distinct
|
|
313
|
+
|
|
314
|
+
**Scroll Behavior** [MAJOR]
|
|
315
|
+
- [ ] Synchronized scrolling option available
|
|
316
|
+
- [ ] Toggle control visible and labeled
|
|
317
|
+
- [ ] Pinned header row remains visible
|
|
318
|
+
- [ ] Independent scrolling works when sync disabled
|
|
319
|
+
|
|
320
|
+
**Item Management** [MAJOR]
|
|
321
|
+
- [ ] Remove button on each item card
|
|
322
|
+
- [ ] Clear all action present
|
|
323
|
+
- [ ] Add item flow intuitive
|
|
324
|
+
- [ ] Baseline selection accessible
|
|
325
|
+
|
|
326
|
+
**Difference Highlighting** [CRITICAL]
|
|
327
|
+
- [ ] Rows with differences visually distinguished
|
|
328
|
+
- [ ] Consistent color scheme across all diffs
|
|
329
|
+
- [ ] Numeric diff magnitude shown
|
|
330
|
+
- [ ] Percentage changes for relevant metrics
|
|
331
|
+
|
|
332
|
+
**Grouping & Organization** [MINOR]
|
|
333
|
+
- [ ] Related attributes grouped logically
|
|
334
|
+
- [ ] Collapsible sections for long lists
|
|
335
|
+
- [ ] Group headers descriptive
|
|
336
|
+
- [ ] Expand/collapse state persists during session
|
|
337
|
+
|
|
338
|
+
**Quick Actions** [MINOR]
|
|
339
|
+
- [ ] Set/unset baseline action available
|
|
340
|
+
- [ ] View detail link per item
|
|
341
|
+
- [ ] Actions contextual and clear
|
|
342
|
+
- [ ] Confirmation for destructive actions
|
|
343
|
+
|
|
344
|
+
**Accessibility** [MAJOR]
|
|
345
|
+
- [ ] Keyboard navigation works across comparison
|
|
346
|
+
- [ ] Screen reader announces diff direction/magnitude
|
|
347
|
+
- [ ] Color not sole indicator of difference
|
|
348
|
+
- [ ] Focus management when adding/removing items
|
|
349
|
+
|
|
350
|
+
**Performance** [MINOR]
|
|
351
|
+
- [ ] Smooth scrolling with many attributes
|
|
352
|
+
- [ ] Efficient re-renders on item add/remove
|
|
353
|
+
- [ ] Debounced scroll sync to prevent jank
|
|
354
|
+
- [ ] Lazy loading for large comparison tables
|