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.
- package/.claude-plugin/plugin.json +7 -0
- package/README.md +95 -0
- package/bin/agentcraft.js +12 -0
- package/commands/agentcraft.md +46 -0
- package/hooks/hooks.json +13 -0
- package/hooks/play-sound.sh +69 -0
- package/opencode.js +87 -0
- package/package.json +19 -0
- package/screenshot.jpg +0 -0
- package/social-share-og.jpg +0 -0
- package/social-share-tw.jpg +0 -0
- package/social-share.png +0 -0
- package/web/README.md +36 -0
- package/web/app/api/agents/[name]/route.ts +44 -0
- package/web/app/api/agents/route.ts +62 -0
- package/web/app/api/assignments/route.ts +53 -0
- package/web/app/api/audio/[...path]/route.ts +35 -0
- package/web/app/api/health/route.ts +5 -0
- package/web/app/api/preview/route.ts +22 -0
- package/web/app/api/skills/route.ts +83 -0
- package/web/app/api/sounds/route.ts +106 -0
- package/web/app/api/ui-sounds/route.ts +44 -0
- package/web/app/favicon.ico +0 -0
- package/web/app/globals.css +99 -0
- package/web/app/icon.svg +13 -0
- package/web/app/layout.tsx +19 -0
- package/web/app/opengraph-image.tsx +96 -0
- package/web/app/page.tsx +268 -0
- package/web/bun.lock +292 -0
- package/web/components/agent-form.tsx +190 -0
- package/web/components/agent-roster-panel.tsx +502 -0
- package/web/components/assignment-log-panel.tsx +109 -0
- package/web/components/hook-slot.tsx +149 -0
- package/web/components/hud-header.tsx +135 -0
- package/web/components/sound-browser-panel.tsx +206 -0
- package/web/components/sound-unit.tsx +203 -0
- package/web/components/ui-sounds-modal.tsx +308 -0
- package/web/lib/types.ts +87 -0
- package/web/lib/ui-audio.ts +126 -0
- package/web/lib/utils.ts +98 -0
- package/web/next.config.ts +8 -0
- package/web/package.json +37 -0
- package/web/postcss.config.mjs +1 -0
- package/web/public/file.svg +1 -0
- package/web/public/fonts/starcraft-normal.ttf +0 -0
- package/web/public/globe.svg +1 -0
- package/web/public/next.svg +1 -0
- package/web/public/vercel.svg +1 -0
- package/web/public/window.svg +1 -0
- 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
|
+
}
|
package/web/lib/types.ts
ADDED
|
@@ -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
|
+
}
|