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,695 @@
|
|
|
1
|
+
# Playback & Replay UI Patterns
|
|
2
|
+
|
|
3
|
+
Patterns for VCR-style playback controls, timeline scrubbing, replay viewers, and media-like navigation. Applies to video players, game replays, animation timelines, event history browsers, and debugging tools.
|
|
4
|
+
|
|
5
|
+
## When to Use This Skill
|
|
6
|
+
|
|
7
|
+
- Game replay viewers
|
|
8
|
+
- Video/audio players
|
|
9
|
+
- Animation timeline editors
|
|
10
|
+
- Event log replay (debugging)
|
|
11
|
+
- Time-travel debugging
|
|
12
|
+
- Presentation/slideshow controls
|
|
13
|
+
- Data visualization playback
|
|
14
|
+
- Historical data scrubbing
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Core Concepts
|
|
19
|
+
|
|
20
|
+
### Playback States
|
|
21
|
+
|
|
22
|
+
| State | Description | Visual Indicator |
|
|
23
|
+
|-------|-------------|------------------|
|
|
24
|
+
| **Stopped** | At beginning, not playing | Stop icon, progress at 0 |
|
|
25
|
+
| **Playing** | Auto-advancing through timeline | Play icon animating |
|
|
26
|
+
| **Paused** | Frozen at current position | Pause icon, position preserved |
|
|
27
|
+
| **Buffering** | Loading next content | Spinner/loading indicator |
|
|
28
|
+
| **Ended** | Reached end of timeline | Replay option |
|
|
29
|
+
|
|
30
|
+
### Speed Options
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
const PLAYBACK_SPEEDS = [0.25, 0.5, 1, 1.5, 2, 4] as const;
|
|
34
|
+
|
|
35
|
+
function formatSpeed(speed: number): string {
|
|
36
|
+
if (speed === 1) return '1x';
|
|
37
|
+
if (speed < 1) return `${speed}x (slow)`;
|
|
38
|
+
return `${speed}x (fast)`;
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Audit Checklist
|
|
45
|
+
|
|
46
|
+
### Core Controls
|
|
47
|
+
- [ ] [CRITICAL] Play/Pause toggle button (single button, changes icon)
|
|
48
|
+
- [ ] [CRITICAL] Stop button (returns to start)
|
|
49
|
+
- [ ] [CRITICAL] Controls accessible via keyboard (Space = play/pause)
|
|
50
|
+
- [ ] [MAJOR] Step forward (advance one frame/event)
|
|
51
|
+
- [ ] [MAJOR] Step backward (go back one frame/event)
|
|
52
|
+
- [ ] [MINOR] Go to start button
|
|
53
|
+
- [ ] [MINOR] Go to end button
|
|
54
|
+
|
|
55
|
+
### Timeline/Progress
|
|
56
|
+
- [ ] [CRITICAL] Progress bar showing current position
|
|
57
|
+
- [ ] [CRITICAL] Click on timeline to seek to position
|
|
58
|
+
- [ ] [MAJOR] Drag scrubber for precise seeking
|
|
59
|
+
- [ ] [MAJOR] Current time/position displayed
|
|
60
|
+
- [ ] [MAJOR] Total duration/length displayed
|
|
61
|
+
- [ ] [MINOR] Thumbnail preview on hover (for video)
|
|
62
|
+
- [ ] [MINOR] Event markers on timeline
|
|
63
|
+
|
|
64
|
+
### Speed Control
|
|
65
|
+
- [ ] [MAJOR] Speed selector dropdown or buttons
|
|
66
|
+
- [ ] [MAJOR] Current speed displayed
|
|
67
|
+
- [ ] [MINOR] Keyboard shortcuts for speed (+/-)
|
|
68
|
+
- [ ] [MINOR] Speed persists across sessions
|
|
69
|
+
|
|
70
|
+
### Event/Frame Information
|
|
71
|
+
- [ ] [MAJOR] Current event/frame details shown
|
|
72
|
+
- [ ] [MAJOR] Sequence number displayed (e.g., "Event 5 of 120")
|
|
73
|
+
- [ ] [MINOR] Event type/category indicator
|
|
74
|
+
- [ ] [MINOR] Timestamp of current event
|
|
75
|
+
|
|
76
|
+
### Keyboard Shortcuts
|
|
77
|
+
- [ ] [CRITICAL] Space = Play/Pause
|
|
78
|
+
- [ ] [MAJOR] Left/Right arrows = Step backward/forward
|
|
79
|
+
- [ ] [MAJOR] Cmd/Ctrl+Left/Right = Jump to start/end
|
|
80
|
+
- [ ] [MINOR] [ / ] = Decrease/increase speed
|
|
81
|
+
- [ ] [MINOR] Escape = Stop
|
|
82
|
+
- [ ] [MINOR] Shortcuts discoverable (help modal)
|
|
83
|
+
|
|
84
|
+
### Visual Feedback
|
|
85
|
+
- [ ] [CRITICAL] Playing state obviously different from paused
|
|
86
|
+
- [ ] [MAJOR] Progress updates smoothly during playback
|
|
87
|
+
- [ ] [MAJOR] Seeking provides immediate visual feedback
|
|
88
|
+
- [ ] [MINOR] Speed change confirmation (toast/indicator)
|
|
89
|
+
- [ ] [MINOR] Animation on state transitions
|
|
90
|
+
|
|
91
|
+
### Accessibility
|
|
92
|
+
- [ ] [CRITICAL] All controls keyboard accessible
|
|
93
|
+
- [ ] [CRITICAL] ARIA labels on all buttons
|
|
94
|
+
- [ ] [MAJOR] Screen reader announces state changes
|
|
95
|
+
- [ ] [MAJOR] Focus visible on all controls
|
|
96
|
+
- [ ] [MINOR] High contrast mode support
|
|
97
|
+
- [ ] [MINOR] Controls work with reduced motion
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Implementation Patterns
|
|
102
|
+
|
|
103
|
+
### Playback State Hook
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
type PlaybackState = 'stopped' | 'playing' | 'paused';
|
|
107
|
+
|
|
108
|
+
interface UseReplayPlayerOptions {
|
|
109
|
+
events: readonly Event[];
|
|
110
|
+
initialIndex?: number;
|
|
111
|
+
autoPlay?: boolean;
|
|
112
|
+
baseInterval?: number; // ms between events at 1x speed
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface UseReplayPlayerReturn {
|
|
116
|
+
// State
|
|
117
|
+
playbackState: PlaybackState;
|
|
118
|
+
currentIndex: number;
|
|
119
|
+
currentEvent: Event | null;
|
|
120
|
+
progress: number; // 0-100
|
|
121
|
+
speed: number;
|
|
122
|
+
totalEvents: number;
|
|
123
|
+
|
|
124
|
+
// Actions
|
|
125
|
+
play: () => void;
|
|
126
|
+
pause: () => void;
|
|
127
|
+
stop: () => void;
|
|
128
|
+
stepForward: () => void;
|
|
129
|
+
stepBackward: () => void;
|
|
130
|
+
jumpToIndex: (index: number) => void;
|
|
131
|
+
jumpToEvent: (eventId: string) => void;
|
|
132
|
+
seek: (progress: number) => void; // 0-100
|
|
133
|
+
setSpeed: (speed: number) => void;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function useReplayPlayer({
|
|
137
|
+
events,
|
|
138
|
+
initialIndex = 0,
|
|
139
|
+
autoPlay = false,
|
|
140
|
+
baseInterval = 1000,
|
|
141
|
+
}: UseReplayPlayerOptions): UseReplayPlayerReturn {
|
|
142
|
+
const [playbackState, setPlaybackState] = useState<PlaybackState>(
|
|
143
|
+
autoPlay ? 'playing' : 'stopped'
|
|
144
|
+
);
|
|
145
|
+
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
|
146
|
+
const [speed, setSpeed] = useState(1);
|
|
147
|
+
|
|
148
|
+
// Auto-advance interval
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (playbackState !== 'playing') return;
|
|
151
|
+
if (currentIndex >= events.length - 1) {
|
|
152
|
+
setPlaybackState('paused');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const interval = baseInterval / speed;
|
|
157
|
+
const timer = setTimeout(() => {
|
|
158
|
+
setCurrentIndex(prev => prev + 1);
|
|
159
|
+
}, interval);
|
|
160
|
+
|
|
161
|
+
return () => clearTimeout(timer);
|
|
162
|
+
}, [playbackState, currentIndex, speed, baseInterval, events.length]);
|
|
163
|
+
|
|
164
|
+
const play = useCallback(() => {
|
|
165
|
+
if (currentIndex >= events.length - 1) {
|
|
166
|
+
setCurrentIndex(0); // Restart if at end
|
|
167
|
+
}
|
|
168
|
+
setPlaybackState('playing');
|
|
169
|
+
}, [currentIndex, events.length]);
|
|
170
|
+
|
|
171
|
+
const pause = useCallback(() => setPlaybackState('paused'), []);
|
|
172
|
+
|
|
173
|
+
const stop = useCallback(() => {
|
|
174
|
+
setPlaybackState('stopped');
|
|
175
|
+
setCurrentIndex(0);
|
|
176
|
+
}, []);
|
|
177
|
+
|
|
178
|
+
const stepForward = useCallback(() => {
|
|
179
|
+
setPlaybackState('paused');
|
|
180
|
+
setCurrentIndex(prev => Math.min(prev + 1, events.length - 1));
|
|
181
|
+
}, [events.length]);
|
|
182
|
+
|
|
183
|
+
const stepBackward = useCallback(() => {
|
|
184
|
+
setPlaybackState('paused');
|
|
185
|
+
setCurrentIndex(prev => Math.max(prev - 1, 0));
|
|
186
|
+
}, []);
|
|
187
|
+
|
|
188
|
+
const seek = useCallback((progress: number) => {
|
|
189
|
+
const index = Math.round((progress / 100) * (events.length - 1));
|
|
190
|
+
setCurrentIndex(index);
|
|
191
|
+
}, [events.length]);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
playbackState,
|
|
195
|
+
currentIndex,
|
|
196
|
+
currentEvent: events[currentIndex] ?? null,
|
|
197
|
+
progress: events.length > 1 ? (currentIndex / (events.length - 1)) * 100 : 0,
|
|
198
|
+
speed,
|
|
199
|
+
totalEvents: events.length,
|
|
200
|
+
play,
|
|
201
|
+
pause,
|
|
202
|
+
stop,
|
|
203
|
+
stepForward,
|
|
204
|
+
stepBackward,
|
|
205
|
+
jumpToIndex: setCurrentIndex,
|
|
206
|
+
jumpToEvent: (id) => {
|
|
207
|
+
const idx = events.findIndex(e => e.id === id);
|
|
208
|
+
if (idx >= 0) setCurrentIndex(idx);
|
|
209
|
+
},
|
|
210
|
+
seek,
|
|
211
|
+
setSpeed,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Replay Controls Component
|
|
217
|
+
|
|
218
|
+
```tsx
|
|
219
|
+
interface ReplayControlsProps {
|
|
220
|
+
playbackState: PlaybackState;
|
|
221
|
+
canStepBackward: boolean;
|
|
222
|
+
canStepForward: boolean;
|
|
223
|
+
onPlay: () => void;
|
|
224
|
+
onPause: () => void;
|
|
225
|
+
onStop: () => void;
|
|
226
|
+
onStepForward: () => void;
|
|
227
|
+
onStepBackward: () => void;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function ReplayControls({
|
|
231
|
+
playbackState,
|
|
232
|
+
canStepBackward,
|
|
233
|
+
canStepForward,
|
|
234
|
+
onPlay,
|
|
235
|
+
onPause,
|
|
236
|
+
onStop,
|
|
237
|
+
onStepForward,
|
|
238
|
+
onStepBackward,
|
|
239
|
+
}: ReplayControlsProps) {
|
|
240
|
+
const isPlaying = playbackState === 'playing';
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<div
|
|
244
|
+
className="flex items-center gap-2"
|
|
245
|
+
role="group"
|
|
246
|
+
aria-label="Playback controls"
|
|
247
|
+
>
|
|
248
|
+
{/* Stop */}
|
|
249
|
+
<button
|
|
250
|
+
onClick={onStop}
|
|
251
|
+
className="p-2 rounded-lg hover:bg-surface-raised"
|
|
252
|
+
aria-label="Stop and return to start"
|
|
253
|
+
title="Stop (Esc)"
|
|
254
|
+
>
|
|
255
|
+
<StopIcon className="w-5 h-5" />
|
|
256
|
+
</button>
|
|
257
|
+
|
|
258
|
+
{/* Step Backward */}
|
|
259
|
+
<button
|
|
260
|
+
onClick={onStepBackward}
|
|
261
|
+
disabled={!canStepBackward}
|
|
262
|
+
className="p-2 rounded-lg hover:bg-surface-raised disabled:opacity-50"
|
|
263
|
+
aria-label="Step backward"
|
|
264
|
+
title="Previous event (Left arrow)"
|
|
265
|
+
>
|
|
266
|
+
<StepBackIcon className="w-5 h-5" />
|
|
267
|
+
</button>
|
|
268
|
+
|
|
269
|
+
{/* Play/Pause */}
|
|
270
|
+
<button
|
|
271
|
+
onClick={isPlaying ? onPause : onPlay}
|
|
272
|
+
className="p-3 rounded-full bg-accent hover:bg-accent/80"
|
|
273
|
+
aria-label={isPlaying ? 'Pause' : 'Play'}
|
|
274
|
+
title={isPlaying ? 'Pause (Space)' : 'Play (Space)'}
|
|
275
|
+
>
|
|
276
|
+
{isPlaying ? (
|
|
277
|
+
<PauseIcon className="w-6 h-6 text-white" />
|
|
278
|
+
) : (
|
|
279
|
+
<PlayIcon className="w-6 h-6 text-white" />
|
|
280
|
+
)}
|
|
281
|
+
</button>
|
|
282
|
+
|
|
283
|
+
{/* Step Forward */}
|
|
284
|
+
<button
|
|
285
|
+
onClick={onStepForward}
|
|
286
|
+
disabled={!canStepForward}
|
|
287
|
+
className="p-2 rounded-lg hover:bg-surface-raised disabled:opacity-50"
|
|
288
|
+
aria-label="Step forward"
|
|
289
|
+
title="Next event (Right arrow)"
|
|
290
|
+
>
|
|
291
|
+
<StepForwardIcon className="w-5 h-5" />
|
|
292
|
+
</button>
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### Timeline Component
|
|
299
|
+
|
|
300
|
+
```tsx
|
|
301
|
+
interface ReplayTimelineProps {
|
|
302
|
+
progress: number; // 0-100
|
|
303
|
+
markers?: TimelineMarker[];
|
|
304
|
+
onSeek: (progress: number) => void;
|
|
305
|
+
currentSequence?: number;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
interface TimelineMarker {
|
|
309
|
+
position: number; // 0-100
|
|
310
|
+
color: string;
|
|
311
|
+
label: string;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function ReplayTimeline({ progress, markers = [], onSeek, currentSequence }: ReplayTimelineProps) {
|
|
315
|
+
const trackRef = useRef<HTMLDivElement>(null);
|
|
316
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
317
|
+
const [hoverPosition, setHoverPosition] = useState<number | null>(null);
|
|
318
|
+
|
|
319
|
+
const handleClick = (e: React.MouseEvent) => {
|
|
320
|
+
if (!trackRef.current) return;
|
|
321
|
+
const rect = trackRef.current.getBoundingClientRect();
|
|
322
|
+
const position = ((e.clientX - rect.left) / rect.width) * 100;
|
|
323
|
+
onSeek(Math.max(0, Math.min(100, position)));
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const handleMouseMove = (e: React.MouseEvent) => {
|
|
327
|
+
if (!trackRef.current) return;
|
|
328
|
+
const rect = trackRef.current.getBoundingClientRect();
|
|
329
|
+
const position = ((e.clientX - rect.left) / rect.width) * 100;
|
|
330
|
+
setHoverPosition(Math.max(0, Math.min(100, position)));
|
|
331
|
+
|
|
332
|
+
if (isDragging) {
|
|
333
|
+
onSeek(position);
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
return (
|
|
338
|
+
<div className="w-full">
|
|
339
|
+
{/* Track */}
|
|
340
|
+
<div
|
|
341
|
+
ref={trackRef}
|
|
342
|
+
className="relative h-2 bg-gray-700 rounded-full cursor-pointer"
|
|
343
|
+
onClick={handleClick}
|
|
344
|
+
onMouseMove={handleMouseMove}
|
|
345
|
+
onMouseLeave={() => setHoverPosition(null)}
|
|
346
|
+
onMouseDown={() => setIsDragging(true)}
|
|
347
|
+
onMouseUp={() => setIsDragging(false)}
|
|
348
|
+
role="slider"
|
|
349
|
+
aria-label="Playback position"
|
|
350
|
+
aria-valuemin={0}
|
|
351
|
+
aria-valuemax={100}
|
|
352
|
+
aria-valuenow={Math.round(progress)}
|
|
353
|
+
tabIndex={0}
|
|
354
|
+
>
|
|
355
|
+
{/* Progress fill */}
|
|
356
|
+
<div
|
|
357
|
+
className="absolute left-0 top-0 h-full bg-accent rounded-full transition-all"
|
|
358
|
+
style={{ width: `${progress}%` }}
|
|
359
|
+
/>
|
|
360
|
+
|
|
361
|
+
{/* Hover indicator */}
|
|
362
|
+
{hoverPosition !== null && (
|
|
363
|
+
<div
|
|
364
|
+
className="absolute top-0 h-full w-0.5 bg-white/50"
|
|
365
|
+
style={{ left: `${hoverPosition}%` }}
|
|
366
|
+
/>
|
|
367
|
+
)}
|
|
368
|
+
|
|
369
|
+
{/* Markers */}
|
|
370
|
+
{markers.map((marker, idx) => (
|
|
371
|
+
<div
|
|
372
|
+
key={idx}
|
|
373
|
+
className="absolute top-1/2 -translate-y-1/2 w-2 h-2 rounded-full"
|
|
374
|
+
style={{
|
|
375
|
+
left: `${marker.position}%`,
|
|
376
|
+
backgroundColor: marker.color,
|
|
377
|
+
transform: 'translate(-50%, -50%)'
|
|
378
|
+
}}
|
|
379
|
+
title={marker.label}
|
|
380
|
+
/>
|
|
381
|
+
))}
|
|
382
|
+
|
|
383
|
+
{/* Scrubber handle */}
|
|
384
|
+
<div
|
|
385
|
+
className="absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-white rounded-full shadow-lg border-2 border-accent"
|
|
386
|
+
style={{ left: `${progress}%`, transform: 'translate(-50%, -50%)' }}
|
|
387
|
+
/>
|
|
388
|
+
</div>
|
|
389
|
+
|
|
390
|
+
{/* Position label */}
|
|
391
|
+
{currentSequence !== undefined && (
|
|
392
|
+
<div className="flex justify-between mt-1 text-xs text-gray-500">
|
|
393
|
+
<span>Event {currentSequence}</span>
|
|
394
|
+
<span>{Math.round(progress)}%</span>
|
|
395
|
+
</div>
|
|
396
|
+
)}
|
|
397
|
+
</div>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
### Speed Selector Component
|
|
403
|
+
|
|
404
|
+
```tsx
|
|
405
|
+
const PLAYBACK_SPEEDS = [0.25, 0.5, 1, 1.5, 2, 4] as const;
|
|
406
|
+
|
|
407
|
+
interface ReplaySpeedSelectorProps {
|
|
408
|
+
speed: number;
|
|
409
|
+
onSpeedChange: (speed: number) => void;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function ReplaySpeedSelector({ speed, onSpeedChange }: ReplaySpeedSelectorProps) {
|
|
413
|
+
return (
|
|
414
|
+
<div className="flex items-center gap-1" role="group" aria-label="Playback speed">
|
|
415
|
+
{PLAYBACK_SPEEDS.map(s => (
|
|
416
|
+
<button
|
|
417
|
+
key={s}
|
|
418
|
+
onClick={() => onSpeedChange(s)}
|
|
419
|
+
className={`
|
|
420
|
+
px-2 py-1 text-xs rounded transition-colors
|
|
421
|
+
${speed === s
|
|
422
|
+
? 'bg-accent text-white'
|
|
423
|
+
: 'text-gray-400 hover:text-white hover:bg-gray-700'
|
|
424
|
+
}
|
|
425
|
+
`}
|
|
426
|
+
aria-pressed={speed === s}
|
|
427
|
+
aria-label={`${s}x speed`}
|
|
428
|
+
>
|
|
429
|
+
{s}x
|
|
430
|
+
</button>
|
|
431
|
+
))}
|
|
432
|
+
</div>
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
### Keyboard Shortcuts Hook
|
|
438
|
+
|
|
439
|
+
```tsx
|
|
440
|
+
interface UseReplayKeyboardShortcutsOptions {
|
|
441
|
+
onPlay: () => void;
|
|
442
|
+
onPause: () => void;
|
|
443
|
+
onStepForward: () => void;
|
|
444
|
+
onStepBackward: () => void;
|
|
445
|
+
onSpeedUp: () => void;
|
|
446
|
+
onSpeedDown: () => void;
|
|
447
|
+
onGoToStart: () => void;
|
|
448
|
+
onGoToEnd: () => void;
|
|
449
|
+
playbackState: PlaybackState;
|
|
450
|
+
enabled?: boolean;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function useReplayKeyboardShortcuts({
|
|
454
|
+
onPlay,
|
|
455
|
+
onPause,
|
|
456
|
+
onStepForward,
|
|
457
|
+
onStepBackward,
|
|
458
|
+
onSpeedUp,
|
|
459
|
+
onSpeedDown,
|
|
460
|
+
onGoToStart,
|
|
461
|
+
onGoToEnd,
|
|
462
|
+
playbackState,
|
|
463
|
+
enabled = true,
|
|
464
|
+
}: UseReplayKeyboardShortcutsOptions) {
|
|
465
|
+
useEffect(() => {
|
|
466
|
+
if (!enabled) return;
|
|
467
|
+
|
|
468
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
469
|
+
// Ignore if typing in input
|
|
470
|
+
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
switch (e.key) {
|
|
475
|
+
case ' ':
|
|
476
|
+
e.preventDefault();
|
|
477
|
+
playbackState === 'playing' ? onPause() : onPlay();
|
|
478
|
+
break;
|
|
479
|
+
case 'ArrowLeft':
|
|
480
|
+
e.preventDefault();
|
|
481
|
+
if (e.metaKey || e.ctrlKey) {
|
|
482
|
+
onGoToStart();
|
|
483
|
+
} else {
|
|
484
|
+
onStepBackward();
|
|
485
|
+
}
|
|
486
|
+
break;
|
|
487
|
+
case 'ArrowRight':
|
|
488
|
+
e.preventDefault();
|
|
489
|
+
if (e.metaKey || e.ctrlKey) {
|
|
490
|
+
onGoToEnd();
|
|
491
|
+
} else {
|
|
492
|
+
onStepForward();
|
|
493
|
+
}
|
|
494
|
+
break;
|
|
495
|
+
case '[':
|
|
496
|
+
e.preventDefault();
|
|
497
|
+
onSpeedDown();
|
|
498
|
+
break;
|
|
499
|
+
case ']':
|
|
500
|
+
e.preventDefault();
|
|
501
|
+
onSpeedUp();
|
|
502
|
+
break;
|
|
503
|
+
case 'Escape':
|
|
504
|
+
e.preventDefault();
|
|
505
|
+
onGoToStart();
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
511
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
512
|
+
}, [enabled, playbackState, onPlay, onPause, onStepForward, onStepBackward, onSpeedUp, onSpeedDown, onGoToStart, onGoToEnd]);
|
|
513
|
+
}
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
---
|
|
517
|
+
|
|
518
|
+
## Visual States
|
|
519
|
+
|
|
520
|
+
### Control Button States
|
|
521
|
+
|
|
522
|
+
```typescript
|
|
523
|
+
const CONTROL_STATES = {
|
|
524
|
+
default: {
|
|
525
|
+
bg: 'bg-transparent',
|
|
526
|
+
hover: 'hover:bg-surface-raised',
|
|
527
|
+
text: 'text-gray-400',
|
|
528
|
+
},
|
|
529
|
+
active: {
|
|
530
|
+
bg: 'bg-accent',
|
|
531
|
+
hover: 'hover:bg-accent/80',
|
|
532
|
+
text: 'text-white',
|
|
533
|
+
},
|
|
534
|
+
disabled: {
|
|
535
|
+
bg: 'bg-transparent',
|
|
536
|
+
hover: '',
|
|
537
|
+
text: 'text-gray-600',
|
|
538
|
+
cursor: 'cursor-not-allowed',
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
### Playback State Indicators
|
|
544
|
+
|
|
545
|
+
```typescript
|
|
546
|
+
const PLAYBACK_INDICATORS = {
|
|
547
|
+
stopped: {
|
|
548
|
+
text: 'Stopped',
|
|
549
|
+
color: 'text-gray-400',
|
|
550
|
+
icon: 'stop',
|
|
551
|
+
},
|
|
552
|
+
playing: {
|
|
553
|
+
text: 'Playing',
|
|
554
|
+
color: 'text-emerald-400',
|
|
555
|
+
icon: 'play',
|
|
556
|
+
pulse: true,
|
|
557
|
+
},
|
|
558
|
+
paused: {
|
|
559
|
+
text: 'Paused',
|
|
560
|
+
color: 'text-amber-400',
|
|
561
|
+
icon: 'pause',
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
---
|
|
567
|
+
|
|
568
|
+
## Anti-Patterns
|
|
569
|
+
|
|
570
|
+
### DON'T: Separate Play and Pause Buttons
|
|
571
|
+
```tsx
|
|
572
|
+
// BAD - Takes up space, confusing
|
|
573
|
+
<button onClick={play}>Play</button>
|
|
574
|
+
<button onClick={pause}>Pause</button>
|
|
575
|
+
|
|
576
|
+
// GOOD - Single toggle button
|
|
577
|
+
<button onClick={isPlaying ? pause : play}>
|
|
578
|
+
{isPlaying ? <PauseIcon /> : <PlayIcon />}
|
|
579
|
+
</button>
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
### DON'T: No Keyboard Support
|
|
583
|
+
```tsx
|
|
584
|
+
// BAD - Mouse only
|
|
585
|
+
<div onClick={handleSeek}>...</div>
|
|
586
|
+
|
|
587
|
+
// GOOD - Full keyboard support
|
|
588
|
+
<div
|
|
589
|
+
onClick={handleSeek}
|
|
590
|
+
onKeyDown={e => e.key === 'Enter' && handleSeek(e)}
|
|
591
|
+
tabIndex={0}
|
|
592
|
+
role="slider"
|
|
593
|
+
aria-label="Seek"
|
|
594
|
+
>...</div>
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
### DON'T: Hidden Progress Information
|
|
598
|
+
```tsx
|
|
599
|
+
// BAD - No indication of position
|
|
600
|
+
<ProgressBar progress={progress} />
|
|
601
|
+
|
|
602
|
+
// GOOD - Clear position indication
|
|
603
|
+
<div>
|
|
604
|
+
<ProgressBar progress={progress} />
|
|
605
|
+
<span>Event {current} of {total} ({Math.round(progress)}%)</span>
|
|
606
|
+
</div>
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
### DON'T: No Speed Indication
|
|
610
|
+
```tsx
|
|
611
|
+
// BAD - Speed selector without current value
|
|
612
|
+
<SpeedSelector onChange={setSpeed} />
|
|
613
|
+
|
|
614
|
+
// GOOD - Current speed always visible
|
|
615
|
+
<div className="flex items-center gap-2">
|
|
616
|
+
<span className="text-sm text-gray-400">Speed:</span>
|
|
617
|
+
<SpeedSelector speed={speed} onChange={setSpeed} />
|
|
618
|
+
</div>
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
---
|
|
622
|
+
|
|
623
|
+
## Accessibility
|
|
624
|
+
|
|
625
|
+
### ARIA Attributes
|
|
626
|
+
|
|
627
|
+
```tsx
|
|
628
|
+
// Progress bar
|
|
629
|
+
<div
|
|
630
|
+
role="slider"
|
|
631
|
+
aria-label="Playback progress"
|
|
632
|
+
aria-valuemin={0}
|
|
633
|
+
aria-valuemax={100}
|
|
634
|
+
aria-valuenow={progress}
|
|
635
|
+
aria-valuetext={`${currentIndex + 1} of ${totalEvents} events`}
|
|
636
|
+
tabIndex={0}
|
|
637
|
+
>
|
|
638
|
+
|
|
639
|
+
// Play/Pause button
|
|
640
|
+
<button
|
|
641
|
+
aria-label={isPlaying ? 'Pause playback' : 'Start playback'}
|
|
642
|
+
aria-pressed={isPlaying}
|
|
643
|
+
>
|
|
644
|
+
|
|
645
|
+
// Speed selector
|
|
646
|
+
<div role="group" aria-label="Playback speed selection">
|
|
647
|
+
<button aria-pressed={speed === 1} aria-label="Normal speed">1x</button>
|
|
648
|
+
<button aria-pressed={speed === 2} aria-label="Double speed">2x</button>
|
|
649
|
+
</div>
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
### Screen Reader Announcements
|
|
653
|
+
|
|
654
|
+
```tsx
|
|
655
|
+
function usePlaybackAnnouncements(playbackState: PlaybackState, currentIndex: number) {
|
|
656
|
+
const prevState = useRef(playbackState);
|
|
657
|
+
|
|
658
|
+
useEffect(() => {
|
|
659
|
+
if (playbackState !== prevState.current) {
|
|
660
|
+
const messages = {
|
|
661
|
+
playing: 'Playback started',
|
|
662
|
+
paused: `Playback paused at event ${currentIndex + 1}`,
|
|
663
|
+
stopped: 'Playback stopped, returned to start',
|
|
664
|
+
};
|
|
665
|
+
announceToScreenReader(messages[playbackState]);
|
|
666
|
+
prevState.current = playbackState;
|
|
667
|
+
}
|
|
668
|
+
}, [playbackState, currentIndex]);
|
|
669
|
+
}
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
---
|
|
673
|
+
|
|
674
|
+
## Testing Checklist
|
|
675
|
+
|
|
676
|
+
- [ ] Play starts playback from current position
|
|
677
|
+
- [ ] Pause stops playback without changing position
|
|
678
|
+
- [ ] Stop returns to position 0
|
|
679
|
+
- [ ] Step forward advances by one event
|
|
680
|
+
- [ ] Step backward goes back by one event
|
|
681
|
+
- [ ] Seek jumps to clicked position
|
|
682
|
+
- [ ] Speed changes affect playback rate
|
|
683
|
+
- [ ] Space bar toggles play/pause
|
|
684
|
+
- [ ] Arrow keys step forward/backward
|
|
685
|
+
- [ ] Progress bar updates during playback
|
|
686
|
+
- [ ] Position indicator shows correct event number
|
|
687
|
+
- [ ] Controls disable at appropriate boundaries
|
|
688
|
+
|
|
689
|
+
---
|
|
690
|
+
|
|
691
|
+
## Related Skills
|
|
692
|
+
|
|
693
|
+
- `event-timeline-patterns` - For event list display
|
|
694
|
+
- `keyboard-shortcuts-patterns` - For shortcut implementation
|
|
695
|
+
- `turn-based-ui-patterns` - For game replay context
|