Files pane and item buttons are icons, with the words in tooltips
Save, delete, view and download in the Files pane, and mark read, keep and open the original under an item's title, are icons now. The word is in each one's title and aria-label, so it is still there on hover and for screen readers. Tests find them by title or action, not text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
This commit is contained in:
@@ -28,6 +28,8 @@ The long form, with what was wrong before and how it was found, is in
|
|||||||
items show without pressing Scan.
|
items show without pressing Scan.
|
||||||
- A file deleted to save space, or by hand, looks as if it was never downloaded: no "reaped"
|
- A file deleted to save space, or by hand, looks as if it was never downloaded: no "reaped"
|
||||||
label, just the Download button. The retention summary says "deleted", not "reaped".
|
label, just the Download button. The retention summary says "deleted", not "reaped".
|
||||||
|
- The Files pane and an item's own buttons are icons, with the words in their tooltips: save,
|
||||||
|
delete, view, download, mark read, keep, and open the original.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ test('the three panes are there and the item text lands in the bottom one', asyn
|
|||||||
const downloaded = page.locator('.ep', { hasText: 'downloaded' }).first();
|
const downloaded = page.locator('.ep', { hasText: 'downloaded' }).first();
|
||||||
await downloaded.click();
|
await downloaded.click();
|
||||||
await expect(page.locator('#files audio')).toBeVisible();
|
await expect(page.locator('#files audio')).toBeVisible();
|
||||||
await expect(page.locator('#files .encbox .btn', { hasText: 'Save' })).toBeVisible();
|
await expect(page.locator('#files .encbox [title="Save to this computer"]')).toBeVisible();
|
||||||
|
|
||||||
// Selecting another item replaces the pane rather than stacking.
|
// Selecting another item replaces the pane rather than stacking.
|
||||||
await page.locator('.ep', { hasText: 'First Episode' }).click();
|
await page.locator('.ep', { hasText: 'First Episode' }).click();
|
||||||
@@ -101,8 +101,8 @@ test('a downloaded file that is not audio gets no player', async ({ page }) => {
|
|||||||
await expect(page.locator('#files .encbox')).toContainText('image');
|
await expect(page.locator('#files .encbox')).toContainText('image');
|
||||||
await expect(page.locator('#files .encbox')).toContainText('downloaded');
|
await expect(page.locator('#files .encbox')).toContainText('downloaded');
|
||||||
// Still offered as a file, just not as an episode: viewable and keepable.
|
// Still offered as a file, just not as an episode: viewable and keepable.
|
||||||
await expect(page.locator('#files .btn', { hasText: 'Save' })).toBeVisible();
|
await expect(page.locator('#files [title="Save to this computer"]')).toBeVisible();
|
||||||
const view = page.locator('#files a', { hasText: 'View' });
|
const view = page.locator('#files a[title="View in a new tab"]');
|
||||||
await expect(view).toHaveAttribute('target', '_blank');
|
await expect(view).toHaveAttribute('target', '_blank');
|
||||||
await expect(view).toHaveAttribute('rel', /noopener/);
|
await expect(view).toHaveAttribute('rel', /noopener/);
|
||||||
await expect(view).toHaveAttribute('href', /\/media\/\d+/);
|
await expect(view).toHaveAttribute('href', /\/media\/\d+/);
|
||||||
@@ -213,7 +213,7 @@ test('inside an OPML, feeds with unread items are listed first', async ({ page }
|
|||||||
await side.getByText('Grouped Show').click();
|
await side.getByText('Grouped Show').click();
|
||||||
const gs = page.locator('.ep', { hasText: 'Grouped Ep' });
|
const gs = page.locator('.ep', { hasText: 'Grouped Ep' });
|
||||||
await gs.click();
|
await gs.click();
|
||||||
await page.locator('#detail button', { hasText: 'Mark unread' }).click();
|
await page.locator('#detail button[title="Mark unread"]').click();
|
||||||
await expect(gs).not.toHaveClass(/read/);
|
await expect(gs).not.toHaveClass(/read/);
|
||||||
|
|
||||||
// Aardvark comes first alphabetically and in the OPML, so only the unread sort puts
|
// Aardvark comes first alphabetically and in the OPML, so only the unread sort puts
|
||||||
@@ -281,9 +281,9 @@ test('opening an item marks it read, and the toggle flips it back', async ({ pag
|
|||||||
// Another test may have opened this item already, so start from a known state: the
|
// Another test may have opened this item already, so start from a known state: the
|
||||||
// toggle in the text below flips it back -- which used to recurse until the stack blew.
|
// toggle in the text below flips it back -- which used to recurse until the stack blew.
|
||||||
await row().click();
|
await row().click();
|
||||||
await page.locator('#detail button', { hasText: 'Mark unread' }).click();
|
await page.locator('#detail button[title="Mark unread"]').click();
|
||||||
await expect(row()).not.toHaveClass(/read/);
|
await expect(row()).not.toHaveClass(/read/);
|
||||||
await expect(page.locator('#detail button', { hasText: 'Mark read' })).toBeVisible();
|
await expect(page.locator('#detail button[title="Mark read"]')).toBeVisible();
|
||||||
|
|
||||||
// Opening it is reading it.
|
// Opening it is reading it.
|
||||||
await row().click();
|
await row().click();
|
||||||
@@ -375,8 +375,9 @@ test('deleting a shared file warns that it is everyone\'s copy', async ({ page }
|
|||||||
await expect(row).toBeVisible({ timeout: 20_000 });
|
await expect(row).toBeVisible({ timeout: 20_000 });
|
||||||
await row.click();
|
await row.click();
|
||||||
|
|
||||||
const del = page.locator('#files button', { hasText: 'Delete' });
|
// An icon now; what it does, and to whom, is in its tooltip.
|
||||||
await expect(del).toHaveText('Delete for everyone');
|
const del = page.locator('#files button[data-a="del"]');
|
||||||
|
await expect(del).toHaveAttribute('title', /^Delete for everyone \(shared with 1 other person/);
|
||||||
|
|
||||||
// Two prompts: the page's own, then the server's, because someone else has not played
|
// Two prompts: the page's own, then the server's, because someone else has not played
|
||||||
// it. Accept the first, decline the second, and the file must survive.
|
// it. Accept the first, decline the second, and the file must survive.
|
||||||
@@ -657,12 +658,12 @@ test('a deleted file looks as if it was never downloaded', async ({ page }) => {
|
|||||||
const row = page.locator('.ep', { hasText: 'An Article' });
|
const row = page.locator('.ep', { hasText: 'An Article' });
|
||||||
await expect(row).toBeVisible({ timeout: 20_000 });
|
await expect(row).toBeVisible({ timeout: 20_000 });
|
||||||
await row.click();
|
await row.click();
|
||||||
await page.locator('#files button', { hasText: 'Delete' }).click();
|
await page.locator('#files button[data-a="del"]').click();
|
||||||
|
|
||||||
// No "reaped", no chip at all: just the way to get it again.
|
// No "reaped", no chip at all: just the way to get it again.
|
||||||
await expect(row).not.toContainText('downloaded');
|
await expect(row).not.toContainText('downloaded');
|
||||||
await expect(row).not.toContainText(/reaped/i);
|
await expect(row).not.toContainText(/reaped/i);
|
||||||
await row.click();
|
await row.click();
|
||||||
await expect(page.locator('#files')).not.toContainText(/reaped/i);
|
await expect(page.locator('#files')).not.toContainText(/reaped/i);
|
||||||
await expect(page.locator('#files .btn', { hasText: 'Download' })).toBeVisible();
|
await expect(page.locator('#files button[title="Download to the server"]')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -214,6 +214,8 @@ a.btn{text-decoration:none;color:inherit}
|
|||||||
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--ink);font-weight:600}
|
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--ink);font-weight:600}
|
||||||
.btn.primary:hover{filter:brightness(1.08)}
|
.btn.primary:hover{filter:brightness(1.08)}
|
||||||
.btn.danger:hover{border-color:var(--bad);color:var(--bad)}
|
.btn.danger:hover{border-color:var(--bad);color:var(--bad)}
|
||||||
|
/* An icon in place of a word; the word is in its tooltip. */
|
||||||
|
.btn.ico{padding:4px 9px;min-width:32px;font-size:14px;line-height:1.25;text-align:center}
|
||||||
.toolbar{
|
.toolbar{
|
||||||
display:flex;gap:10px;align-items:center;margin-bottom:12px;flex-wrap:wrap;
|
display:flex;gap:10px;align-items:center;margin-bottom:12px;flex-wrap:wrap;
|
||||||
position:sticky;top:0;background:var(--bg);padding:6px 0 8px;z-index:3;
|
position:sticky;top:0;background:var(--bg);padding:6px 0 8px;z-index:3;
|
||||||
@@ -983,9 +985,12 @@ function showDetail(e){
|
|||||||
${num?`<span>${num}</span><span class="dot"></span>`:''}
|
${num?`<span>${num}</span><span class="dot"></span>`:''}
|
||||||
<span>${dateOf(e.published)}</span>
|
<span>${dateOf(e.published)}</span>
|
||||||
${e.duration?`<span class="dot"></span><span>${clock(e.duration)}</span>`:''}
|
${e.duration?`<span class="dot"></span><span>${clock(e.duration)}</span>`:''}
|
||||||
<button class="btn" data-a="read">Mark ${e.read?'unread':'read'}</button>
|
<button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
|
||||||
<button class="btn" data-a="flag">${e.flagged?'\u2605 Kept':'\u2606 Keep'}</button>
|
aria-label="Mark ${e.read?'unread':'read'}">${e.read?'\u25cf':'\u2713'}</button>
|
||||||
${e.link?`<a class="btn" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer">Open original \u2197</a>`:''}
|
<button class="btn ico" data-a="flag" title="${e.flagged?'Kept: never deleted. Stop keeping':'Keep, so it is never deleted'}"
|
||||||
|
aria-label="${e.flagged?'Stop keeping':'Keep'}">${e.flagged?'\u2605':'\u2606'}</button>
|
||||||
|
${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer"
|
||||||
|
title="Open the original" aria-label="Open the original">\u2197</a>`:''}
|
||||||
</div>
|
</div>
|
||||||
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>
|
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>
|
||||||
${narrow?encs:''}`;
|
${narrow?encs:''}`;
|
||||||
@@ -1006,16 +1011,19 @@ function encBox(x){
|
|||||||
// One file serves everyone reading the feed, so deleting is not a private act.
|
// One file serves everyone reading the feed, so deleting is not a private act.
|
||||||
const f=S.feeds.find(y=>y.id===x.feed_id);
|
const f=S.feeds.find(y=>y.id===x.feed_id);
|
||||||
const shared=f&&f.subscribers>1;
|
const shared=f&&f.subscribers>1;
|
||||||
const delBtn=`<button class="btn danger" data-a="del" data-enc="${x.id}"${
|
// Icons, with the words in the tooltip and for screen readers.
|
||||||
shared?` title="Shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'} reading this feed"`:''
|
const delLabel=shared
|
||||||
}>Delete${shared?' for everyone':' file'}</button>`;
|
? `Delete for everyone (shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'} reading this feed)`
|
||||||
|
: 'Delete file';
|
||||||
|
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">🗑</button>`;
|
||||||
|
const saveBtn=`<a class="btn ico" href="/media/${x.id}" download title="Save to this computer" aria-label="Save to this computer">💾</a>`;
|
||||||
if(x.path && !isPlayable(x)){
|
if(x.path && !isPlayable(x)){
|
||||||
// On disk, but not audio or video: view it, keep it, or remove it -- no player.
|
// On disk, but not audio or video: view it, keep it, or remove it -- no player.
|
||||||
return `<div class="encbox">
|
return `<div class="encbox">
|
||||||
<span class="chip done">${esc(kindOf(x))}</span>
|
<span class="chip done">${esc(kindOf(x))}</span>
|
||||||
<span class="meta" style="flex:1">downloaded${size?' \u00b7 '+size:''}</span>
|
<span class="meta" style="flex:1">downloaded${size?' \u00b7 '+size:''}</span>
|
||||||
<a class="btn" href="/media/${x.id}" target="_blank" rel="noopener noreferrer">View</a>
|
<a class="btn ico" href="/media/${x.id}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">↗</a>
|
||||||
<a class="btn" href="/media/${x.id}" download>Save</a>
|
${saveBtn}
|
||||||
${delBtn}
|
${delBtn}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -1023,7 +1031,7 @@ function encBox(x){
|
|||||||
return `<div class="encbox">
|
return `<div class="encbox">
|
||||||
<audio id="audio-${x.id}" controls preload="none" src="/media/${x.id}"></audio>
|
<audio id="audio-${x.id}" controls preload="none" src="/media/${x.id}"></audio>
|
||||||
<span class="meta">${size}</span>
|
<span class="meta">${size}</span>
|
||||||
<a class="btn" href="/media/${x.id}" download>Save</a>
|
${saveBtn}
|
||||||
${delBtn}
|
${delBtn}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -1036,8 +1044,8 @@ function encBox(x){
|
|||||||
${x.state==='reaped'?'':`<span class="chip ${esc(x.state)}">${x.state==='skipped'?kindOf(x):esc(x.state)}</span>`}
|
${x.state==='reaped'?'':`<span class="chip ${esc(x.state)}">${x.state==='skipped'?kindOf(x):esc(x.state)}</span>`}
|
||||||
<span class="meta" style="flex:1">${esc(kindOf(x))}${size?' \u00b7 '+size:''}</span>
|
<span class="meta" style="flex:1">${esc(kindOf(x))}${size?' \u00b7 '+size:''}</span>
|
||||||
${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
|
${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
|
||||||
${viewable?`<a class="btn" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer">View</a>`:''}
|
${viewable?`<a class="btn ico" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">↗</a>`:''}
|
||||||
<button class="btn" data-a="get" data-enc="${x.id}">Download</button>
|
<button class="btn ico" data-a="get" data-enc="${x.id}" title="Download to the server" aria-label="Download to the server">⤓</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user