From 04b620e3c7186da5e7e8192222694f5a5a85e42e Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Sun, 4 Oct 2026 02:48:08 +0800 Subject: [PATCH] Polish download action buttons in Classic and Glass Stage --- frontend/download-actions.css | 11 +++++++ frontend/download-actions.js | 24 ++++++++++++++++ frontend/downloads-page.js | 21 ++++---------- frontend/downloads.js | 3 +- frontend/index.html | 2 ++ frontend/sw.js | 2 ++ playwright.download-actions.config.js | 2 ++ tests/download-actions.spec.js | 41 +++++++++++++++++++++++++++ 8 files changed, 89 insertions(+), 17 deletions(-) create mode 100644 frontend/download-actions.css create mode 100644 frontend/download-actions.js create mode 100644 playwright.download-actions.config.js create mode 100644 tests/download-actions.spec.js diff --git a/frontend/download-actions.css b/frontend/download-actions.css new file mode 100644 index 0000000..3a413a1 --- /dev/null +++ b/frontend/download-actions.css @@ -0,0 +1,11 @@ +/* Shared quiet controls; use each layout's existing surface and focus tokens. */ +:is(.dl-row,.download-job) .download-action { display:inline-flex; align-items:center; justify-content:center; flex:none; gap:8px; height:auto; min-height:44px; min-width:44px; padding:8px 14px; border:1px solid var(--line); border-radius:var(--radius-sm); background:var(--bg-2); color:var(--text); font:600 13px var(--ui); line-height:1.4; cursor:pointer; white-space:nowrap; } +:is(.dl-row,.download-job) .download-action:hover { background:var(--bg-3); border-color:var(--accent); } +:is(.dl-row,.download-job) .download-action.danger { color:var(--accent); } +:is(.dl-row,.download-job) .download-action:focus-visible { outline:2px solid var(--accent); outline-offset:3px; } +:is(.dl-row,.download-job) .download-action:disabled { opacity:.5; cursor:not-allowed; } +.download-action-icon { width:20px; height:20px; flex:none; fill:none; stroke:currentColor; stroke-width:1.9; stroke-linecap:round; stroke-linejoin:round; pointer-events:none; } +html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action { border-radius:18px; border-color:var(--glass-line); background:var(--tile); color:var(--text-2); box-shadow:none; } +html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action:hover { border-color:var(--glass-line); background:var(--tile-hi); color:var(--text); } +html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action.danger { color:var(--accent-bright); } +html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action:focus-visible { outline-color:var(--accent-bright); } diff --git a/frontend/download-actions.js b/frontend/download-actions.js new file mode 100644 index 0000000..0a92f22 --- /dev/null +++ b/frontend/download-actions.js @@ -0,0 +1,24 @@ +/* One action control for the Downloads page and Settings download manager. */ +(function(root) { + 'use strict'; + const paths = { + retry: '', + cancel: '', + pause: '', + resume: '', + }; + function create(doc, action, title) { + if (!paths[action]) throw new Error('Unknown download action'); + const label = action[0].toUpperCase() + action.slice(1); + const button = doc.createElement('button'); button.type = 'button'; + button.className = `dl-act download-action${action === 'cancel' ? ' danger' : ''}`; + button.setAttribute('aria-label', `${label} ${title}`); + const icon = doc.createElementNS('http://www.w3.org/2000/svg', 'svg'); + icon.setAttribute('viewBox', '0 0 24 24'); icon.setAttribute('aria-hidden', 'true'); icon.setAttribute('focusable', 'false'); + icon.classList.add('download-action-icon'); icon.innerHTML = paths[action]; + const text = doc.createElement('span'); text.textContent = label; + button.append(icon, text); return button; + } + const api = { create }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.DownloadActions = api; +})(globalThis); diff --git a/frontend/downloads-page.js b/frontend/downloads-page.js index 279efa5..169ad44 100644 --- a/frontend/downloads-page.js +++ b/frontend/downloads-page.js @@ -1,6 +1,7 @@ /* Downloads page UI module. Grouped jobs, storage info, confirmed cancellation. */ (function (root) { 'use strict'; + const actionButtons = typeof module !== 'undefined' ? require('./download-actions.js') : root.DownloadActions; const errors = typeof module !== 'undefined' ? require('./download-errors.js') : root.DownloadErrors; const bytes = n => { @@ -222,10 +223,7 @@ acts.className = 'dl-actions'; if (isActive) { - const pauseBtn = doc.createElement('button'); - pauseBtn.type = 'button'; - pauseBtn.className = 'dl-act'; - pauseBtn.textContent = 'Pause'; + const pauseBtn = actionButtons.create(doc, 'pause', job.title || job.id); pauseBtn.setAttribute('aria-label', `Pause ${job.title || job.id}`); pauseBtn.onclick = (e) => { e.stopPropagation(); @@ -234,10 +232,7 @@ }; acts.appendChild(pauseBtn); } else if (isPaused) { - const resumeBtn = doc.createElement('button'); - resumeBtn.type = 'button'; - resumeBtn.className = 'dl-act'; - resumeBtn.textContent = 'Resume'; + const resumeBtn = actionButtons.create(doc, 'resume', job.title || job.id); resumeBtn.setAttribute('aria-label', `Resume ${job.title || job.id}`); resumeBtn.onclick = (e) => { e.stopPropagation(); @@ -246,10 +241,7 @@ }; acts.appendChild(resumeBtn); } else if (isFailed) { - const retryBtn = doc.createElement('button'); - retryBtn.type = 'button'; - retryBtn.className = 'dl-act'; - retryBtn.textContent = 'Retry'; + const retryBtn = actionButtons.create(doc, 'retry', job.title || job.id); retryBtn.setAttribute('aria-label', `Retry ${job.title || job.id}`); retryBtn.onclick = (e) => { e.stopPropagation(); @@ -259,10 +251,7 @@ acts.appendChild(retryBtn); } - const cancelBtn = doc.createElement('button'); - cancelBtn.type = 'button'; - cancelBtn.className = 'dl-act danger'; - cancelBtn.textContent = 'Cancel'; + const cancelBtn = actionButtons.create(doc, 'cancel', job.title || job.id); cancelBtn.setAttribute('aria-label', `Cancel ${job.title || job.id}`); cancelBtn.onclick = (e) => { e.stopPropagation(); diff --git a/frontend/downloads.js b/frontend/downloads.js index 0ba29b9..903c1c8 100644 --- a/frontend/downloads.js +++ b/frontend/downloads.js @@ -1,6 +1,7 @@ /* Download controls and device storage accounting. */ (function(root) { 'use strict'; + const actionButtons = typeof module !== 'undefined' ? require('./download-actions.js') : root.DownloadActions; const errors = typeof module !== 'undefined' ? require('./download-errors.js') : root.DownloadErrors; const bytes = n => n >= 1073741824 ? `${(n / 1073741824).toFixed(1)} GB` : `${(n / 1048576).toFixed(1)} MB`; function breakdown(files, playlists) { @@ -29,7 +30,7 @@ const progress = doc.createElement('progress'); progress.max = job.progress?.total || 1; if (job.progress?.total) progress.value = job.progress.received || 0; progress.setAttribute('aria-label', `Saving ${job.title || job.id}`); const actions = doc.createElement('div'); for (const action of [...(['active', 'queued'].includes(job.status) ? ['pause'] : [job.status === 'failed' ? 'retry' : 'resume']), 'cancel']) { - const button = doc.createElement('button'); button.type = 'button'; button.textContent = action[0].toUpperCase() + action.slice(1); button.setAttribute('aria-label', `${button.textContent} ${job.title || job.id}`); button.onclick = () => { adapter.action(job.id, action); signature = ''; paint(); }; actions.append(button); + const button = actionButtons.create(doc, action, job.title || job.id); button.onclick = () => { adapter.action(job.id, action); signature = ''; paint(); }; actions.append(button); } row.append(title, status, progress, actions); if (job.error) errors.appendDetails(row, job.error); diff --git a/frontend/index.html b/frontend/index.html index c65f2fe..f75954a 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -37,6 +37,7 @@ +
@@ -670,6 +671,7 @@ + diff --git a/frontend/sw.js b/frontend/sw.js index 663f7a0..b1756e7 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -96,6 +96,8 @@ const SHELL = [ '/audio-continuity.js', '/fullscreen-orientation.js', '/fullscreen-orientation.css', + '/download-actions.js', + '/download-actions.css', '/download-errors.js', '/downloads.js', '/downloads-page.js', diff --git a/playwright.download-actions.config.js b/playwright.download-actions.config.js new file mode 100644 index 0000000..3cea649 --- /dev/null +++ b/playwright.download-actions.config.js @@ -0,0 +1,2 @@ +const { defineConfig } = require('@playwright/test'); +module.exports = defineConfig({ testDir: './tests', testMatch: /download-actions\.spec\.js/, timeout: 30000, workers: 1, projects: [{ name: 'chromium', use: { browserName: 'chromium' } }, { name: 'webkit', use: { browserName: 'webkit' } }], use: { baseURL: 'http://localhost:8104', serviceWorkers: 'block' }, webServer: { command: 'npx serve frontend -l 8104 --no-clipboard', port: 8104 } }); diff --git a/tests/download-actions.spec.js b/tests/download-actions.spec.js new file mode 100644 index 0000000..258568b --- /dev/null +++ b/tests/download-actions.spec.js @@ -0,0 +1,41 @@ +const { test, expect } = require('@playwright/test'); +const { openClassic } = require('./helpers/classic-fixture'); +const { mkdirSync } = require('node:fs'); +const before = process.env.CAPTURE_BEFORE === '1'; +for (const layout of ['classic', 'glass-stage']) for (const width of [390, 1440]) for (const screen of ['downloads', 'settings']) test(`${layout} ${width} ${screen}: failed download actions`, async ({ page }, info) => { + await page.setViewportSize({ width, height: 844 }); + await openClassic(page); + await page.evaluate(({ layout, screen }) => { + data.settings.layout = layout; applyAppearance(); + window.actionCalls = []; + Downloads.configure({ slots: () => 4, files: async () => [], playlists: () => [], jobs: () => [{ id: 'aaaaaaaaaaa', title: 'A long worship recording with piano, strings and choir — evening session', channel: 'Studio', thumbnail: '/fixture-cover.svg', status: 'failed', error: 'ERROR: [youtube] Video unavailable' }], action: (id, action) => actionCalls.push([id, action]) }); + API.cacheList = async () => ({ ok: true, items: [], total: 0 }); + view = { type: screen }; render(); + }, { layout, screen }); + if (screen === 'settings') await page.getByRole('button', { name: /Downloads & storage/ }).click(); + const retry = page.getByRole('button', { name: /^Retry A long/ }); + const cancel = page.getByRole('button', { name: /^Cancel A long/ }); + await expect(retry).toBeVisible(); await expect(cancel).toBeVisible(); + await retry.scrollIntoViewIfNeeded(); + if (before) { + mkdirSync('/home/josh/deliverables/ytplayer-done12', { recursive: true }); + await page.screenshot({ path: `/home/josh/deliverables/ytplayer-done12/before-${layout}-${width}-${screen}-${info.project.name}.png` }); return; + } + for (const button of [retry, cancel]) { + await expect(button.locator('svg')).toHaveAttribute('aria-hidden', 'true'); + const rect = await button.boundingBox(); expect(rect.height).toBeGreaterThanOrEqual(44); expect(rect.width).toBeGreaterThanOrEqual(44); + await expect(button.locator('span')).toHaveText(button === retry ? 'Retry' : 'Cancel'); + } + const a = await retry.boundingBox(), b = await cancel.boundingBox(); + expect(a.x + a.width <= b.x || a.y + a.height <= b.y).toBe(true); + const visual = await retry.evaluate(el => { const s = getComputedStyle(el), probe = document.createElement('span'); probe.style.backgroundColor = getComputedStyle(document.documentElement).getPropertyValue(document.documentElement.dataset.layout === 'glass-stage' ? '--tile' : '--bg-2').trim(); document.body.append(probe); const expected = getComputedStyle(probe).backgroundColor; probe.remove(); return { radius: s.borderRadius, background: s.backgroundColor, expected }; }); + expect(visual.radius).toBe(layout === 'glass-stage' ? '18px' : '8px'); + expect(visual.background).toBe(visual.expected); + await page.keyboard.press('Tab'); await retry.focus(); expect(await retry.evaluate(el => getComputedStyle(el).outlineStyle)).not.toBe('none'); + mkdirSync('/home/josh/deliverables/ytplayer-done12', { recursive: true }); + await page.screenshot({ path: `/home/josh/deliverables/ytplayer-done12/after-${layout}-${width}-${screen}-${info.project.name}.png` }); + await retry.click(); expect(await page.evaluate(() => actionCalls)).toContainEqual(['aaaaaaaaaaa', 'retry']); + await cancel.click(); + if (screen === 'downloads') await page.locator('.modal').getByRole('button', { name: 'Cancel save', exact: true }).click(); + expect(await page.evaluate(() => actionCalls)).toContainEqual(['aaaaaaaaaaa', 'cancel']); +});