8 Commits

Author SHA1 Message Date
680b5d4773 Release 0.8.2
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:34:13 +00:00
84e4b428e4 List the themes in alphabetical order
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:33:40 +00:00
768d02c840 Catppuccin, Gruvbox, Solarized and High contrast themes
Each in light and dark. The published palettes missed AA in 19 places,
mostly Solarized and Catppuccin Latte, so each failing colour is moved
the least distance, toward black or white, that clears every pair it is
drawn in. Solarized dark's base0 had to rise to base1 to read on base02,
so its dim sits between base2 and base1 to keep three steps of type.

tests/contrast.js checks every palette against the pairs the page draws,
and a border that matches the ground it sits on. Its first run caught
Classic's links at 3.7:1 on the source list and Modern's faint at 4.3:1
on inputs; both are tuned. A failed toast moves to --panel, since the
error colours are tuned for the page's grounds, not --raise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:24:56 +00:00
b5ff57ac0f Release 0.8.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:13:06 +00:00
35b57fa997 Settings and the shortcuts list close from the corner
Both have nothing to confirm, so their only button was a lone X at the
foot of the card, below the fold of a long Settings card. A dialog with a
confirm keeps Cancel beside it at the bottom, where the pair belongs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 01:45:02 +00:00
43ffafe7ac A UI pass: contrast in every theme, and a sign-in page that fits
Measured every theme's palette against WCAG AA. The unread badge's count
on its --accent2 fill fell as low as 2.6:1 (Flat Remix light), and the
unread dot with it; each theme's --accent2 is taken down until white on
it clears 4.5. Tags were --warn or --bad on --raise, short of AA in most
light themes, so they are outlined on the row's own ground instead.

Nordic dark had --line equal to --panel2, so bordered buttons on a
panel2 ground drew no edge at all. Classic's selected row left the
row's icon buttons grey on the blue. A zero badge on a selected row was
--raise on --raise and vanished.

login.html never had a doctype or viewport meta, so it rendered in
quirks mode and at desktop width on a phone, and its light palette sat
under data-theme="light", which nothing ever set. It follows
prefers-color-scheme now, signed out having no account to ask.

Dropped the unused log and users icons. The Classic theme's label is
now just "Classic".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 01:37:21 +00:00
45cbd3a239 The scan spinner takes the unread count's place
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 01:21:49 +00:00
905dfa0b02 A spinner on the feed being checked, not toasts; check only your own feeds
The scan's events reach everyone, so every browser showed "<feed>: N new" and
"Scanning…" toasts, and refreshed, for everyone's feeds. Now a feed's row, and
its folder's, carries a spinner between feed_start and its done, skip or error;
the list refreshes only for the reader's own feeds; the scan toasts are gone, and
"Downloaded" is said only for a file on screen.

"Check every feed" from the web UI sent a scan of every feed on the server.
Command::Fetch takes an optional `feeds` list -- those feeds and the feeds
inside any OPML among them -- and the web fills it with the asker's
subscriptions. The schedule and the CLI send none, meaning every feed.

Closes #37.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 01:17:40 +00:00
19 changed files with 410 additions and 61 deletions

View File

@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.8.2] - 2026-09-19
### Added
- Four themes, each in light and dark: Catppuccin (Latte and Mocha), Gruvbox, Solarized, and High
contrast, black and white with every colour at 7:1 or more.
### Changed
- The themes in Settings are listed in alphabetical order.
### Fixed
- Links in Classic, and hints and headings in Modern's dark half, are dark or light enough to
read comfortably. A failed-action message is easier to read in every theme.
## [0.8.1] - 2026-09-19
### Changed
- A feed being checked shows a small spinner in place of its unread count (and its folder's),
instead of toasts: no more "Scanning…" or "1 new" pop-ups, and none at all for feeds you do not read. A
"Downloaded" toast is only for a file on your screen.
- "Check every feed" checks every feed you subscribe to, not every feed on the server.
- The Classic theme is listed in Settings as just "Classic".
- Settings and the keyboard shortcuts close from an X in their top corner, not a button at the bottom.
### Fixed
- Unread counts, the unread dot and the Gone and Error tags are readable in every theme: several
light themes (Flat Remix, Paper, Adwaita, Nordic, Modern) drew them below AA contrast.
- The sign-in page follows the system's light or dark setting, and fits a phone's screen instead of
drawing at desktop width.
- In Classic, a selected item's play and delete buttons are white on the blue, not grey.
- In Nordic's dark half, button and toolbar borders show.
- A zero unread count on the selected feed no longer disappears into the selection.
## [0.8.0] - 2026-09-18
### Added
@@ -503,6 +540,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...main
[0.8.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.1...v0.8.2
[0.8.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.0...v0.8.1
[0.8.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.7.0...v0.8.0
[0.7.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...v0.7.0
[0.6.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.0...v0.6.1

View File

@@ -100,6 +100,7 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
npx tsc -p . # type-checks web/src
node tests/page-smoke.js
node tests/contrast.js # every theme's palette against WCAG AA
npx playwright test # 40 browser tests against a real daemon on fixture feeds
```

2
Cargo.lock generated
View File

@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]]
name = "ipx"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"ammonia",
"anyhow",

View File

@@ -1,6 +1,6 @@
[package]
name = "ipx"
version = "0.8.0"
version = "0.8.2"
edition = "2024"
[dependencies]

View File

@@ -88,6 +88,11 @@ pub enum Command {
feed: Option<String>,
#[serde(default)]
force: bool,
/// Only these feeds, and the feeds inside any of them that is an OPML: "check every feed"
/// from the web UI is every feed of the person asking, not of everyone (issue #37).
/// Empty is every feed, as the schedule and the CLI mean it.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
feeds: Vec<String>,
},
Reap {
#[serde(default)]
@@ -301,10 +306,10 @@ mod tests {
#[test]
fn commands_parse_from_the_wire_form() {
let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap();
assert!(matches!(got, Command::Fetch { feed: None, force: false }));
assert!(matches!(got, Command::Fetch { feed: None, force: false, .. }));
let got: Command = serde_json::from_str(r#"{"cmd":"fetch","feed":"atp","force":true}"#).unwrap();
assert!(matches!(got, Command::Fetch { feed: Some(f), force: true } if f == "atp"));
assert!(matches!(got, Command::Fetch { feed: Some(f), force: true, .. } if f == "atp"));
let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap();
assert!(matches!(got, Command::Reap { dry_run: true }));

View File

@@ -197,7 +197,7 @@ async fn main() -> Result<()> {
// A daemon owns the state; don't have two processes downloading the same thing.
let wire_cmd = match &cli.command {
Command::Fetch { feed, force } => {
Some(Cmd::Fetch { feed: feed.clone(), force: *force })
Some(Cmd::Fetch { feed: feed.clone(), force: *force, feeds: vec![] })
}
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
Command::Status => Some(Cmd::Status),
@@ -354,10 +354,10 @@ async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> {
match cmd {
Cmd::Fetch { feed, force } => {
Cmd::Fetch { feed, force, feeds } => {
// Make room before pulling more down, as the original did per download.
reap(ctx, false, false).await?;
fetch(ctx, feed.as_deref(), force).await
fetch(ctx, feed.as_deref(), force, &feeds).await
}
Cmd::Reap { dry_run } => reap(ctx, dry_run, true).await,
Cmd::Download { enclosure } => download_one(ctx, enclosure).await,
@@ -510,7 +510,7 @@ async fn daemon(
}
_ = ticker.tick() => {
// Per-feed schedule and TTL decide what actually gets polled.
let job = run(&ctx, Cmd::Fetch { feed: None, force: false });
let job = run(&ctx, Cmd::Fetch { feed: None, force: false, feeds: vec![] });
if !until_stopped(&ctx, &rx_stop, job).await {
break;
}
@@ -892,7 +892,8 @@ async fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> {
Ok(())
}
async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> {
/// `scope`, when not empty, narrows the scan to those feeds and the feeds inside any of them.
async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]) -> Result<()> {
let cfg = ctx.cfg();
let subs = subscriptions(ctx).await?;
if let Some(id) = only
@@ -903,7 +904,10 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> {
let mut scanned = 0;
let mut fresh: Vec<String> = vec![];
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id)) {
let in_scope = |s: &Sub| {
scope.is_empty() || scope.contains(&s.id) || s.cfg.group.as_ref().is_some_and(|g| scope.contains(g))
};
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id) && in_scope(s)) {
let (id, feed_cfg) = (&sub.id, &sub.cfg);
let state = ctx.db.http_state(id).await?;

View File

@@ -1239,7 +1239,7 @@ async fn add_feed(
/// succeeded either way, so a daemon not taking commands is only logged.
async fn scan_soon(state: &WebState, feed: Option<String>) {
let force = feed.is_some();
if state.cmds.send(Command::Fetch { feed, force }).await.is_err() {
if state.cmds.send(Command::Fetch { feed, force, feeds: vec![] }).await.is_err() {
tracing::warn!("could not queue a scan: the daemon is not accepting commands");
}
}
@@ -1527,11 +1527,21 @@ struct FetchBody {
async fn fetch_now(
State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<FetchBody>,
) -> Result<StatusCode, ApiError> {
// "Check every feed" is every feed this person reads, not everyone's (issue #37).
let feeds = if body.feed.is_some() {
vec![]
} else {
state.ctx.db.subscriptions_for(user.id).await?.into_iter().map(|s| s.feed_id).collect()
};
if body.feed.is_none() && feeds.is_empty() {
return Ok(StatusCode::ACCEPTED); // nothing of theirs to check; an empty list would mean all
}
state
.cmds
.send(Command::Fetch { feed: body.feed, force: body.force })
.send(Command::Fetch { feed: body.feed, force: body.force, feeds })
.await
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
Ok(StatusCode::ACCEPTED)

62
tests/contrast.js Normal file
View File

@@ -0,0 +1,62 @@
// Every theme's palette, checked against WCAG AA for the pairs the page actually draws. The
// published palettes ipx borrows (Flat Remix, Paper, Adwaita...) fell short in places: an unread
// count at 2.6:1, tags at 3.2:1. Each was tuned by hand; this keeps a new or edited theme honest.
//
// node tests/contrast.js
const fs = require('fs');
const path = require('path');
const css = fs.readFileSync(path.join(__dirname, '../web/app.css'), 'utf8');
const blocks = {};
for (const m of css.matchAll(/^(:root(?:\[[^\]]+\])*)\s*\{([^}]*)\}/gm)) {
const vars = {};
for (const v of m[2].matchAll(/--(\w+):\s*(#[0-9a-f]{6})\b/gi)) vars[v[1]] = v[2].toLowerCase();
if (Object.keys(vars).length) blocks[m[1]] = vars;
}
const lum = h => {
const c = [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255)
.map(c => c <= .03928 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4);
return .2126 * c[0] + .7152 * c[1] + .0722 * c[2];
};
const ratio = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + .05) / (Math.min(x, y) + .05); };
// [colour, grounds it is drawn on, minimum]. 4.5 for text, 3 for an icon, dot or bar.
const RULES = [
['fg', ['bg', 'panel', 'panel2', 'raise'], 4.5],
['dim', ['bg', 'panel', 'panel2'], 4.5], // metadata, labels
['faint', ['bg', 'panel', 'panel2'], 4.5], // hints, column headings, the status bar
['accent', ['bg', 'panel'], 4.5], // links, in the list and the reader
['ink', ['accent'], 4.5], // a primary button's label
['ink', ['accent2'], 4.5], // the unread count on its badge
['accent2', ['bg', 'panel', 'raise'], 3], // the unread dot, the EQ bars, download bars
['bad', ['bg', 'panel'], 4.5], // error text, the Error tag, a failed toast
['warn', ['bg', 'panel'], 4.5], // the Gone tag, log warnings
['good', ['bg', 'panel', 'panel2'], 3], // the downloaded and subscribed icons
];
const base = blocks[':root'];
const themes = {};
for (const sel of Object.keys(blocks)) {
const t = sel.match(/data-theme="(\w+)"/);
if (!t) continue;
const light = /data-mode="light"/.test(sel);
// A theme's dark half is :root under its own block; its light half adds the light block.
const name = `${t[1]}${light ? ' light' : ''}`;
themes[name] = light
? { ...base, ...blocks[`:root[data-theme="${t[1]}"]`], ...blocks[sel] }
: { ...base, ...blocks[sel] };
}
themes['modern'] = base;
let bad = 0;
for (const [name, p] of Object.entries(themes)) {
for (const [fg, grounds, min] of RULES) for (const g of grounds) {
const r = ratio(p[fg], p[g]);
if (r < min) { bad++; console.log(`FAIL ${name}: --${fg} on --${g} is ${r.toFixed(2)}:1, needs ${min}`); }
}
// A border the same colour as the ground it is drawn on does not show (Nordic, once).
if (p.line === p.panel2) { bad++; console.log(`FAIL ${name}: --line is --panel2, so borders on it vanish`); }
}
if (bad) process.exit(1);
console.log(`OK: ${Object.keys(themes).length} palettes clear AA for every pair the page draws`);

View File

@@ -576,7 +576,7 @@ test('a second person has their own feeds and their own read state', async ({ br
await page.locator('#prefs').click();
await expect(page.locator('#modalCard')).toContainText('Subscriptions');
await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
await page.locator('#modalCard .cardacts .btn').first().click();
await page.locator('#modalCard .cardx').click();
await expect(page.locator('#admin')).toHaveCount(0);
expect(await (await page.request.get('/')).text()).not.toContain('href=/admin');
// Asking for it anyway goes back to the app, and its script is refused.
@@ -1299,3 +1299,45 @@ test('a pinned feed, even one from inside a folder, goes to the top of the list'
await expect(page.locator('.feed.pinned')).toHaveCount(0);
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1);
});
test('a feed being checked shows a spinner on its row, and no toast', async ({ page }) => {
const row = page.locator('#feedlist .feed', { hasText: 'Test Show' });
await expect(row).toBeVisible();
await page.evaluate(() => setScanning('test-show', true));
await expect(row).toHaveClass(/\bscanning\b/);
await expect(row.locator('.badge'), 'the spinner stands in for the count').toBeHidden();
await page.evaluate(() => setScanning('test-show', false));
await expect(row).not.toHaveClass(/\bscanning\b/);
await expect(row.locator('.badge')).toBeVisible();
// Checking every feed says so on the rows, not in a toast (issue #37).
await page.locator('#scanAll').click();
await page.waitForTimeout(1500);
await expect(page.locator('.toast', { hasText: /Scanning|Checking| new/ })).toHaveCount(0);
});
test('"check every feed" checks only the feeds of the person asking', async ({ browser }) => {
// piper, made in an earlier test, subscribes to Test Show alone.
const ctx = await browser.newContext();
const piper = await ctx.newPage();
await piper.goto('/login');
await piper.locator('#name').fill('piper');
await piper.locator('#pw').fill('piperpassword');
await piper.locator('button[type=submit]').click();
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
const mine = await piper.evaluate(() => S.feeds.map(f => f.id));
// Listen as the page does, then ask.
await piper.evaluate(() => {
window.started = []; window.done = false;
const es = new EventSource('/api/events');
es.onmessage = m => { const ev = JSON.parse(m.data);
if (ev.ev === 'feed_start') window.started.push(ev.feed);
if (ev.ev === 'scan_done') window.done = true; };
});
await piper.waitForTimeout(500);
await piper.locator('#scanAll').click();
await expect.poll(() => piper.evaluate(() => window.done), { timeout: 30_000 }).toBe(true);
const started = await piper.evaluate(() => window.started);
expect(started.length, 'something was checked').toBeGreaterThan(0);
for (const id of started) expect(mine, `${id} is not one of piper's feeds`).toContain(id);
await ctx.close();
});

View File

@@ -12,7 +12,7 @@
--line:#2c3849;
--fg:#f5f5f5; /* #F5F5F5 device highlight */
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */
--faint:#7e8b9c; /* lifted from the icon ramp until it clears AA at small sizes */
--accent:#92b2e6; /* #92B2E6 the screen blue */
--accent2:#f49e2c; /* #F49E2C the EQ bars */
--ink:#0e131b; /* text on an accent fill */
@@ -32,12 +32,12 @@
--line:#d6d6d6; /* #D6D6D6 device edge */
--fg:#1a1a1a; /* #1A1A1A icon outline */
--dim:#606060; /* #606060 */
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */
--faint:#6b6b6b; /* between the icon's #929292 and #606060, to clear AA on the page ground */
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
--accent2:#9a5f0a; /* the EQ amber, taken down until white on it clears AA */
--accent2:#985e0a; /* the EQ amber, taken down until white on it clears AA */
--ink:#ffffff;
--good:#2f7d4f;
--warn:#b06f10;
--warn:#985e0a; /* the same amber; the lighter one failed AA as text */
--bad:#b3402f;
--shadow:0 8px 28px rgba(45,83,145,.14);
}
@@ -124,7 +124,7 @@
--dim:#c4c4c8;
--faint:#a0a0a7;
--accent:#81d0ff;
--accent2:#3584e4;
--accent2:#3685e4; /* a hair lighter so the badge's dark count clears AA */
--ink:#1d1d20;
--good:#78e9ab;
--warn:#ffc252;
@@ -141,7 +141,7 @@
--dim:#57575d;
--faint:#66666c;
--accent:#0461be;
--accent2:#3584e4;
--accent2:#1c71d8; /* blue 4, a step down from the accent blue, so white on it clears AA */
--ink:#ffffff;
--good:#00753a;
--warn:#905400;
@@ -177,7 +177,7 @@
--dim:#5c616c; /* fg */
--faint:#686d78;
--accent:#0060f0; /* link */
--accent2:#fd7d00;
--accent2:#bb5c00; /* the warning orange taken down until white on it clears AA */
--ink:#ffffff;
--good:#23794f;
--warn:#a85400;
@@ -196,7 +196,7 @@
--dim:#3d3a33;
--faint:#5a564c;
--accent:#1a5fae;
--accent2:#b58900;
--accent2:#957100; /* taken down until white on it clears AA */
--ink:#ffffff;
--good:#216609;
--warn:#795a00;
@@ -210,7 +210,7 @@
--panel:#3b4252; /* nord1 */
--panel2:#434c5e; /* nord2 */
--raise:#4c566a; /* nord3 */
--line:#434c5e;
--line:#4c566a; /* nord3: nord2 is --panel2, and borders on it vanished */
--fg:#eceff4; /* nord6 */
--dim:#d8dee9; /* nord4 */
--faint:#b4bccb;
@@ -232,13 +232,158 @@
--dim:#3b4252; /* nord1 */
--faint:#4c566a; /* nord3 */
--accent:#3a5e8a;
--accent2:#b0674e;
--accent2:#ab644c;
--ink:#ffffff;
--good:#446632;
--warn:#7e590e;
--bad:#9c3f49;
--shadow:0 8px 28px rgba(46,52,64,.15);
}
/* Catppuccin: Mocha for the dark half, Latte for the light, from catppuccin.com/palette. Base,
mantle and the surfaces for the grounds, text and subtext for type, mauve (its default accent)
and peach for the rest. */
:root[data-theme="catppuccin"] {
--bg:#1e1e2e; /* base */
--panel:#181825; /* mantle */
--panel2:#313244; /* surface0 */
--raise:#45475a; /* surface1 */
--line:#45475a;
--fg:#cdd6f4; /* text */
--dim:#bac2de; /* subtext1 */
--faint:#a6adc8; /* subtext0 */
--accent:#cba6f7; /* mauve */
--accent2:#fab387; /* peach */
--ink:#11111b; /* crust */
--good:#a6e3a1;
--warn:#f9e2af;
--bad:#f38ba8;
--shadow:0 8px 28px rgba(0,0,0,.45);
}
:root[data-theme="catppuccin"][data-mode="light"] {
--bg:#eff1f5; /* base */
--panel:#e6e9ef; /* mantle */
--panel2:#dce0e8; /* crust */
--raise:#ccd0da; /* surface0 */
--line:#bcc0cc; /* surface1 */
--fg:#4c4f69; /* text */
--dim:#5c5f77; /* subtext1 */
--faint:#5f6276; /* subtext0 */
--accent:#8638ec; /* mauve */
--accent2:#c74e09; /* peach */
--ink:#ffffff;
--good:#3a9127;
--warn:#925d13;
--bad:#d00f39;
--shadow:0 8px 28px rgba(76,79,105,.15);
}
/* Gruvbox, from its README's palette: the bg and fg ramps, with its faded colours for the light
half as gruvbox itself does. Blue for the accent, orange for what is new. */
:root[data-theme="gruvbox"] {
--bg:#282828; /* bg0 */
--panel:#1d2021; /* bg0_h */
--panel2:#32302f; /* bg0_s */
--raise:#3c3836; /* bg1 */
--line:#504945; /* bg2 */
--fg:#ebdbb2; /* fg */
--dim:#d5c4a1; /* fg2 */
--faint:#a89984; /* fg4 */
--accent:#83a598; /* blue */
--accent2:#fe8019; /* orange */
--ink:#1d2021;
--good:#b8bb26;
--warn:#fabd2f;
--bad:#fb533f;
--shadow:0 8px 28px rgba(0,0,0,.45);
}
:root[data-theme="gruvbox"][data-mode="light"] {
--bg:#fbf1c7; /* bg0 */
--panel:#f2e5bc; /* bg0_s */
--panel2:#ebdbb2; /* bg1 */
--raise:#d5c4a1; /* bg2 */
--line:#d5c4a1;
--fg:#3c3836; /* fg */
--dim:#504945; /* fg2 */
--faint:#665c54; /* fg3 */
--accent:#076678; /* faded blue */
--accent2:#af3a03; /* faded orange */
--ink:#fbf1c7;
--good:#79740e;
--warn:#8d5c10;
--bad:#9d0006;
--shadow:0 8px 28px rgba(60,56,54,.15);
}
/* Solarized, from ethanschoonover.com/solarized: base03 and base3 for the grounds, the base tones
for type. It has two grounds per half, so panel2 and raise are steps between its own. */
:root[data-theme="solarized"] {
--bg:#002b36; /* base03 */
--panel:#073642; /* base02 */
--panel2:#0b3f4c;
--raise:#124a58;
--line:#1d5563;
--fg:#eee8d5; /* base2 */
--dim:#c3c7be; /* between base2 and base1: base1 had to lift to read on base02 */
--faint:#97a5a7; /* base0, lifted to base1 until it reads on base02 */
--accent:#4b9fda; /* blue */
--accent2:#b58900; /* yellow */
--ink:#002b36;
--good:#859900;
--warn:#bb9315;
--bad:#e87674;
--shadow:0 8px 28px rgba(0,0,0,.45);
}
:root[data-theme="solarized"][data-mode="light"] {
--bg:#fdf6e3; /* base3 */
--panel:#eee8d5; /* base2 */
--panel2:#e6dfca;
--raise:#ddd5bd;
--line:#d3cab0;
--fg:#073642; /* base02 */
--dim:#52666d; /* base01 */
--faint:#54666d; /* base00 */
--accent:#1e6da5; /* blue */
--accent2:#cb4b16; /* orange */
--ink:#ffffff;
--good:#768700;
--warn:#846400;
--bad:#c52d2a;
--shadow:0 8px 28px rgba(0,43,54,.14);
}
/* High contrast: black and white with no mid-greys, every pair at 7:1 (AAA) or more, and borders
that show. For reading, not for looks. */
:root[data-theme="contrast"] {
--bg:#000000;
--panel:#000000;
--panel2:#121212;
--raise:#333333;
--line:#9a9a9a;
--fg:#ffffff;
--dim:#ffffff;
--faint:#d6d6d6;
--accent:#8cc8ff;
--accent2:#ffd400;
--ink:#000000;
--good:#7ee787;
--warn:#ffd400;
--bad:#ff9a8f;
--shadow:0 0 0 1px #9a9a9a;
}
:root[data-theme="contrast"][data-mode="light"] {
--bg:#ffffff;
--panel:#ffffff;
--panel2:#f0f0f0;
--raise:#d6d6d6;
--line:#555555;
--fg:#000000;
--dim:#000000;
--faint:#303030;
--accent:#0033a0;
--accent2:#7a3d00;
--ink:#ffffff;
--good:#0a5c1c;
--warn:#6b4500;
--bad:#a30000;
--shadow:0 0 0 1px #555555;
}
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
:root[data-theme="classic"] {
color-scheme:light;
@@ -250,11 +395,11 @@
--fg:#000000;
--dim:#444444;
--faint:#666666;
--accent:#3875d7; /* Aqua selection blue */
--accent:#3268c0; /* Aqua selection blue, a shade down so links clear AA on the source list */
--accent2:#2a5db0;
--ink:#ffffff;
--good:#237a23;
--warn:#a15f00;
--warn:#9b5b00;
--bad:#c42b1c;
--shadow:0 4px 16px rgba(0,0,0,.28);
}
@@ -368,6 +513,13 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
}
.chev:hover{color:var(--fg)}
.chev.bad,.chev.bad:hover{color:var(--bad)}
/* A feed being checked: a small spinner in place of its unread count (issue #37). */
.feed.scanning .badge{display:none}
.feed.scanning::after{
content:"";flex:none;width:12px;height:12px;margin:0 6px;border-radius:50%;
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite;
}
@keyframes ipxspin{to{transform:rotate(360deg)}}
/* 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)}
@@ -421,9 +573,11 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
.tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3}
.tile .txt small{display:block;color:var(--faint);font-size:11.5px}
.tile:hover .txt b{color:var(--accent)}
/* Outlined, not filled: on --raise the warning and error colours fell short of AA in most light
themes, where on the row's own ground they clear it. */
.tag{
font-size:11px;font-weight:600;
padding:1px 5px;border-radius:4px;background:var(--raise);color:var(--warn);flex:none;
padding:0 4px;border-radius:4px;border:1px solid;color:var(--warn);flex:none;
}
.art{
border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
@@ -444,6 +598,8 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
font-size:11px;font-weight:600;flex:none;min-width:26px;text-align:center;
}
.badge.zero{background:var(--raise);color:var(--faint)}
/* A selected row is --raise too, and the zero count's pill vanished into it. */
.sel .badge.zero{background:var(--bg)}
/* Counts, sizes and times that change in place should not jiggle the text around them. */
.badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
@@ -690,6 +846,9 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
.logbar{display:flex;gap:8px;align-items:center;margin-bottom:9px;flex-wrap:wrap}
.logbar .grow{flex:1;min-width:120px}
.card h3{margin:0 0 14px;font-size:17px}
/* A dialog with nothing to confirm closes from the corner, beside its title, not from a lone button
at the foot of a long card. */
.cardx{float:right;margin:-5px -6px 0 8px}
.field{display:grid;gap:4px;margin-bottom:12px}
.field label{font-size:12px;color:var(--dim)}
.field .hint{font-size:11.5px;color:var(--faint)}
@@ -706,7 +865,8 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
background:var(--raise);border:1px solid var(--line);border-radius:9px;
padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px;
}
.toast.bad{border-color:var(--bad);color:var(--bad)}
/* On --panel, not a toast's --raise: the error colours are tuned to read on the page's grounds. */
.toast.bad{border-color:var(--bad);color:var(--bad);background:var(--panel)}
@keyframes in{from{opacity:0;transform:translateY(6px)}}
#burger,#dback{display:none}
/* Totals for what is showing, along the bottom, as the original's status bar. */
@@ -838,7 +998,8 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
:root[data-theme="classic"] .ep.sel .st,
:root[data-theme="classic"] .ep.sel .fl,
:root[data-theme="classic"] .ep.sel .file,
:root[data-theme="classic"] .ep.sel .kind{color:#fff}
:root[data-theme="classic"] .ep.sel .kind,
:root[data-theme="classic"] .ep.sel .iconbtn:not(:hover){color:#fff}
/* Green and red stay green and red on the blue, just lighter so they read. */
:root[data-theme="classic"] .ep.sel .kind.here{color:#a6f3a6}
:root[data-theme="classic"] .ep.sel .kind.bad{color:#ffb8ad}

View File

@@ -1,3 +1,8 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>Sign in — iPodderX</title>
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
@@ -12,7 +17,7 @@
--line:#2c3849;
--fg:#f5f5f5; /* #F5F5F5 device highlight */
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */
--faint:#7e8b9c; /* lifted from the icon ramp until it clears AA at small sizes */
--accent:#92b2e6; /* #92B2E6 the screen blue */
--accent2:#f49e2c; /* #F49E2C the EQ bars */
--ink:#0e131b; /* text on an accent fill */
@@ -22,7 +27,8 @@
--shadow:0 8px 28px rgba(6,10,16,.55);
--r:10px;
}
:root[data-theme="light"] {
/* Signed out, there is no account to take a theme from, so this follows the system. */
@media (prefers-color-scheme:light){:root {
--bg:#f2f4f7;
--panel:#ffffff; /* #FFFFFF device body */
--panel2:#e9edf3;
@@ -30,15 +36,15 @@
--line:#d6d6d6; /* #D6D6D6 device edge */
--fg:#1a1a1a; /* #1A1A1A icon outline */
--dim:#606060; /* #606060 */
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */
--faint:#6b6b6b;
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
--accent2:#b06f10;
--accent2:#985e0a;
--ink:#ffffff;
--good:#2f7d4f;
--warn:#b06f10;
--warn:#985e0a;
--bad:#b3402f;
--shadow:0 8px 28px rgba(45,83,145,.14);
}
}}
*{box-sizing:border-box}
html,body{height:100%}
body{

View File

@@ -218,7 +218,7 @@ function due(ts){
async function prefsModal(){
const g = await api('/api/settings');
const admin = !!(S.me&&S.me.admin);
openModal(`<h3>Settings</h3>
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Settings</h3>
<div class="field"><label>Theme</label>
<select id="stheme">${Object.entries(THEMES).map(([k,t])=>
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
@@ -238,8 +238,7 @@ async function prefsModal(){
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>
<div class="field"><label>Download folder</label>
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>`);
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
$('#gopml').onclick=opmlModal;
@@ -371,7 +370,8 @@ function opmlModal(){
};
}
async function scanAll(){ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
// No toast: the spinners on the rows being checked say it (issue #37).
async function scanAll(){ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
$('#scanAll').onclick=scanAll;
$('#prefs').onclick=prefsModal;
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while

View File

@@ -5,12 +5,8 @@ function connect(){
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
const refreshFeeds=soon(()=>loadFeeds(true));
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
let fresh={};
const tellNew=soon(()=>{
const feeds=Object.keys(fresh), n=feeds.reduce((a,k)=>a+fresh[k],0);
if(n) toast(feeds.length===1 ? `${feeds[0]}: ${n} new` : `${n} new in ${feeds.length} feeds`);
fresh={};
},900);
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
const mine=id=>S.feeds.some(f=>f.id===id);
sse.onmessage=m=>{
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
if(ev.ev==='progress'){
@@ -23,22 +19,28 @@ function connect(){
}
else if(ev.ev==='download_done'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
if(bar) bar.classList.remove('live');
toast('Downloaded '+ev.path.split('/').pop()); refreshEntries(); refreshFeeds();
// Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of
// everyone's feeds used to announce themselves to everyone.
if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
refreshEntries(); refreshFeeds();
}
else if(ev.ev==='download_error'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
if(bar) bar.classList.remove('live');
toast('Download failed: '+ev.msg,true); refreshEntries();
}
// A spinner on the feed's row while it is checked, in place of a toast per feed (issue #37).
else if(ev.ev==='feed_start') setScanning(ev.feed,true);
else if(ev.ev==='feed_skip') setScanning(ev.feed,false);
else if(ev.ev==='feed_done'){
if(ev.new){ fresh[ev.feed]=(fresh[ev.feed]||0)+ev.new; tellNew(); }
setScanning(ev.feed,false);
if(!mine(ev.feed)) return;
refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries();
}
// No toast: a scan of every feed raised one per failure, to everyone. The feed list's
// red ! marks the feed instead, and its page says why.
else if(ev.ev==='feed_error') refreshFeeds();
else if(ev.ev==='scan_done'){ refreshFeeds(); refreshEntries(); }
else if(ev.ev==='feed_error'){ setScanning(ev.feed,false); if(mine(ev.feed)) refreshFeeds(); }
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); refreshFeeds(); refreshEntries(); }
};
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
}

View File

@@ -156,7 +156,7 @@ function renderGroup(f,kids){
}
async function feedAction(a,f){
if(a==='scan'){ toast('Scanning '+(f.title||f.id)+'…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
if(a==='scan'){ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
if(a==='rm') removeFeed(f);
if(a==='pin'){

View File

@@ -25,6 +25,22 @@ const VIEWS={
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
':all':{title:'All Subscriptions',icon:ICON.all},
};
/// Feeds being checked right now, from the event stream: their rows, and the row of a folder
/// holding one, carry a spinner.
const scanning=new Set<string>();
function setScanning(id: string, on: boolean){
if(on) scanning.add(id); else scanning.delete(id);
paintScanning();
}
function paintScanning(){
for(const row of $$('#feedlist .feed')){
const id=row.dataset.id;
const on=scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id));
row.classList.toggle('scanning',on);
if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
}
}
function renderFeeds(){
const q=$('#feedFilter').value.trim().toLowerCase();
const list=$('#feedlist'); const top=list.scrollTop;
@@ -104,6 +120,7 @@ function renderFeeds(){
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
list.appendChild(el);
}
paintScanning();
done();
}
function selectFeed(id){

View File

@@ -34,7 +34,6 @@ function pullShow(dy: number){
function refreshFeed(){
const f = S.feeds.find(x => x.id === S.feed);
if(!f && S.feed !== ':all') return;
toast(f ? `Checking ${f.title || f.id} for new items…` : 'Checking every feed for new items…');
// New items arrive by the event stream when the scan finishes, as they do for a button press.
api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
.catch(e => toast(e.message, true));

View File

@@ -191,8 +191,7 @@ function keysModal(){
['Anywhere'],
[k('?'),'This list'],[k('Esc'),'Close a dialog'],
];
openModal(`<h3>Keyboard shortcuts</h3><table class="keys">${rows.map(([a,b])=>b===undefined
?`<tr><th colspan="2">${a}</th></tr>`:`<tr><td>${a}</td><td>${b}</td></tr>`).join('')}</table>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Keyboard shortcuts</h3><table class="keys">${rows.map(([a,b])=>b===undefined
?`<tr><th colspan="2">${a}</th></tr>`:`<tr><td>${a}</td><td>${b}</td></tr>`).join('')}</table>`);
}

View File

@@ -5,14 +5,18 @@
// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from
// the system, so no palette is written twice.
const THEMES: Record<string, {name: string, modes: boolean}> = {
modern: {name: 'Modern', modes: true},
classic: {name: 'Classic, the 2004 Mac app', modes: false},
dracula: {name: 'Dracula', modes: true},
material: {name: 'Material', modes: true},
adwaita: {name: 'Adwaita', modes: true},
catppuccin: {name: 'Catppuccin', modes: true},
classic: {name: 'Classic', modes: false},
dracula: {name: 'Dracula', modes: true},
flatremix: {name: 'Flat Remix', modes: true},
paper: {name: 'Paper', modes: false},
gruvbox: {name: 'Gruvbox', modes: true},
contrast: {name: 'High contrast', modes: true},
material: {name: 'Material', modes: true},
modern: {name: 'Modern', modes: true},
nordic: {name: 'Nordic', modes: true},
paper: {name: 'Paper', modes: false},
solarized: {name: 'Solarized', modes: true},
};
const MODES: Record<string, string> = {auto: 'Auto (matches your system)', light: 'Light', dark: 'Dark'};
// Before themes came in light and dark, ipx.theme in localStorage held one of these.

View File

@@ -26,7 +26,6 @@ const ICON={
trash:fa('0 0 448 512','<path fill="currentColor" d="M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"/>'), // solid/trash-can
open:fa('0 0 512 512','<path fill="currentColor" d="M320 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l82.7 0-201.4 201.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L448 109.3 448 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160c0-17.7-14.3-32-32-32L320 0zM80 96C35.8 96 0 131.8 0 176L0 432c0 44.2 35.8 80 80 80l256 0c44.2 0 80-35.8 80-80l0-80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 80c0 8.8-7.2 16-16 16L80 448c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l80 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L80 96z"/>'), // solid/arrow-up-right-from-square
settings:fa('0 0 512 512','<path fill="currentColor" d="M195.1 9.5C198.1-5.3 211.2-16 226.4-16l59.8 0c15.2 0 28.3 10.7 31.3 25.5L332 79.5c14.1 6 27.3 13.7 39.3 22.8l67.8-22.5c14.4-4.8 30.2 1.2 37.8 14.4l29.9 51.8c7.6 13.2 4.9 29.8-6.5 39.9L447 233.3c.9 7.4 1.3 15 1.3 22.7s-.5 15.3-1.3 22.7l53.4 47.5c11.4 10.1 14 26.8 6.5 39.9l-29.9 51.8c-7.6 13.1-23.4 19.2-37.8 14.4l-67.8-22.5c-12.1 9.1-25.3 16.7-39.3 22.8l-14.4 69.9c-3.1 14.9-16.2 25.5-31.3 25.5l-59.8 0c-15.2 0-28.3-10.7-31.3-25.5l-14.4-69.9c-14.1-6-27.2-13.7-39.3-22.8L73.5 432.3c-14.4 4.8-30.2-1.2-37.8-14.4L5.8 366.1c-7.6-13.2-4.9-29.8 6.5-39.9l53.4-47.5c-.9-7.4-1.3-15-1.3-22.7s.5-15.3 1.3-22.7L12.3 185.8c-11.4-10.1-14-26.8-6.5-39.9L35.7 94.1c7.6-13.2 23.4-19.2 37.8-14.4l67.8 22.5c12.1-9.1 25.3-16.7 39.3-22.8L195.1 9.5zM256.3 336a80 80 0 1 0 -.6-160 80 80 0 1 0 .6 160z"/>'), // solid/gear
log:fa('0 0 384 512','<path fill="currentColor" d="M0 64C0 28.7 28.7 0 64 0L213.5 0c17 0 33.3 6.7 45.3 18.7L365.3 125.3c12 12 18.7 28.3 18.7 45.3L384 448c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64zm208-5.5l0 93.5c0 13.3 10.7 24 24 24L325.5 176 208 58.5zM120 256c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-144 0zm0 96c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-144 0z"/>'), // solid/file-lines
close:fa('0 0 384 512','<path fill="currentColor" d="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/>'), // solid/xmark
menu:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/>'), // solid/bars
directory:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm64 0l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 224l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 352l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64z"/>'), // solid/table-list
@@ -40,7 +39,6 @@ const ICON={
torrent:fa('0 0 448 512','<path fill="currentColor" d="M0 176L0 288C0 411.7 100.3 512 224 512S448 411.7 448 288l0-112-128 0 0 112c0 53-43 96-96 96s-96-43-96-96l0-112-128 0zm0-48l128 0 0-64c0-17.7-14.3-32-32-32L32 32C14.3 32 0 46.3 0 64l0 64zm320 0l128 0 0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32l0 64z"/>'), // solid/magnet
file:fa('0 0 384 512','<path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-277.5c0-17-6.7-33.3-18.7-45.3L258.7 18.7C246.7 6.7 230.5 0 213.5 0L64 0zM325.5 176L232 176c-13.3 0-24-10.7-24-24L208 58.5 325.5 176z"/>'), // solid/file
copy:fa('0 0 448 512','<path fill="currentColor" d="M192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-200.6c0-17.4-7.1-34.1-19.7-46.2L370.6 17.8C358.7 6.4 342.8 0 326.3 0L192 0zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-16-64 0 0 16-192 0 0-256 16 0 0-64-16 0z"/>'), // solid/copy
users:fa('0 0 640 512','<path fill="currentColor" d="M320 16a104 104 0 1 1 0 208 104 104 0 1 1 0-208zM96 88a72 72 0 1 1 0 144 72 72 0 1 1 0-144zM0 416c0-70.7 57.3-128 128-128 12.8 0 25.2 1.9 36.9 5.4-32.9 36.8-52.9 85.4-52.9 138.6l0 16c0 11.4 2.4 22.2 6.7 32L32 480c-17.7 0-32-14.3-32-32l0-32zm521.3 64c4.3-9.8 6.7-20.6 6.7-32l0-16c0-53.2-20-101.8-52.9-138.6 11.7-3.5 24.1-5.4 36.9-5.4 70.7 0 128 57.3 128 128l0 32c0 17.7-14.3 32-32 32l-86.7 0zM472 160a72 72 0 1 1 144 0 72 72 0 1 1 -144 0zM160 432c0-88.4 71.6-160 160-160s160 71.6 160 160l0 16c0 17.7-14.3 32-32 32l-256 0c-17.7 0-32-14.3-32-32l0-16z"/>'), // solid/users
signout:fa('0 0 512 512','<path fill="currentColor" d="M505 273c9.4-9.4 9.4-24.6 0-33.9L361 95c-6.9-6.9-17.2-8.9-26.2-5.2S320 102.3 320 112l0 80-112 0c-26.5 0-48 21.5-48 48l0 32c0 26.5 21.5 48 48 48l112 0 0 80c0 9.7 5.8 18.5 14.8 22.2s19.3 1.7 26.2-5.2L505 273zM160 96c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 32C43 32 0 75 0 128L0 384c0 53 43 96 96 96l64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l64 0z"/>'), // solid/right-from-bracket
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