picasso-skill 2.4.0 → 2.6.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.
@@ -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 provides confirmation that an action occurred, draws attention to important state changes, and adds personality to the interface. It is underused on the web but highly effective when applied with restraint.
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** -- free CC0 game/UI sound packs. High quality, no attribution required.
17
- - **Freesound.org** -- filter by CC0 license for attribution-free use. Verify license on each clip.
18
- - **Tone.js synthesis** -- generate procedural sounds at runtime. No files to load. Good for simple tones, chimes, and click sounds. Use `Tone.Synth`, `Tone.MembraneSynth`, or `Tone.NoiseSynth` for UI feedback.
19
- - **Self-recorded** -- record short foley sounds and export as WAV/MP3 at 44.1kHz mono.
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
- ```typescript
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
- This hook handles AudioContext lifecycle (including the browser autoplay policy that blocks AudioContext until a user gesture), caches decoded buffers so each sound is fetched and decoded only once, and exposes volume control via a GainNode.
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
- const events = ["click", "touchstart", "keydown"] as const;
57
- events.forEach((e) => document.addEventListener(e, resume, { once: false }));
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; // 0 to 1, default 0.4
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
- const arrayBuf = await response.arrayBuffer();
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
- if (offset !== undefined && duration !== undefined) {
100
- source.start(0, offset, duration);
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
- (v: number) => {
108
- if (gainRef.current) {
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
- ### App Root Setup
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 click sound (50-100ms)
101
+ - **Button clicks**: soft, short (50-100ms)
188
102
  - **Success actions**: pleasant confirmation tone
189
- - **Notifications**: attention-getting but not alarming chime
190
- - **Errors**: subtle alert, not a harsh buzz
103
+ - **Notifications**: attention-getting, not alarming
104
+ - **Errors**: subtle alert, not harsh
191
105
  - **Toggle switches**: satisfying mechanical click
192
- - **Transitions**: whoosh or swipe sound for page changes
106
+ - **Transitions**: whoosh for page changes
193
107
 
194
108
  ### Rules
195
- - Always provide a sound toggle in the UI (respect user preference)
196
- - Keep sounds under 200ms for UI interactions
197
- - Use the Web Audio API, not `<audio>` elements (lower latency)
198
- - Sound volume should be subtle by default (0.3-0.5 of max)
199
- - Never auto-play sounds on page load
200
- - Gate behind a user preference stored in localStorage or app settings
201
- - Pre-decode buffers when possible; never decode on every play
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
- ### The Vibration API (Android, some desktop browsers)
208
-
121
+ ### Vibration API (Android, some desktop)
209
122
  ```typescript
210
- // utils/haptics.ts
211
- export function hapticTap() {
212
- navigator.vibrate?.(10);
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
- 2. **Graceful degradation** -- For pure web apps on iOS, accept that haptics are unavailable. Never show broken behavior. Wrap all haptic calls behind a safe helper:
248
-
249
- ```typescript
250
- export function haptic(pattern: number | number[]) {
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
- - **Button press confirmation**: 10ms pulse on touch
261
- - **Toggle switch**: 10ms pulse on state change
262
- - **Destructive action confirmation**: 30ms pulse before confirmation dialog
263
- - **Pull-to-refresh threshold**: 10ms pulse when the threshold is reached
264
- - **Drag and drop**: 10ms pulse on pickup and drop
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 (`'vibrate' in navigator` or Capacitor availability)
268
- - Respect `prefers-reduced-motion` by disabling haptics when motion is reduced
269
- - Keep durations very short (10-30ms for taps, never longer than 100ms)
270
- - Do not use haptics for every interaction, only pivotal moments
271
- - Test on real hardware; emulators do not produce haptic feedback
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
- The strongest UI moments combine visual, auditory, and haptic feedback simultaneously. This triple feedback creates a moment of certainty that a single visual change cannot match. Reserve it for milestone moments: order placed, task completed, level achieved, payment confirmed.
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
- ### Complete Example: Task Completion Feedback
286
-
287
- ```tsx
288
- import { useCallback } from "react";
289
- import { motion, AnimatePresence } from "framer-motion";
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 | Button state changes, checkmark scales in with spring animation | Success chime begins (100-150ms clip) | Double-pulse fires: 10ms on, 50ms gap, 10ms on |
366
- | 150ms | Spring animation settles, color transition to emerald completes | Sound fades out naturally | Haptic complete |
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 a single unified moment.
168
+ All three channels start at t=0 and resolve independently. The user perceives them as one unified moment.