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,109 @@
1
+ 'use client';
2
+
3
+ import { getEventLabel } from '@/lib/utils';
4
+ import type { SoundAssignments, HookEvent } from '@/lib/types';
5
+
6
+ interface AssignmentEntry {
7
+ scope: string;
8
+ event: HookEvent;
9
+ sound: string;
10
+ }
11
+
12
+ interface AssignmentLogPanelProps {
13
+ assignments: SoundAssignments;
14
+ isDirty: boolean;
15
+ onClear: (scope: string, event: HookEvent) => void;
16
+ onSave: () => void;
17
+ }
18
+
19
+ export function AssignmentLogPanel({ assignments, isDirty, onClear, onSave }: AssignmentLogPanelProps) {
20
+ const entries: AssignmentEntry[] = [];
21
+
22
+ // Global entries
23
+ for (const [event, sound] of Object.entries(assignments.global)) {
24
+ if (sound) entries.push({ scope: 'GLOBAL', event: event as HookEvent, sound });
25
+ }
26
+
27
+ // Agent entries
28
+ for (const [agentName, config] of Object.entries(assignments.agents)) {
29
+ for (const [event, sound] of Object.entries(config.hooks)) {
30
+ if (sound) entries.push({ scope: agentName, event: event as HookEvent, sound });
31
+ }
32
+ }
33
+
34
+ return (
35
+ <div className="flex flex-col overflow-hidden">
36
+ <div className="shrink-0 px-4 py-2 border-b" style={{ borderColor: 'var(--sf-border)', backgroundColor: 'var(--sf-panel)' }}>
37
+ <div className="sf-heading text-xs font-semibold tracking-widest uppercase" style={{ color: 'var(--sf-cyan)' }}>
38
+ ASSIGNMENT LOG
39
+ </div>
40
+ {isDirty && (
41
+ <div className="text-[10px] mt-1" style={{ color: 'var(--sf-gold)' }}>
42
+ &#x25CF; UNSAVED CHANGES
43
+ </div>
44
+ )}
45
+ </div>
46
+
47
+ {/* Assignment table */}
48
+ <div className="flex-1 overflow-y-auto">
49
+ {entries.length === 0 ? (
50
+ <div className="text-xs opacity-30 text-center py-8">NO ASSIGNMENTS</div>
51
+ ) : (
52
+ <table className="w-full text-[10px]">
53
+ <thead className="sticky top-0" style={{ backgroundColor: 'var(--sf-panel)' }}>
54
+ <tr>
55
+ <th className="text-left px-3 py-1 opacity-40 uppercase tracking-wider font-normal">Scope</th>
56
+ <th className="text-left px-2 py-1 opacity-40 uppercase tracking-wider font-normal">Event</th>
57
+ <th className="text-left px-2 py-1 opacity-40 uppercase tracking-wider font-normal">Sound</th>
58
+ <th className="px-2 py-1 w-6" />
59
+ </tr>
60
+ </thead>
61
+ <tbody>
62
+ {entries.map((entry, i) => (
63
+ <tr
64
+ key={i}
65
+ className="border-b transition-all hover:bg-white/5"
66
+ style={{ borderColor: 'rgba(0,229,255,0.06)' }}
67
+ >
68
+ <td className="px-3 py-1.5 truncate max-w-[80px]" style={{ color: entry.scope === 'GLOBAL' ? 'var(--sf-gold)' : undefined }}>
69
+ {entry.scope}
70
+ </td>
71
+ <td className="px-2 py-1.5 truncate max-w-[90px] opacity-70">
72
+ {getEventLabel(entry.event)}
73
+ </td>
74
+ <td className="px-2 py-1.5 truncate max-w-[100px] opacity-60">
75
+ {entry.sound.split('/').pop()}
76
+ </td>
77
+ <td className="px-2 py-1.5 text-center">
78
+ <button
79
+ onClick={() => onClear(entry.scope === 'GLOBAL' ? 'global' : entry.scope, entry.event)}
80
+ className="opacity-40 hover:opacity-80 transition-opacity text-[10px]"
81
+ style={{ color: 'var(--sf-alert)' }}
82
+ >
83
+ &#x2715;
84
+ </button>
85
+ </td>
86
+ </tr>
87
+ ))}
88
+ </tbody>
89
+ </table>
90
+ )}
91
+ </div>
92
+
93
+ {/* Save button */}
94
+ <div className="shrink-0 p-3 border-t" style={{ borderColor: 'var(--sf-border)' }}>
95
+ <button
96
+ onClick={onSave}
97
+ className={`w-full py-2 text-xs sf-heading font-bold uppercase tracking-widest transition-all ${isDirty ? 'sf-pulse-gold' : ''}`}
98
+ style={{
99
+ border: `1px solid ${isDirty ? 'var(--sf-gold)' : 'var(--sf-border)'}`,
100
+ color: isDirty ? 'var(--sf-gold)' : 'rgba(255,255,255,0.3)',
101
+ backgroundColor: isDirty ? 'rgba(255,192,0,0.06)' : 'transparent',
102
+ }}
103
+ >
104
+ {isDirty ? '\u2B21 ESTABLISH UPLINK' : 'SYNCED'}
105
+ </button>
106
+ </div>
107
+ </div>
108
+ );
109
+ }
@@ -0,0 +1,149 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { useDroppable } from '@dnd-kit/core';
5
+ import { getEventLabel } from '@/lib/utils';
6
+ import { playUISound } from '@/lib/ui-audio';
7
+ import type { HookEvent, SelectMode } from '@/lib/types';
8
+
9
+ interface HookSlotProps {
10
+ event: HookEvent | string;
11
+ scope: string;
12
+ assignedSound?: string;
13
+ onClear: () => void;
14
+ onPreview: (path: string) => void;
15
+ selectMode: SelectMode | null;
16
+ onSelect: () => void; // called when empty slot is clicked to enter select mode
17
+ }
18
+
19
+ export function HookSlot({ event, scope, assignedSound, onClear, onPreview, selectMode, onSelect }: HookSlotProps) {
20
+ const dropId = `${scope}:${event}`;
21
+ const { isOver, setNodeRef } = useDroppable({ id: dropId });
22
+ const [isHovered, setIsHovered] = useState(false);
23
+
24
+ const label = getEventLabel(event as HookEvent);
25
+ const filename = assignedSound ? assignedSound.split('/').pop() ?? assignedSound : null;
26
+ const isSelected = selectMode?.scope === scope && selectMode?.event === event;
27
+ const isSelectModeActive = !!selectMode;
28
+
29
+ const handleContainerClick = () => {
30
+ if (assignedSound) {
31
+ onPreview(assignedSound);
32
+ } else {
33
+ onSelect();
34
+ }
35
+ };
36
+
37
+ const handlePlayClick = (e: React.MouseEvent) => {
38
+ e.stopPropagation();
39
+ if (assignedSound) onPreview(assignedSound);
40
+ };
41
+
42
+ const handleClearClick = (e: React.MouseEvent) => {
43
+ e.stopPropagation();
44
+ onClear();
45
+ };
46
+
47
+ // Border logic
48
+ let borderColor: string;
49
+ let borderStyle: 'solid' | 'dashed';
50
+ let bgColor: string;
51
+
52
+ if (isOver) {
53
+ borderColor = 'var(--sf-cyan)';
54
+ borderStyle = 'solid';
55
+ bgColor = 'rgba(0,229,255,0.12)';
56
+ } else if (isSelected) {
57
+ borderColor = 'var(--sf-cyan)';
58
+ borderStyle = 'dashed';
59
+ bgColor = 'rgba(0,229,255,0.08)';
60
+ } else if (assignedSound) {
61
+ borderColor = isHovered ? 'rgba(0,229,255,0.7)' : 'rgba(0,229,255,0.4)';
62
+ borderStyle = 'solid';
63
+ bgColor = isHovered ? 'rgba(0,229,255,0.08)' : 'rgba(0,229,255,0.04)';
64
+ } else if (isSelectModeActive) {
65
+ // Another slot is being selected — mute everything
66
+ borderColor = 'rgba(0,229,255,0.06)';
67
+ borderStyle = 'dashed';
68
+ bgColor = 'transparent';
69
+ } else {
70
+ borderColor = isHovered ? 'rgba(0,229,255,0.25)' : 'rgba(0,229,255,0.12)';
71
+ borderStyle = 'dashed';
72
+ bgColor = isHovered ? 'rgba(0,229,255,0.03)' : 'transparent';
73
+ }
74
+
75
+ return (
76
+ <div
77
+ ref={setNodeRef}
78
+ data-sf-hover
79
+ {...(assignedSound ? { 'data-no-ui-sound': '' } : {})}
80
+ className="flex items-center justify-between px-2 py-1 text-xs transition-all cursor-pointer select-none"
81
+ style={{
82
+ borderWidth: '1px',
83
+ borderStyle,
84
+ borderColor,
85
+ backgroundColor: bgColor,
86
+ boxShadow: isOver ? 'inset 0 0 8px rgba(0,229,255,0.08)' : isSelected ? '0 0 6px rgba(0,229,255,0.15)' : undefined,
87
+ minHeight: '28px',
88
+ opacity: isSelectModeActive && !isSelected ? 0.45 : 1,
89
+ }}
90
+ onMouseEnter={() => { setIsHovered(true); playUISound('hover', 0.15); }}
91
+ onMouseLeave={() => setIsHovered(false)}
92
+ onClick={handleContainerClick}
93
+ >
94
+ <span
95
+ className="text-[10px] uppercase tracking-wider shrink-0 mr-2 transition-opacity"
96
+ style={{ color: 'var(--sf-cyan)', opacity: isOver ? 0.9 : 0.5 }}
97
+ >
98
+ {label}
99
+ </span>
100
+
101
+ {isOver ? (
102
+ <span className="text-[10px] tracking-wider" style={{ color: 'var(--sf-cyan)' }}>
103
+ RELEASE TO ASSIGN ▼
104
+ </span>
105
+ ) : isSelected ? (
106
+ <span className="text-[10px] tracking-wider animate-pulse" style={{ color: 'var(--sf-cyan)' }}>
107
+ CLICK A SOUND →
108
+ </span>
109
+ ) : assignedSound ? (
110
+ <div className="flex items-center gap-2 overflow-hidden">
111
+ <span
112
+ className="truncate text-[11px] transition-opacity"
113
+ style={{ opacity: isHovered ? 1 : 0.7, color: isHovered ? 'rgba(255,255,255,0.95)' : 'inherit' }}
114
+ title={assignedSound}
115
+ >
116
+ {filename}
117
+ </span>
118
+ <div className="flex items-center gap-1 shrink-0" style={{ opacity: isHovered ? 1 : 0 }}>
119
+ <button
120
+ data-no-ui-sound
121
+ onClick={handlePlayClick}
122
+ className="text-[10px] px-1 transition-opacity"
123
+ style={{ color: 'var(--sf-cyan)' }}
124
+ title="Preview"
125
+ >
126
+ &#x25B6;
127
+ </button>
128
+ <button
129
+ data-no-ui-sound
130
+ onClick={handleClearClick}
131
+ className="text-[10px] px-1 transition-opacity"
132
+ style={{ color: 'var(--sf-alert)' }}
133
+ title="Clear"
134
+ >
135
+ &#x2715;
136
+ </button>
137
+ </div>
138
+ </div>
139
+ ) : (
140
+ <span
141
+ className="text-[10px] italic transition-opacity"
142
+ style={{ opacity: isHovered ? 0.5 : 0.2 }}
143
+ >
144
+ {isHovered ? 'click to assign' : 'empty'}
145
+ </span>
146
+ )}
147
+ </div>
148
+ );
149
+ }
@@ -0,0 +1,135 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import type { UITheme } from '@/lib/types';
5
+
6
+ interface HudHeaderProps {
7
+ enabled: boolean;
8
+ onToggle: () => void;
9
+ uiTheme: UITheme;
10
+ onUiThemeChange: (theme: UITheme) => void;
11
+ onConfigureUISounds: () => void;
12
+ }
13
+
14
+ const UI_THEMES: { value: UITheme; label: string }[] = [
15
+ { value: 'sc2', label: 'SC2' },
16
+ { value: 'wc3', label: 'WC3' },
17
+ { value: 'ff7', label: 'FF7' },
18
+ { value: 'ff9', label: 'FF9' },
19
+ { value: 'off', label: 'OFF' },
20
+ ];
21
+
22
+ export function HudHeader({ enabled, onToggle, uiTheme, onUiThemeChange, onConfigureUISounds }: HudHeaderProps) {
23
+ const [showDropdown, setShowDropdown] = useState(false);
24
+ const activeLabel = UI_THEMES.find((t) => t.value === uiTheme)?.label ?? uiTheme.toUpperCase();
25
+
26
+ return (
27
+ <header className="shrink-0 flex items-center justify-between px-6 py-3 border-b" style={{ borderColor: 'var(--sf-border)', backgroundColor: 'var(--sf-panel)' }}>
28
+ <div className="flex items-center gap-4">
29
+ <h1 className="sf-logo text-lg font-bold tracking-widest uppercase" style={{ color: 'var(--sf-cyan)' }}>
30
+ AGENTCRAFT
31
+ </h1>
32
+ <span className="text-xs opacity-40" style={{ color: 'var(--sf-cyan)' }}>v0.0.3</span>
33
+ <div className="h-4 w-px opacity-20" style={{ backgroundColor: 'var(--sf-cyan)' }} />
34
+ <span className="text-xs opacity-60">AUDIO ASSIGNMENT TERMINAL</span>
35
+ </div>
36
+
37
+ <div className="flex items-center gap-4">
38
+ {/* UI sound theme dropdown */}
39
+ <div className="flex items-center gap-2">
40
+ <span className="text-[10px] tracking-widest uppercase opacity-40">UI SFX</span>
41
+
42
+ <div className="relative">
43
+ {/* Click-away overlay */}
44
+ {showDropdown && (
45
+ <div className="fixed inset-0 z-10" onClick={() => setShowDropdown(false)} />
46
+ )}
47
+
48
+ {/* Trigger */}
49
+ <button
50
+ data-sf-hover
51
+ data-no-ui-sound
52
+ onClick={() => setShowDropdown(!showDropdown)}
53
+ className="flex items-center gap-1.5 px-3 py-0.5 text-[10px] sf-heading font-medium uppercase tracking-wider transition-all"
54
+ style={{
55
+ position: 'relative',
56
+ zIndex: 11,
57
+ border: `1px solid ${showDropdown ? 'var(--sf-cyan)' : 'var(--sf-border)'}`,
58
+ color: uiTheme === 'off' ? 'rgba(255,255,255,0.3)' : 'var(--sf-cyan)',
59
+ backgroundColor: showDropdown ? 'rgba(0,229,255,0.08)' : 'transparent',
60
+ minWidth: '3.5rem',
61
+ }}
62
+ >
63
+ <span>{activeLabel}</span>
64
+ <span style={{ opacity: 0.5 }}>{showDropdown ? '▴' : '▾'}</span>
65
+ </button>
66
+
67
+ {/* Dropdown */}
68
+ {showDropdown && (
69
+ <div
70
+ className="absolute right-0 top-full mt-1"
71
+ style={{
72
+ zIndex: 20,
73
+ border: '1px solid var(--sf-border)',
74
+ backgroundColor: 'var(--sf-panel)',
75
+ boxShadow: '0 4px 24px rgba(0,0,0,0.6)',
76
+ minWidth: '5rem',
77
+ }}
78
+ >
79
+ {UI_THEMES.map((t, i) => (
80
+ <button
81
+ key={t.value}
82
+ data-sf-hover
83
+ onClick={() => {
84
+ onUiThemeChange(t.value);
85
+ setShowDropdown(false);
86
+ }}
87
+ className="w-full flex items-center gap-1.5 px-3 py-1.5 text-left text-[10px] sf-heading font-medium uppercase tracking-wider transition-all"
88
+ style={{
89
+ color: uiTheme === t.value ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.5)',
90
+ backgroundColor: uiTheme === t.value ? 'rgba(0,229,255,0.08)' : 'transparent',
91
+ borderBottom: i < UI_THEMES.length - 1 ? '1px solid var(--sf-border)' : 'none',
92
+ }}
93
+ >
94
+ <span style={{ opacity: uiTheme === t.value ? 1 : 0 }}>▸</span>
95
+ {t.label}
96
+ </button>
97
+ ))}
98
+ </div>
99
+ )}
100
+ </div>
101
+
102
+ {/* Configure gear */}
103
+ <button
104
+ data-sf-hover
105
+ onClick={onConfigureUISounds}
106
+ title="Configure UI sound assignments"
107
+ className="px-2 py-0.5 text-[10px] sf-heading font-medium transition-all"
108
+ style={{
109
+ border: '1px solid var(--sf-border)',
110
+ color: 'rgba(255,255,255,0.35)',
111
+ }}
112
+ >
113
+
114
+ </button>
115
+ </div>
116
+
117
+ <div className="h-4 w-px opacity-20" style={{ backgroundColor: 'var(--sf-cyan)' }} />
118
+
119
+ {/* Master enable/disable */}
120
+ <button
121
+ onClick={onToggle}
122
+ className="flex items-center gap-2 px-4 py-1 text-xs sf-heading font-semibold uppercase tracking-wider transition-all"
123
+ style={{
124
+ border: `1px solid ${enabled ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.2)'}`,
125
+ color: enabled ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.4)',
126
+ backgroundColor: enabled ? 'rgba(0,229,255,0.08)' : 'transparent',
127
+ }}
128
+ >
129
+ <span className="inline-block w-2 h-2 rounded-full" style={{ backgroundColor: enabled ? 'var(--sf-green)' : 'rgba(255,255,255,0.2)' }} />
130
+ {enabled ? 'ONLINE' : 'OFFLINE'}
131
+ </button>
132
+ </div>
133
+ </header>
134
+ );
135
+ }
@@ -0,0 +1,206 @@
1
+ 'use client';
2
+
3
+ import { useState, useMemo, useCallback } from 'react';
4
+ import { SoundUnit } from './sound-unit';
5
+ import { groupSoundsByCategory, getGroupLabel, getSubTabLabel } from '@/lib/utils';
6
+ import { playUISound } from '@/lib/ui-audio';
7
+ import type { SoundAsset, SoundAssignments, SelectMode } from '@/lib/types';
8
+
9
+ interface SoundBrowserPanelProps {
10
+ sounds: SoundAsset[];
11
+ assignments: SoundAssignments;
12
+ onPreview: (path: string) => void;
13
+ selectMode: SelectMode | null;
14
+ onSelectModeAssign: (path: string) => void;
15
+ onClearSelectMode: () => void;
16
+ }
17
+
18
+ export function SoundBrowserPanel({ sounds, assignments, onPreview, selectMode, onSelectModeAssign, onClearSelectMode }: SoundBrowserPanelProps) {
19
+ const [activeGroup, setActiveGroup] = useState<string>('sc2');
20
+ const [activeCategory, setActiveCategory] = useState<string>('sc2/terran');
21
+ const [search, setSearch] = useState('');
22
+
23
+ const handleGroupChange = useCallback((group: string) => {
24
+ setActiveGroup(group);
25
+ playUISound('pageChange', 0.4);
26
+ }, []);
27
+
28
+ const handleCategoryChange = useCallback((cat: string) => {
29
+ setActiveCategory(cat);
30
+ playUISound('pageChange', 0.35);
31
+ }, []);
32
+
33
+ const allGroups = useMemo(() => {
34
+ return [...new Set(sounds.map((s) => s.category.split('/')[0]))].sort();
35
+ }, [sounds]);
36
+
37
+ const groupCategories = useMemo(() => {
38
+ return [...new Set(
39
+ sounds.filter((s) => s.category.split('/')[0] === activeGroup).map((s) => s.category)
40
+ )].sort();
41
+ }, [sounds, activeGroup]);
42
+
43
+ // If activeCategory doesn't belong to current group, use first category of the group
44
+ const effectiveCategory = groupCategories.includes(activeCategory)
45
+ ? activeCategory
46
+ : (groupCategories[0] ?? '');
47
+
48
+ const assignedPaths = useMemo(() => {
49
+ const paths = new Set<string>();
50
+ Object.values(assignments.global).forEach((p) => p && paths.add(p));
51
+ Object.values(assignments.agents).forEach((a) => {
52
+ Object.values(a.hooks).forEach((p) => p && paths.add(p));
53
+ });
54
+ return paths;
55
+ }, [assignments]);
56
+
57
+ const isSearching = search.trim().length > 0;
58
+
59
+ const filteredSounds = useMemo(() => {
60
+ if (isSearching) {
61
+ // Global search across all sounds
62
+ const q = search.toLowerCase();
63
+ return sounds.filter((s) =>
64
+ s.filename.toLowerCase().includes(q) ||
65
+ s.category.toLowerCase().includes(q) ||
66
+ s.subcategory.toLowerCase().includes(q)
67
+ );
68
+ }
69
+ // Normal tab-filtered view
70
+ return sounds.filter((s) => s.category === effectiveCategory);
71
+ }, [sounds, effectiveCategory, search, isSearching]);
72
+
73
+ const grouped = useMemo(() => groupSoundsByCategory(filteredSounds), [filteredSounds]);
74
+
75
+ const showSubTabs = !isSearching && groupCategories.length > 1;
76
+
77
+ return (
78
+ <div className="flex flex-col overflow-hidden" style={{ borderLeft: '1px solid var(--sf-border)', borderRight: '1px solid var(--sf-border)' }}>
79
+ {/* Header */}
80
+ <div className="shrink-0 px-4 py-2 border-b" style={{ borderColor: 'var(--sf-border)', backgroundColor: 'var(--sf-panel)' }}>
81
+ <div className="sf-heading text-xs font-semibold tracking-widest uppercase mb-2" style={{ color: 'var(--sf-cyan)' }}>
82
+ SOUND LIBRARY
83
+ </div>
84
+
85
+ {/* Search */}
86
+ <input
87
+ type="text"
88
+ placeholder="SEARCH ALL SOUNDS..."
89
+ value={search}
90
+ onChange={(e) => setSearch(e.target.value)}
91
+ onKeyDown={(e) => e.key === 'Escape' && setSearch('')}
92
+ className="w-full px-3 py-1 text-xs bg-transparent outline-none"
93
+ style={{
94
+ border: `1px solid ${isSearching ? 'var(--sf-cyan)' : 'var(--sf-border)'}`,
95
+ color: 'var(--sf-cyan)',
96
+ boxShadow: isSearching ? '0 0 6px rgba(0,229,255,0.15)' : undefined,
97
+ }}
98
+ />
99
+
100
+ {/* Group tabs */}
101
+ <div className="flex gap-1 mt-2 overflow-x-auto" style={{ opacity: isSearching ? 0.3 : 1, pointerEvents: isSearching ? 'none' : 'auto' }}>
102
+ {allGroups.map((group) => (
103
+ <button
104
+ key={group}
105
+ data-sf-hover
106
+ onClick={() => handleGroupChange(group)}
107
+ className="shrink-0 px-3 py-1 text-[10px] sf-heading font-semibold uppercase tracking-wider transition-all"
108
+ style={{
109
+ border: `1px solid ${activeGroup === group ? 'var(--sf-cyan)' : 'var(--sf-border)'}`,
110
+ color: activeGroup === group ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.4)',
111
+ backgroundColor: activeGroup === group ? 'rgba(0,229,255,0.08)' : 'transparent',
112
+ }}
113
+ >
114
+ {getGroupLabel(group)}
115
+ </button>
116
+ ))}
117
+ </div>
118
+
119
+ {/* Sub-tabs (race / platform) */}
120
+ {showSubTabs && (
121
+ <div className="flex gap-1 mt-1 overflow-x-auto">
122
+ {groupCategories.map((cat) => (
123
+ <button
124
+ key={cat}
125
+ data-sf-hover
126
+ onClick={() => handleCategoryChange(cat)}
127
+ className="shrink-0 px-2 py-0.5 text-[9px] sf-heading font-medium uppercase tracking-wider transition-all"
128
+ style={{
129
+ border: `1px solid ${effectiveCategory === cat ? 'rgba(0,229,255,0.6)' : 'rgba(0,229,255,0.15)'}`,
130
+ color: effectiveCategory === cat ? 'rgba(0,229,255,0.8)' : 'rgba(255,255,255,0.3)',
131
+ backgroundColor: effectiveCategory === cat ? 'rgba(0,229,255,0.05)' : 'transparent',
132
+ }}
133
+ >
134
+ {getSubTabLabel(cat)}
135
+ </button>
136
+ ))}
137
+ </div>
138
+ )}
139
+ </div>
140
+
141
+ {/* Select mode banner */}
142
+ {selectMode && (
143
+ <div
144
+ className="shrink-0 flex items-center justify-between px-4 py-2"
145
+ style={{
146
+ backgroundColor: 'rgba(0,229,255,0.08)',
147
+ borderBottom: '1px solid var(--sf-cyan)',
148
+ boxShadow: '0 0 12px rgba(0,229,255,0.1)',
149
+ }}
150
+ >
151
+ <div>
152
+ <span className="text-[10px] sf-heading font-semibold tracking-widest" style={{ color: 'var(--sf-cyan)' }}>
153
+ ASSIGNING → {selectMode.label}
154
+ </span>
155
+ <span className="text-[10px] opacity-40 ml-3">click a sound card to assign</span>
156
+ </div>
157
+ <button
158
+ data-no-ui-sound
159
+ onClick={onClearSelectMode}
160
+ className="text-[10px] px-2 py-0.5 transition-opacity"
161
+ style={{ border: '1px solid rgba(0,229,255,0.3)', color: 'rgba(0,229,255,0.6)' }}
162
+ >
163
+ ESC
164
+ </button>
165
+ </div>
166
+ )}
167
+
168
+ {/* Sound grid */}
169
+ <div className="flex-1 overflow-y-auto p-3">
170
+ {isSearching && filteredSounds.length > 0 && (
171
+ <div className="text-[9px] uppercase tracking-widest mb-3 opacity-40 px-1">
172
+ {filteredSounds.length} result{filteredSounds.length !== 1 ? 's' : ''}
173
+ </div>
174
+ )}
175
+ {Object.entries(grouped).map(([cat, subcats]) => (
176
+ <div key={cat}>
177
+ {isSearching && (
178
+ <div className="text-[9px] uppercase tracking-widest mb-1 mt-3 px-1 first:mt-0" style={{ color: 'var(--sf-cyan)', opacity: 0.6 }}>
179
+ {cat.replace(/\//g, ' › ')}
180
+ </div>
181
+ )}
182
+ {Object.entries(subcats).map(([subcat, catSounds]) => (
183
+ <div key={`${cat}/${subcat}`} className="mb-4">
184
+ {subcat && <div className="text-[10px] uppercase tracking-widest mb-2 opacity-40 px-1">{subcat.replace(/-/g, ' ')}</div>}
185
+ <div className="grid grid-cols-2 gap-2">
186
+ {catSounds.map((sound) => (
187
+ <SoundUnit
188
+ key={sound.id}
189
+ sound={sound}
190
+ isAssigned={assignedPaths.has(sound.path)}
191
+ onPreview={onPreview}
192
+ onSelectAssign={selectMode ? () => onSelectModeAssign(sound.path) : undefined}
193
+ />
194
+ ))}
195
+ </div>
196
+ </div>
197
+ ))}
198
+ </div>
199
+ ))}
200
+ {filteredSounds.length === 0 && (
201
+ <div className="text-xs opacity-30 text-center py-8">NO SOUNDS FOUND</div>
202
+ )}
203
+ </div>
204
+ </div>
205
+ );
206
+ }