ux-toolkit 0.1.0 → 0.4.1

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 (44) hide show
  1. package/README.md +113 -7
  2. package/agents/card-reviewer.md +173 -0
  3. package/agents/comparison-reviewer.md +143 -0
  4. package/agents/density-reviewer.md +207 -0
  5. package/agents/detail-page-reviewer.md +143 -0
  6. package/agents/editor-reviewer.md +165 -0
  7. package/agents/form-reviewer.md +156 -0
  8. package/agents/game-ui-reviewer.md +181 -0
  9. package/agents/list-page-reviewer.md +132 -0
  10. package/agents/navigation-reviewer.md +145 -0
  11. package/agents/panel-reviewer.md +182 -0
  12. package/agents/replay-reviewer.md +174 -0
  13. package/agents/settings-reviewer.md +166 -0
  14. package/agents/ux-auditor.md +145 -45
  15. package/agents/ux-engineer.md +211 -38
  16. package/dist/cli.js +172 -5
  17. package/dist/cli.js.map +1 -1
  18. package/dist/index.cjs +172 -5
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.cts +128 -4
  21. package/dist/index.d.ts +128 -4
  22. package/dist/index.js +172 -5
  23. package/dist/index.js.map +1 -1
  24. package/package.json +6 -4
  25. package/skills/canvas-grid-patterns/SKILL.md +367 -0
  26. package/skills/comparison-patterns/SKILL.md +354 -0
  27. package/skills/data-density-patterns/SKILL.md +493 -0
  28. package/skills/detail-page-patterns/SKILL.md +522 -0
  29. package/skills/drag-drop-patterns/SKILL.md +406 -0
  30. package/skills/editor-workspace-patterns/SKILL.md +552 -0
  31. package/skills/event-timeline-patterns/SKILL.md +542 -0
  32. package/skills/form-patterns/SKILL.md +608 -0
  33. package/skills/info-card-patterns/SKILL.md +531 -0
  34. package/skills/keyboard-shortcuts-patterns/SKILL.md +365 -0
  35. package/skills/list-page-patterns/SKILL.md +351 -0
  36. package/skills/modal-patterns/SKILL.md +750 -0
  37. package/skills/navigation-patterns/SKILL.md +476 -0
  38. package/skills/page-structure-patterns/SKILL.md +271 -0
  39. package/skills/playback-replay-patterns/SKILL.md +695 -0
  40. package/skills/react-ux-patterns/SKILL.md +434 -0
  41. package/skills/split-panel-patterns/SKILL.md +609 -0
  42. package/skills/status-visualization-patterns/SKILL.md +635 -0
  43. package/skills/toast-notification-patterns/SKILL.md +207 -0
  44. 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