feat: improve queue card readability and detail controls
Windows CI / verify (push) Canceled after 0s

This commit is contained in:
Sucukdeluxe
2026-09-06 12:41:30 +02:00
parent c21520ee3d
commit d935e622d2
17 changed files with 285 additions and 61 deletions
+2
View File
@@ -2,6 +2,8 @@
## Unreleased
- Rework queue cards with two-line titles, clearer status badges, larger dates below the progress bar on the right, and 32-pixel remove and retry buttons. Keep paused progress visible and wrap long download errors.
- Expand or collapse queue details by double-clicking the card surface or using the dedicated keyboard-accessible toggle. Add isolated queue-card checks for both languages and themes.
- Resolve the repository icon during Windows hot development even when the branded Electron executable reports itself as packaged, preventing a startup crash.
## 1.0.19 - 2026-08-14
+11
View File
@@ -18,6 +18,8 @@ Am 6. September 2026 nach einem Festplatten-Reset aus den vorhandenen Remote-Rep
## Letzte Änderungen
- 6. September 2026: Queue-Cards überarbeitet. Titel mit bis zu zwei Zeilen in 13 px; Datum von 10 auf 12 px vergrößert und rechts unter den Fortschrittsbalken gesetzt. Status als beschriftetes Badge, Entfernen/Wiederholen mit 32 × 32 px Klickfläche, Details und Fortschritt in 12 px, keine Kartenschrift unter 10 px. Wartende Einträge ohne doppelte Statuszeile, pausierte Downloads mit erhaltenem Prozentstand, längere Fehler umbrechend.
- Details lassen sich per Doppelklick auf die freie Kartenfläche einschließlich Titel, Datum und Balken umschalten. Ein eigener Pfeil unterstützt Einzelklick, Enter und Leertaste; Schaltflächen lösen keine zusätzliche Kartenaktion aus. Aufklappen aktualisiert die vorhandenen Elemente und erhält den Tastaturfokus.
- 6. September 2026: Windows-Hot-Dev-Start repariert. Die umbenannte Electron-EXE meldet `app.isPackaged` auch im Entwicklungsbetrieb als wahr; die Symbolauflösung berücksichtigt nun ausdrücklich `TWITCH_VOD_MANAGER_DEV` und verwendet das vorhandene `build/icon.ico`.
- 1.0.19: Streamlink und FFmpeg werden nach der Installation automatisch im Hintergrund eingerichtet; die Queue zeigt die Vorbereitung der Download-Werkzeuge an. Außerdem wurden instabile Electron-Smoke-Tests korrigiert.
- 1.0.18: Reparaturen bei Werkzeuginstallation und Windows-Kurzpfaden, robustere Download- und Wiederherstellungsabläufe, Verbesserungen an Updates, Diagnosen, Queue, Streamer-Auswahl und Video-Cutter sowie zusätzliche isolierte Prüfungen.
@@ -43,6 +45,7 @@ npm run dev
- `npm run dev`: Entwicklungsstart mit Hot Reload; `npm start`: einmaliger Entwicklungsstart.
- Entwicklungsdaten liegen isoliert in `.dev-program-data/` und `.dev-user-data/`; beide Ordner sind ignoriert.
- Basisprüfungen: `npm run build`, `npm run lint`, `npm run security:check`, `npm run test:unit`.
- Queue-UI-Prüfung: `npm run test:e2e:queue-cards`; prüft sechs Zustände in Deutsch/Englisch und Hell/Dunkel sowie Doppelklick, Tastatur, Fortschrittsaktualisierung und Entfernen. Screenshots liegen ignoriert in `tmp_queue-card-artifacts/`. Auch in `test:e2e:focused` und dessen CI-Vertrag eingebunden.
- Isolierter Anwendungstest: `npm run test:e2e`.
- Umfassende Release-Prüfung: `npm run test:e2e:release`; Windows-Paket: `npm run dist:win`.
- Relevante Struktur: `src/main.ts`, `src/main/`, `src/renderer-*.ts`, `src/index.html`, `src/styles*.css`, `src/workspace*.css`, `scripts/` und `build/`.
@@ -77,3 +80,11 @@ npm run dev
- Streamlink 8.4.0 im Entwicklungsverzeichnis eingerichtet und per `--version` geprüft. Der vorhandene Managed-Tool-Installer hat Archiv- und EXE-Prüfsummen verifiziert; zum Entpacken wurde lokal PowerShell 7 mit `Expand-Archive -LiteralPath` und über Umgebungsvariablen übergebenen Pfaden verwendet.
- Der automatische Installationsversuch mit Windows PowerShell meldete zuvor `required-executable-missing`. Die lokale Einrichtung ist behoben; die Ursache im allgemeinen Entpackablauf ist noch offen.
- Anschließender Hot-Reload-Neustart tatsächlich ausgelöst und geprüft: Das neue Anwendungsfenster reagiert, und der reale Preflight meldet Internet, Streamlink, FFmpeg, FFprobe und beschreibbares Download-Verzeichnis als erfolgreich. Keine Live-Downloads gestartet.
6. September 2026, Queue-Card-Rework (unveröffentlicht, Version weiterhin 1.0.19):
- Build, alle 630 Unit-Tests, isolierte Queue-Card-Prüfung und umfassender Workspace-UI-Test erfolgreich; keine gemeldeten Laufzeitfehler.
- Queue-Screenshots in Hell und Dunkel visuell geprüft; Datum rechts unter dem Balken, ausreichend große Aktionsflächen und keine horizontale Überläufe.
- Lint ohne Fehler bei den bekannten 15 Warnungen. Lokale Entwicklungsdaten und installierte Drittanbieter-Werkzeuge sind nun ausdrücklich von ESLint ausgeschlossen; vier Lint-Konfigurationstests erfolgreich.
- Security-/Public-Manifest-, CI- und E2E-Isolationsprüfungen erfolgreich. Der neue UI-Test steht in der öffentlichen Datei-Allowlist; Entwicklungsdaten und Screenshots bleiben außerhalb von Git.
- Hot-Dev bleibt aktiv. Keine Veröffentlichung und kein Live-Download für diese UI-Änderung.
+1 -1
View File
@@ -5,7 +5,7 @@ import globals from 'globals';
export default [
{
ignores: ['dist/**', 'release/**', 'node_modules/**', 'tmp_*/**', 'docs/**']
ignores: ['dist/**', 'release/**', 'node_modules/**', '.dev-program-data/**', '.dev-user-data/**', 'tmp_*/**', 'docs/**']
},
js.configs.recommended,
...tseslint.configs.recommended,
+2 -1
View File
@@ -22,12 +22,13 @@
"test:e2e:guide": "node scripts/smoke-test-template-guide.js",
"test:e2e:full": "node scripts/smoke-test-full.js",
"test:e2e:workspace-ui": "npm run build && node scripts/smoke-test-workspace-ui.js",
"test:e2e:queue-cards": "npm run build && node scripts/smoke-test-queue-cards.js",
"test:e2e:cutter": "npm run build && node scripts/smoke-test-cutter.js",
"test:e2e:cutter-matrix": "npm run build && node scripts/smoke-test-cutter-media-matrix.js",
"test:e2e:isolation": "node scripts/smoke-test-e2e-isolation-contract.js",
"test:capability-contract": "node scripts/smoke-test-file-capability-contract.js",
"test:e2e:settings-autosave": "node scripts/smoke-test-settings-autosave.js",
"test:e2e:focused": "npm run test:e2e:isolation && npm run test:e2e:workspace-ui",
"test:e2e:focused": "npm run test:e2e:isolation && npm run test:e2e:workspace-ui && npm run test:e2e:queue-cards",
"test:security": "node --test scripts/security-check.test.js",
"test:lint-config": "node --test scripts/lint-config.test.mjs",
"test:ci-contract": "node scripts/smoke-test-ci-contract.js",
+6
View File
@@ -7,6 +7,12 @@ import { ESLint } from 'eslint';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const eslint = new ESLint({ cwd: root, overrideConfigFile: path.join(root, 'eslint.config.mjs') });
test('ignores local development data and bundled third-party tools', async () => {
for (const file of ['.dev-program-data/tools/worker.js', '.dev-user-data/cache/script.js']) {
assert.equal(await eslint.isPathIgnored(path.join(root, file)), true);
}
});
async function messagesFor(source, filePath) {
const [result] = await eslint.lintText(source, { filePath: path.join(root, filePath) });
return result.messages;
+1
View File
@@ -34,6 +34,7 @@
"scripts/smoke-test-template-guide.js",
"scripts/smoke-test-update-version-logic.js",
"scripts/smoke-test-workspace-ui.js",
"scripts/smoke-test-queue-cards.js",
"scripts/smoke-test.js",
"src/index.html",
"src/main/domain/app-state-store.test.ts",
+2 -1
View File
@@ -553,7 +553,8 @@ const requiredScripts = {
'test:live:twitch': 'node scripts/smoke-test-live-integration.js twitch',
'test:live:updater-postpublish': 'node scripts/smoke-test-live-integration.js updater',
'test:e2e:cutter-matrix': 'npm run build && node scripts/smoke-test-cutter-media-matrix.js',
'test:e2e:focused': 'npm run test:e2e:isolation && npm run test:e2e:workspace-ui',
'test:e2e:focused': 'npm run test:e2e:isolation && npm run test:e2e:workspace-ui && npm run test:e2e:queue-cards',
'test:e2e:queue-cards': 'npm run build && node scripts/smoke-test-queue-cards.js',
'test:packaged-launch': 'node scripts/smoke-test-packaged-launch.js',
'test:installer': 'node scripts/smoke-test-installer.js',
'dist:ci': 'electron-builder --win nsis'
+2 -1
View File
@@ -8,7 +8,8 @@ const SMOKE_FILES = [
'scripts/smoke-test-template-guide.js',
'scripts/smoke-test-full.js',
'scripts/smoke-test-settings-autosave.js',
'scripts/smoke-test-workspace-ui.js'
'scripts/smoke-test-workspace-ui.js',
'scripts/smoke-test-queue-cards.js'
];
function inspectSources() {
+108
View File
@@ -0,0 +1,108 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { _electron: electron } = require('playwright');
const { createE2eEnvironment, getElectronLaunchOptions, verifyE2eIsolation, installOfflineFixtures, cleanupE2eEnvironment } = require('./e2e-test-environment');
async function main() {
const environment = createE2eEnvironment('queue-cards');
const artifacts = path.resolve(__dirname, '../tmp_queue-card-artifacts');
fs.mkdirSync(artifacts, { recursive: true });
let app;
try {
app = await electron.launch(getElectronLaunchOptions(environment));
const win = await app.firstWindow();
await verifyE2eIsolation(app, win, environment);
await installOfflineFixtures(app);
const errors = [];
win.on('pageerror', (error) => errors.push(String(error)));
await win.waitForFunction(() => typeof window.showTab === 'function' && typeof window.changeLanguage === 'function');
await win.evaluate(() => {
showTab('vods');
const panel = document.querySelector('[data-context-for="vods"]');
panel.dataset.vodsLayout = 'tabs';
panel.dataset.vodsWorkspace = 'queue';
queue = [
{ id: 'pending', status: 'pending', title: 'Ein langer Streamtitel mit gut lesbaren Download-Details und einer zweiten Zeile', progress: 0 },
{ id: 'running', status: 'downloading', title: 'Sommerstream am See gemeinsam unterwegs', progress: 42, progressStatus: 'Video wird heruntergeladen', speed: '12.5 MB/s', eta: '08:24' },
{ id: 'paused', status: 'paused', title: 'Community-Abend mit Freunden', progress: 23 },
{ id: 'error', status: 'error', title: 'Ein weiterer langer Streamtitel mit einer Fehlermeldung', progress: 10, last_error: 'Die Verbindung wurde unterbrochen. Bitte erneut versuchen.' },
{ id: 'completed', status: 'completed', title: 'Highlights vom Wochenende', progress: 100, outputFiles: ['C:\\fixture\\highlights.mp4'] },
{ id: 'live', status: 'downloading', title: 'Live aus dem Studio', progress: 0, isLive: true, recordingHealth: 'ok', progressStatus: 'Live-Aufnahme läuft' }
].map((item) => ({ url: `https://example.invalid/${item.id}`, date: '2026-09-06T10:00:00Z', streamer: 'Beispielkanal', duration_str: '2h 34m', ...item }));
renderQueue();
});
for (const theme of ['twitch', 'light']) {
for (const language of ['de', 'en']) {
await win.setViewportSize({ width: 1280, height: 900 });
await win.evaluate(({ theme, language }) => {
document.body.className = `theme-${theme}`;
changeLanguage(language);
}, { theme, language });
const layout = await win.locator('#queueList').evaluate((list) => [...list.querySelectorAll('.queue-item')].map((item) => {
const rect = (selector) => item.querySelector(selector).getBoundingClientRect();
const date = rect('.queue-date');
const bar = rect('.queue-progress-wrap');
const remove = rect('.remove');
return {
id: item.dataset.id,
dateBelow: date.top >= bar.bottom,
dateRight: Math.abs(date.right - bar.right) <= 1,
dateSize: parseFloat(getComputedStyle(item.querySelector('.queue-date')).fontSize),
removeSize: Math.min(remove.width, remove.height),
overflow: item.scrollWidth - item.clientWidth,
tinyText: [...item.querySelectorAll('*')].filter((element) => element.textContent.trim() && element.getBoundingClientRect().height > 0 && parseFloat(getComputedStyle(element).fontSize) < 10).length,
};
}));
for (const card of layout) {
assert(card.dateBelow && card.dateRight, `Date placement: ${JSON.stringify(card)}`);
assert(card.dateSize >= 12 && card.removeSize >= 32 && card.tinyText === 0, `Readability: ${JSON.stringify(card)}`);
assert(card.overflow <= 1, `Card overflow: ${JSON.stringify(card)}`);
}
await win.locator('.queue-section').screenshot({ path: path.join(artifacts, `queue-${theme}-${language}.png`) });
}
}
const card = win.locator('.queue-item[data-id="pending"]');
const toggle = card.locator('[data-queue-action="details"]');
for (const selector of ['.title', '.queue-date', '.queue-progress-wrap', '.queue-status-label']) {
await card.locator(selector).dblclick();
assert.equal(await toggle.getAttribute('aria-expanded'), 'true', `${selector} expands`);
await card.locator(selector).dblclick();
assert.equal(await toggle.getAttribute('aria-expanded'), 'false', `${selector} collapses`);
}
await card.dblclick({ position: { x: 4, y: 4 } });
assert.equal(await toggle.getAttribute('aria-expanded'), 'true', 'Card padding expands');
await toggle.click();
assert.equal(await toggle.getAttribute('aria-expanded'), 'false', 'Toggle collapses');
await toggle.dblclick();
assert.equal(await toggle.getAttribute('aria-expanded'), 'true', 'Double-clicking toggle changes state only once');
await toggle.focus();
await win.keyboard.press('Space');
assert.equal(await toggle.getAttribute('aria-expanded'), 'false', 'Space collapses');
await win.keyboard.press('Enter');
assert.equal(await toggle.getAttribute('aria-expanded'), 'true', 'Enter expands');
assert(await toggle.evaluate((button) => document.activeElement === button), 'Toggle retains keyboard focus');
await win.evaluate(() => {
queue.find((item) => item.id === 'running').progress = 61;
renderQueue();
});
assert.equal(await toggle.getAttribute('aria-expanded'), 'true', 'Expansion survives queue rerender');
assert.equal(await win.locator('[data-id="running"] .queue-progress-wrap').getAttribute('aria-valuenow'), '61');
await app.evaluate(({ ipcMain }) => {
ipcMain.removeHandler('remove-from-queue');
ipcMain.handle('remove-from-queue', async (_event, id) => {
if (id !== 'pending') throw new Error('Unexpected removal target');
return [];
});
});
await card.locator('.remove svg path').click();
await win.waitForFunction(() => document.querySelectorAll('#queueList .queue-item').length === 0);
assert.deepEqual(errors, []);
console.log(JSON.stringify({ failures: [], themes: 2, languages: 2, states: 6, interactions: 'passed', artifacts }));
} finally {
if (app) await app.close();
cleanupE2eEnvironment(environment);
}
}
main().catch((error) => { console.error(error); process.exitCode = 1; });
+4 -2
View File
@@ -1265,7 +1265,8 @@ async function run() {
progressInfoText: progressInfo?.textContent || '',
progressInfoBelowBar: Boolean(progressWrap && progressInfo && progressInfo.getBoundingClientRect().top >= progressWrap.getBoundingClientRect().bottom),
progressIsGreen,
statusRemoveCenterDelta: statusRect && removeRect ? Math.abs((statusRect.top + statusRect.bottom) / 2 - (removeRect.top + removeRect.bottom) / 2) : null,
statusBelowBar: Boolean(statusRect && progressWrap && statusRect.top >= progressWrap.getBoundingClientRect().bottom),
removeSize: removeRect ? Math.min(removeRect.width, removeRect.height) : 0,
reservedSelectionControls: document.querySelectorAll('#queueList .queue-selector, #queueList .queue-selector-placeholder').length,
mergeSelectActionVisible,
selectionOrderText: selectionOrder?.textContent?.trim() || '',
@@ -1290,7 +1291,8 @@ async function run() {
check(dynamicQueue.progressInfoText.includes('42.0%') && dynamicQueue.progressInfoText.includes('55.8 MB/s') && dynamicQueue.progressInfoText.includes('1/1'), `Queue progress details are incomplete: ${dynamicQueue.progressInfoText}`);
check(dynamicQueue.progressInfoBelowBar, 'Queue progress details are not positioned below the progress bar');
check(dynamicQueue.progressIsGreen, 'Running queue progress is not green');
check(dynamicQueue.statusRemoveCenterDelta !== null && dynamicQueue.statusRemoveCenterDelta <= 1, `Queue status and remove action are not vertically aligned: ${dynamicQueue.statusRemoveCenterDelta}`);
check(dynamicQueue.statusBelowBar, 'Queue status is not positioned below the progress bar');
check(dynamicQueue.removeSize >= 32, `Queue remove target is too small: ${dynamicQueue.removeSize}`);
check(dynamicQueue.reservedSelectionControls === 0, `Queue still reserves ${dynamicQueue.reservedSelectionControls} in-flow selection controls`);
check(dynamicQueue.mergeSelectActionVisible, 'Pending Queue item has no merge selection action in its context menu');
check(dynamicQueue.selectionOrderText === '1' && dynamicQueue.selectionOrderPosition === 'absolute', `Merge selection order is not a floating badge: ${dynamicQueue.selectionOrderText}/${dynamicQueue.selectionOrderPosition}`);
+2
View File
@@ -378,6 +378,8 @@ const UI_TEXT_DE = {
statusRunning: 'Läuft',
statusPaused: 'Pausiert',
statusWaiting: 'Wartet',
toggleDetails: 'Download-Details ein-/ausklappen',
removeItem: 'Aus der Queue entfernen',
progressError: 'Fehler',
progressReady: 'Bereit',
progressLoading: 'Lade...',
+2
View File
@@ -378,6 +378,8 @@ const UI_TEXT_EN = {
statusRunning: 'Running',
statusPaused: 'Paused',
statusWaiting: 'Waiting',
toggleDetails: 'Expand or collapse download details',
removeItem: 'Remove from queue',
progressError: 'Error',
progressReady: 'Ready',
progressLoading: 'Loading...',
+1 -1
View File
@@ -408,7 +408,7 @@ describe('renderer queue production paths', () => {
expect(metrics({ status: 'downloading', progress: 12.34, speed: '4 MB/s', eta: '2m' })).toBe('12.3% | 4 MB/s | 2m');
expect(metrics({ status: 'pending', progress: 12.34, speed: '4 MB/s', eta: '2m' })).toBe('');
expect(metrics({ status: 'paused', progress: 12.34, speed: '4 MB/s', eta: '2m' })).toBe('');
expect(metrics({ status: 'paused', progress: 12.34, speed: '4 MB/s', eta: '2m' })).toBe('12.3%');
expect(metrics({ status: 'error', progress: 12.34, speed: '4 MB/s', eta: '2m' })).toBe('');
expect(metrics({ status: 'completed', progress: 100, speed: '4 MB/s', eta: '2m' })).toBe('100%');
});
+29 -12
View File
@@ -135,8 +135,17 @@ function initQueueActions(): void {
list.addEventListener('click', (event: MouseEvent) => {
const control = resolveQueueControl(event.target);
if (!control || !list.contains(control)) return;
if (control.dataset.queueAction === 'details' && event.detail > 1) return;
void activateQueueControl(control);
});
list.addEventListener('dblclick', (event: MouseEvent) => {
if (!(event.target instanceof Element)) return;
if (event.target.closest('button, a, input, select, textarea, [role="button"], [data-queue-action], [data-queue-file-action]')) return;
const item = event.target.closest<HTMLElement>('.queue-item');
if (!item?.dataset.id || !list.contains(item)) return;
event.preventDefault();
toggleQueueDetails(item.dataset.id);
});
list.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
const control = resolveQueueControl(event.target);
@@ -457,7 +466,7 @@ function getQueueProgressStatusText(item: QueueItem): string {
function getQueueProgressMetricsText(item: QueueItem): string {
const parts: string[] = [];
if (item.status === 'completed') parts.push('100%');
if (item.status === 'downloading' && item.progress > 0) {
if ((item.status === 'downloading' || item.status === 'paused') && item.progress > 0) {
parts.push(`${Math.max(0, Math.min(100, item.progress)).toFixed(1)}%`);
}
if (item.status === 'downloading' && item.speed) parts.push(item.speed);
@@ -564,7 +573,10 @@ function toggleQueueDetails(id: string): void {
} else {
expandedQueueIds.add(id);
}
renderQueue();
const item = Array.from(byId<HTMLElement>('queueList').querySelectorAll<HTMLElement>('.queue-item'))
.find((candidate) => candidate.dataset.id === id);
item?.querySelector('.queue-details')?.classList.toggle('expanded', expandedQueueIds.has(id));
item?.querySelector('[data-queue-action="details"]')?.setAttribute('aria-expanded', String(expandedQueueIds.has(id)));
}
function initQueueDragDrop(): void {
@@ -627,7 +639,7 @@ function renderQueue(): void {
const list = byId('queueList');
initQueueActions();
byId('queueCount').textContent = String(queue.length);
byId('queueCount').textContent = formatUiNumber(queue.length);
const retryBtn = byId<HTMLButtonElement>('btnRetryFailed');
const clearBtn = byId<HTMLButtonElement>('btnClear');
const hasFailed = queue.some((item) => item.status === 'error' && !item.mergeRecoveryBlocked);
@@ -683,26 +695,32 @@ function renderQueue(): void {
? renderRecordingHealthBadge(item.recordingHealth)
: '';
const mergeMetaExtra = isMergeGroup
? ` (${UI_TEXT.mergeGroup.metaLabel.replace('{count}', String(item.mergeGroup!.items.length))})`
? escapeHtml(UI_TEXT.mergeGroup.metaLabel.replace('{count}', formatUiNumber(item.mergeGroup!.items.length)))
: '';
const detailsId = `queue-details-${itemIndex}`;
return `
<div class="queue-item${isMergeGroup ? ' merge-group' : ''}${isSelected ? ' merge-selected' : ''}" draggable="${item.status === 'pending' ? 'true' : 'false'}" data-id="${escapeHtml(item.id)}">
${isSelected ? `<span class="queue-selection-order" title="${selectionTitle}" aria-label="${selectionTitle}">${selectionPosition}</span>` : ''}
<div class="status ${item.status}"></div>
<div class="queue-main">
<div class="queue-title-row">
<div class="title" title="${safeTitle}" role="button" tabindex="0" aria-expanded="${expandedQueueIds.has(item.id) ? 'true' : 'false'}" aria-controls="${detailsId}" data-queue-action="details">${liveBadge}${healthBadge}${mergeIcon}${isClip}${safeTitle}</div>
<div class="queue-status-label">${safeStatusLabel}</div>
<span class="remove" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.streamers.removeAria)}" data-queue-action="remove">x</span>
<div class="title" title="${safeTitle}">${liveBadge}${healthBadge}${mergeIcon}${isClip}${safeTitle}</div>
${item.status === 'error' && !item.mergeRecoveryBlocked ? `<button class="queue-retry-btn" type="button" title="${escapeHtml(UI_TEXT.queue.retryItem)}" aria-label="${escapeHtml(UI_TEXT.queue.retryItem)}" data-queue-action="retry">&#x21bb;</button>` : ''}
<button class="remove" type="button" title="${escapeHtml(UI_TEXT.queue.removeItem)}" aria-label="${escapeHtml(UI_TEXT.queue.removeItem)}" data-queue-action="remove"><svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v5M14 11v5"/></svg></button>
</div>
<div class="queue-meta"><span class="queue-date">${safeDate}</span>${mergeMetaExtra}</div>
${mergeMetaExtra ? `<div class="queue-meta">${mergeMetaExtra}</div>` : ''}
<div class="queue-progress-wrap" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(progressValue)}" aria-label="${escapeHtml(safeStatusLabel)}">
<div class="queue-progress-bar" style="width: ${progressValue}%;"></div>
</div>
<div class="queue-progress-info">
<span class="queue-progress-status${progressStatusClass}">${safeProgressStatus}</span>
<div class="queue-footer">
<div class="queue-summary">
<button class="queue-details-toggle" type="button" aria-label="${escapeHtml(UI_TEXT.queue.toggleDetails)}" title="${escapeHtml(UI_TEXT.queue.toggleDetails)}" aria-expanded="${expandedQueueIds.has(item.id) ? 'true' : 'false'}" aria-controls="${detailsId}" data-queue-action="details"><svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m7 10 5 5 5-5"/></svg></button>
<span class="queue-status-badge" title="${safeProgressStatus}"><span class="status ${item.status}" aria-hidden="true"></span><span class="queue-status-label">${safeStatusLabel}</span></span>
</div>
<span class="queue-date">${safeDate}</span>
</div>
<div class="queue-progress-info${item.status === 'pending' || item.status === 'completed' ? ' is-hidden' : ''}">
<span class="queue-progress-status${progressStatusClass}${item.status === 'paused' ? ' is-hidden' : ''}">${safeProgressStatus}</span>
<span class="queue-progress-metrics">${safeProgressMetrics}</span>
</div>
<div class="queue-details${expandedQueueIds.has(item.id) ? ' expanded' : ''}" id="${detailsId}">
@@ -713,7 +731,6 @@ function renderQueue(): void {
${renderQueueItemFileActions(item)}
</div>
</div>
${item.status === 'error' && !item.mergeRecoveryBlocked ? `<button class="queue-retry-btn" type="button" title="${escapeHtml(UI_TEXT.queue.retryItem)}" aria-label="${escapeHtml(UI_TEXT.queue.retryItem)}" data-queue-action="retry">&#x21bb;</button>` : ''}
</div>
`;
}).join('');
+2 -2
View File
@@ -20,7 +20,7 @@ describe('production style modules', () => {
.replace(/\r\n/g, '\n'));
const digest = createHash('sha256').update(content).digest('hex');
expect(digest).toBe('143fb0cc3e6c2ca3f04ded7e3b83175db424b34b6ca9ed4ef32e337b4a60a898');
expect(digest).toBe('2ed5d8cce6dca293fba4f83a0347950ee887c01cafb9febfb345842a6c569cd5');
});
test('derives the Windows hot-development executable version from package metadata', () => {
@@ -57,7 +57,7 @@ describe('production style modules', () => {
test('animates queue progress only while downloading and visibly marks paused items', () => {
const styles = readFileSync(join(__dirname, 'styles-workflows.css'), 'utf8');
const baseShimmer = styles.match(/\.queue-progress-bar::after\s*\{([\s\S]*?)\}/)?.[1] ?? '';
const activeShimmer = styles.match(/\.status\.downloading\s*~\s*\.queue-main\s+\.queue-progress-bar::after\s*\{([\s\S]*?)\}/)?.[1] ?? '';
const activeShimmer = styles.match(/\.queue-item:has\(\.status\.downloading\)\s+\.queue-progress-bar::after\s*\{([\s\S]*?)\}/)?.[1] ?? '';
const pausedStatus = styles.match(/\.queue-item\s+\.status\.paused\s*\{([\s\S]*?)\}/)?.[1] ?? '';
expect(baseShimmer).toMatch(/display:\s*none/);
+108 -38
View File
@@ -93,7 +93,6 @@
.queue-item:has(.status.completed) {
border-left-color: var(--success);
opacity: 0.85;
}
.queue-item .status {
@@ -102,7 +101,7 @@
border-radius: 50%;
background: var(--text-secondary);
flex-shrink: 0;
margin-top: 4px;
margin: 0;
}
.queue-item .status.pending { background: var(--warning); box-shadow: 0 0 6px rgba(255, 167, 38, 0.5); }
@@ -118,10 +117,15 @@
.queue-item .title {
flex: 1;
min-width: 0;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
overflow-wrap: anywhere;
font-size: 13px;
font-weight: 600;
line-height: 19px;
}
.queue-detail-label {
@@ -167,19 +171,21 @@
.queue-title-row {
display: flex;
align-items: center;
gap: 8px;
min-height: 16px;
gap: 6px;
min-height: 32px;
margin-bottom: 9px;
}
.queue-status-label {
flex-shrink: 0;
font-size: 10px;
color: var(--text-secondary);
line-height: 16px;
font-size: 11px;
font-weight: 600;
color: var(--text);
line-height: 18px;
}
.queue-meta {
font-size: 10px;
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
margin-bottom: 4px;
@@ -188,11 +194,50 @@
text-overflow: ellipsis;
}
.queue-footer,
.queue-summary {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.queue-footer {
justify-content: space-between;
margin-top: 6px;
}
.queue-date {
flex-shrink: 0;
margin-left: auto;
color: var(--text-secondary);
font-size: 12px;
line-height: 18px;
font-variant-numeric: tabular-nums;
text-align: right;
}
.queue-status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 7px;
border: 1px solid var(--border-soft);
border-radius: 6px;
background: color-mix(in srgb, var(--text-secondary) 8%, transparent);
white-space: nowrap;
}
.queue-status-badge:has(.status.pending) {
background: color-mix(in srgb, var(--warning) 10%, transparent);
border-color: color-mix(in srgb, var(--warning) 25%, transparent);
}
.queue-progress-wrap {
height: 5px;
border-radius: 999px;
overflow: hidden;
background: rgba(255,255,255,0.10);
background: color-mix(in srgb, var(--text-secondary) 18%, transparent);
position: relative;
}
@@ -215,7 +260,7 @@
animation: none;
}
.status.downloading ~ .queue-main .queue-progress-bar::after {
.queue-item:has(.status.downloading) .queue-progress-bar::after {
display: block;
animation: queue-progress-shimmer 1.8s ease-in-out infinite;
}
@@ -227,21 +272,20 @@
.queue-progress-info {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-top: 3px;
font-size: 10px;
flex-wrap: wrap;
font-size: 12px;
color: var(--text-secondary);
line-height: 14px;
line-height: 18px;
}
.queue-progress-status,
.queue-progress-metrics {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
}
.queue-progress-status.is-starting {
@@ -259,22 +303,51 @@
font-variant-numeric: tabular-nums;
}
.queue-item .remove {
.queue-item .remove,
.queue-item .queue-retry-btn,
.queue-details-toggle {
display: inline-flex;
width: 16px;
height: 16px;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
flex: 0 0 16px;
flex: 0 0 32px;
padding: 0;
cursor: pointer;
color: var(--error);
opacity: 0.7;
font-size: 11px;
line-height: 16px;
color: var(--text-secondary);
background: transparent;
border: 1px solid var(--border-soft);
border-radius: 7px;
font-size: 18px;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.queue-item .remove:hover {
opacity: 1;
color: var(--error);
background: color-mix(in srgb, var(--error) 12%, transparent);
border-color: color-mix(in srgb, var(--error) 45%, transparent);
}
.queue-details-toggle {
width: 28px;
height: 28px;
flex-basis: 28px;
border-color: transparent;
}
.queue-details-toggle:hover,
.queue-item .queue-retry-btn:hover {
color: var(--text);
background: color-mix(in srgb, var(--text-secondary) 12%, transparent);
}
.queue-details-toggle[aria-expanded="true"] svg {
transform: rotate(180deg);
}
.queue-details-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.queue-item[draggable="true"] {
@@ -291,10 +364,13 @@
.queue-details {
display: none;
font-size: 10px;
font-size: 12px;
line-height: 18px;
color: var(--text-secondary);
padding: 4px 0;
word-break: break-all;
margin-top: 8px;
padding: 8px 0 2px;
border-top: 1px solid var(--border-soft);
overflow-wrap: anywhere;
}
.queue-details.expanded {
@@ -330,12 +406,6 @@
box-shadow: inset 0 0 0 1px rgba(0, 200, 83, 0.45);
}
.queue-item .title:focus-visible {
outline: none;
border-radius: 3px;
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.45);
}
.queue-item.merge-group {
border-left: 3px solid var(--accent);
}
@@ -780,7 +850,7 @@ input[type="checkbox"].vod-select-checkbox {
display: inline-block;
background: #ff4444;
color: white;
font-size: 9px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
padding: 1px 5px;
+2 -2
View File
@@ -956,8 +956,8 @@ textarea:disabled,
}
.context-sidebar .queue-item {
margin: 3px 0;
padding: 7px;
margin: 0 0 8px;
padding: 10px;
background: var(--workspace-panel-raised);
border: 1px solid var(--workspace-border);
border-radius: var(--workspace-radius-small);