picasso-skill 2.5.0 → 2.6.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.
- package/agents/picasso.md +4 -6
- package/commands/mood.md +3 -2
- package/commands/preset.md +2 -2
- package/commands/preview.md +3 -3
- package/commands/variants.md +3 -4
- package/package.json +1 -1
- package/references/accessibility-wcag.md +3 -0
- package/references/code-typography.md +36 -166
- package/references/color-and-contrast.md +78 -345
- package/references/generative-art.md +49 -561
- package/references/modern-css-performance.md +46 -258
- package/references/motion-and-animation.md +225 -88
- package/references/navigation-patterns.md +29 -186
- package/references/performance-optimization.md +42 -678
- package/references/react-patterns.md +56 -216
- package/references/responsive-design.md +77 -379
- package/references/sensory-design.md +62 -263
- package/references/ux-writing.md +64 -354
- package/references/visual-preview.md +17 -9
- package/references/animation-performance.md +0 -244
- package/references/interaction-design.md +0 -162
|
@@ -1,67 +1,49 @@
|
|
|
1
1
|
# Sensory Design Reference
|
|
2
2
|
|
|
3
|
-
## Table of Contents
|
|
4
|
-
1. UI Sound Design
|
|
5
|
-
2. Haptic Feedback
|
|
6
|
-
3. Multi-Sensory Integration
|
|
7
|
-
|
|
8
3
|
---
|
|
9
4
|
|
|
10
5
|
## 1. UI Sound Design
|
|
11
6
|
|
|
12
7
|
### Why Sound
|
|
13
|
-
Sound
|
|
8
|
+
Sound confirms actions, draws attention to state changes, and adds personality. Underused on the web but highly effective with restraint.
|
|
14
9
|
|
|
15
10
|
### Sourcing Sounds
|
|
16
|
-
- **Kenney.nl
|
|
17
|
-
- **Freesound.org
|
|
18
|
-
- **Tone.js synthesis
|
|
19
|
-
- **Self-recorded
|
|
20
|
-
|
|
21
|
-
For inline embedding, convert sounds to base64 data URIs to avoid CORS issues and runtime fetching. Each sound becomes a self-contained TypeScript module.
|
|
11
|
+
- **Kenney.nl**: free CC0 game/UI sound packs, no attribution
|
|
12
|
+
- **Freesound.org**: filter by CC0, verify per clip
|
|
13
|
+
- **Tone.js synthesis**: procedural sounds at runtime, no files to load
|
|
14
|
+
- **Self-recorded foley**: export as WAV/MP3 at 44.1kHz mono
|
|
22
15
|
|
|
23
|
-
|
|
24
|
-
// sounds/click-soft.ts
|
|
25
|
-
export const clickSoftSound = "data:audio/wav;base64,UklGR...";
|
|
26
|
-
```
|
|
16
|
+
For inline embedding, convert to base64 data URIs to avoid CORS and runtime fetching.
|
|
27
17
|
|
|
28
18
|
### The useSound Hook (Production-Ready)
|
|
29
19
|
|
|
30
|
-
|
|
20
|
+
Handles AudioContext lifecycle (browser autoplay policy), caches decoded buffers, exposes volume control via GainNode.
|
|
31
21
|
|
|
32
22
|
```typescript
|
|
33
23
|
// hooks/use-sound.ts
|
|
34
24
|
import { useCallback, useEffect, useRef } from "react";
|
|
35
25
|
|
|
36
|
-
// Shared AudioContext singleton -- one per page avoids resource waste.
|
|
37
26
|
let sharedCtx: AudioContext | null = null;
|
|
38
27
|
const bufferCache = new Map<string, AudioBuffer>();
|
|
39
28
|
|
|
40
29
|
function getAudioContext(): AudioContext {
|
|
41
|
-
if (!sharedCtx)
|
|
42
|
-
sharedCtx = new AudioContext();
|
|
43
|
-
}
|
|
30
|
+
if (!sharedCtx) sharedCtx = new AudioContext();
|
|
44
31
|
return sharedCtx;
|
|
45
32
|
}
|
|
46
33
|
|
|
47
|
-
// Resume AudioContext on first user gesture (required by Chrome, Safari, Firefox).
|
|
48
|
-
// Call this once at app root.
|
|
49
34
|
export function initAudioOnGesture(): void {
|
|
50
35
|
const resume = () => {
|
|
51
36
|
const ctx = getAudioContext();
|
|
52
|
-
if (ctx.state === "suspended")
|
|
53
|
-
ctx.resume();
|
|
54
|
-
}
|
|
37
|
+
if (ctx.state === "suspended") ctx.resume();
|
|
55
38
|
};
|
|
56
|
-
|
|
57
|
-
|
|
39
|
+
(["click", "touchstart", "keydown"] as const).forEach((e) =>
|
|
40
|
+
document.addEventListener(e, resume, { once: false })
|
|
41
|
+
);
|
|
58
42
|
}
|
|
59
43
|
|
|
60
44
|
interface UseSoundOptions {
|
|
61
|
-
volume?: number;
|
|
62
|
-
/** For sound sprites: start offset in seconds */
|
|
45
|
+
volume?: number;
|
|
63
46
|
offset?: number;
|
|
64
|
-
/** For sound sprites: duration in seconds */
|
|
65
47
|
duration?: number;
|
|
66
48
|
}
|
|
67
49
|
|
|
@@ -71,22 +53,15 @@ export function useSound(src: string, options: UseSoundOptions = {}) {
|
|
|
71
53
|
|
|
72
54
|
const play = useCallback(async () => {
|
|
73
55
|
const ctx = getAudioContext();
|
|
56
|
+
if (ctx.state === "suspended") await ctx.resume();
|
|
74
57
|
|
|
75
|
-
// Resume if suspended (covers edge cases where gesture init missed)
|
|
76
|
-
if (ctx.state === "suspended") {
|
|
77
|
-
await ctx.resume();
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Fetch + decode only on first play, then cache
|
|
81
58
|
let buffer = bufferCache.get(src);
|
|
82
59
|
if (!buffer) {
|
|
83
60
|
const response = await fetch(src);
|
|
84
|
-
|
|
85
|
-
buffer = await ctx.decodeAudioData(arrayBuf);
|
|
61
|
+
buffer = await ctx.decodeAudioData(await response.arrayBuffer());
|
|
86
62
|
bufferCache.set(src, buffer);
|
|
87
63
|
}
|
|
88
64
|
|
|
89
|
-
// GainNode for volume control
|
|
90
65
|
const gain = ctx.createGain();
|
|
91
66
|
gain.gain.value = volume;
|
|
92
67
|
gain.connect(ctx.destination);
|
|
@@ -95,64 +70,24 @@ export function useSound(src: string, options: UseSoundOptions = {}) {
|
|
|
95
70
|
const source = ctx.createBufferSource();
|
|
96
71
|
source.buffer = buffer;
|
|
97
72
|
source.connect(gain);
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
source.start(0
|
|
101
|
-
} else {
|
|
102
|
-
source.start(0);
|
|
103
|
-
}
|
|
73
|
+
offset !== undefined && duration !== undefined
|
|
74
|
+
? source.start(0, offset, duration)
|
|
75
|
+
: source.start(0);
|
|
104
76
|
}, [src, volume, offset, duration]);
|
|
105
77
|
|
|
106
|
-
const setVolume = useCallback(
|
|
107
|
-
(
|
|
108
|
-
|
|
109
|
-
gainRef.current.gain.value = Math.max(0, Math.min(1, v));
|
|
110
|
-
}
|
|
111
|
-
},
|
|
112
|
-
[]
|
|
113
|
-
);
|
|
78
|
+
const setVolume = useCallback((v: number) => {
|
|
79
|
+
if (gainRef.current) gainRef.current.gain.value = Math.max(0, Math.min(1, v));
|
|
80
|
+
}, []);
|
|
114
81
|
|
|
115
82
|
return { play, setVolume } as const;
|
|
116
83
|
}
|
|
117
84
|
```
|
|
118
85
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
```tsx
|
|
122
|
-
// app/layout.tsx or app entry
|
|
123
|
-
import { initAudioOnGesture } from "@/hooks/use-sound";
|
|
124
|
-
|
|
125
|
-
// Call once on mount
|
|
126
|
-
useEffect(() => {
|
|
127
|
-
initAudioOnGesture();
|
|
128
|
-
}, []);
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
### Basic Usage
|
|
132
|
-
|
|
133
|
-
```tsx
|
|
134
|
-
import { useSound } from "@/hooks/use-sound";
|
|
135
|
-
import { clickSoftSound } from "@/sounds/click-soft";
|
|
136
|
-
|
|
137
|
-
function SaveButton() {
|
|
138
|
-
const { play } = useSound(clickSoftSound, { volume: 0.3 });
|
|
139
|
-
return (
|
|
140
|
-
<button onClick={() => { play(); handleSave(); }}>
|
|
141
|
-
Save
|
|
142
|
-
</button>
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
```
|
|
86
|
+
Call `initAudioOnGesture()` once at app root on mount.
|
|
146
87
|
|
|
147
88
|
### Sound Sprite Pattern
|
|
148
|
-
|
|
149
|
-
Pack multiple short sounds into a single audio file to reduce HTTP requests and simplify asset management. Reference each sound by its offset and duration within the file.
|
|
150
|
-
|
|
89
|
+
Pack multiple short sounds into a single file. Reference by offset + duration:
|
|
151
90
|
```typescript
|
|
152
|
-
// sounds/ui-sprite.ts
|
|
153
|
-
export const uiSprite = "data:audio/wav;base64,UklGR...";
|
|
154
|
-
|
|
155
|
-
// Offsets in seconds within the sprite file
|
|
156
91
|
export const SPRITE_MAP = {
|
|
157
92
|
click: { offset: 0.0, duration: 0.08 },
|
|
158
93
|
success: { offset: 0.1, duration: 0.15 },
|
|
@@ -162,208 +97,72 @@ export const SPRITE_MAP = {
|
|
|
162
97
|
} as const;
|
|
163
98
|
```
|
|
164
99
|
|
|
165
|
-
```tsx
|
|
166
|
-
import { useSound } from "@/hooks/use-sound";
|
|
167
|
-
import { uiSprite, SPRITE_MAP } from "@/sounds/ui-sprite";
|
|
168
|
-
|
|
169
|
-
function ToggleSwitch({ checked, onChange }: ToggleProps) {
|
|
170
|
-
const { play } = useSound(uiSprite, {
|
|
171
|
-
volume: 0.35,
|
|
172
|
-
offset: SPRITE_MAP.toggle.offset,
|
|
173
|
-
duration: SPRITE_MAP.toggle.duration,
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
return (
|
|
177
|
-
<button
|
|
178
|
-
role="switch"
|
|
179
|
-
aria-checked={checked}
|
|
180
|
-
onClick={() => { play(); onChange(!checked); }}
|
|
181
|
-
/>
|
|
182
|
-
);
|
|
183
|
-
}
|
|
184
|
-
```
|
|
185
|
-
|
|
186
100
|
### When to Use Sound
|
|
187
|
-
- **Button clicks**: soft, short
|
|
101
|
+
- **Button clicks**: soft, short (50-100ms)
|
|
188
102
|
- **Success actions**: pleasant confirmation tone
|
|
189
|
-
- **Notifications**: attention-getting
|
|
190
|
-
- **Errors**: subtle alert, not
|
|
103
|
+
- **Notifications**: attention-getting, not alarming
|
|
104
|
+
- **Errors**: subtle alert, not harsh
|
|
191
105
|
- **Toggle switches**: satisfying mechanical click
|
|
192
|
-
- **Transitions**: whoosh
|
|
106
|
+
- **Transitions**: whoosh for page changes
|
|
193
107
|
|
|
194
108
|
### Rules
|
|
195
|
-
- Always provide a sound toggle in the UI
|
|
196
|
-
- Keep sounds under 200ms for
|
|
197
|
-
- Use
|
|
198
|
-
-
|
|
199
|
-
- Never auto-play
|
|
200
|
-
- Gate behind
|
|
201
|
-
- Pre-decode buffers
|
|
109
|
+
- Always provide a sound toggle in the UI
|
|
110
|
+
- Keep sounds under 200ms for interactions
|
|
111
|
+
- Use Web Audio API, not `<audio>` elements (lower latency)
|
|
112
|
+
- Default volume: 0.3-0.5
|
|
113
|
+
- Never auto-play on page load
|
|
114
|
+
- Gate behind user preference in localStorage
|
|
115
|
+
- Pre-decode buffers; never decode on every play
|
|
202
116
|
|
|
203
117
|
---
|
|
204
118
|
|
|
205
119
|
## 2. Haptic Feedback
|
|
206
120
|
|
|
207
|
-
###
|
|
208
|
-
|
|
121
|
+
### Vibration API (Android, some desktop)
|
|
209
122
|
```typescript
|
|
210
|
-
|
|
211
|
-
export function
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
export function hapticSuccess() {
|
|
216
|
-
navigator.vibrate?.([10, 50, 10]);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export function hapticError() {
|
|
220
|
-
navigator.vibrate?.(30);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
export function hapticWarning() {
|
|
224
|
-
navigator.vibrate?.([10, 30, 10, 30, 10]);
|
|
225
|
-
}
|
|
226
|
-
```
|
|
227
|
-
|
|
228
|
-
### iOS Haptic Considerations
|
|
229
|
-
|
|
230
|
-
The Vibration API is not supported on iOS Safari. For web apps, iOS has no standard haptic API. Strategies for iOS haptic feedback:
|
|
231
|
-
|
|
232
|
-
1. **Native bridge (Capacitor/React Native)** -- If the app runs in a native wrapper, call `UIImpactFeedbackGenerator` through the bridge. This provides the best haptics on iOS with three intensity levels: `.light`, `.medium`, `.heavy`.
|
|
233
|
-
|
|
234
|
-
```typescript
|
|
235
|
-
// For Capacitor-based apps
|
|
236
|
-
import { Haptics, ImpactStyle } from "@capacitor/haptics";
|
|
237
|
-
|
|
238
|
-
export async function hapticTapIOS() {
|
|
239
|
-
await Haptics.impact({ style: ImpactStyle.Light });
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
export async function hapticSuccessIOS() {
|
|
243
|
-
await Haptics.notification({ type: "success" });
|
|
244
|
-
}
|
|
123
|
+
export function hapticTap() { navigator.vibrate?.(10); }
|
|
124
|
+
export function hapticSuccess() { navigator.vibrate?.([10, 50, 10]); }
|
|
125
|
+
export function hapticError() { navigator.vibrate?.(30); }
|
|
126
|
+
export function hapticWarning() { navigator.vibrate?.([10, 30, 10, 30, 10]); }
|
|
245
127
|
```
|
|
246
128
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (typeof navigator !== "undefined" && "vibrate" in navigator) {
|
|
252
|
-
navigator.vibrate(pattern);
|
|
253
|
-
}
|
|
254
|
-
// On iOS Safari this is a no-op. The visual and audio
|
|
255
|
-
// feedback channels carry the interaction.
|
|
256
|
-
}
|
|
257
|
-
```
|
|
129
|
+
### iOS Considerations
|
|
130
|
+
Vibration API not supported on iOS Safari. Options:
|
|
131
|
+
- **Capacitor/React Native bridge**: call `UIImpactFeedbackGenerator` with `.light`/`.medium`/`.heavy`
|
|
132
|
+
- **Graceful degradation**: wrap all calls behind `'vibrate' in navigator` check; on iOS it's a no-op
|
|
258
133
|
|
|
259
134
|
### When to Use Haptics
|
|
260
|
-
-
|
|
261
|
-
-
|
|
262
|
-
-
|
|
263
|
-
-
|
|
264
|
-
-
|
|
135
|
+
- Button press confirmation (10ms)
|
|
136
|
+
- Toggle switch state change (10ms)
|
|
137
|
+
- Destructive action confirmation (30ms)
|
|
138
|
+
- Pull-to-refresh threshold reached (10ms)
|
|
139
|
+
- Drag and drop pickup/drop (10ms)
|
|
265
140
|
|
|
266
141
|
### Rules
|
|
267
|
-
- Gate behind feature detection
|
|
268
|
-
- Respect `prefers-reduced-motion`
|
|
269
|
-
- Keep durations
|
|
270
|
-
-
|
|
271
|
-
- Test on real hardware
|
|
142
|
+
- Gate behind feature detection
|
|
143
|
+
- Respect `prefers-reduced-motion`
|
|
144
|
+
- Keep durations 10-30ms, never over 100ms
|
|
145
|
+
- Only for pivotal moments, not every interaction
|
|
146
|
+
- Test on real hardware
|
|
272
147
|
|
|
273
148
|
---
|
|
274
149
|
|
|
275
150
|
## 3. Multi-Sensory Integration
|
|
276
151
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
### Integration Principles
|
|
280
|
-
1. **Synchronize channels** -- sound, haptic, and animation should fire at the same instant. Do not stagger them.
|
|
281
|
-
2. **Match intensity** -- a subtle visual pulse pairs with a quiet click and a light tap. A big celebration pairs with a brighter chime and stronger vibration.
|
|
282
|
-
3. **Degrade gracefully** -- if sound is muted, the visual and haptic still work. If haptics are unavailable, sound and visual carry it. Each channel must stand on its own.
|
|
283
|
-
4. **Respect preferences** -- check `prefers-reduced-motion`, sound toggle state, and haptic toggle state independently.
|
|
152
|
+
Reserve combined visual + audio + haptic feedback for milestone moments: order placed, task completed, level achieved, payment confirmed.
|
|
284
153
|
|
|
285
|
-
###
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
import { useSound } from "@/hooks/use-sound";
|
|
291
|
-
import { uiSprite, SPRITE_MAP } from "@/sounds/ui-sprite";
|
|
292
|
-
import { hapticSuccess, haptic } from "@/utils/haptics";
|
|
293
|
-
import { CheckIcon } from "lucide-react";
|
|
294
|
-
|
|
295
|
-
interface TaskCompleteButtonProps {
|
|
296
|
-
completed: boolean;
|
|
297
|
-
onComplete: () => void;
|
|
298
|
-
soundEnabled?: boolean;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
export function TaskCompleteButton({
|
|
302
|
-
completed,
|
|
303
|
-
onComplete,
|
|
304
|
-
soundEnabled = true,
|
|
305
|
-
}: TaskCompleteButtonProps) {
|
|
306
|
-
const { play } = useSound(uiSprite, {
|
|
307
|
-
volume: 0.4,
|
|
308
|
-
offset: SPRITE_MAP.success.offset,
|
|
309
|
-
duration: SPRITE_MAP.success.duration,
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
const prefersReducedMotion =
|
|
313
|
-
typeof window !== "undefined" &&
|
|
314
|
-
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
315
|
-
|
|
316
|
-
const handleComplete = useCallback(() => {
|
|
317
|
-
if (completed) return;
|
|
318
|
-
|
|
319
|
-
// Fire all three channels simultaneously
|
|
320
|
-
onComplete();
|
|
321
|
-
if (soundEnabled) play();
|
|
322
|
-
hapticSuccess();
|
|
323
|
-
}, [completed, onComplete, soundEnabled, play]);
|
|
324
|
-
|
|
325
|
-
return (
|
|
326
|
-
<button
|
|
327
|
-
onClick={handleComplete}
|
|
328
|
-
className="relative flex items-center gap-2 rounded-lg bg-zinc-900
|
|
329
|
-
px-4 py-2 text-sm font-medium text-white
|
|
330
|
-
transition-colors hover:bg-zinc-800
|
|
331
|
-
disabled:opacity-50"
|
|
332
|
-
disabled={completed}
|
|
333
|
-
>
|
|
334
|
-
<AnimatePresence mode="wait">
|
|
335
|
-
{completed ? (
|
|
336
|
-
<motion.span
|
|
337
|
-
key="done"
|
|
338
|
-
initial={prefersReducedMotion ? {} : { scale: 0, opacity: 0 }}
|
|
339
|
-
animate={{ scale: 1, opacity: 1 }}
|
|
340
|
-
transition={{ type: "spring", stiffness: 500, damping: 25 }}
|
|
341
|
-
className="flex items-center gap-2 text-emerald-400"
|
|
342
|
-
>
|
|
343
|
-
<CheckIcon className="h-4 w-4" />
|
|
344
|
-
Done
|
|
345
|
-
</motion.span>
|
|
346
|
-
) : (
|
|
347
|
-
<motion.span
|
|
348
|
-
key="complete"
|
|
349
|
-
exit={prefersReducedMotion ? {} : { scale: 0.8, opacity: 0 }}
|
|
350
|
-
transition={{ duration: 0.1 }}
|
|
351
|
-
>
|
|
352
|
-
Mark Complete
|
|
353
|
-
</motion.span>
|
|
354
|
-
)}
|
|
355
|
-
</AnimatePresence>
|
|
356
|
-
</button>
|
|
357
|
-
);
|
|
358
|
-
}
|
|
359
|
-
```
|
|
154
|
+
### Principles
|
|
155
|
+
1. **Synchronize**: sound, haptic, and animation fire at t=0
|
|
156
|
+
2. **Match intensity**: subtle visual = quiet click = light tap; big celebration = brighter chime = stronger vibration
|
|
157
|
+
3. **Degrade gracefully**: each channel must stand alone if others are unavailable
|
|
158
|
+
4. **Respect preferences**: check `prefers-reduced-motion`, sound toggle, haptic toggle independently
|
|
360
159
|
|
|
361
160
|
### Timeline of a Multi-Sensory Moment
|
|
362
161
|
|
|
363
162
|
| Time | Visual | Sound | Haptic |
|
|
364
163
|
|------|--------|-------|--------|
|
|
365
|
-
| 0ms |
|
|
366
|
-
| 150ms |
|
|
164
|
+
| 0ms | State change, spring animation | Chime begins (100-150ms) | Double-pulse: 10ms/50ms/10ms |
|
|
165
|
+
| 150ms | Animation settles | Sound fades | Complete |
|
|
367
166
|
| 300ms | Final resting state | Silent | Idle |
|
|
368
167
|
|
|
369
|
-
All three channels start at t=0 and resolve independently. The user perceives them as
|
|
168
|
+
All three channels start at t=0 and resolve independently. The user perceives them as one unified moment.
|