agentcraft 0.0.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 (50) hide show
  1. package/.claude-plugin/plugin.json +7 -0
  2. package/README.md +95 -0
  3. package/bin/agentcraft.js +12 -0
  4. package/commands/agentcraft.md +46 -0
  5. package/hooks/hooks.json +13 -0
  6. package/hooks/play-sound.sh +69 -0
  7. package/opencode.js +87 -0
  8. package/package.json +19 -0
  9. package/screenshot.jpg +0 -0
  10. package/social-share-og.jpg +0 -0
  11. package/social-share-tw.jpg +0 -0
  12. package/social-share.png +0 -0
  13. package/web/README.md +36 -0
  14. package/web/app/api/agents/[name]/route.ts +44 -0
  15. package/web/app/api/agents/route.ts +62 -0
  16. package/web/app/api/assignments/route.ts +53 -0
  17. package/web/app/api/audio/[...path]/route.ts +35 -0
  18. package/web/app/api/health/route.ts +5 -0
  19. package/web/app/api/preview/route.ts +22 -0
  20. package/web/app/api/skills/route.ts +83 -0
  21. package/web/app/api/sounds/route.ts +106 -0
  22. package/web/app/api/ui-sounds/route.ts +44 -0
  23. package/web/app/favicon.ico +0 -0
  24. package/web/app/globals.css +99 -0
  25. package/web/app/icon.svg +13 -0
  26. package/web/app/layout.tsx +19 -0
  27. package/web/app/opengraph-image.tsx +96 -0
  28. package/web/app/page.tsx +268 -0
  29. package/web/bun.lock +292 -0
  30. package/web/components/agent-form.tsx +190 -0
  31. package/web/components/agent-roster-panel.tsx +502 -0
  32. package/web/components/assignment-log-panel.tsx +109 -0
  33. package/web/components/hook-slot.tsx +149 -0
  34. package/web/components/hud-header.tsx +135 -0
  35. package/web/components/sound-browser-panel.tsx +206 -0
  36. package/web/components/sound-unit.tsx +203 -0
  37. package/web/components/ui-sounds-modal.tsx +308 -0
  38. package/web/lib/types.ts +87 -0
  39. package/web/lib/ui-audio.ts +126 -0
  40. package/web/lib/utils.ts +98 -0
  41. package/web/next.config.ts +8 -0
  42. package/web/package.json +37 -0
  43. package/web/postcss.config.mjs +1 -0
  44. package/web/public/file.svg +1 -0
  45. package/web/public/fonts/starcraft-normal.ttf +0 -0
  46. package/web/public/globe.svg +1 -0
  47. package/web/public/next.svg +1 -0
  48. package/web/public/vercel.svg +1 -0
  49. package/web/public/window.svg +1 -0
  50. package/web/tsconfig.json +34 -0
@@ -0,0 +1,203 @@
1
+ 'use client';
2
+
3
+ import { useDraggable } from '@dnd-kit/core';
4
+ import { formatSoundName } from '@/lib/utils';
5
+ import type { SoundAsset } from '@/lib/types';
6
+ import { useState, useRef, useEffect, useCallback } from 'react';
7
+
8
+ interface SoundUnitProps {
9
+ sound: SoundAsset;
10
+ isAssigned: boolean;
11
+ onPreview: (path: string) => void;
12
+ isOverlay?: boolean;
13
+ onSelectAssign?: () => void; // if set, card click assigns instead of plays
14
+ }
15
+
16
+ const BARS = 16;
17
+
18
+ export function SoundUnit({ sound, isAssigned, onPreview, isOverlay, onSelectAssign }: SoundUnitProps) {
19
+ const [isPlaying, setIsPlaying] = useState(false);
20
+ const [isHovered, setIsHovered] = useState(false);
21
+ const [bars, setBars] = useState<number[]>(sound.waveform.map(h => h / 10));
22
+
23
+ const ctxRef = useRef<AudioContext | null>(null);
24
+ const sourceRef = useRef<AudioBufferSourceNode | null>(null);
25
+ const rafRef = useRef<number | null>(null);
26
+ // Track whether a drag actually happened so we can suppress the click
27
+ const didDragRef = useRef(false);
28
+
29
+ useEffect(() => {
30
+ setBars(sound.waveform.map(h => h / 10));
31
+ }, [sound.waveform]);
32
+
33
+ const stop = useCallback(() => {
34
+ if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; }
35
+ if (sourceRef.current) { try { sourceRef.current.stop(); } catch {} sourceRef.current = null; }
36
+ if (ctxRef.current) { ctxRef.current.close(); ctxRef.current = null; }
37
+ setIsPlaying(false);
38
+ setBars(sound.waveform.map(h => h / 10));
39
+ }, [sound.waveform]);
40
+
41
+ const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
42
+ id: sound.path,
43
+ data: { sound },
44
+ disabled: isOverlay,
45
+ });
46
+
47
+ // Track drag so we can suppress click-to-play after a drag
48
+ useEffect(() => {
49
+ if (isDragging) didDragRef.current = true;
50
+ }, [isDragging]);
51
+
52
+ const playSound = useCallback(async () => {
53
+ if (isPlaying) { stop(); return; }
54
+ setIsPlaying(true);
55
+
56
+ const ctx = new AudioContext();
57
+ ctxRef.current = ctx;
58
+
59
+ const analyser = ctx.createAnalyser();
60
+ analyser.fftSize = 256;
61
+ analyser.smoothingTimeConstant = 0.6;
62
+ analyser.connect(ctx.destination);
63
+
64
+ let audioBuffer: AudioBuffer;
65
+ try {
66
+ const res = await fetch(`/api/audio/${sound.path}`);
67
+ const arrayBuf = await res.arrayBuffer();
68
+ audioBuffer = await ctx.decodeAudioData(arrayBuf);
69
+ } catch {
70
+ stop();
71
+ return;
72
+ }
73
+
74
+ const source = ctx.createBufferSource();
75
+ source.buffer = audioBuffer;
76
+ source.connect(analyser);
77
+ source.start();
78
+ sourceRef.current = source;
79
+ source.onended = stop;
80
+
81
+ const freqData = new Uint8Array(analyser.frequencyBinCount);
82
+ const binsPerBar = Math.floor(analyser.frequencyBinCount / BARS);
83
+
84
+ const draw = () => {
85
+ analyser.getByteFrequencyData(freqData);
86
+ const next = Array.from({ length: BARS }, (_, i) => {
87
+ let sum = 0;
88
+ for (let j = 0; j < binsPerBar; j++) sum += freqData[i * binsPerBar + j];
89
+ return Math.max(0.04, (sum / binsPerBar) / 255);
90
+ });
91
+ setBars(next);
92
+ rafRef.current = requestAnimationFrame(draw);
93
+ };
94
+ draw();
95
+ }, [isPlaying, sound.path, stop]);
96
+
97
+ const handleCardClick = useCallback(() => {
98
+ if (didDragRef.current) { didDragRef.current = false; return; }
99
+ if (onSelectAssign) {
100
+ onSelectAssign();
101
+ } else {
102
+ playSound();
103
+ }
104
+ }, [playSound, onSelectAssign]);
105
+
106
+ if (isOverlay) {
107
+ return (
108
+ <div
109
+ className="sf-card relative flex flex-col gap-2 p-3"
110
+ style={{
111
+ backgroundColor: 'var(--sf-panel2)',
112
+ border: '1px solid var(--sf-cyan)',
113
+ boxShadow: '0 0 24px rgba(0,229,255,0.5), 0 0 8px rgba(0,229,255,0.3)',
114
+ opacity: 1,
115
+ cursor: 'grabbing',
116
+ transform: 'rotate(1.5deg) scale(1.04)',
117
+ }}
118
+ >
119
+ <div className="flex items-end gap-px h-5">
120
+ {bars.map((v, i) => (
121
+ <div
122
+ key={i}
123
+ className="flex-1"
124
+ style={{ height: `${v * 100}%`, backgroundColor: 'var(--sf-cyan)', minWidth: '2px' }}
125
+ />
126
+ ))}
127
+ </div>
128
+ <span className="text-[10px] opacity-70 truncate leading-tight">{formatSoundName(sound.filename)}</span>
129
+ </div>
130
+ );
131
+ }
132
+
133
+ const lit = isHovered || isPlaying;
134
+
135
+ return (
136
+ <div
137
+ ref={setNodeRef}
138
+ // data-sf-hover: hover sound fires on enter
139
+ // data-no-ui-sound: suppress UI click sound (card click plays actual audio instead)
140
+ data-sf-hover
141
+ data-no-ui-sound
142
+ className="sf-card relative flex flex-col gap-2 p-3 transition-all select-none"
143
+ style={{
144
+ backgroundColor: lit ? 'rgba(0,229,255,0.06)' : 'var(--sf-panel2)',
145
+ border: `1px solid ${
146
+ isDragging ? 'var(--sf-cyan)'
147
+ : isPlaying ? 'var(--sf-cyan)'
148
+ : onSelectAssign && lit ? 'rgba(0,229,255,0.8)'
149
+ : onSelectAssign ? 'rgba(0,229,255,0.35)'
150
+ : lit ? 'rgba(0,229,255,0.55)'
151
+ : isAssigned ? 'rgba(0,255,136,0.4)'
152
+ : 'var(--sf-border)'
153
+ }`,
154
+ opacity: isDragging ? 0.3 : 1,
155
+ boxShadow: isPlaying ? '0 0 8px rgba(0,229,255,0.2)' : isDragging ? '0 0 16px rgba(0,229,255,0.4)' : undefined,
156
+ cursor: isDragging ? 'grabbing' : 'pointer',
157
+ }}
158
+ onMouseEnter={() => setIsHovered(true)}
159
+ onMouseLeave={() => setIsHovered(false)}
160
+ onClick={handleCardClick}
161
+ {...listeners}
162
+ {...attributes}
163
+ >
164
+ {isAssigned && !isPlaying && (
165
+ <div
166
+ className="absolute top-2 right-2 w-1.5 h-1.5 rounded-full"
167
+ style={{ backgroundColor: 'var(--sf-green)', boxShadow: '0 0 4px var(--sf-green)' }}
168
+ />
169
+ )}
170
+
171
+ <div className="flex items-end gap-px h-5">
172
+ {bars.map((v, i) => (
173
+ <div
174
+ key={i}
175
+ className="flex-1"
176
+ style={{
177
+ height: `${v * 100}%`,
178
+ backgroundColor: isPlaying ? 'var(--sf-cyan)' : lit ? 'rgba(0,229,255,0.55)' : 'rgba(0,229,255,0.3)',
179
+ minWidth: '2px',
180
+ transition: 'height 50ms linear',
181
+ }}
182
+ />
183
+ ))}
184
+ </div>
185
+
186
+ <div className="flex items-center justify-between gap-1">
187
+ <span
188
+ className="text-[10px] truncate leading-tight"
189
+ style={{ color: lit ? 'rgba(255,255,255,0.92)' : 'rgba(255,255,255,0.65)' }}
190
+ title={sound.filename}
191
+ >
192
+ {formatSoundName(sound.filename)}
193
+ </span>
194
+ {onSelectAssign && !isPlaying && (
195
+ <span className="shrink-0 text-[9px]" style={{ color: 'rgba(0,229,255,0.5)' }}>→</span>
196
+ )}
197
+ {isPlaying && (
198
+ <span className="shrink-0 text-[9px] animate-pulse" style={{ color: 'var(--sf-cyan)' }}>♪</span>
199
+ )}
200
+ </div>
201
+ </div>
202
+ );
203
+ }
@@ -0,0 +1,308 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+ import { previewUISound } from '@/lib/ui-audio';
5
+ import type { UITheme, UISlotMap } from '@/lib/types';
6
+
7
+ interface UISound {
8
+ path: string;
9
+ filename: string;
10
+ group: string;
11
+ }
12
+
13
+ type SlotName = 'click' | 'hover' | 'error' | 'pageChange' | 'toggle' | 'confirm';
14
+
15
+ const SLOTS: { name: SlotName; label: string; desc: string }[] = [
16
+ { name: 'hover', label: 'HOVER', desc: 'Mouse over interactive element' },
17
+ { name: 'click', label: 'CLICK', desc: 'Button or card clicked' },
18
+ { name: 'toggle', label: 'TOGGLE', desc: 'Expand / collapse sidebar items' },
19
+ { name: 'pageChange', label: 'PAGE CHANGE', desc: 'Tab / group navigation' },
20
+ { name: 'confirm', label: 'CONFIRM', desc: 'Sound assigned, settings saved' },
21
+ { name: 'error', label: 'ERROR', desc: 'Action failed or invalid' },
22
+ ];
23
+
24
+ interface Props {
25
+ uiTheme: UITheme;
26
+ uiSounds: Record<string, UISlotMap>;
27
+ onSave: (theme: UITheme, sounds: Record<string, UISlotMap>) => void;
28
+ onClose: () => void;
29
+ }
30
+
31
+ export function UISoundsModal({ uiTheme, uiSounds, onSave, onClose }: Props) {
32
+ const [sounds, setSounds] = useState<UISound[]>([]);
33
+ const [activeTheme, setActiveTheme] = useState<UITheme>(uiTheme === 'off' ? 'sc2' : uiTheme);
34
+ const [slots, setSlots] = useState<UISlotMap>(uiSounds[uiTheme === 'off' ? 'sc2' : uiTheme] ?? {});
35
+ const [activeSlot, setActiveSlot] = useState<SlotName>('hover');
36
+ const [playing, setPlaying] = useState<string | null>(null);
37
+
38
+ useEffect(() => {
39
+ fetch('/api/ui-sounds').then((r) => r.json()).then(setSounds).catch(console.error);
40
+ }, []);
41
+
42
+ // When theme changes, load existing slot config for that theme
43
+ const switchTheme = useCallback((theme: UITheme) => {
44
+ setActiveTheme(theme);
45
+ setSlots(uiSounds[theme] ?? {});
46
+ }, [uiSounds]);
47
+
48
+ const handlePreview = useCallback(async (path: string) => {
49
+ setPlaying(path);
50
+ await previewUISound(path, 0.5);
51
+ setTimeout(() => setPlaying((p) => p === path ? null : p), 800);
52
+ }, []);
53
+
54
+ const assignSound = useCallback((path: string) => {
55
+ setSlots((prev) => ({ ...prev, [activeSlot]: path }));
56
+ handlePreview(path);
57
+ }, [activeSlot, handlePreview]);
58
+
59
+ const clearSlot = useCallback((slot: SlotName) => {
60
+ setSlots((prev) => { const next = { ...prev }; delete next[slot]; return next; });
61
+ }, []);
62
+
63
+ const handleSave = useCallback(() => {
64
+ const nextSounds = { ...uiSounds, [activeTheme]: slots };
65
+ onSave(activeTheme, nextSounds);
66
+ onClose();
67
+ }, [uiSounds, activeTheme, slots, onSave, onClose]);
68
+
69
+ // Group sounds by their group
70
+ const grouped = sounds.reduce<Record<string, UISound[]>>((acc, s) => {
71
+ (acc[s.group] ??= []).push(s);
72
+ return acc;
73
+ }, {});
74
+
75
+ const formatName = (filename: string) =>
76
+ filename.replace(/\.(mp3|wav|ogg|m4a)$/i, '').replace(/-/g, ' ').replace(/_/g, ' ');
77
+
78
+ return (
79
+ <div
80
+ className="fixed inset-0 z-50 flex items-center justify-center"
81
+ style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}
82
+ onClick={(e) => e.target === e.currentTarget && onClose()}
83
+ >
84
+ <div
85
+ className="flex flex-col"
86
+ style={{
87
+ width: 680,
88
+ maxHeight: '85vh',
89
+ backgroundColor: 'var(--sf-panel)',
90
+ border: '1px solid var(--sf-cyan)',
91
+ boxShadow: '0 0 40px rgba(0,229,255,0.2)',
92
+ }}
93
+ >
94
+ {/* Header */}
95
+ <div
96
+ className="flex items-center justify-between px-5 py-3 shrink-0"
97
+ style={{ borderBottom: '1px solid var(--sf-border)' }}
98
+ >
99
+ <span className="sf-heading text-sm font-bold tracking-widest uppercase" style={{ color: 'var(--sf-cyan)' }}>
100
+ UI SFX CONFIGURATOR
101
+ </span>
102
+ <button
103
+ onClick={onClose}
104
+ data-no-ui-sound
105
+ className="text-xs opacity-40 hover:opacity-100 transition-opacity px-2 py-1"
106
+ style={{ color: 'var(--sf-cyan)' }}
107
+ >
108
+ ✕ CLOSE
109
+ </button>
110
+ </div>
111
+
112
+ {/* Theme selector */}
113
+ <div className="flex items-center gap-2 px-5 py-2 shrink-0" style={{ borderBottom: '1px solid var(--sf-border)' }}>
114
+ <span className="text-[10px] tracking-widest uppercase opacity-40">THEME</span>
115
+ {(['sc2', 'wc3', 'ff7', 'ff9'] as const).map((t) => (
116
+ <button
117
+ key={t}
118
+ data-sf-hover
119
+ onClick={() => switchTheme(t)}
120
+ className="px-3 py-0.5 text-[10px] sf-heading font-semibold uppercase tracking-wider transition-all"
121
+ style={{
122
+ border: `1px solid ${activeTheme === t ? 'var(--sf-cyan)' : 'var(--sf-border)'}`,
123
+ color: activeTheme === t ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.35)',
124
+ backgroundColor: activeTheme === t ? 'rgba(0,229,255,0.08)' : 'transparent',
125
+ }}
126
+ >
127
+ {t.toUpperCase()}
128
+ </button>
129
+ ))}
130
+ <span className="text-[10px] opacity-30 ml-2">
131
+ Click a slot on the left, then pick a sound on the right to assign.
132
+ </span>
133
+ </div>
134
+
135
+ {/* Body: 2-col */}
136
+ <div className="flex flex-1 overflow-hidden">
137
+ {/* Left: slots */}
138
+ <div className="flex flex-col gap-0 shrink-0" style={{ width: 240, borderRight: '1px solid var(--sf-border)' }}>
139
+ <div className="px-4 pt-3 pb-1 text-[10px] uppercase tracking-widest opacity-40">
140
+ EVENT SLOTS
141
+ </div>
142
+ {SLOTS.map(({ name, label, desc }) => {
143
+ const assigned = slots[name];
144
+ const isActive = activeSlot === name;
145
+ return (
146
+ <div
147
+ key={name}
148
+ data-sf-hover
149
+ onClick={() => setActiveSlot(name)}
150
+ className="flex flex-col gap-1 px-4 py-3 cursor-pointer transition-all"
151
+ style={{
152
+ borderLeft: `3px solid ${isActive ? 'var(--sf-cyan)' : 'transparent'}`,
153
+ backgroundColor: isActive ? 'rgba(0,229,255,0.06)' : 'transparent',
154
+ borderBottom: '1px solid var(--sf-border)',
155
+ }}
156
+ >
157
+ <div className="flex items-center justify-between">
158
+ <span
159
+ className="text-[11px] sf-heading font-semibold tracking-wider"
160
+ style={{ color: isActive ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.7)' }}
161
+ >
162
+ {label}
163
+ </span>
164
+ <div className="flex items-center gap-1">
165
+ {assigned && (
166
+ <>
167
+ <button
168
+ data-no-ui-sound
169
+ onClick={(e) => { e.stopPropagation(); handlePreview(assigned); }}
170
+ className="text-[9px] px-1.5 py-0.5 transition-all"
171
+ style={{
172
+ border: '1px solid rgba(0,229,255,0.4)',
173
+ color: playing === assigned ? 'var(--sf-cyan)' : 'rgba(0,229,255,0.6)',
174
+ }}
175
+ >
176
+
177
+ </button>
178
+ <button
179
+ data-no-ui-sound
180
+ onClick={(e) => { e.stopPropagation(); clearSlot(name); }}
181
+ className="text-[9px] px-1.5 py-0.5 transition-all opacity-40 hover:opacity-100"
182
+ style={{ border: '1px solid rgba(255,255,255,0.2)', color: 'white' }}
183
+ >
184
+
185
+ </button>
186
+ </>
187
+ )}
188
+ </div>
189
+ </div>
190
+ <span className="text-[9px] opacity-40">{desc}</span>
191
+ {assigned ? (
192
+ <span
193
+ className="text-[9px] truncate"
194
+ style={{ color: 'var(--sf-green)' }}
195
+ title={assigned}
196
+ >
197
+ {formatName(assigned.split('/').pop() ?? assigned)}
198
+ </span>
199
+ ) : (
200
+ <span className="text-[9px] opacity-25 italic">— unassigned (uses default) —</span>
201
+ )}
202
+ </div>
203
+ );
204
+ })}
205
+
206
+ {/* Default paths note */}
207
+ <div className="px-4 pt-3 pb-2">
208
+ <div className="text-[9px] opacity-25 leading-relaxed">
209
+ Defaults: ui/{activeTheme}/hover.mp3<br />
210
+ ui/{activeTheme}/click.mp3<br />
211
+ ui/{activeTheme}/error.mp3
212
+ </div>
213
+ </div>
214
+ </div>
215
+
216
+ {/* Right: sound browser */}
217
+ <div className="flex flex-col flex-1 overflow-hidden">
218
+ <div className="px-4 pt-3 pb-1 text-[10px] uppercase tracking-widest opacity-40 shrink-0">
219
+ AVAILABLE SOUNDS — click to preview & assign to{' '}
220
+ <span style={{ color: 'var(--sf-cyan)' }}>{activeSlot.toUpperCase()}</span>
221
+ </div>
222
+ <div className="flex-1 overflow-y-auto px-3 pb-3">
223
+ {Object.entries(grouped).sort().map(([group, groupSounds]) => (
224
+ <div key={group} className="mb-4">
225
+ <div
226
+ className="text-[9px] uppercase tracking-widest mb-2 px-1 py-0.5"
227
+ style={{ color: 'var(--sf-cyan)', opacity: 0.5, borderBottom: '1px solid var(--sf-border)' }}
228
+ >
229
+ {group.replace(/-/g, ' ')}
230
+ </div>
231
+ <div className="flex flex-col gap-1">
232
+ {groupSounds.map((s) => {
233
+ const isAssignedHere = Object.values(slots).includes(s.path);
234
+ const isPlaying = playing === s.path;
235
+ return (
236
+ <button
237
+ key={s.path}
238
+ data-no-ui-sound
239
+ onClick={() => assignSound(s.path)}
240
+ className="flex items-center gap-2 px-3 py-2 text-left transition-all"
241
+ style={{
242
+ border: `1px solid ${isAssignedHere ? 'var(--sf-green)' : isPlaying ? 'var(--sf-cyan)' : 'var(--sf-border)'}`,
243
+ backgroundColor: isPlaying ? 'rgba(0,229,255,0.08)' : isAssignedHere ? 'rgba(0,255,136,0.06)' : 'transparent',
244
+ color: isPlaying ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.75)',
245
+ }}
246
+ >
247
+ <span
248
+ className="text-[9px] shrink-0 px-1"
249
+ style={{ color: isPlaying ? 'var(--sf-cyan)' : 'rgba(0,229,255,0.5)' }}
250
+ >
251
+ {isPlaying ? '♪' : '▶'}
252
+ </span>
253
+ <span className="text-[11px] truncate">{formatName(s.filename)}</span>
254
+ {isAssignedHere && (
255
+ <span
256
+ className="ml-auto text-[9px] shrink-0"
257
+ style={{ color: 'var(--sf-green)' }}
258
+ >
259
+ {Object.entries(slots).find(([, v]) => v === s.path)?.[0].toUpperCase()}
260
+ </span>
261
+ )}
262
+ </button>
263
+ );
264
+ })}
265
+ </div>
266
+ </div>
267
+ ))}
268
+ {sounds.length === 0 && (
269
+ <div className="text-xs opacity-30 text-center py-8">LOADING...</div>
270
+ )}
271
+ </div>
272
+ </div>
273
+ </div>
274
+
275
+ {/* Footer */}
276
+ <div
277
+ className="flex items-center justify-end gap-3 px-5 py-3 shrink-0"
278
+ style={{ borderTop: '1px solid var(--sf-border)' }}
279
+ >
280
+ <button
281
+ data-no-ui-sound
282
+ onClick={onClose}
283
+ className="px-4 py-1.5 text-xs sf-heading uppercase tracking-wider transition-all"
284
+ style={{
285
+ border: '1px solid var(--sf-border)',
286
+ color: 'rgba(255,255,255,0.4)',
287
+ }}
288
+ >
289
+ CANCEL
290
+ </button>
291
+ <button
292
+ data-sf-hover
293
+ onClick={handleSave}
294
+ className="px-5 py-1.5 text-xs sf-heading font-semibold uppercase tracking-wider transition-all"
295
+ style={{
296
+ border: '1px solid var(--sf-cyan)',
297
+ color: 'var(--sf-cyan)',
298
+ backgroundColor: 'rgba(0,229,255,0.1)',
299
+ boxShadow: '0 0 10px rgba(0,229,255,0.15)',
300
+ }}
301
+ >
302
+ SAVE CONFIG
303
+ </button>
304
+ </div>
305
+ </div>
306
+ </div>
307
+ );
308
+ }
@@ -0,0 +1,87 @@
1
+ export type HookEvent =
2
+ | 'SessionStart'
3
+ | 'SessionEnd'
4
+ | 'Stop'
5
+ | 'SubagentStop'
6
+ | 'PreToolUse'
7
+ | 'PostToolUse'
8
+ | 'PostToolUseFailure'
9
+ | 'Notification'
10
+ | 'PreCompact';
11
+
12
+ export type SkillHookEvent = 'PreToolUse' | 'PostToolUse';
13
+
14
+ export interface AgentConfig {
15
+ enabled: boolean;
16
+ hooks: Partial<Record<HookEvent, string>>;
17
+ }
18
+
19
+ export interface SkillConfig {
20
+ enabled: boolean;
21
+ hooks: Partial<Record<SkillHookEvent, string>>;
22
+ }
23
+
24
+ export type UITheme = 'sc2' | 'wc3' | 'ff7' | 'ff9' | 'off';
25
+
26
+ export interface SelectMode {
27
+ scope: string; // 'global' | agent-name | 'skill/qualifiedName'
28
+ event: string; // HookEvent | SkillHookEvent
29
+ label: string; // e.g. "SESSION START"
30
+ }
31
+
32
+ export interface UISlotMap {
33
+ click?: string; // relative path under ~/code/claude-sounds/
34
+ hover?: string;
35
+ error?: string;
36
+ pageChange?: string; // tab/group navigation
37
+ toggle?: string; // expand/collapse sidebar items
38
+ confirm?: string; // sound assigned, settings saved
39
+ }
40
+
41
+ export interface SoundAssignments {
42
+ global: Partial<Record<HookEvent, string>>;
43
+ agents: Record<string, AgentConfig>;
44
+ skills: Record<string, SkillConfig>;
45
+ settings: {
46
+ masterVolume: number;
47
+ enabled: boolean;
48
+ theme: 'terran' | 'protoss' | 'zerg';
49
+ uiTheme: UITheme;
50
+ uiSounds?: Record<string, UISlotMap>; // theme -> slot -> path
51
+ };
52
+ }
53
+
54
+ export interface SoundAsset {
55
+ id: string;
56
+ filename: string;
57
+ category: string;
58
+ subcategory: string;
59
+ path: string;
60
+ waveform: number[]; // 16 RMS values, normalized 1–10
61
+ }
62
+
63
+ export interface AgentInfo {
64
+ name: string;
65
+ filename: string;
66
+ description?: string;
67
+ model?: string;
68
+ tools?: string;
69
+ color?: string;
70
+ prompt?: string;
71
+ }
72
+
73
+ export interface SkillInfo {
74
+ name: string; // directory name, e.g. "hook-development"
75
+ qualifiedName: string; // "plugin-dev:hook-development" or "ask-gemini" (user skill)
76
+ description: string;
77
+ namespace?: string; // "plugin-dev" for plugin skills, undefined for user skills
78
+ }
79
+
80
+ export interface AgentFormData {
81
+ name: string;
82
+ description: string;
83
+ model: string;
84
+ tools: string;
85
+ color: string;
86
+ prompt: string;
87
+ }