diff --git a/CHANGELOG.md b/CHANGELOG.md index 340387e..19b2d38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index b47e8f1..2dbf6ac 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -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. diff --git a/eslint.config.mjs b/eslint.config.mjs index 9b6865c..0ba3db7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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, diff --git a/package.json b/package.json index 7e428e5..1f02c0c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/lint-config.test.mjs b/scripts/lint-config.test.mjs index b0de9af..ad2e605 100644 --- a/scripts/lint-config.test.mjs +++ b/scripts/lint-config.test.mjs @@ -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; diff --git a/scripts/public-release-files.json b/scripts/public-release-files.json index 83dd2f4..1a34abc 100644 --- a/scripts/public-release-files.json +++ b/scripts/public-release-files.json @@ -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", diff --git a/scripts/smoke-test-ci-contract.js b/scripts/smoke-test-ci-contract.js index 06906b2..909c1d2 100644 --- a/scripts/smoke-test-ci-contract.js +++ b/scripts/smoke-test-ci-contract.js @@ -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' diff --git a/scripts/smoke-test-e2e-isolation-contract.js b/scripts/smoke-test-e2e-isolation-contract.js index 77ac28c..5299dcb 100644 --- a/scripts/smoke-test-e2e-isolation-contract.js +++ b/scripts/smoke-test-e2e-isolation-contract.js @@ -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() { diff --git a/scripts/smoke-test-queue-cards.js b/scripts/smoke-test-queue-cards.js new file mode 100644 index 0000000..4b210b5 --- /dev/null +++ b/scripts/smoke-test-queue-cards.js @@ -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; }); diff --git a/scripts/smoke-test-workspace-ui.js b/scripts/smoke-test-workspace-ui.js index d297c14..b880b66 100644 --- a/scripts/smoke-test-workspace-ui.js +++ b/scripts/smoke-test-workspace-ui.js @@ -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}`); diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index 2de98de..18bc5b5 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -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...', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index 4ead8e8..c418a51 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -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...', diff --git a/src/renderer-queue.production-path.test.ts b/src/renderer-queue.production-path.test.ts index 5ec5a2f..f1db2a4 100644 --- a/src/renderer-queue.production-path.test.ts +++ b/src/renderer-queue.production-path.test.ts @@ -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%'); }); diff --git a/src/renderer-queue.ts b/src/renderer-queue.ts index 7f11066..248865d 100644 --- a/src/renderer-queue.ts +++ b/src/renderer-queue.ts @@ -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('.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('queueList').querySelectorAll('.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('btnRetryFailed'); const clearBtn = byId('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 `
${isSelected ? `${selectionPosition}` : ''} -
-
${liveBadge}${healthBadge}${mergeIcon}${isClip}${safeTitle}
-
${safeStatusLabel}
- x +
${liveBadge}${healthBadge}${mergeIcon}${isClip}${safeTitle}
+ ${item.status === 'error' && !item.mergeRecoveryBlocked ? `` : ''} +
-
${safeDate}${mergeMetaExtra}
+ ${mergeMetaExtra ? `
${mergeMetaExtra}
` : ''}
-
- ${safeProgressStatus} + +
+ ${safeProgressStatus} ${safeProgressMetrics}
@@ -713,7 +731,6 @@ function renderQueue(): void { ${renderQueueItemFileActions(item)}
- ${item.status === 'error' && !item.mergeRecoveryBlocked ? `` : ''}
`; }).join(''); diff --git a/src/style-modules.production-path.test.ts b/src/style-modules.production-path.test.ts index fbdd173..6186afa 100644 --- a/src/style-modules.production-path.test.ts +++ b/src/style-modules.production-path.test.ts @@ -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/); diff --git a/src/styles-workflows.css b/src/styles-workflows.css index 1ced0ad..ef0cf1b 100644 --- a/src/styles-workflows.css +++ b/src/styles-workflows.css @@ -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; diff --git a/src/workspace.css b/src/workspace.css index 58cde12..79a316c 100644 --- a/src/workspace.css +++ b/src/workspace.css @@ -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);