Favicon, level file icons, and a feed error mark in the triangle's column

- The logo as favicon, squared up (it is 128x121), at /favicon.png and at
  /favicon.ico outside the auth layer, where a browser asking on its own got a
  401; an apple-touch-icon on white (#32).
- An item not yet downloaded had its download bar on a line of its own under the
  file icon, lifting the icon above its row's; the bar now sits under it without
  taking space (#31).
- A feed error is Font Awesome's exclamation, hung in the margin where a folder's
  triangle is, in the same column; a folder holding a failing feed has its
  triangle turn red.

Closes #31, #32.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 14:09:38 +00:00
parent 5483355021
commit fc09e8a6b7
9 changed files with 92 additions and 8 deletions

View File

@@ -19,8 +19,9 @@ The long form, with what was wrong before and how it was found, is in
### Changed ### Changed
- A feed that fails to check gets a red ! in the feed list, and its page says why, in place of a - A feed that fails to check gets a red exclamation mark in the feed list, in the margin where a
pop-up per failure that everyone saw during a scan of every feed. folder's triangle sits, and its page says why, in place of a pop-up per failure that everyone
saw during a scan of every feed. A folder holding a failing feed has its triangle turn red.
- The theme is chosen in Settings only; the button beside the iPodderX name is gone. - The theme is chosen in Settings only; the button beside the iPodderX name is gone.
- Add a feed asks only for the feed; Popular and Directory in the sidebar are where you browse. - Add a feed asks only for the feed; Popular and Directory in the sidebar are where you browse.
- On a phone, an item's files, with play and delete, sit above its show notes rather than below - On a phone, an item's files, with play and delete, sit above its show notes rather than below
@@ -34,6 +35,10 @@ The long form, with what was wrong before and how it was found, is in
- Switching tabs straight after marking everything read no longer shows the previous tab's - Switching tabs straight after marking everything read no longer shows the previous tab's
items: of two lists asked for at once, only the later one is shown. items: of two lists asked for at once, only the later one is shown.
- ipx has a favicon: the logo, squared up, also at /favicon.ico for browsers that ask there on
their own, and on white for an iPhone's home screen.
- The file icon of an item not yet downloaded sits level with the rest of its row, instead of
higher than a downloaded one's.
- An item you open stays read. A list refresh that crossed with marking it read could put its - An item you open stays read. A list refresh that crossed with marking it read could put its
unread dot back until the next refresh. unread dot back until the next refresh.
- On the Unread tab, the item you were reading leaves the list as soon as you move to the next - On the Unread tab, the item you were reading leaves the list as soon as you move to the next

View File

@@ -65,6 +65,9 @@ pub fn router(state: WebState) -> Router {
.route("/login", get(login_page)) .route("/login", get(login_page))
.route("/api/login", post(login)) .route("/api/login", post(login))
.route("/icon.png", get(icon)) .route("/icon.png", get(icon))
.route("/favicon.ico", get(favicon))
.route("/favicon.png", get(favicon))
.route("/apple-touch-icon.png", get(touch_icon))
.route("/app.js", get(app_js)) .route("/app.js", get(app_js))
.route("/login.js", get(login_js)) .route("/login.js", get(login_js))
.route("/inter.woff2", get(inter)) .route("/inter.woff2", get(inter))
@@ -477,6 +480,25 @@ async fn icon() -> impl IntoResponse {
) )
} }
/// The logo, squared up with transparent padding: it is 128x121, and a tab icon that is not
/// square can be passed over. /favicon.ico is the same PNG, for a browser that asks for that
/// on its own; behind the auth layer it answered 401, and the tab stayed blank.
async fn favicon() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
include_bytes!("../web/favicon.png").as_slice(),
)
}
/// For an iPhone's home screen, which paints a transparent icon's background black, so this
/// one is on white.
async fn touch_icon() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
include_bytes!("../web/apple-touch-icon.png").as_slice(),
)
}
/// Inter, the pages' typeface, served from the binary as the icon is, so neither page loads /// Inter, the pages' typeface, served from the binary as the icon is, so neither page loads
/// anything from anyone else. Outside the auth layer for the sign-in page. Its licence, the SIL /// anything from anyone else. Outside the auth layer for the sign-in page. Its licence, the SIL
/// Open Font License, is web/Inter-LICENSE.txt. /// Open Font License, is web/Inter-LICENSE.txt.

View File

@@ -1085,3 +1085,47 @@ test.describe('on a phone', () => {
.toBe(true); .toBe(true);
}); });
}); });
test('a file not yet downloaded has its icon in line with the rest of its row', async ({ page }) => {
await page.locator('#feedlist .place', { hasText: 'All Subscriptions' }).click();
await expect(page.locator('.ep .dlbar').first()).toBeAttached({ timeout: 20_000 });
// The icon's middle against the date's, downloaded or not. The download bar used to take a
// line of its own and lift the icon of every pending file (issue #31).
const offsets = await page.$$eval('.ep', rows => rows.map(r => {
const k = r.querySelector('.file .kind'), d = r.querySelector('.date');
if (!k || !d) return null;
const a = k.getBoundingClientRect(), b = d.getBoundingClientRect();
return Math.round((a.top + a.height / 2) - (b.top + b.height / 2));
}).filter(x => x !== null));
expect(offsets.length).toBeGreaterThan(1);
for (const o of offsets) expect(Math.abs(o)).toBeLessThanOrEqual(1);
});
test('the favicon is the logo, square, from both pages', async ({ page }) => {
await expect(page.locator('link[rel="icon"]')).toHaveAttribute('href', '/favicon.png');
// A browser asks for /favicon.ico on its own, signed in or not.
for (const path of ['/favicon.ico', '/favicon.png', '/apple-touch-icon.png']) {
const r = await page.request.get(path, { headers: { cookie: '' } });
expect(r.status(), path).toBe(200);
expect(r.headers()['content-type'], path).toBe('image/png');
}
});
test('a feed error is marked in the same column as the folder triangles', async ({ page }) => {
await expect(page.locator('.feed.group .chev').first()).toBeVisible();
if ((await page.locator('.feed.group .chev').first().getAttribute('aria-expanded')) !== 'true')
await page.locator('.feed.group .chev').first().click();
// Faked in the page: no fixture feed fails. A feed on its own, and one inside a folder.
await page.evaluate(() => {
S.feeds.find(f => f.group).last_error = 'HTTP 404';
S.feeds.find(f => !f.group && !S.feeds.some(c => c.group === f.id)).last_error = 'timed out';
renderFeeds();
});
await expect(page.locator('.ferr')).toHaveCount(2);
await expect(page.locator('.ferr svg')).toHaveCount(2); // the icon, not a "!"
await expect(page.locator('.chev.bad')).toHaveCount(1); // the folder holding one
const xs = await page.$$eval('.chev, .ferr', els =>
els.map(e => { const r = e.getBoundingClientRect(); return Math.round(r.left + r.width / 2); }));
expect(new Set(xs).size, JSON.stringify(xs)).toBe(1);
await page.reload(); // put the real list back
});

BIN
web/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
web/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -5,7 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<title>iPodderX</title> <title>iPodderX</title>
<link rel="icon" href="/icon.png"> <link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<style> <style>
/* Inter, served by ipx itself (/inter.woff2) rather than a font CDN, so the page asks nothing of /* Inter, served by ipx itself (/inter.woff2) rather than a font CDN, so the page asks nothing of
anyone else. One variable file covers every weight used here; italics are synthesized. Classic anyone else. One variable file covers every weight used here; italics are synthesized. Classic
@@ -371,6 +372,12 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
border-radius:4px;color:var(--faint); border-radius:4px;color:var(--faint);
} }
.chev:hover{color:var(--fg)} .chev:hover{color:var(--fg)}
.chev.bad,.chev.bad:hover{color:var(--bad)}
/* A feed's error mark, in the triangle's place: the same column as every folder's triangle,
a child's included, which is why it moves left by the child's indent. */
.ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)}
.ferr .i{width:12px;height:12px}
.feed.child .ferr{left:-27px}
.chev .i{width:12px;height:12px;transition:transform .12s} .chev .i{width:12px;height:12px;transition:transform .12s}
.chev[aria-expanded="true"] .i{transform:rotate(90deg)} .chev[aria-expanded="true"] .i{transform:rotate(90deg)}
.childlist{display:grid;grid-template-columns:minmax(0,1fr);gap:4px;margin-top:10px} .childlist{display:grid;grid-template-columns:minmax(0,1fr);gap:4px;margin-top:10px}
@@ -586,8 +593,10 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s}
.ep .line{display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px} .ep .line{display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px}
.ep .line:empty{display:none} .ep .line:empty{display:none}
.ep .fd,.ep .size,.ep .date{color:var(--dim);font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .ep .fd,.ep .size,.ep .date{color:var(--dim);font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ep .file{display:flex;gap:6px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px} .ep .file{display:flex;gap:6px;align-items:center;position:relative;color:var(--faint);font-size:11.5px}
.ep .file .dlbar{flex-basis:100%;margin-top:2px} /* Under the icon, not beside it or on a line of its own: taking a line, it lifted the icon of
every file not yet downloaded above the downloaded ones' (issue #31). */
.ep .file .dlbar{position:absolute;left:0;right:0;bottom:-5px;margin:0}
.ep .rowacts{justify-content:flex-end} .ep .rowacts{justify-content:flex-end}
/* One feed's own table has no need of a Feed column; All Subscriptions does. */ /* One feed's own table has no need of a Feed column; All Subscriptions does. */
#split.one .ephead,#split.one .ep{grid-template-columns:22px 22px minmax(120px,1fr) 56px minmax(0,72px) minmax(0,92px) 64px} #split.one .ephead,#split.one .ep{grid-template-columns:22px 22px minmax(120px,1fr) 56px minmax(0,72px) minmax(0,92px) 64px}

View File

@@ -1,5 +1,6 @@
<title>Sign in — iPodderX</title> <title>Sign in — iPodderX</title>
<link rel="icon" href="/icon.png"> <link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<style> <style>
/* Inter, from ipx itself; see the same rule in index.html. */ /* Inter, from ipx itself; see the same rule in index.html. */
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap} @font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}

View File

@@ -83,13 +83,15 @@ function renderFeeds(){
el.tabIndex=0; el.dataset.id=f.id; el.tabIndex=0; el.dataset.id=f.id;
const open = !!(kids && (expanded.has(f.id) || q)); const open = !!(kids && (expanded.has(f.id) || q));
el.innerHTML = el.innerHTML =
(kids?`<button class="chev" aria-expanded="${open}" title="Show or hide the feeds inside" aria-label="Show or hide the feeds inside">${ICON.caret}</button>`:'')+ // The error mark hangs in the margin where a folder's triangle does. A folder already has
// its triangle there, so that turns red instead, and the feed inside shows the mark.
(kids?`<button class="chev${err?' bad':''}" aria-expanded="${open}" title="${err?`A feed inside has a problem: ${esc(err)}`:'Show or hide the feeds inside'}" aria-label="Show or hide the feeds inside">${ICON.caret}</button>`
:err?`<span class="ferr" role="img" title="${esc(err)}" aria-label="Error: ${esc(err)}">${ICON.alert}</span>`:'')+
(mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id))+ (mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id))+
`<div class="txt"><b>${esc(f.title||f.id)}</b><small>`+ `<div class="txt"><b>${esc(f.title||f.id)}</b><small>`+
`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+ `${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+
`</small></div>`+ `</small></div>`+
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+ (f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
(err?`<span class="tag" style="color:var(--bad)" title="${esc(err)}" aria-label="Error: ${esc(err)}">!</span>`:'')+
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`; `<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
el.onclick=()=>{ selectFeed(f.id); nav(false); }; el.onclick=()=>{ selectFeed(f.id); nav(false); };
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); }; if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };

View File

@@ -45,6 +45,7 @@ const ICON={
back:fa('0 0 512 512','<path fill="currentColor" d="M24 192l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-46.7-46.7c75.3-58.6 184.3-53.3 253.5 15.9 75 75 75 196.5 0 271.5s-196.5 75-271.5 0c-10.2-10.2-19-21.3-26.4-33-9.5-14.9-29.3-19.3-44.2-9.8s-19.3 29.3-9.8 44.2C49.7 408.7 61.4 423.5 75 437 175 537 337 537 437 437S537 175 437 75C342.8-19.3 193.3-24.7 92.7 58.8L41 7C34.1 .2 23.8-1.9 14.8 1.8S0 14.3 0 24L0 168c0 13.3 10.7 24 24 24z"/>'), // solid/rotate-left back:fa('0 0 512 512','<path fill="currentColor" d="M24 192l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-46.7-46.7c75.3-58.6 184.3-53.3 253.5 15.9 75 75 75 196.5 0 271.5s-196.5 75-271.5 0c-10.2-10.2-19-21.3-26.4-33-9.5-14.9-29.3-19.3-44.2-9.8s-19.3 29.3-9.8 44.2C49.7 408.7 61.4 423.5 75 437 175 537 337 537 437 437S537 175 437 75C342.8-19.3 193.3-24.7 92.7 58.8L41 7C34.1 .2 23.8-1.9 14.8 1.8S0 14.3 0 24L0 168c0 13.3 10.7 24 24 24z"/>'), // solid/rotate-left
fwd:fa('0 0 512 512','<path fill="currentColor" d="M488 192l-144 0c-9.7 0-18.5-5.8-22.2-14.8s-1.7-19.3 5.2-26.2l46.7-46.7c-75.3-58.6-184.3-53.3-253.5 15.9-75 75-75 196.5 0 271.5s196.5 75 271.5 0c8.2-8.2 15.5-16.9 21.9-26.1 10.1-14.5 30.1-18 44.6-7.9s18 30.1 7.9 44.6c-8.5 12.2-18.2 23.8-29.1 34.7-100 100-262.1 100-362 0S-25 175 75 75c94.3-94.3 243.7-99.6 344.3-16.2L471 7c6.9-6.9 17.2-8.9 26.2-5.2S512 14.3 512 24l0 144c0 13.3-10.7 24-24 24z"/>'), // solid/rotate-right fwd:fa('0 0 512 512','<path fill="currentColor" d="M488 192l-144 0c-9.7 0-18.5-5.8-22.2-14.8s-1.7-19.3 5.2-26.2l46.7-46.7c-75.3-58.6-184.3-53.3-253.5 15.9-75 75-75 196.5 0 271.5s196.5 75 271.5 0c8.2-8.2 15.5-16.9 21.9-26.1 10.1-14.5 30.1-18 44.6-7.9s18 30.1 7.9 44.6c-8.5 12.2-18.2 23.8-29.1 34.7-100 100-262.1 100-362 0S-25 175 75 75c94.3-94.3 243.7-99.6 344.3-16.2L471 7c6.9-6.9 17.2-8.9 26.2-5.2S512 14.3 512 24l0 144c0 13.3-10.7 24-24 24z"/>'), // solid/rotate-right
pause:fa('0 0 384 512','<path fill="currentColor" d="M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z"/>'), // solid/pause pause:fa('0 0 384 512','<path fill="currentColor" d="M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z"/>'), // solid/pause
alert:fa('0 0 128 512','<path fill="currentColor" d="M64 432c22.1 0 40 17.9 40 40s-17.9 40-40 40-40-17.9-40-40c0-22.1 17.9-40 40-40zM64 0c26.5 0 48 21.5 48 48 0 .6 0 1.1 0 1.7l-16 304c-.9 17-15 30.3-32 30.3S33 370.7 32 353.7L16 49.7c0-.6 0-1.1 0-1.7 0-26.5 21.5-48 48-48z"/>'), // solid/exclamation
caret:fa('0 0 256 512','<path fill="currentColor" d="M249.3 235.8c10.2 12.6 9.5 31.1-2.2 42.8l-128 128c-9.2 9.2-22.9 11.9-34.9 6.9S64.5 396.9 64.5 384l0-256c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l128 128 2.2 2.4z"/>'), // solid/caret-right caret:fa('0 0 256 512','<path fill="currentColor" d="M249.3 235.8c10.2 12.6 9.5 31.1-2.2 42.8l-128 128c-9.2 9.2-22.9 11.9-34.9 6.9S64.5 396.9 64.5 384l0-256c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l128 128 2.2 2.4z"/>'), // solid/caret-right
left:fa('0 0 512 512','<path fill="currentColor" d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 288 480 288c17.7 0 32-14.3 32-32s-14.3-32-32-32l-370.7 0 105.4-105.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/>'), // solid/arrow-left left:fa('0 0 512 512','<path fill="currentColor" d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 288 480 288c17.7 0 32-14.3 32-32s-14.3-32-32-32l-370.7 0 105.4-105.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/>'), // solid/arrow-left
subbed:fa('0 0 512 512','<path fill="currentColor" d="M256 512a256 256 0 1 1 0-512 256 256 0 1 1 0 512zM374 145.7c-10.7-7.8-25.7-5.4-33.5 5.3L221.1 315.2 169 263.1c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l72 72c5 5 11.8 7.5 18.8 7s13.4-4.1 17.5-9.8L379.3 179.2c7.8-10.7 5.4-25.7-5.3-33.5z"/>'), // solid/circle-check subbed:fa('0 0 512 512','<path fill="currentColor" d="M256 512a256 256 0 1 1 0-512 256 256 0 1 1 0 512zM374 145.7c-10.7-7.8-25.7-5.4-33.5 5.3L221.1 315.2 169 263.1c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l72 72c5 5 11.8 7.5 18.8 7s13.4-4.1 17.5-9.8L379.3 179.2c7.8-10.7 5.4-25.7-5.3-33.5z"/>'), // solid/circle-check