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']);
+});