From e0bad5e5acc2b1616bb307d598caa7b15c721e3a Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Mon, 22 Jun 2026 02:35:55 -0700 Subject: [PATCH 1/3] feat: card-based Sample Library layout with grouping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesigned WAV Browser as a structured Sample Library with card layout: - WAVs grouped by library (ISO name) and program (volume name) - Library cards with collapsible headers showing program/sample counts - Program sections within each library - Sample cards in a compact grid with play-on-click Updated playWav to show a clean sample name in the player bar, hide the player when playback ends, and use sample-card selectors for active-state highlighting. Clean display names (underscores → spaces, stripped .wav extensions) --- web/ui/app.js | 105 ++++++++++++++++++++++++++++++------ web/ui/index.html | 17 +++--- web/ui/style.css | 132 ++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 208 insertions(+), 46 deletions(-) diff --git a/web/ui/app.js b/web/ui/app.js index 29d57f2..675ffb4 100644 --- a/web/ui/app.js +++ b/web/ui/app.js @@ -200,42 +200,109 @@ let animationFrame = null; document.getElementById('wav-browse-btn').addEventListener('click', browseWavs); +function groupWavs(paths) { + const libs = {}; + for (const p of paths) { + const parts = p.split('/'); + let lib, prog, file; + if (parts.length === 1) { + file = parts[0]; + } else if (parts.length === 2) { + lib = parts[0]; prog = parts[0]; file = parts[1]; + } else { + lib = parts[0]; prog = parts[1]; file = parts[parts.length - 1]; + } + if (!libs[lib]) libs[lib] = {}; + if (!libs[lib][prog]) libs[lib][prog] = []; + libs[lib][prog].push({ fullPath: p, name: file.replace(/\.wav$/i, '') }); + } + return libs; +} + +function cleanName(name) { + return name.replace(/_+/g, ' ').trim(); +} + +function renderSampleCards(samples) { + return samples.map(s => ` +
+ ${esc(s.name)} + +
+ `).join(''); +} + +function renderPrograms(programs) { + const progNames = Object.keys(programs).sort(); + return progNames.map(prog => { + const samples = programs[prog]; + return ` +
+
+

${esc(cleanName(prog))}

+ ${samples.length} samples +
+
+ ${renderSampleCards(samples)} +
+
`; + }).join(''); +} + +function renderLibraries(libs) { + const libNames = Object.keys(libs).sort(); + return libNames.map(lib => { + const programs = libs[lib]; + const progCount = Object.keys(programs).length; + const sampleCount = Object.values(programs).reduce((sum, s) => sum + s.length, 0); + return ` +
+
+

${esc(cleanName(lib))}

+ ${progCount} programs · ${sampleCount} samples +
+
+ ${renderPrograms(programs)} +
+
`; + }).join(''); +} + async function browseWavs() { const dir = document.getElementById('wav-dir').value; + const results = document.getElementById('wav-results'); + const summary = document.getElementById('wav-summary'); const list = document.getElementById('wav-list'); list.innerHTML = 'Loading...'; - console.log('browseWavs: dir =', dir); try { const url = `${API}/list-wavs?dir=${encodeURIComponent(dir)}`; - console.log('browseWavs: fetching', url); const resp = await fetch(url); - console.log('browseWavs: response status', resp.status, 'ok', resp.ok); if (!resp.ok) { list.innerHTML = 'HTTP ' + resp.status + ''; + results.style.display = 'block'; return; } const data = await resp.json(); - console.log('browseWavs: parsed data', data, 'type', typeof data, 'isArray', Array.isArray(data)); if (!data || data.error) { list.innerHTML = 'Error: ' + (data && data.error ? data.error : 'server error') + ''; + results.style.display = 'block'; return; } if (!Array.isArray(data) || data.length === 0) { list.innerHTML = 'No WAV files found'; + results.style.display = 'block'; return; } - console.log('browseWavs: found', data.length, 'wavs'); - list.innerHTML = data.map(f => ` -
- ${esc(f)} - -
- `).join(''); + const libs = groupWavs(data); + const libCount = Object.keys(libs).length; + summary.textContent = libCount + ' librar' + (libCount === 1 ? 'y' : 'ies') + ' · ' + data.length + ' samples'; + list.innerHTML = renderLibraries(libs); + results.style.display = 'block'; } catch (e) { - console.error('browseWavs: error', e); list.innerHTML = 'Failed: ' + e.message; + results.style.display = 'block'; } } @@ -255,8 +322,11 @@ async function playWav(filename) { animationFrame = null; } + const name = filename.split('/').pop().replace(/\.wav$/i, ''); + const player = document.getElementById('wav-player'); const nowPlaying = document.getElementById('wav-now-playing'); - nowPlaying.textContent = 'Loading: ' + filename; + player.style.display = 'block'; + nowPlaying.textContent = 'Loading: ' + name; try { const resp = await fetch(`/wavs/${encodeURIComponent(filename)}`); @@ -276,6 +346,7 @@ async function playWav(filename) { document.getElementById('wav-progress-fill').style.width = '0%'; document.getElementById('wav-time').textContent = formatTime(0); clearActiveItem(); + player.style.display = 'none'; } }; @@ -284,7 +355,7 @@ async function playWav(filename) { isPlaying = true; pauseOffset = 0; - nowPlaying.textContent = '▶ ' + filename; + nowPlaying.textContent = '▶ ' + name; setActiveItem(filename); updateProgress(); } catch (e) { @@ -308,13 +379,13 @@ function updateProgress() { } function setActiveItem(filename) { - document.querySelectorAll('.wav-item').forEach(el => { - el.classList.toggle('playing', el.dataset.file === filename); + document.querySelectorAll('.sample-card').forEach(el => { + el.classList.toggle('playing', el.dataset.wav === filename); }); } function clearActiveItem() { - document.querySelectorAll('.wav-item').forEach(el => { + document.querySelectorAll('.sample-card').forEach(el => { el.classList.remove('playing'); }); } diff --git a/web/ui/index.html b/web/ui/index.html index 1794e9b..889edb8 100644 --- a/web/ui/index.html +++ b/web/ui/index.html @@ -40,18 +40,21 @@
-

WAV Browser

+

Sample Library

-
-
-
-
- 0:00 +
diff --git a/web/ui/style.css b/web/ui/style.css index 0262c08..32aae1a 100644 --- a/web/ui/style.css +++ b/web/ui/style.css @@ -272,42 +272,130 @@ h2 { min-width: 40px; } -.wav-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 8px; +.no-files { + color: var(--text2); + font-size: 13px; + padding: 12px 0; } -.wav-item { +.wav-summary { + font-size: 13px; + color: var(--text2); + margin-top: 12px; + margin-bottom: 12px; + min-height: 18px; +} + +/* Library cards */ +.wav-library { + display: flex; + flex-direction: column; + gap: 12px; +} + +.library-card { + background: var(--bg2); + border: 1px solid var(--bg3); + border-radius: var(--radius); + overflow: hidden; +} + +.library-header { + padding: 14px 18px; + cursor: pointer; + user-select: none; + background: var(--bg); + border-bottom: 1px solid transparent; + transition: border-color 0.15s; +} +.library-header:hover { + border-color: var(--accent); +} +.library-header h3 { + font-size: 15px; + font-weight: 600; + color: var(--text); + margin: 0; + text-transform: none; + letter-spacing: 0; +} +.library-header .lib-meta { + font-size: 12px; + color: var(--text2); + margin-top: 4px; +} + +.library-programs { + padding: 10px 18px 16px; +} + +/* Program cards */ +.program-card { + margin-top: 14px; +} +.program-card:first-child { + margin-top: 0; +} + +.program-header { + cursor: pointer; + user-select: none; + margin-bottom: 8px; +} +.program-header h4 { + font-size: 13px; + font-weight: 600; + color: var(--accent); + margin: 0; + text-transform: none; + letter-spacing: 0; + display: inline; +} +.program-header .prog-meta { + font-size: 11px; + color: var(--text2); + margin-left: 8px; +} + +.program-samples { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 6px; +} + +/* Sample cards */ +.sample-card { background: var(--bg); border: 1px solid var(--bg3); border-radius: var(--radius); - padding: 10px 12px; - display: flex; - align-items: center; - gap: 8px; + padding: 8px 10px; cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + min-height: 64px; + transition: border-color 0.15s, background 0.15s; } -.wav-item:hover { +.sample-card:hover { border-color: var(--accent); } -.wav-item.playing { +.sample-card.playing { border-color: var(--green); background: rgba(78, 204, 163, 0.1); } -.wav-item .wav-name { - flex: 1; - font-size: 13px; +.sample-card .sample-name { + font-size: 11px; + color: var(--text); + text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + max-width: 100%; } -.wav-item .wav-play-btn { - background: var(--accent2); - color: white; - border: none; - border-radius: 4px; - padding: 4px 10px; - font-size: 11px; - cursor: pointer; +.sample-card .sample-play-icon { + font-size: 14px; + color: var(--accent2); + line-height: 1; } \ No newline at end of file -- 2.52.0 From 7124b16fc164a5b9f2e973e506d1808f0de84da4 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Mon, 22 Jun 2026 02:46:34 -0700 Subject: [PATCH 2/3] docs: update AGENTS.md and README for new features, add JS tests Docs: - AGENTS.md: updated toolchain section with js-lint/js-test tasks, expanded web/ui/ directory listing, added event delegation and Web Audio API to key patterns, updated testing section - README: marked issue #9 (Audio Preview Player) as done - .gitignore: added akai-fetch.test and cover.out Tests: - Moved groupWavs and cleanName to util.js (pure functions) - Added 10 new JS tests: 6 groupWavs, 4 cleanName - Total JS tests: 29 (up from 19) --- .gitignore | 2 ++ AGENTS.md | 27 ++++++++++++----- README.md | 2 +- web/ui/app.js | 23 --------------- web/ui/util.js | 25 +++++++++++++++- web/ui/util.test.js | 72 ++++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 118 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index 0ecb5b5..dc3a578 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,5 @@ web/ui/node_modules/ # Temp /tmp/ +akai-fetch.test +cover.out diff --git a/AGENTS.md b/AGENTS.md index 23ce31c..977c4de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,9 @@ mise run serve # → starts HTTP server, opens browser mise run electron-deps # installs electron + electron-builder mise run electron-dev # runs electron in dev mode mise run docker-up # docker compose up --build -mise run lint # go vet ./... +mise run lint # golangci-lint run (Go) +mise run js-lint # eslint (JS) +mise run js-test # node --test (JS) mise run check-all # verify gcc, brew, mise, go, node, docker, colima ``` @@ -55,7 +57,14 @@ Single binary (`fetch`) with subcommands. No external Go dependencies — pure s ``` main.go CLI entry, commands (search/list/download/extract/pipeline/serve) serve.go HTTP server + REST API + embedded web UI (embed.FS) -web/ui/ Static frontend (index.html, style.css, app.js) +web/ui/ Static frontend + ├── index.html HTML layout + ├── style.css CSS custom properties theme + ├── util.js shared utility functions + ├── app.js application logic, event delegation + ├── util.test.js JS tests (node --test) + ├── eslint.config.js ESLint flat config + └── package.json ESLint + test scripts electron/ Electron wrapper shell scripts/ Bash helpers called via exec ├── extract_wavs.sh batch WAV extraction via akaiutil @@ -70,8 +79,9 @@ third_party/ Vendored akaiutil C binary (GPLv2) - **Flag parsing**: each command creates its own `flag.NewFlagSet` — avoids global flag state - **Concurrency**: semaphore channel pattern (`sem := make(chan struct{}, N)`) for bounded parallelism - **SSE progress**: `serve.go:handleProgress` — 500ms ticker, EventSource stream, auto-closes when all done +- **Event delegation**: `app.js` — single click listener on document, dispatches by `[data-action]` attribute (download, list, play) - **akaiutil wrapper**: `extract.go` — persistent stdin/stdout pipes, no bash intermediary -- **Zero-deps web UI**: vanilla JS, no bundler, no framework. API calls use `fetch()` + `EventSource` +- **Zero-deps web UI**: vanilla JS, no bundler, no framework. API calls use `fetch()` + `EventSource`. Web Audio API for WAV playback. - **Embed directive**: `//go:embed web/ui/*` in `serve.go` — frontend baked into the binary - **Bind address handling**: `serve.go:37` — `--host` flag defaults to `127.0.0.1` for local dev; set `--host 0.0.0.0` for Docker. When binding to `0.0.0.0`, prints both `localhost` and `` URLs for clarity. @@ -124,12 +134,15 @@ All akaiutil calls use `-r` (read-only) flag. Write operations (S900 compress, p ## Testing -No test framework yet. Manual verification: ```bash -go build -o /dev/null . # compile check +# Go +go test -cover # unit tests (extract, serve, http, download, cli, util) go vet ./... # static analysis -golangci-lint run # comprehensive lint -./akai-fetch serve # manual smoke test +golangci-lint run # comprehensive lint (uses .golangci.yml) + +# JavaScript +mise run js-test # node --test (19 tests: esc, sanitize, formatBytes, formatTime) +mise run js-lint # eslint (flat config) ``` ## Secrets & Tokens diff --git a/README.md b/README.md index 61e70b7..002e93b 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ See [open issues](https://git.notsosm.art/david/akai-utils/issues). | #6 | Direct-to-Disk Take Extraction | todo | | #7 | TAR Bulk Import/Export | todo | | #8 | Sample Tag Editor | todo | -| #9 | Audio Preview Player | todo | +| #9 | Audio Preview Player | done | ## License diff --git a/web/ui/app.js b/web/ui/app.js index 675ffb4..6124a6f 100644 --- a/web/ui/app.js +++ b/web/ui/app.js @@ -200,29 +200,6 @@ let animationFrame = null; document.getElementById('wav-browse-btn').addEventListener('click', browseWavs); -function groupWavs(paths) { - const libs = {}; - for (const p of paths) { - const parts = p.split('/'); - let lib, prog, file; - if (parts.length === 1) { - file = parts[0]; - } else if (parts.length === 2) { - lib = parts[0]; prog = parts[0]; file = parts[1]; - } else { - lib = parts[0]; prog = parts[1]; file = parts[parts.length - 1]; - } - if (!libs[lib]) libs[lib] = {}; - if (!libs[lib][prog]) libs[lib][prog] = []; - libs[lib][prog].push({ fullPath: p, name: file.replace(/\.wav$/i, '') }); - } - return libs; -} - -function cleanName(name) { - return name.replace(/_+/g, ' ').trim(); -} - function renderSampleCards(samples) { return samples.map(s => `
diff --git a/web/ui/util.js b/web/ui/util.js index a40a83c..c81ce90 100644 --- a/web/ui/util.js +++ b/web/ui/util.js @@ -24,6 +24,29 @@ function formatTime(secs) { return m + ':' + s.toString().padStart(2, '0') } +function groupWavs(paths) { + const libs = {} + for (const p of paths) { + const parts = p.split('/') + let lib, prog, file + if (parts.length === 1) { + lib = ''; prog = parts[0]; file = parts[0] + } else if (parts.length === 2) { + lib = parts[0]; prog = parts[0]; file = parts[1] + } else { + lib = parts[0]; prog = parts[1]; file = parts[parts.length - 1] + } + if (!libs[lib]) libs[lib] = {} + if (!libs[lib][prog]) libs[lib][prog] = [] + libs[lib][prog].push({ fullPath: p, name: file.replace(/\.wav$/i, '') }) + } + return libs +} + +function cleanName(name) { + return name.replace(/_+/g, ' ').trim() +} + if (typeof module !== 'undefined' && module.exports) { - module.exports = { esc, sanitize, formatBytes, formatTime } + module.exports = { esc, sanitize, formatBytes, formatTime, groupWavs, cleanName } } \ No newline at end of file diff --git a/web/ui/util.test.js b/web/ui/util.test.js index 26b461b..4f6f173 100644 --- a/web/ui/util.test.js +++ b/web/ui/util.test.js @@ -1,6 +1,6 @@ const { describe, it } = require('node:test') const assert = require('node:assert/strict') -const { esc, sanitize, formatBytes, formatTime } = require('./util.js') +const { esc, sanitize, formatBytes, formatTime, groupWavs, cleanName } = require('./util.js') describe('esc', () => { it('escapes HTML entities', () => { @@ -88,4 +88,74 @@ describe('formatTime', () => { it('formats large times', () => { assert.strictEqual(formatTime(3661), '61:01') }) +}) + +describe('groupWavs', () => { + it('groups by library and program', () => { + const paths = [ + 'LibA/Prog1/kick.wav', + 'LibA/Prog1/snare.wav', + 'LibA/Prog2/hat.wav' + ] + const result = groupWavs(paths) + assert.strictEqual(Object.keys(result).length, 1) + assert.strictEqual(Object.keys(result.LibA).length, 2) + assert.strictEqual(result.LibA.Prog1.length, 2) + assert.strictEqual(result.LibA.Prog2.length, 1) + }) + + it('handles multiple libraries', () => { + const paths = [ + 'LibA/kick.wav', + 'LibB/snare.wav' + ] + const result = groupWavs(paths) + assert.strictEqual(Object.keys(result).length, 2) + assert.strictEqual(result.LibA.LibA[0].name, 'kick') + assert.strictEqual(result.LibB.LibB[0].name, 'snare') + }) + + it('strips .wav extension case-insensitively', () => { + const paths = ['Lib/Prog/KICK.WAV'] + const result = groupWavs(paths) + assert.strictEqual(result.Lib.Prog[0].name, 'KICK') + }) + + it('handles deeply nested paths', () => { + const paths = ['A/B/C/D/file.wav'] + const result = groupWavs(paths) + assert.strictEqual(result.A.B.length, 1) + assert.strictEqual(result.A.B[0].name, 'file') + assert.strictEqual(result.A.B[0].fullPath, 'A/B/C/D/file.wav') + }) + + it('handles flat paths with no directory', () => { + const paths = ['sound.wav'] + const result = groupWavs(paths) + assert.strictEqual(Object.keys(result).length, 1) + assert.strictEqual(Object.keys(result['']).length, 1) + }) + + it('returns empty object for empty input', () => { + const result = groupWavs([]) + assert.strictEqual(Object.keys(result).length, 0) + }) +}) + +describe('cleanName', () => { + it('replaces underscores with spaces', () => { + assert.strictEqual(cleanName('hello_world'), 'hello world') + }) + + it('collapses multiple underscores', () => { + assert.strictEqual(cleanName('AMG_-_Black__II'), 'AMG - Black II') + }) + + it('trims leading/trailing spaces from underscores', () => { + assert.strictEqual(cleanName('_padded_'), 'padded') + }) + + it('preserves regular text', () => { + assert.strictEqual(cleanName('Kick Drum'), 'Kick Drum') + }) }) \ No newline at end of file -- 2.52.0 From 669d3abe278f83ccf89712e68eb8cefaadc6c6d5 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Mon, 22 Jun 2026 02:52:24 -0700 Subject: [PATCH 3/3] feat: collapsible section and libraries, compact card layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sample Library section now collapsible via header click - Library cards collapsible via header click (toggle icon ▾) - Collapse All button to batch-collapse all libraries - Compact sample cards: 28px height, 10px font, 70px min grid column - Tighter spacing throughout (8px lib gap, 4px sample grid gap) - Play icon removed from cards (card itself is clickable) --- web/ui/app.js | 19 +++++++-- web/ui/index.html | 9 +++- web/ui/style.css | 103 +++++++++++++++++++++++++++++++++++----------- 3 files changed, 102 insertions(+), 29 deletions(-) diff --git a/web/ui/app.js b/web/ui/app.js index 6124a6f..2e934d7 100644 --- a/web/ui/app.js +++ b/web/ui/app.js @@ -204,7 +204,6 @@ function renderSampleCards(samples) { return samples.map(s => `
${esc(s.name)} -
`).join(''); } @@ -234,8 +233,8 @@ function renderLibraries(libs) { const sampleCount = Object.values(programs).reduce((sum, s) => sum + s.length, 0); return `
-
-

${esc(cleanName(lib))}

+
+

${esc(cleanName(lib))}

${progCount} programs · ${sampleCount} samples
@@ -383,3 +382,17 @@ document.addEventListener('click', function(e) { playWav(btn.dataset.wav); } }); + +// Section collapse toggle +document.querySelectorAll('.section-toggle').forEach(h => { + h.addEventListener('click', function () { + this.classList.toggle('collapsed'); + const target = document.getElementById(this.dataset.section); + target.style.display = target.style.display === 'none' ? '' : 'none'; + }); +}); + +// Collapse all libraries +document.getElementById('wav-collapse-all').addEventListener('click', function () { + document.querySelectorAll('.library-card').forEach(c => c.classList.add('collapsed')); +}); diff --git a/web/ui/index.html b/web/ui/index.html index 889edb8..3f462e6 100644 --- a/web/ui/index.html +++ b/web/ui/index.html @@ -40,13 +40,17 @@
-

Sample Library

+

Sample Library

+
diff --git a/web/ui/style.css b/web/ui/style.css index 32aae1a..217dc61 100644 --- a/web/ui/style.css +++ b/web/ui/style.css @@ -50,6 +50,23 @@ section { margin-bottom: 20px; } +/* Section collapse toggle */ +.section-toggle { + cursor: pointer; + user-select: none; + display: flex; + align-items: center; + gap: 8px; +} +.toggle-icon { + font-size: 12px; + transition: transform 0.2s; + display: inline-block; +} +.section-toggle.collapsed .toggle-icon { + transform: rotate(-90deg); +} + h2 { font-size: 16px; color: var(--text2); @@ -298,52 +315,59 @@ h2 { border: 1px solid var(--bg3); border-radius: var(--radius); overflow: hidden; + margin-bottom: 8px; } .library-header { - padding: 14px 18px; + padding: 10px 16px; cursor: pointer; user-select: none; background: var(--bg); - border-bottom: 1px solid transparent; + border-bottom: 1px solid var(--bg3); transition: border-color 0.15s; + display: flex; + align-items: center; + gap: 12px; } .library-header:hover { border-color: var(--accent); } .library-header h3 { - font-size: 15px; + font-size: 14px; font-weight: 600; color: var(--text); margin: 0; text-transform: none; letter-spacing: 0; + display: flex; + align-items: center; + gap: 4px; } .library-header .lib-meta { - font-size: 12px; + font-size: 11px; color: var(--text2); - margin-top: 4px; + margin-left: auto; } .library-programs { - padding: 10px 18px 16px; + padding: 8px 16px 12px; } /* Program cards */ .program-card { - margin-top: 14px; + margin-top: 10px; } .program-card:first-child { margin-top: 0; } .program-header { - cursor: pointer; + cursor: default; user-select: none; - margin-bottom: 8px; + margin-bottom: 6px; } .program-header h4 { - font-size: 13px; + font-size: 12px; font-weight: 600; color: var(--accent); margin: 0; @@ -352,30 +376,28 @@ h2 { display: inline; } .program-header .prog-meta { - font-size: 11px; + font-size: 10px; color: var(--text2); - margin-left: 8px; + margin-left: 6px; } .program-samples { display: grid; - grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); - gap: 6px; + grid-template-columns: repeat(auto-fill, minmax(70px, 1fr)); + gap: 4px; } /* Sample cards */ .sample-card { background: var(--bg); border: 1px solid var(--bg3); - border-radius: var(--radius); - padding: 8px 10px; + border-radius: 4px; + padding: 4px 8px; cursor: pointer; display: flex; - flex-direction: column; align-items: center; justify-content: center; - gap: 4px; - min-height: 64px; + min-height: 28px; transition: border-color 0.15s, background 0.15s; } .sample-card:hover { @@ -383,10 +405,10 @@ h2 { } .sample-card.playing { border-color: var(--green); - background: rgba(78, 204, 163, 0.1); + background: rgba(78, 204, 163, 0.15); } .sample-card .sample-name { - font-size: 11px; + font-size: 10px; color: var(--text); text-align: center; overflow: hidden; @@ -395,7 +417,40 @@ h2 { max-width: 100%; } .sample-card .sample-play-icon { - font-size: 14px; - color: var(--accent2); - line-height: 1; + display: none; +} + +/* Collapse all button */ +.wav-summary-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.collapse-all-btn { + background: var(--bg3); + color: var(--text2); + border: none; + border-radius: 4px; + padding: 4px 10px; + font-size: 11px; + cursor: pointer; + white-space: nowrap; +} +.collapse-all-btn:hover { + color: var(--text); +} + +/* Library collapse state */ +.library-card.collapsed .library-programs { + display: none; +} +.library-header .lib-toggle { + font-size: 11px; + transition: transform 0.2s; + margin-left: 8px; + color: var(--text2); +} +.library-card.collapsed .lib-toggle { + transform: rotate(-90deg); } \ No newline at end of file -- 2.52.0