feat: Implement sortable Table View and Inspector drag-and-drop support

- Added a new 'Table View' button to the library sort bar.
- Implemented a CSS grid layout to display voice properties in a high-density table.
- Added sortable column headers (Name, Lang, Gender, Speed, dBFS, Length, Rating, Source, Seed, Note, Tags, Active).
- Aligned CSS grid to account for the bulk edit checkbox injection.
- Added drag-and-drop profile image support to the Inspector's large avatar.
- Ensured picture updates instantly synchronize across the List, Table, and Inspector views.
This commit is contained in:
mARTin-B78 2026-06-22 14:40:39 +02:00
parent 5becb4341f
commit a325f53e62
6 changed files with 265 additions and 13 deletions

View File

@ -38,7 +38,7 @@ _SETTINGS_KEYS = {
"llm_url", "llm_model", "llm_url", "llm_model",
# Browser-persistent UI state # Browser-persistent UI state
"engine_local_urls", "engine_container_names", "custom_engine_cards", "engine_local_urls", "engine_container_names", "custom_engine_cards",
"refine_llm_url", "conv_llm_url", "refine_llm_url", "conv_llm_url", "seed_finder_text",
} }
# ── TTS stability defaults ──────────────────────────────────────────────────── # ── TTS stability defaults ────────────────────────────────────────────────────
@ -275,6 +275,7 @@ def _load_settings() -> dict:
"custom_engine_cards": [], "custom_engine_cards": [],
"refine_llm_url": "", "refine_llm_url": "",
"conv_llm_url": "", "conv_llm_url": "",
"seed_finder_text": "",
} }
if CONFIG_FILE.exists(): if CONFIG_FILE.exists():
try: try:

View File

@ -26,7 +26,8 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css?v=1.8.0-3">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
<link rel="preload" as="style" <link rel="preload" as="style"
@ -274,7 +275,7 @@
<script src="/static/vendor/wavesurfer-regions.min.js"></script> <script src="/static/vendor/wavesurfer-regions.min.js"></script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js"></script> <script src="/static/loader.js?v=1.8.0-2"></script>
</body> </body>
</html> </html>

View File

@ -99,9 +99,9 @@ function selectVoice(wrap) {
<button class="insp-edit-id-btn" type="button" title="Rename voice ID"><span class="mdi mdi-pencil-outline"></span> edit ID</button> <button class="insp-edit-id-btn" type="button" title="Rename voice ID"><span class="mdi mdi-pencil-outline"></span> edit ID</button>
<button class="insp-copy-id-btn" type="button" title="Copy voice ID">copy ID</button> <button class="insp-copy-id-btn" type="button" title="Copy voice ID">copy ID</button>
</div> </div>
<div class="insp-hd-row2-right" style="display:flex; align-items:center;"> <div class="insp-hd-row2-right" style="display:flex; flex-direction:column; align-items:flex-end; gap:4px;">
<div class="insp-actions-active"></div> <div class="insp-actions-active"></div>
${(v.seed !== undefined && v.seed !== null) ? `<div class="insp-pinned-seed" style="color:#d32f2f; font-weight:600; font-size:12px; margin-left:12px;">Pin Seed # ${v.seed}</div>` : `<div class="insp-pinned-seed" style="color:#d32f2f; font-weight:600; font-size:12px; margin-left:12px; display:none;"></div>`} ${(v.seed !== undefined && v.seed !== null) ? `<div class="insp-pinned-seed" style="color:#d32f2f; font-weight:600; font-size:12px;">Pin Seed # ${v.seed}</div>` : `<div class="insp-pinned-seed" style="color:#d32f2f; font-weight:600; font-size:12px; display:none;"></div>`}
</div> </div>
</div> </div>
<div class="insp-hd-divider"></div> <div class="insp-hd-divider"></div>
@ -164,10 +164,59 @@ function selectVoice(wrap) {
} }
// ── Avatar click → photo upload ─────────────────────────────────────────── // ── Avatar click → photo upload ───────────────────────────────────────────
inspector.querySelector('.inspector-avatar').addEventListener('click', () => { const inspAvatar = inspector.querySelector('.inspector-avatar');
inspAvatar.addEventListener('click', () => {
body.querySelector('.photo-input')?.click(); body.querySelector('.photo-input')?.click();
}); });
inspAvatar.addEventListener('dragenter', e => { e.preventDefault(); inspAvatar.classList.add('drag-over'); });
inspAvatar.addEventListener('dragover', e => { e.preventDefault(); inspAvatar.classList.add('drag-over'); });
inspAvatar.addEventListener('dragleave', () => inspAvatar.classList.remove('drag-over'));
inspAvatar.addEventListener('drop', async e => {
e.preventDefault();
inspAvatar.classList.remove('drag-over');
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const photoInput = body.querySelector('.photo-input');
if (photoInput) {
photoInput.files = e.dataTransfer.files;
photoInput.dispatchEvent(new Event('change'));
}
return;
}
let url = e.dataTransfer.getData('text/uri-list');
if (!url) {
const html = e.dataTransfer.getData('text/html');
if (html) {
const match = html.match(/src=["'](.*?)["']/);
if (match) url = match[1];
}
}
if (!url) url = e.dataTransfer.getData('text/plain');
if (url && /^https?:\/\//i.test(url)) {
if (typeof status === 'function') status('Downloading picture from URL...');
try {
const r = await fetch('/api/voice/picture-url', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({voice_id: voiceId, image_url: url})
});
if (!r.ok) throw new Error((await r.json()).detail);
// Trigger the photo update in the row
const photoCell = body.querySelector('.vr-photo');
if (photoCell) photoCell.dispatchEvent(new Event('update-photo'));
if (typeof toast === 'function') toast('Photo saved from URL', 'success');
if (typeof status === 'function') status('Photo saved successfully');
} catch(err) {
if (typeof toast === 'function') toast('Photo URL download failed: ' + err.message, 'error');
if (typeof status === 'function') status('Photo URL download failed');
}
}
});
// ── Copy ID button ──────────────────────────────────────────────────────── // ── Copy ID button ────────────────────────────────────────────────────────
inspector.querySelector('.insp-copy-id-btn').addEventListener('click', () => { inspector.querySelector('.insp-copy-id-btn').addEventListener('click', () => {
copyText(voiceId).then(() => toast('Copied: ' + voiceId)); copyText(voiceId).then(() => toast('Copied: ' + voiceId));

View File

@ -84,6 +84,9 @@ function getSortValue(v, field) {
case 'benchmark': return voiceBenchmarkElapsed(v) ?? 999999; case 'benchmark': return voiceBenchmarkElapsed(v) ?? 999999;
case 'transcript': return (v.transcript || '').toLowerCase(); case 'transcript': return (v.transcript || '').toLowerCase();
case 'note': return (v.note || '').toLowerCase(); case 'note': return (v.note || '').toLowerCase();
case 'source': return (v.origin || '').toLowerCase();
case 'seed': return v.seed != null ? v.seed : 9999999;
case 'tag': return (v.tag || '').toLowerCase();
case 'rating': return v.rating || 0; case 'rating': return v.rating || 0;
case 'enabled': return v.enabled === false ? 0 : 1; case 'enabled': return v.enabled === false ? 0 : 1;
default: return ''; default: return '';
@ -132,9 +135,42 @@ document.addEventListener('click', e => {
if (icon) icon.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline'; if (icon) icon.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline';
renderVoiceList(); renderVoiceList();
} }
if (e.target.closest('#voice-table-view-btn')) {
window._voiceTableView = !window._voiceTableView;
try { localStorage.setItem('vl-table-view', window._voiceTableView ? '1' : '0'); } catch (_) {}
const btn = document.getElementById('voice-table-view-btn');
btn?.classList.toggle('active', window._voiceTableView);
document.querySelector('.voices-workbench')?.classList.toggle('table-view', window._voiceTableView);
// Auto-close any open inspector row when toggling table view
if (window._voiceTableView) {
document.querySelectorAll('.edit-open').forEach(r => r.classList.remove('edit-open'));
const inspector = document.getElementById('voices-inspector');
if (inspector) inspector.innerHTML = '<div class="inspector-placeholder"><span><span class="mdi mdi-table"></span></span><p>Table View Mode<br>Click a row to exit table view and edit.</p></div>';
}
}
}); });
// Restore the grouped-by-tag preference on load // Restore the preferences on load
try { window._voiceGroupByTag = localStorage.getItem('vl-group-by-tag') === '1'; } catch (_) {} try { window._voiceGroupByTag = localStorage.getItem('vl-group-by-tag') === '1'; } catch (_) {}
try { window._voiceTableView = localStorage.getItem('vl-table-view') === '1'; } catch (_) {}
document.addEventListener('click', e => {
const th = e.target.closest('.vl-th-sortable');
if (th) {
const field = th.dataset.sort;
if (field) {
const sel = document.getElementById('voice-sort-field');
if (sel && sel.value === field) {
window._voiceSortDir *= -1; // toggle dir
} else if (sel) {
sel.value = field;
window._voiceSortDir = 1; // default to desc for new field (or asc if you want)
}
readLibrarySortConfig();
renderVoiceList();
}
}
});
document.addEventListener('change', e => { document.addEventListener('change', e => {
if (e.target.id === 'voice-sort-field') setSort(e.target.value); if (e.target.id === 'voice-sort-field') setSort(e.target.value);
}); });
@ -260,7 +296,10 @@ function fmtDbfs(v) {
} }
function voiceBenchmark(v) { function voiceBenchmark(v) {
return v.benchmark && typeof v.benchmark === 'object' ? v.benchmark : null; if (v.benchmark && typeof v.benchmark === 'object' && Object.keys(v.benchmark).length > 0) {
return v.benchmark;
}
return null;
} }
function voiceBenchmarkElapsed(v) { function voiceBenchmarkElapsed(v) {
@ -1412,6 +1451,12 @@ function renderVoiceList() {
if (ic) ic.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline'; if (ic) ic.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline';
} }
const _tvBtn = $('voice-table-view-btn');
if (_tvBtn) {
_tvBtn.classList.toggle('active', !!window._voiceTableView);
document.querySelector('.voices-workbench')?.classList.toggle('table-view', !!window._voiceTableView);
}
// Apply sidebar category filter // Apply sidebar category filter
const cat = window._voiceSidebarCat || 'all'; const cat = window._voiceSidebarCat || 'all';
const enabledOk = v => showDisabled || v.enabled !== false; const enabledOk = v => showDisabled || v.enabled !== false;
@ -1796,6 +1841,18 @@ function makeVoiceRow(v) {
<div class="vr-play vr-play-original"><button class="play-btn-orig" title="Play original recording" aria-label="Play original recording"><span class="mdi mdi-play"></span></button></div> <div class="vr-play vr-play-original"><button class="play-btn-orig" title="Play original recording" aria-label="Play original recording"><span class="mdi mdi-play"></span></button></div>
<div class="vr-play vr-play-synth"><button class="play-btn-synth" title="Generate and play TTS preview" aria-label="Generate and play TTS preview"><span class="mdi mdi-play"></span></button></div> <div class="vr-play vr-play-synth"><button class="play-btn-synth" title="Generate and play TTS preview" aria-label="Generate and play TTS preview"><span class="mdi mdi-play"></span></button></div>
</div> </div>
<div class="vl-tbl-lang" title="${escHtml(currentFlag || langCode)}">${flagEmoji} ${escHtml(langCode)}</div>
<div class="vl-tbl-gender" title="${escHtml(genderLabel[gender]||'')}">${genderMap[gender]||'?'} ${escHtml(genderLabel[gender]||'—')}</div>
<div class="vl-tbl-type"><span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span></div>
<div class="vl-tbl-bench">${benchText !== '-' ? `<span class="vl-bench-chip ${benchCls}" title="${escHtml(benchTitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(benchText)}</span>` : '-'}</div>
<div class="vl-tbl-dbfs" title="${dbTitle}">${dbfs} dB</div>
<div class="vl-tbl-len">${fmtDuration(v.duration)}</div>
<div class="vl-tbl-rating">${starsHtml}</div>
<div class="vl-tbl-source">${escHtml(v.origin || '-')}</div>
<div class="vl-tbl-seed">${v.seed != null ? '#' + v.seed : 'Auto'}</div>
<div class="vl-tbl-note" title="${escHtml(v.note||'')}">${escHtml(v.note || '')}</div>
<div class="vl-tbl-tag" title="${escHtml(v.tag||'')}">${escHtml(v.tag || '')}</div>
<div class="vl-tbl-active"><span class="mdi ${v.enabled !== false ? 'mdi-check-circle' : 'mdi-close-circle'}" style="color:${v.enabled !== false ? 'var(--green)' : 'var(--subtext)'}"></span></div>
</div> </div>
<div class="vr-main-row"> <div class="vr-main-row">
@ -2001,6 +2058,37 @@ function makeVoiceRow(v) {
// Photo upload // Photo upload
const photoCell = wrap.querySelector('.vr-photo'); const photoCell = wrap.querySelector('.vr-photo');
const photoInput = wrap.querySelector('.photo-input'); const photoInput = wrap.querySelector('.photo-input');
const updatePhotoImg = () => {
const ts = Date.now();
const imgSrc = `/api/voice/picture/${encodeURIComponent(v.id)}?t=${ts}`;
// Update main photo cell
const img = document.createElement('img');
img.src = imgSrc;
img.alt = '';
photoCell.innerHTML = ''; photoCell.appendChild(img); photoCell.appendChild(photoInput);
// Update compact row avatar
const compactAvatar = wrap.querySelector('.vl-avatar');
if (compactAvatar) {
compactAvatar.className = 'vl-avatar vl-avatar-photo';
compactAvatar.style.background = '';
compactAvatar.innerHTML = `<img src="${imgSrc}" alt="" class="vl-avatar-img">`;
}
// Update inspector avatar if this voice is currently open
const inspectorAvatar = document.querySelector('.inspector-avatar');
if (inspectorAvatar && wrap.classList.contains('vr-selected')) {
inspectorAvatar.classList.add('insp-avatar-photo');
inspectorAvatar.style.background = '';
inspectorAvatar.innerHTML = `<img src="${imgSrc}" alt="" class="insp-avatar-img">`;
}
v.has_picture = true;
};
photoCell.addEventListener('update-photo', updatePhotoImg);
photoCell.addEventListener('click', () => photoInput.click()); photoCell.addEventListener('click', () => photoInput.click());
photoInput.addEventListener('change', async () => { photoInput.addEventListener('change', async () => {
if (!photoInput.files.length) return; if (!photoInput.files.length) return;
@ -2010,14 +2098,53 @@ function makeVoiceRow(v) {
try { try {
const r = await fetch('/api/voice/picture', { method:'POST', body:fd }); const r = await fetch('/api/voice/picture', { method:'POST', body:fd });
if (!r.ok) throw new Error((await r.json()).detail); if (!r.ok) throw new Error((await r.json()).detail);
const img = document.createElement('img'); updatePhotoImg();
img.src = `/api/voice/picture/${encodeURIComponent(v.id)}?t=${Date.now()}`; toast('Photo uploaded','success');
img.alt = '';
photoCell.innerHTML = ''; photoCell.appendChild(img); photoCell.appendChild(photoInput);
v.has_picture = true; toast('Photo uploaded','success');
} catch(e) { toast('Photo upload failed: '+e.message,'error'); } } catch(e) { toast('Photo upload failed: '+e.message,'error'); }
}); });
photoCell.addEventListener('dragenter', e => { e.preventDefault(); photoCell.classList.add('drag-over'); });
photoCell.addEventListener('dragover', e => { e.preventDefault(); photoCell.classList.add('drag-over'); });
photoCell.addEventListener('dragleave', () => photoCell.classList.remove('drag-over'));
photoCell.addEventListener('drop', async e => {
e.preventDefault();
photoCell.classList.remove('drag-over');
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
photoInput.files = e.dataTransfer.files;
photoInput.dispatchEvent(new Event('change'));
return;
}
let url = e.dataTransfer.getData('text/uri-list');
if (!url) {
const html = e.dataTransfer.getData('text/html');
if (html) {
const match = html.match(/src=["'](.*?)["']/);
if (match) url = match[1];
}
}
if (!url) url = e.dataTransfer.getData('text/plain');
if (url && /^https?:\/\//i.test(url)) {
status('Downloading picture from URL...');
try {
const r = await fetch('/api/voice/picture-url', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({voice_id: v.id, image_url: url})
});
if (!r.ok) throw new Error((await r.json()).detail);
updatePhotoImg();
toast('Photo saved from URL', 'success');
status('Photo saved successfully');
} catch(err) {
toast('Photo URL download failed: ' + err.message, 'error');
status('Photo URL download failed');
}
}
});
// Per-voice loudness normalization // Per-voice loudness normalization
const normalizeBtn = wrap.querySelector('.normalize-voice-btn'); const normalizeBtn = wrap.querySelector('.normalize-voice-btn');
const dbValue = wrap.querySelector('.vr-db-value'); const dbValue = wrap.querySelector('.vr-db-value');

View File

@ -65,10 +65,15 @@
<option value="rating">Rating</option> <option value="rating">Rating</option>
<option value="duration">Duration</option> <option value="duration">Duration</option>
<option value="dbfs">Volume dB</option> <option value="dbfs">Volume dB</option>
<option value="source">Source</option>
<option value="seed">Seed</option>
<option value="note">Note</option>
<option value="tag">Tags</option>
<option value="enabled">Active</option> <option value="enabled">Active</option>
</select> </select>
<button id="voice-sort-dir" class="vl-sort-dir-btn" title="Toggle sort direction"><span class="mdi mdi-arrow-up"></span></button> <button id="voice-sort-dir" class="vl-sort-dir-btn" title="Toggle sort direction"><span class="mdi mdi-arrow-up"></span></button>
<button id="voice-group-tag-btn" class="vl-sort-dir-btn" title="Group into virtual folders by tag"><span class="mdi mdi-folder-outline"></span></button> <button id="voice-group-tag-btn" class="vl-sort-dir-btn" title="Group into virtual folders by tag"><span class="mdi mdi-folder-outline"></span></button>
<button id="voice-table-view-btn" class="vl-sort-dir-btn" title="Toggle full-width table view"><span class="mdi mdi-table"></span></button>
<button id="vl-select-all-visible" class="vl-sort-dir-btn" title="Select all visible voices (toggle)"><span class="mdi mdi-checkbox-multiple-marked-outline"></span></button> <button id="vl-select-all-visible" class="vl-sort-dir-btn" title="Select all visible voices (toggle)"><span class="mdi mdi-checkbox-multiple-marked-outline"></span></button>
</div> </div>
@ -95,6 +100,24 @@
<button class="vl-bulk-btn vl-bulk-danger" id="vl-bulk-delete" title="Delete selected voices permanently"><span class="mdi mdi-delete-outline"></span> Delete</button> <button class="vl-bulk-btn vl-bulk-danger" id="vl-bulk-delete" title="Delete selected voices permanently"><span class="mdi mdi-delete-outline"></span> Delete</button>
</div> </div>
<div class="vl-table-header" id="vl-table-header">
<div></div>
<div>Img</div>
<div>Play</div>
<div class="vl-th-sortable" data-sort="id">Name <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="flag">Lang <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="gender">Gender <span class="mdi mdi-sort"></span></div>
<div>Type</div>
<div class="vl-th-sortable" data-sort="benchmark">Speed <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="dbfs">dBFS <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="duration">Length <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="rating">Rating <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="source">Source <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="seed">Seed <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="note">Note <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="tag">Tags <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="enabled">Active <span class="mdi mdi-sort"></span></div>
</div>
<div id="voice-list" tabindex="0" role="region" aria-label="Voice library"></div> <div id="voice-list" tabindex="0" role="region" aria-label="Voice library"></div>
<div class="vl-toolbar"> <div class="vl-toolbar">
<button class="btn-secondary vl-tb-btn" id="refresh-voices-btn" title="Refresh voice list"><span class="mdi mdi-refresh"></span> Refresh</button> <button class="btn-secondary vl-tb-btn" id="refresh-voices-btn" title="Refresh voice list"><span class="mdi mdi-refresh"></span> Refresh</button>

View File

@ -611,6 +611,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.vr-photo img { width: 100%; height: 100%; object-fit: cover; } .vr-photo img { width: 100%; height: 100%; object-fit: cover; }
.vr-photo .ph-icon { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 64px; color: white; font-weight: 700; } .vr-photo .ph-icon { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 64px; color: white; font-weight: 700; }
.vr-photo:hover::after { content: ''; } .vr-photo:hover::after { content: ''; }
.vr-photo.drag-over { opacity: 0.8; outline: 3px dashed var(--accent); outline-offset: -4px; transition: all 0.2s; }
.vr-identity { display: flex; gap: 10px; align-items: center; min-width: 0; width: 100%; } .vr-identity { display: flex; gap: 10px; align-items: center; min-width: 0; width: 100%; }
.vr-flag { display: none; } .vr-flag { display: none; }
.vr-flag .flag-emoji { font-size: 22px; line-height: 1; transition: transform .15s; } .vr-flag .flag-emoji { font-size: 22px; line-height: 1; transition: transform .15s; }
@ -1360,6 +1361,56 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.vl-bench-chip.bench-bad { background: rgba(239,68,68,.1); } .vl-bench-chip.bench-bad { background: rgba(239,68,68,.1); }
.vl-dbfs { display: none; } .vl-dbfs { display: none; }
/* Table View mode */
.vl-tbl-lang, .vl-tbl-gender, .vl-tbl-type, .vl-tbl-bench, .vl-tbl-dbfs, .vl-tbl-len, .vl-tbl-rating, .vl-tbl-source, .vl-tbl-seed, .vl-tbl-note, .vl-tbl-tag, .vl-tbl-active { display: none; }
.vl-table-header { display: none; }
.voices-workbench.table-view .voices-list-pane { width: 100% !important; max-width: 100%; }
.voices-workbench.table-view .voices-inspector-pane { display: none !important; }
.voices-workbench.table-view .vl-table-header {
display: grid;
grid-template-columns: 24px 32px 60px minmax(140px, 1.2fr) 60px 70px 60px 80px 70px 60px 90px 70px 60px minmax(120px, 1fr) minmax(80px, 1fr) 50px;
gap: 10px;
align-items: center;
padding: 8px 10px;
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--subtext);
border-bottom: 1px solid var(--border);
background: var(--surface);
position: sticky; top: 0; z-index: 5;
}
.vl-th-sortable { cursor: pointer; display: flex; align-items: center; gap: 3px; transition: color .15s; }
.vl-th-sortable:hover { color: var(--text); }
.vl-th-sortable .mdi { font-size: 12px; opacity: .6; }
.vl-th-sortable:hover .mdi { opacity: 1; color: var(--accent); }
.voices-workbench.table-view .vl-compact {
display: grid;
grid-template-columns: 24px 32px 60px minmax(140px, 1.2fr) 60px 70px 60px 80px 70px 60px 90px 70px 60px minmax(120px, 1fr) minmax(80px, 1fr) 50px;
gap: 10px;
align-items: center;
}
.voices-workbench.table-view .vl-bulk-cb { order: 0; margin: 0; justify-self: center; }
.voices-workbench.table-view .vl-info { display: contents; }
.voices-workbench.table-view .vl-meta { display: none; }
.voices-workbench.table-view .vl-avatar { order: 1; width: 32px; height: 32px; }
.voices-workbench.table-view .vr-play-group { order: 2; flex-shrink: 0; }
.voices-workbench.table-view .vl-name { order: 3; display: block; font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
.voices-workbench.table-view .vl-tbl-lang { display: block; order: 4; font-size: 12px; }
.voices-workbench.table-view .vl-tbl-gender { display: block; order: 5; font-size: 12px; }
.voices-workbench.table-view .vl-tbl-type { display: block; order: 6; font-size: 12px; }
.voices-workbench.table-view .vl-tbl-bench { display: block; order: 7; font-size: 12px; }
.voices-workbench.table-view .vl-tbl-dbfs { display: block; order: 8; font-size: 12px; font-family: monospace; }
.voices-workbench.table-view .vl-tbl-len { display: block; order: 9; font-size: 12px; font-family: monospace; }
.voices-workbench.table-view .vl-tbl-rating { display: flex; order: 10; font-size: 14px; color: var(--subtext); }
.voices-workbench.table-view .vl-tbl-rating .star.on { color: #f59e0b; }
.voices-workbench.table-view .vl-tbl-source { display: block; order: 11; font-size: 12px; overflow: hidden; text-overflow: ellipsis; }
.voices-workbench.table-view .vl-tbl-seed { display: block; order: 12; font-size: 12px; font-family: monospace; }
.voices-workbench.table-view .vl-tbl-note { display: block; order: 13; font-size: 12px; color: var(--subtext); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-style: italic; }
.voices-workbench.table-view .vl-tbl-tag { display: block; order: 14; font-size: 12px; color: var(--accent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
.voices-workbench.table-view .vl-tbl-active { display: block; order: 15; font-size: 16px; text-align: center; }
/* Synth mode panel (segmented control + preview input) */ /* Synth mode panel (segmented control + preview input) */
.vl-synth-panel { background: var(--surface); } .vl-synth-panel { background: var(--surface); }
.vl-synth-mode-row { display: flex; align-items: center; gap: 7px; padding: 5px 8px 5px; } .vl-synth-mode-row { display: flex; align-items: center; gap: 7px; padding: 5px 8px 5px; }