Design pass on the web UI: amber for new, EQ bars, keyboard feed list

Works through TODO.md from the 2026-09-11 review. Unread badges, dots and
download bars take the icon's amber; the playing item is marked by EQ bars
that move only while it plays. The feed list is usable from the keyboard,
focus rings show everywhere, and folders get a mosaic of their shows' art
with the triangle hung in the margin. Sentence-case labels, fewer bold
weights, tinted initials tiles, shorter header lines, "Kept" everywhere,
and the list gets the room the empty panes had. Reduced motion is honoured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
This commit is contained in:
2026-09-11 22:24:41 +00:00
parent 0cc002cdfa
commit 0990f2a90d
11 changed files with 228 additions and 106 deletions

View File

@@ -19,8 +19,33 @@ The long form, with what was wrong before and how it was found, is in
- Add feed has an "Allow items marked explicit" box, so a new feed's first scan no longer skips - Add feed has an "Allow items marked explicit" box, so a new feed's first scan no longer skips
every explicit item. every explicit item.
### Changed
- Unread counts, unread dots and download progress are amber, the colour of the icon's EQ bars.
Blue is kept for the primary action and links, so a count no longer looks like a button.
- What is playing is marked by small EQ bars, in its row and in the player. They move only while it
plays.
- A folder in the sidebar shows its first four shows' art as a mosaic, and its shows sit under its
title. Only folders have a triangle, so every feed lines up with Directory and Popular above.
- Feeds without art get initials in a colour of their own, instead of all the same grey.
- The Flagged tab is called Kept, as the Keep button and Settings already said.
- A feed's header is one short line; when it checks next is in its tooltip.
- The item list takes more of the window, and the Files pane shows only when the item has files.
- Column headings and tags are in sentence case, and fewer things are bold.
- Unsubscribe is a round button beside the feed's other actions.
- Export and Import in Settings say what they do.
- The sign-in page shows the original icon large.
- Nothing animates when your system asks for reduced motion.
### Fixed ### Fixed
- The feed list works from the keyboard: Tab reaches every feed, Enter opens it, and Right and Left
open and close a folder. Every button shows where the focus is, including in the toolbar, which
used to clip the ring.
- "1 items" reads "1 item".
- A selected feed without art no longer loses its initials tile in Dark and Light.
- The player shows the feed's initials when there is no art, not the episode's.
- Turning on Allow explicit, or changing keywords or auto-download, brings back what those settings - Turning on Allow explicit, or changing keywords or auto-download, brings back what those settings
had skipped on the feed's next scan. Before, an item was judged once, when first seen, and a had skipped on the feed's next scan. Before, an item was judged once, when first seen, and a
skipped one stayed skipped whatever you changed. skipped one stayed skipped whatever you changed.

View File

@@ -11,47 +11,47 @@ Dark and Light, the themes people actually use, read as a generic dark dashboard
everywhere, one pale blue doing every job, the same radius on everything. The first three items below everywhere, one pale blue doing every job, the same radius on everything. The first three items below
are the ones that would make Dark look like iPodderX; the rest are tidying. are the ones that would make Dark look like iPodderX; the rest are tidying.
- [ ] **Amber means new.** The blue accent is on unread badges, unread dots, the primary Scan button, - [x] **Amber means new.** The blue accent is on unread badges, unread dots, the primary Scan button,
selection, the playing outline, the slider thumb and links, so a count looks like a button and nothing selection, the playing outline, the slider thumb and links, so a count looks like a button and nothing
stands out. Give the icon's EQ-bar amber (`--accent2`) to unread badges, unread dots and download stands out. Give the icon's EQ-bar amber (`--accent2`) to unread badges, unread dots and download
progress; keep blue for selection and the one primary action. `.badge` (:169), `.ep .st` (:280), progress; keep blue for selection and the one primary action. `.badge` (:169), `.ep .st` (:280),
`.dlbar i` (:308). `.dlbar i` (:308).
- [ ] **EQ bars as the playing marker.** The strongest image in the brand. Small amber bars on the - [x] **EQ bars as the playing marker.** The strongest image in the brand. Small amber bars on the
playing row and in the player bar, standing still under `prefers-reduced-motion`. This is the one playing row and in the player bar, standing still under `prefers-reduced-motion`. This is the one
memorable detail; keep everything around it quiet. memorable detail; keep everything around it quiet.
- [ ] **Focus you can see.** No `:focus-visible` styles anywhere, and `overflow:hidden` on `.tgroup` (:105) - [x] **Focus you can see.** No `:focus-visible` styles anywhere, and `overflow:hidden` on `.tgroup` (:105)
and `#topbar` (:100) clips the focus ring of every toolbar button, so keyboard users cannot see where and `#topbar` (:100) clips the focus ring of every toolbar button, so keyboard users cannot see where
they are. Add a `:focus-visible` ring and inset it, or drop the overflow clipping. they are. Add a `:focus-visible` ring and inset it, or drop the overflow clipping.
- [ ] **Fewer bold weights.** Directory, Popular and All Subscriptions, every feed name and every unread - [x] **Fewer bold weights.** Directory, Popular and All Subscriptions, every feed name and every unread
title are 600700, so nothing leads. Keep bold for headings and unread titles, 500 elsewhere. title are 600700, so nothing leads. Keep bold for headings and unread titles, 500 elsewhere.
`font-variant-numeric: tabular-nums` on counts, sizes and durations. Keep `system-ui`: the page fetches `font-variant-numeric: tabular-nums` on counts, sizes and durations. Keep `system-ui`: the page fetches
nothing from anyone else, on purpose. nothing from anyone else, on purpose.
- [ ] **Sentence-case labels.** Drop `text-transform:uppercase` from `.ephead` (:262), `.fhd` (:195), - [x] **Sentence-case labels.** Drop `text-transform:uppercase` from `.ephead` (:262), `.fhd` (:195),
`.chip` (:296) and `.tag` (:157). Classic already writes "Title, File, Size", and it reads better. `.chip` (:296) and `.tag` (:157). Classic already writes "Title, File, Size", and it reads better.
- [ ] **The list gets the room.** With nothing selected, the reading pane takes about 60% of the height to - [x] **The list gets the room.** With nothing selected, the reading pane takes about 60% of the height to
say "Pick an item to read it", and the Files pane keeps 270 px to say "No files". Raise the default say "Pick an item to read it", and the Files pane keeps 270 px to say "No files". Raise the default
`--listh` from 40% to about 60% (:187), and hide `#files` (:192) when nothing is selected or the item `--listh` from 40% to about 60% (:187), and hide `#files` (:192) when nothing is selected or the item
has no files. has no files.
- [ ] **Shorter header lines.** "2 items · 1 downloaded · checked 1m ago · every 1 hour · next in 60m" - [x] **Shorter header lines.** "2 items · 1 downloaded · checked 1m ago · every 1 hour · next in 60m"
cuts off at "ev…" on a phone, and "every 1 hour · next in 60m" says nothing right after a check. Show cuts off at "ev…" on a phone, and "every 1 hour · next in 60m" says nothing right after a check. Show
"2 items, 1 downloaded · checked 1m ago"; move the schedule into the tooltip (:829, :914). "2 items, 1 downloaded · checked 1m ago"; move the schedule into the tooltip (:829, :914).
- [ ] **One word per thing.** - [x] **One word per thing.**
- "1 items" in the sidebar and folder rows (:794, :829, :943). - "1 items" in the sidebar and folder rows (:794, :829, :943).
- "saved" in the sidebar (:793794), "downloaded" in headers and on the Downloaded tab. Use - "saved" in the sidebar (:793794), "downloaded" in headers and on the Downloaded tab. Use
"downloaded". "downloaded".
- One feature, three names: the Flagged tab, the "Keep the selected item" tooltip (:537), "Starred - One feature, three names: the Flagged tab, the "Keep the selected item" tooltip (:537), "Starred
items are never touched" in Settings (:1629). Use "Keep", and call the tab "Kept". items are never touched" in Settings (:1629). Use "Keep", and call the tab "Kept".
- [ ] **Unsubscribe with the other actions.** "Minus unsubscribes" is the rule from the icon pass and it - [x] **Unsubscribe with the other actions.** "Minus unsubscribes" is the rule from the icon pass and it
holds, but in the feed header the minus sits bare at the far right, apart from the round buttons, and holds, but in the feed header the minus sits bare at the far right, apart from the round buttons, and
reads as a window's minimise control. Give it the same round frame and put it with the others reads as a window's minimise control. Give it the same round frame and put it with the others
(`.acts .btn.last`, :224). (`.acts .btn.last`, :224).
- [ ] **Respect reduced motion.** The drawer slide, the folder chevron and the toast animation should stop - [x] **Respect reduced motion.** The drawer slide, the folder chevron and the toast animation should stop
under `prefers-reduced-motion`. under `prefers-reduced-motion`.
- [ ] **Art.** The player shows the episode title's initials ("SE") even when the feed has art: use the - [x] **Art.** The player shows the episode title's initials ("SE") even when the feed has art: use the
feed's. Initials tiles for feeds without art are all the same grey, and two "TS" sit in a row in the feed's. Initials tiles for feeds without art are all the same grey, and two "TS" sit in a row in the
fixtures: tint each from the palette by a hash of the title. fixtures: tint each from the palette by a hash of the title.
- [ ] **Sign-in.** A plain centred card. It is the one place the original icon could be shown large. - [x] **Sign-in.** A plain centred card. It is the one place the original icon could be shown large.
- [ ] **Settings export and import.** A floppy disk and a plus, which only make sense with the hint text - [x] **Settings export and import.** A floppy disk and a plus, which only make sense with the hint text
under them. The one spot where the icon-only rule does not hold up; consider words there. under them. The one spot where the icon-only rule does not hold up; consider words there.
### Folders in the sidebar ### Folders in the sidebar
@@ -60,25 +60,25 @@ From a closer look the same day: 2× crops of the sidebar in all three themes, w
the browser. Closed by default, remembered across reloads (`ipx.expanded`) and opened by a search is the browser. Closed by default, remembered across reloads (`ipx.expanded`) and opened by a search is
right, and so is the quarter turn when you click. How it is drawn is not. right, and so is the quarter turn when you click. How it is drawn is not.
- [ ] **Keyboard.** The triangle is a `<span>` and a feed row a `<div>`, with no `tabindex` or - [x] **Keyboard.** The triangle is a `<span>` and a feed row a `<div>`, with no `tabindex` or
`aria-expanded` anywhere: Tab goes from the feed filter straight to Sign out, so the feed list cannot be `aria-expanded` anywhere: Tab goes from the feed filter straight to Sign out, so the feed list cannot be
used from the keyboard at all. Make the triangle a `<button aria-expanded>` and each row focusable with used from the keyboard at all. Make the triangle a `<button aria-expanded>` and each row focusable with
a visible ring; Left and Right to close and open are a cheap extra. This one is a bug, the rest is a visible ring; Left and Right to close and open are a cheap extra. This one is a bug, the rest is
polish. (`renderFeeds`, :786800) polish. (`renderFeeds`, :786800)
- [ ] **Triangle in the margin, on folders only.** Every row keeps the 18 px `.chev` slot and a gap, so - [x] **Triangle in the margin, on folders only.** Every row keeps the 18 px `.chev` slot and a gap, so
every feed's art starts at x=44 while the Directory, Popular and All Subscriptions icons start at x=16: every feed's art starts at x=44 while the Directory, Popular and All Subscriptions icons start at x=16:
about 100 feeds pushed 28 px right for two triangles, in a 290 px sidebar. Hang the triangle in the left about 100 feeds pushed 28 px right for two triangles, in a 290 px sidebar. Hang the triangle in the left
padding of folder rows only, so ordinary feeds line up with the places above them. (`.chev`, :141) padding of folder rows only, so ordinary feeds line up with the places above them. (`.chev`, :141)
- [ ] **A target you can hit.** 18 × 18 px, under the 24 px minimum, with an 11 px glyph in `--faint`, in - [x] **A target you can hit.** 18 × 18 px, under the 24 px minimum, with an 11 px glyph in `--faint`, in
a 48 px row where a near miss opens the folder's page instead. Keep the glyph small but give its button a 48 px row where a near miss opens the folder's page instead. Keep the glyph small but give its button
the full height of the row. the full height of the row.
- [ ] **Shows nest under the folder's title.** `.feed.child` steps in 16 px (:140), so a show's art lands - [x] **Shows nest under the folder's title.** `.feed.child` steps in 16 px (:140), so a show's art lands
at x=60, between the folder's art (x=44) and its title (x=88). Indent to the title, with slightly at x=60, between the folder's art (x=44) and its title (x=88). Indent to the title, with slightly
smaller art (about 28 px), so an open folder reads as a folder rather than a crooked list. smaller art (about 28 px), so an open folder reads as a folder rather than a crooked list.
- [ ] **A folder that looks like one.** "TS" in a grey tile cannot be told from a feed. Draw a 2×2 mosaic - [x] **A folder that looks like one.** "TS" in a grey tile cannot be told from a feed. Draw a 2×2 mosaic
of the first four feeds' art, the way iTunes built playlist artwork from album covers; Glass Cannon's of the first four feeds' art, the way iTunes built playlist artwork from album covers; Glass Cannon's
shows all have art. The one bold touch the sidebar gets. shows all have art. The one bold touch the sidebar gets.
- [ ] **The selected tile disappears.** In Dark and Light a selected feed without art loses its initials - [x] **The selected tile disappears.** In Dark and Light a selected feed without art loses its initials
tile, because `.feed.sel` and `.art` are both `--raise`. tile, because `.feed.sel` and `.art` are both `--raise`.
After any of these: rebuild, `node tests/page-smoke.js`, `npx playwright test`, and look at all three After any of these: rebuild, `node tests/page-smoke.js`, `npx playwright test`, and look at all three

View File

@@ -111,7 +111,7 @@ else a `401`.
| `GET /api/entries` | the same, across every feed you subscribe to (All Subscriptions) | | `GET /api/entries` | the same, across every feed you subscribe to (All Subscriptions) |
| `POST /api/feeds/{id}/read-all`, `POST /api/feeds/{id}/download-latest` | | | `POST /api/feeds/{id}/read-all`, `POST /api/feeds/{id}/download-latest` | |
| `POST /api/read-all` | everything read in every feed you subscribe to (All Subscriptions) | | `POST /api/read-all` | everything read in every feed you subscribe to (All Subscriptions) |
| `POST /api/entries/{feed}/{guid}/flags`, `…/position` | your read, starred, position | | `POST /api/entries/{feed}/{guid}/flags`, `…/position` | your read, kept, position |
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning | | `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
| `POST /api/fetch` | | | `POST /api/fetch` | |
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML | | `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |

View File

@@ -60,7 +60,7 @@ ipx reap # actually delete
``` ```
Files are deleted to get back under `max_total_gb`, oldest first, and items past `max_age_days` Files are deleted to get back under `max_total_gb`, oldest first, and items past `max_age_days`
with no file are pruned from the database. **Starred by anyone keeps a file**, and one only counts with no file are pruned from the database. **An item anyone kept keeps its file**, and one only counts
as read when everyone subscribed has read it. The enclosure row survives as `reaped`, which is what as read when everyone subscribed has read it. The enclosure row survives as `reaped`, which is what
stops the next scan fetching it again. stops the next scan fetching it again.

View File

@@ -32,10 +32,10 @@ media_types = ["audio", "video"]
be polled *less* often, and a per-feed `schedule` overrides both. Admin-only from the UI. be polled *less* often, and a per-feed `schedule` overrides both. Admin-only from the UI.
* **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`. * **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`.
* **`max_total_gb`** — the reaper deletes to get back under this, oldest first, keeping a 50 MB * **`max_total_gb`** — the reaper deletes to get back under this, oldest first, keeping a 50 MB
pad. Starred items are never deleted, and a file only counts as read once every subscriber has pad. Kept items are never deleted, and a file only counts as read once every subscriber has
read it. `0` disables it entirely. read it. `0` disables it entirely.
* **`max_age_days`** — items older than this with no file on disk are pruned from the database. * **`max_age_days`** — items older than this with no file on disk are pruned from the database.
Starred ones stay. `0` disables it. Kept ones stay. `0` disables it.
* **`max_new_per_check`** — the cap that stops a new subscription pulling a whole back catalogue. * **`max_new_per_check`** — the cap that stops a new subscription pulling a whole back catalogue.
`0` means unlimited, which is rarely what you want: subscribing to an OPML of 80 feeds with no cap `0` means unlimited, which is rarely what you want: subscribing to an OPML of 80 feeds with no cap
fetched 216 files and 22 GB in one scan. fetched 216 files and 22 GB in one scan.

View File

@@ -6,6 +6,38 @@ reasoning lives. New write-ups go at the top.
See [README.md](../README.md) for what the thing is. See [README.md](../README.md) for what the thing is.
## 2026-09-11 — A design pass on the web UI
A review against screenshots of every view in all three themes found that Dark and Light read as a
generic dark dashboard: one pale blue did every job, most labels were bold, and nothing led. The
list it produced, in `TODO.md`, was worked through in one go. What was worth knowing:
- **Amber means new.** Badges, unread dots and download bars take the icon's EQ amber; blue is left
for the primary action and links. Light's amber was `#b06f10`, which gives white text 4.1:1,
short of AA for 11 px bold. It is `#9a5f0a` now, 5.2:1.
- **EQ bars mark what is playing.** Three `<i>` bars stand at 60, 100 and 40 % and animate only
while `body.playing` is set. The first version left the animation on but paused, expecting each
bar to hold a different frame. The frames it held were within a pixel of each other, and on
screen the bars read as three dots. Under reduced motion one rule drops every animation and
transition, which leaves the bars standing.
- **The focus ring was clipped.** `.tgroup` and `#topbar` both set `overflow:hidden`, so a ring
drawn outside a toolbar button was cut off. Rings inside clipping parents are inset instead.
- **The feed list could not be used from the keyboard at all.** Rows were `<div>`s and the triangle
a `<span>`, so Tab went from the feed filter to Sign out. Rows now take focus, the triangle is a
`<button aria-expanded>`, and `renderFeeds` puts focus back on the same row after redrawing,
since every live update replaces every row. The list's own key handler stops Space and the
arrows from reaching the player's shortcuts on the document.
- **The triangle hangs in the margin.** Every row used to reserve an 18 px slot for it, pushing a
hundred feeds 28 px right of the places above for the sake of two folders. It is now absolutely
placed in the row's left padding, the full height of the row, so a near miss no longer opens
the folder's page.
- **A selected tile vanished** because the initials tile and the selected row were both `--raise`.
Tiles now mix their tint into `--bg`, which no row uses.
- **The Files pane hides itself** with `#split:has(>#files[hidden])`, which collapses its column.
The phone layout already hides the pane, and a zero-width extra track there is harmless.
- **Flagged became Kept** in the tab, and in the server's refusal to delete a file someone else
kept. The filter value and the column stay `flagged`; renaming those buys nothing.
## 2026-09-11 — A Patreon creator is a list of shows ## 2026-09-11 — A Patreon creator is a list of shows
Ray asked whether ipx could sync with Patreon. Not in full. The documented API (v2, the Ray asked whether ipx could sync with Patreon. Not in full. The documented API (v2, the

View File

@@ -8,7 +8,7 @@ fetch, one parse and one file.
| Yours alone | The same for everyone | | Yours alone | The same for everyone |
|---|---| |---|---|
| Read, starred, playback position | The feed's URL | | Read, kept, playback position | The feed's URL |
| Which feeds you see at all | Its download folder | | Which feeds you see at all | Its download folder |
| Keywords, auto-download, explicit, per-scan cap | When it is scanned | | Keywords, auto-download, explicit, per-scan cap | When it is scanned |
| | The file on disk | | | The file on disk |
@@ -34,10 +34,10 @@ is shared.
Deleting a file deletes everyone's copy. A feed with other subscribers labels the button **Delete Deleting a file deletes everyone's copy. A feed with other subscribers labels the button **Delete
for everyone** and names them in the confirmation, and the server has the last word: if anyone else for everyone** and names them in the confirmation, and the server has the last word: if anyone else
has starred the item or not played it yet, `DELETE /api/enclosures/{id}` answers `409` with the has kept the item or not played it yet, `DELETE /api/enclosures/{id}` answers `409` with the
reason, and only `?force=true` goes through. reason, and only `?force=true` goes through.
Retention follows the same rule: starred by anyone keeps a file, and it counts as read only once Retention follows the same rule: an item anyone kept keeps its file, and it counts as read only once
every subscriber has read it. every subscriber has read it.
## Signing in ## Signing in

View File

@@ -1196,9 +1196,9 @@ async fn delete_file(
let complaint = match (starred, unread) { let complaint = match (starred, unread) {
(0, 0) => None, (0, 0) => None,
(0, u) => Some(format!("{} subscribed to this feed {} not played it yet", people(u), if u == 1 { "has" } else { "have" })), (0, u) => Some(format!("{} subscribed to this feed {} not played it yet", people(u), if u == 1 { "has" } else { "have" })),
(st, 0) => Some(format!("another {} starred it to keep", people(st))), (st, 0) => Some(format!("another {} kept it", people(st))),
(st, u) => Some(format!( (st, u) => Some(format!(
"another {} starred it to keep, and {} not played it yet", "another {} kept it, and {} not played it yet",
people(st), people(st),
if u == 1 { "one person has".to_string() } else { format!("{u} have") } if u == 1 { "one person has".to_string() } else { format!("{u} have") }
)), )),

View File

@@ -142,7 +142,7 @@ test('the filter tabs change what is listed', async ({ page }) => {
await page.locator('.tabs button', { hasText: 'Unread' }).first().click(); await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
expect(await page.locator('.ep').count()).toBeLessThanOrEqual(all); expect(await page.locator('.ep').count()).toBeLessThanOrEqual(all);
await page.locator('.tabs button', { hasText: 'Flagged' }).first().click(); await page.locator('.tabs button', { hasText: 'Kept' }).first().click();
await expect(page.locator('#count')).toContainText('0 items'); await expect(page.locator('#count')).toContainText('0 items');
}); });
@@ -187,9 +187,11 @@ test('an OPML subscription is a collapsible folder', async ({ page }) => {
body: JSON.stringify({ feed: 'test-subscriptions', force: true }), body: JSON.stringify({ feed: 'test-subscriptions', force: true }),
})); }));
// Every row reserves the chevron slot for alignment; only a folder's is clickable. // Only a folder has a triangle, and it is a button that says whether the folder is open.
const chev = page.locator('.feed.group .chev'); const chev = page.locator('.feed.group .chev');
await expect(chev).toBeVisible({ timeout: 20_000 }); await expect(chev).toBeVisible({ timeout: 20_000 });
await expect(page.locator('.feed:not(.group) .chev')).toHaveCount(0);
await expect(chev).toHaveAttribute('aria-expanded', 'false');
// Closed by default: the children are not listed until the folder is opened. // Closed by default: the children are not listed until the folder is opened.
const before = await page.locator('.feed').count(); const before = await page.locator('.feed').count();

View File

@@ -37,7 +37,7 @@
--dim:#606060; /* #606060 */ --dim:#606060; /* #606060 */
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */ --faint:#767676; /* between the icon's #929292 and #606060, to clear AA */
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */ --accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
--accent2:#b06f10; --accent2:#9a5f0a; /* the EQ amber, taken down until white on it clears AA */
--ink:#ffffff; --ink:#ffffff;
--good:#2f7d4f; --good:#2f7d4f;
--warn:#b06f10; --warn:#b06f10;
@@ -75,6 +75,12 @@ body{
} }
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer} button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
a{color:var(--accent)} a{color:var(--accent)}
/* Inset wherever a parent clips its overflow, or the toolbar and the feed list cut the ring off. */
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.tgroup button:focus-visible,.feed:focus-visible,.place:focus-visible,.chev:focus-visible{outline-offset:-2px}
@media (prefers-reduced-motion:reduce){
*,*::before,*::after{animation:none!important;transition:none!important}
}
::-webkit-scrollbar{width:10px;height:10px} ::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-thumb{background:var(--raise);border-radius:6px} ::-webkit-scrollbar-thumb{background:var(--raise);border-radius:6px}
::-webkit-scrollbar-track{background:transparent} ::-webkit-scrollbar-track{background:transparent}
@@ -133,18 +139,22 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
#feedlist{overflow-y:auto;padding:0 8px 12px;flex:1;min-height:0} #feedlist{overflow-y:auto;padding:0 8px 12px;flex:1;min-height:0}
.feed{ .feed{
display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:9px; display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:9px;
cursor:pointer;margin-bottom:1px; cursor:pointer;margin-bottom:1px;position:relative;
} }
.feed:hover{background:var(--panel2)} .feed:hover{background:var(--panel2)}
.feed.sel{background:var(--raise)} .feed.sel{background:var(--raise)}
.feed.child{margin-left:16px} /* A show sits under its folder's title, a size down, so an open folder reads as one. */
.feed.child{margin-left:44px}
.feed.child .art{width:28px;height:28px;font-size:11px}
/* Only a folder has a triangle, hung in the margin so every feed's art lines up with the places
above it. The button is the row's full height: a near miss used to open the folder's page. */
.chev{ .chev{
flex:none;width:18px;height:18px;display:grid;place-items:center;border-radius:4px; position:absolute;left:-8px;top:0;bottom:0;width:16px;display:grid;place-items:center;
color:var(--faint);font-size:11px;transition:transform .12s; border-radius:4px;color:var(--faint);
} }
.chev:empty{pointer-events:none} .chev:hover{color:var(--fg)}
.chev:not(:empty):hover{background:var(--raise);color:var(--fg)} .chev .i{width:12px;height:12px;transition:transform .12s}
.chev.open{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}
.childrow{ .childrow{
display:flex;gap:10px;align-items:center;padding:7px 9px;border:1px solid var(--line); display:flex;gap:10px;align-items:center;padding:7px 9px;border:1px solid var(--line);
@@ -153,24 +163,32 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
.childrow:hover{border-color:var(--accent)} .childrow:hover{border-color:var(--accent)}
.childrow .art{width:32px;height:32px;font-size:12px} .childrow .art{width:32px;height:32px;font-size:12px}
.childrow .txt{flex:1;min-width:0} .childrow .txt{flex:1;min-width:0}
.childrow .txt b{display:block;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .childrow .txt b{display:block;font-weight:500;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tag{ .tag{
font-size:10px;text-transform:uppercase;letter-spacing:.04em;font-weight:700; font-size:11px;font-weight:600;
padding:1px 5px;border-radius:4px;background:var(--raise);color:var(--warn);flex:none; padding:1px 5px;border-radius:4px;background:var(--raise);color:var(--warn);flex:none;
} }
.art{ .art{
border-radius:7px;object-fit:cover;background:var(--raise);flex:none; border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
display:grid;place-items:center;color:var(--faint);font-weight:700;overflow:hidden; display:grid;place-items:center;color:var(--faint);font-weight:600;overflow:hidden;
} }
/* Initials, tinted by a hash of the name so two "TS" in a row can be told apart. Mixed into the
page ground, not --raise: that is also the selected row, and a selected tile vanished into it. */
.art.ini{background:color-mix(in srgb,var(--tint) 24%,var(--bg));color:var(--tint)}
/* A folder: its first four shows' art, as iTunes built a playlist's from its albums. */
.art.mosaic{grid-template:1fr 1fr/1fr 1fr;place-items:stretch}
.art.mosaic img{width:100%;height:100%;min-height:0;object-fit:cover;display:block}
.feed .art{width:34px;height:34px;font-size:13px} .feed .art{width:34px;height:34px;font-size:13px}
.feed .txt{min-width:0;flex:1} .feed .txt{min-width:0;flex:1}
.feed .txt b{display:block;font-weight:600;font-size:13.5px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .feed .txt b{display:block;font-weight:500;font-size:13.5px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.feed .txt small{display:block;color:var(--faint);font-size:11.5px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .feed .txt small{display:block;color:var(--faint);font-size:11.5px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.badge{ .badge{
background:var(--accent);color:var(--ink);border-radius:20px;padding:1px 7px; background:var(--accent2);color:var(--ink);border-radius:20px;padding:1px 7px;
font-size:11px;font-weight:700;flex:none;min-width:26px;text-align:center; font-size:11px;font-weight:600;flex:none;min-width:26px;text-align:center;
} }
.badge.zero{background:var(--raise);color:var(--faint)} .badge.zero{background:var(--raise);color:var(--faint)}
/* Counts, sizes and times that change in place should not jiggle the text around them. */
.badge,.feed small,.childrow small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
/* ---------- main ---------- */ /* ---------- main ---------- */
#main{overflow:hidden;min-height:0;min-width:0;display:flex;flex-direction:column} #main{overflow:hidden;min-height:0;min-width:0;display:flex;flex-direction:column}
@@ -184,15 +202,17 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
#split{ #split{
display:grid;flex:1;min-height:0; display:grid;flex:1;min-height:0;
grid-template-columns:minmax(0,1fr) 270px; grid-template-columns:minmax(0,1fr) 270px;
grid-template-rows:minmax(90px,var(--listh,40%)) 7px 1fr; grid-template-rows:minmax(90px,var(--listh,60%)) 7px 1fr;
grid-template-areas:"list files" "grab grab" "detail detail"; grid-template-areas:"list files" "grab grab" "detail detail";
} }
#list{grid-area:list;overflow:auto;padding:0 14px 10px} #list{grid-area:list;overflow:auto;padding:0 14px 10px}
/* The selected item's files, beside the list, as the original's Files pane was. */ /* The selected item's files, beside the list, as the original's Files pane was. */
#files{grid-area:files;overflow-y:auto;padding:8px 12px;border-left:1px solid var(--line);background:var(--panel)} #files{grid-area:files;overflow-y:auto;padding:8px 12px;border-left:1px solid var(--line);background:var(--panel)}
/* Nothing selected, or an item with no files: the list takes the width. */
#split:has(>#files[hidden]){grid-template-columns:minmax(0,1fr) 0}
#files .encbox{margin-top:8px} #files .encbox{margin-top:8px}
#files .empty{padding:24px 0} #files .empty{padding:24px 0}
.fhd{font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint)} .fhd{font-size:12px;color:var(--faint)}
#grab{grid-area:grab;cursor:row-resize;background:var(--line)} #grab{grid-area:grab;cursor:row-resize;background:var(--line)}
#grab:hover{background:var(--accent)} #grab:hover{background:var(--accent)}
#detail{grid-area:detail;overflow-y:auto;padding:16px 20px 28px;background:var(--panel);border-top:1px solid var(--line)} #detail{grid-area:detail;overflow-y:auto;padding:16px 20px 28px;background:var(--panel);border-top:1px solid var(--line)}
@@ -219,10 +239,7 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
.acts .btn i{font-style:normal;font-size:13px;line-height:1;opacity:.7} .acts .btn i{font-style:normal;font-size:13px;line-height:1;opacity:.7}
.acts .btn.primary i{opacity:.9} .acts .btn.primary i{opacity:.9}
.acts .btn:hover{background:var(--raise)} .acts .btn:hover{background:var(--raise)}
.acts .btn.primary:hover{background:var(--accent2);border-color:var(--accent2)} .acts .btn.primary:hover{background:var(--accent)}
/* Leaving a feed is not one of the everyday actions: keep it off on its own. */
.acts .btn.last{margin-left:auto;border-color:transparent;background:none;color:var(--faint)}
.acts .btn.last:hover{background:none;border-color:var(--bad);color:var(--bad)}
.btn{ .btn{
background:var(--panel2);border:1px solid var(--line);border-radius:8px; background:var(--panel2);border:1px solid var(--line);border-radius:8px;
padding:6px 12px;font-size:13px; padding:6px 12px;font-size:13px;
@@ -250,7 +267,7 @@ a.btn{text-decoration:none;color:inherit}
} }
.tabs{display:flex;gap:2px;background:var(--panel2);border-radius:9px;padding:3px} .tabs{display:flex;gap:2px;background:var(--panel2);border-radius:9px;padding:3px}
.tabs button{padding:5px 12px;border-radius:7px;font-size:13px;color:var(--dim)} .tabs button{padding:5px 12px;border-radius:7px;font-size:13px;color:var(--dim)}
.tabs button.on{background:var(--raise);color:var(--fg);font-weight:600} .tabs button.on{background:var(--raise);color:var(--fg);font-weight:500}
.toolbar .grow{flex:1;min-width:150px;max-width:320px} .toolbar .grow{flex:1;min-width:150px;max-width:320px}
/* ---------- items ---------- */ /* ---------- items ---------- */
@@ -262,7 +279,7 @@ a.btn{text-decoration:none;color:inherit}
.ephead{ .ephead{
position:sticky;top:0;z-index:2;background:var(--bg);padding:7px 10px 5px; position:sticky;top:0;z-index:2;background:var(--bg);padding:7px 10px 5px;
border-bottom:1px solid var(--line);margin-bottom:3px; border-bottom:1px solid var(--line);margin-bottom:3px;
font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint); font-size:12px;color:var(--faint);
} }
/* Each heading sorts by its column; the caret says which way. */ /* Each heading sorts by its column; the caret says which way. */
.ephead .hs{display:flex;align-items:center;gap:4px;min-width:0;padding:0;border:0;background:none; .ephead .hs{display:flex;align-items:center;gap:4px;min-width:0;padding:0;border:0;background:none;
@@ -276,10 +293,21 @@ a.btn{text-decoration:none;color:inherit}
.ep>*{min-width:0} .ep>*{min-width:0}
.ep.sel{background:var(--raise);border-color:var(--line)} .ep.sel{background:var(--raise);border-color:var(--line)}
.ep:hover{background:var(--panel)} .ep:hover{background:var(--panel)}
.ep.playing{border-color:var(--accent)} /* Amber means new: the unread dot, the badges and a download under way. Blue is for the one
.ep .st,.ep .fl{width:22px;height:22px;border-radius:5px;display:grid;place-items:center;font-size:11px;color:var(--accent)} primary action and links, so a count no longer looks like a button. */
.ep .st,.ep .fl{width:22px;height:22px;border-radius:5px;display:grid;place-items:center;font-size:11px;color:var(--accent2)}
.ep .fl{color:var(--faint);font-size:13px} .ep .fl{color:var(--faint);font-size:13px}
.ep .fl.on{color:var(--accent2)} .ep .fl.on{color:var(--fg)}
/* The icon's EQ bars mark what is playing: standing still, and moving only while it plays. A
paused animation was tried first; the frames it held were a pixel apart and read as dots. */
.eq{display:inline-flex;align-items:flex-end;gap:2px;width:13px;height:12px;flex:none}
.eq i{flex:1;height:100%;background:currentColor;border-radius:1px;transform-origin:bottom}
.eq i:nth-child(1){height:60%}
.eq i:nth-child(3){height:40%}
body.playing .eq i{animation:eq .9s ease-in-out infinite alternate}
body.playing .eq i:nth-child(1){animation-delay:-.3s}
body.playing .eq i:nth-child(3){animation-delay:-.6s}
@keyframes eq{from{transform:scaleY(.35)}}
.ep .st:hover,.ep .fl:hover{background:var(--raise)} .ep .st:hover,.ep .fl:hover{background:var(--raise)}
.ep .t{font-weight:600;font-size:13.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .ep .t{font-weight:600;font-size:13.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ep.read .t{color:var(--dim);font-weight:500} .ep.read .t{color:var(--dim);font-weight:500}
@@ -293,19 +321,11 @@ a.btn{text-decoration:none;color:inherit}
#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}
#split.one .h-fd,#split.one .ep .fd{display:none} #split.one .h-fd,#split.one .ep .fd{display:none}
.dot{width:3px;height:3px;border-radius:50%;background:var(--faint);flex:none} .dot{width:3px;height:3px;border-radius:50%;background:var(--faint);flex:none}
.chip{
font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;font-weight:700;
padding:1px 6px;border-radius:5px;background:var(--raise);color:var(--dim);
}
.chip.done{background:color-mix(in srgb,var(--good) 16%,transparent);color:var(--good)}
.chip.error{background:color-mix(in srgb,var(--bad) 16%,transparent);color:var(--bad)}
.chip.pending{background:color-mix(in srgb,var(--warn) 16%,transparent);color:var(--warn)}
.chip.new{background:var(--accent);color:var(--ink)}
.ep .rowacts{display:flex;gap:2px;align-items:flex-start;opacity:0;transition:opacity .12s} .ep .rowacts{display:flex;gap:2px;align-items:flex-start;opacity:0;transition:opacity .12s}
.ep:hover .rowacts,.ep.open .rowacts{opacity:1} .ep:hover .rowacts,.ep.open .rowacts{opacity:1}
.dlbar{height:3px;background:transparent;border-radius:2px;overflow:hidden;margin-top:7px} .dlbar{height:3px;background:transparent;border-radius:2px;overflow:hidden;margin-top:7px}
.dlbar.live{background:var(--raise)} .dlbar.live{background:var(--raise)}
.dlbar i{display:block;height:100%;width:0;background:var(--accent);transition:width .25s} .dlbar i{display:block;height:100%;width:0;background:var(--accent2);transition:width .25s}
.empty{color:var(--faint);text-align:center;padding:50px 0} .empty{color:var(--faint);text-align:center;padding:50px 0}
#more{display:block;width:100%;margin-top:10px} #more{display:block;width:100%;margin-top:10px}
@@ -320,6 +340,7 @@ a.btn{text-decoration:none;color:inherit}
#pnow .art{width:44px;height:44px;font-size:13px} #pnow .art{width:44px;height:44px;font-size:13px}
#pnow .txt{min-width:0} #pnow .txt{min-width:0}
#pnow b{display:block;font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} #pnow b{display:block;font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#pnow .eq{color:var(--accent2);margin-right:6px}
#pnow small{color:var(--faint);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block} #pnow small{color:var(--faint);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block}
#pmid{display:flex;flex-direction:column;gap:3px;min-width:0} #pmid{display:flex;flex-direction:column;gap:3px;min-width:0}
#pbtns{display:flex;gap:5px;align-items:center;justify-content:center} #pbtns{display:flex;gap:5px;align-items:center;justify-content:center}
@@ -400,14 +421,13 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
.fhead.slim .sub{margin-bottom:0;font-size:12.5px} .fhead.slim .sub{margin-bottom:0;font-size:12.5px}
.fhead.slim .acts{margin-top:0;flex:none} .fhead.slim .acts{margin-top:0;flex:none}
.fhead.slim .acts .btn{padding:5px 11px;font-size:12.5px} .fhead.slim .acts .btn{padding:5px 11px;font-size:12.5px}
.fhead.slim .acts .btn.last{margin-left:0}
/* Places above the feeds: not subscriptions, but where to look. */ /* Places above the feeds: not subscriptions, but where to look. */
.places{border-bottom:1px solid var(--line);margin:0 0 6px;padding-bottom:6px} .places{border-bottom:1px solid var(--line);margin:0 0 6px;padding-bottom:6px}
.place{display:flex;gap:10px;align-items:center;padding:6px 8px;border-radius:9px;cursor:pointer} .place{display:flex;gap:10px;align-items:center;padding:6px 8px;border-radius:9px;cursor:pointer}
.place:hover{background:var(--panel2)} .place:hover{background:var(--panel2)}
.place.sel{background:var(--raise)} .place.sel{background:var(--raise)}
.place .ico{width:18px;text-align:center;color:var(--accent);flex:none} .place .ico{width:18px;text-align:center;color:var(--accent);flex:none}
.place b{flex:1;font-weight:600;font-size:13.5px} .place b{flex:1;font-weight:500;font-size:13.5px}
@media (max-width:820px){ @media (max-width:820px){
#shell{grid-template-columns:1fr} #shell{grid-template-columns:1fr}
@@ -495,7 +515,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
:root[data-theme="classic"] .badge.zero{background:#b4bdc9;color:#fff} :root[data-theme="classic"] .badge.zero{background:#b4bdc9;color:#fff}
:root[data-theme="classic"] .ephead, :root[data-theme="classic"] .ephead,
:root[data-theme="classic"] .fhd{ :root[data-theme="classic"] .fhd{
background:linear-gradient(#fbfbfb,#dcdcdc);color:#1a1a1a;text-transform:none;letter-spacing:0;font-size:11px; background:linear-gradient(#fbfbfb,#dcdcdc);color:#1a1a1a;font-size:11px;
} }
:root[data-theme="classic"] .fhd{padding:3px 6px;border:1px solid #c2c2c2} :root[data-theme="classic"] .fhd{padding:3px 6px;border:1px solid #c2c2c2}
:root[data-theme="classic"] .ephead{border-bottom-color:#a3a3a3} :root[data-theme="classic"] .ephead{border-bottom-color:#a3a3a3}
@@ -565,7 +585,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
<div id="player"> <div id="player">
<div id="pnow"> <div id="pnow">
<div id="partwrap"></div> <div id="partwrap"></div>
<div class="txt"><b id="ptitle"></b><small id="pfeed"></small></div> <div class="txt"><b><span class="eq" aria-hidden="true"><i></i><i></i><i></i></span><span id="ptitle"></span></b><small id="pfeed"></small></div>
</div> </div>
<div id="pmid"> <div id="pmid">
<div id="pbtns"> <div id="pbtns">
@@ -642,6 +662,8 @@ const ICON={
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
}; };
// What is playing, as the icon's EQ bars; the stylesheet moves them.
const EQ='<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>';
// One meaning per icon: minus unsubscribes, x closes or cancels, plus adds or subscribes, and a // One meaning per icon: minus unsubscribes, x closes or cancels, plus adds or subscribes, and a
// dialog's confirm button carries the icon of what it does. Words go in the tooltip. // dialog's confirm button carries the icon of what it does. Words go in the tooltip.
// The page's own buttons name their icon; this draws it in, ahead of any label they carry. // The page's own buttons name their icon; this draws it in, ahead of any label they carry.
@@ -707,10 +729,25 @@ const dateOf = t => t?new Date(t*1000).toLocaleDateString(undefined,{month:'shor
const mb = n => !n?'' : n<1048576?Math.max(1,Math.round(n/1024))+' KB' const mb = n => !n?'' : n<1048576?Math.max(1,Math.round(n/1024))+' KB'
: n<1073741824?Math.round(n/1048576)+' MB' : (n/1073741824).toFixed(1)+' GB'; : n<1073741824?Math.round(n/1048576)+' MB' : (n/1073741824).toFixed(1)+' GB';
const initials = s => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?'; const initials = s => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?';
const plural=(n,word)=>`${n} ${word}${n===1?'':'s'}`;
// An initials tile takes one of these, by a hash of the name, so neighbours rarely match.
const TINTS=['var(--accent)','var(--good)','var(--dim)','color-mix(in srgb,var(--accent),var(--good))'];
const tint=name=>{ let h=0; for(const c of name||'?') h=(h*31+c.charCodeAt(0))>>>0; return TINTS[h%TINTS.length]; };
function tileHTML(name,cls){
return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`;
}
function artHTML(url,name,cls){ function artHTML(url,name,cls){
return url return url
? `<img class="art ${cls||''}" src="${esc(url)}" alt="" loading="lazy" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'art ${cls||''}',textContent:'${esc(initials(name))}'}))">` ? `<img class="art ${cls||''}" src="${esc(url)}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">`
: `<div class="art ${cls||''}">${esc(initials(name))}</div>`; : tileHTML(name,cls);
}
/// A folder's tile is its first four shows' art. With fewer than four to show, the folder's own.
function folderArt(f,kids){
const art=kids.filter(c=>c.image).slice(0,4);
if(art.length<4) return artHTML(f.image,f.title||f.id);
// Tinted underneath, so art that fails to load leaves colour behind rather than a hole.
return `<div class="art ini mosaic" style="--tint:${tint(f.title||f.id)}">${art.map(c=>
`<img src="${esc(c.image)}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</div>`;
} }
/// The sidebar slides over the page on a phone, so it needs a scrim to tap away. /// The sidebar slides over the page on a phone, so it needs a scrim to tap away.
@@ -747,13 +784,23 @@ const VIEWS={
}; };
function renderFeeds(){ function renderFeeds(){
const q=$('#feedFilter').value.trim().toLowerCase(); const q=$('#feedFilter').value.trim().toLowerCase();
const list=$('#feedlist'); const top=list.scrollTop; list.innerHTML=''; const list=$('#feedlist'); const top=list.scrollTop;
// Every row is replaced, so put the keyboard back on the row, or the triangle, it was on.
const a=document.activeElement, was=a&&a.closest&&a.closest('#feedlist [data-id]');
const back=was&&[was.dataset.id,a.classList.contains('chev')];
const done=()=>{
list.scrollTop=top;
const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`);
if(row) (back[1]&&$('.chev',row)||row).focus();
};
list.innerHTML='';
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0); const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
const places=document.createElement('div'); const places=document.createElement('div');
places.className='places'; places.className='places';
for(const [id,v] of Object.entries(VIEWS)){ for(const [id,v] of Object.entries(VIEWS)){
const el=document.createElement('div'); const el=document.createElement('div');
el.className='place'+(S.feed===id?' sel':''); el.className='place'+(S.feed===id?' sel':'');
el.tabIndex=0; el.dataset.id=id;
el.innerHTML=`<span class="ico">${v.icon}</span><b>${v.title}</b>`+(id===':all' el.innerHTML=`<span class="ico">${v.icon}</span><b>${v.title}</b>`+(id===':all'
?`<span class="badge${unreadAll?'':' zero'}" title="${unreadAll} unread">${unreadAll>999?'999+':unreadAll}</span>`:''); ?`<span class="badge${unreadAll?'':' zero'}" title="${unreadAll} unread">${unreadAll>999?'999+':unreadAll}</span>`:'');
el.onclick=()=>{ selectFeed(id); nav(false); }; el.onclick=()=>{ selectFeed(id); nav(false); };
@@ -761,7 +808,7 @@ function renderFeeds(){
} }
list.appendChild(places); list.appendChild(places);
const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q)); const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q));
if(!shown.length){ list.insertAdjacentHTML('beforeend','<p class="empty" style="padding:20px 8px">No feeds.</p>'); return; } if(!shown.length){ list.insertAdjacentHTML('beforeend','<p class="empty" style="padding:20px 8px">No feeds.</p>'); return done(); }
// Feeds from a subscribed OPML sit under it, so the group reads as one thing. // Feeds from a subscribed OPML sit under it, so the group reads as one thing.
const byId=Object.fromEntries(shown.map(f=>[f.id,f])); const byId=Object.fromEntries(shown.map(f=>[f.id,f]));
@@ -785,21 +832,21 @@ function renderFeeds(){
: [f.unread,f.entries,f.downloaded]; : [f.unread,f.entries,f.downloaded];
const el=document.createElement('div'); const el=document.createElement('div');
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':''); el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'');
const open = kids && (expanded.has(f.id) || q); el.tabIndex=0; el.dataset.id=f.id;
const open = !!(kids && (expanded.has(f.id) || q));
el.innerHTML = el.innerHTML =
`<span class="chev${open?' open':''}"${kids?' title="Show or hide the feeds inside"':''}>${kids?ICON.caret:''}</span>`+ (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>`:'')+
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?`${mine.length} feed${mine.length===1?'':'s'} · ${saved} saved` `${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+
:`${eps} items · ${saved} saved`)+
`</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>':'')+
`<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); };
list.appendChild(el); list.appendChild(el);
} }
list.scrollTop=top; done();
} }
function selectFeed(id){ function selectFeed(id){
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value=''; S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
@@ -826,8 +873,8 @@ function renderFeed(){
${artHTML(f.image,name)} ${artHTML(f.image,name)}
<div class="meta"> <div class="meta">
<h2>${esc(name)}</h2> <h2>${esc(name)}</h2>
<div class="sub stat">${f.entries} items · ${f.downloaded} downloaded · checked ${ago(f.last_checked)} <div class="sub stat" title="Checked every ${everyText(f.every_mins)}${f.next_check?`, next ${due(f.next_check)}`:''}">${
· every ${everyText(f.every_mins)}${f.next_check?` · next ${due(f.next_check)}`:''}${ plural(f.entries,'item')}, ${f.downloaded} downloaded · checked ${ago(f.last_checked)}${
f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}</div> f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}</div>
${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`:''} ${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`:''}
${f.orphaned?`<div class="sub" style="color:var(--warn)">This feed is no longer listed in its ${f.orphaned?`<div class="sub" style="color:var(--warn)">This feed is no longer listed in its
@@ -839,7 +886,7 @@ function renderFeed(){
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button> <button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button> <button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button> <button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
<button class="btn ico danger last" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.minus}</button> <button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.minus}</button>
</div> </div>
</div>` : ` </div>` : `
<div class="fhead slim"> <div class="fhead slim">
@@ -856,8 +903,8 @@ function renderFeed(){
</div>`) + ` </div>`) + `
<div class="toolbar"> <div class="toolbar">
<div class="tabs"> <div class="tabs">
${['all','unread','downloaded','flagged'].map(t=> ${[['all','All'],['unread','Unread'],['downloaded','Downloaded'],['flagged','Kept']].map(([t,label])=>
`<button data-f="${t}" class="${S.filter===t?'on':''}">${t[0].toUpperCase()+t.slice(1)}</button>`).join('')} `<button data-f="${t}" class="${S.filter===t?'on':''}">${label}</button>`).join('')}
</div> </div>
</div>`; </div>`;
@@ -868,7 +915,7 @@ function renderFeed(){
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>'; '<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
$$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort)); $$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort));
box.appendChild(pane); box.appendChild(pane);
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '40%'); pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '60%');
dragSplit(pane); dragSplit(pane);
showDetail(null); showDetail(null);
@@ -907,12 +954,11 @@ function renderGroup(f,kids){
// The same header as a feed's, buttons in the same places: it is a feed underneath. // The same header as a feed's, buttons in the same places: it is a feed underneath.
$('#content').innerHTML = ` $('#content').innerHTML = `
<div class="fhead slim"> <div class="fhead slim">
${artHTML(f.image,f.title||f.id)} ${folderArt(f,kids)}
<div class="meta"> <div class="meta">
<h2>${esc(f.title||f.id)}</h2> <h2>${esc(f.title||f.id)}</h2>
<div class="sub stat">${isPatreon(f)?'Patreon creator':'OPML subscription'} · ${kids.length} feed${kids.length===1?'':'s'} <div class="sub stat" title="Checked every ${everyText(f.every_mins)}">${isPatreon(f)?'Patreon creator':'OPML subscription'}
· ${unread} unread · ${saved} downloaded · checked ${ago(f.last_checked)} · ${plural(kids.length,'feed')}, ${unread} unread, ${saved} downloaded · checked ${ago(f.last_checked)}</div>
· every ${everyText(f.every_mins)}</div>
${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`:''} ${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`:''}
${gone?`<div class="sub" style="color:var(--warn)">${gone} feed${gone===1?' is':'s are'} no longer ${gone?`<div class="sub" style="color:var(--warn)">${gone} feed${gone===1?' is':'s are'} no longer
listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''} listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
@@ -921,7 +967,7 @@ function renderGroup(f,kids){
<button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button> <button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button> <button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button> <button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
<button class="btn ico danger last" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.minus}</button> <button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.minus}</button>
</div> </div>
</div> </div>
<div class="toolbar"> <div class="toolbar">
@@ -940,9 +986,9 @@ function renderGroup(f,kids){
el.className='childrow'; el.className='childrow';
el.innerHTML = artHTML(c.image,c.title||c.id)+ el.innerHTML = artHTML(c.image,c.title||c.id)+
`<div class="txt"><b>${esc(c.title||c.id)}</b>`+ `<div class="txt"><b>${esc(c.title||c.id)}</b>`+
`<small class="meta">${c.entries} items · ${c.downloaded} downloaded`+ `<small class="meta">${plural(c.entries,'item')} · ${c.downloaded} downloaded`+
(c.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+ (c.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+
(c.orphaned?'<span class="tag">gone</span>':'')+ (c.orphaned?'<span class="tag">Gone</span>':'')+
`<span class="badge${c.unread?'':' zero'}">${c.unread}</span>`; `<span class="badge${c.unread?'':' zero'}">${c.unread}</span>`;
el.onclick=()=>selectFeed(c.id); el.onclick=()=>selectFeed(c.id);
box.appendChild(el); box.appendChild(el);
@@ -1016,16 +1062,15 @@ function epEl(e){
const playable=isPlayable(enc); const playable=isPlayable(enc);
const others=e.enclosures.length-1; const others=e.enclosures.length-1;
const el=document.createElement('div'); const el=document.createElement('div');
el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':'')+ el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':'');
(player.guid===e.guid?' playing':'');
el.dataset.guid=e.guid; el.dataset.guid=e.guid;
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join(''); const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):''); const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):'');
el.innerHTML=` el.innerHTML=`
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${ <button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
player.guid===e.guid?ICON.play:(e.read?'':'●')}</button> player.guid===e.guid?EQ:(e.read?'':'●')}</button>
<button class="fl${e.flagged?' on':''}" data-a="flag" title="${ <button class="fl${e.flagged?' on':''}" data-a="flag" title="${
e.flagged?'Stop keeping':'Keep (never auto-delete)'}">${e.flagged?ICON.flagOn:ICON.flag}</button> e.flagged?'Stop keeping':'Keep, so it is never deleted'}">${e.flagged?ICON.flagOn:ICON.flag}</button>
<div class="body"> <div class="body">
<span class="t">${esc(e.title||'(untitled)')}</span> <span class="t">${esc(e.title||'(untitled)')}</span>
<div class="line">${[ <div class="line">${[
@@ -1160,6 +1205,8 @@ function syncTools(e){
/// The item's text in the pane below the list, and its files in the pane beside it. /// The item's text in the pane below the list, and its files in the pane beside it.
function showDetail(e){ function showDetail(e){
const box=$('#detail'), files=$('#files'); if(!box) return; const box=$('#detail'), files=$('#files'); if(!box) return;
// A pane that can only say "No files" gives its width to the list instead.
if(files) files.hidden=!(e&&e.enclosures.length);
document.body.classList.toggle('reading',!!e); document.body.classList.toggle('reading',!!e);
syncTools(e); syncTools(e);
if(!e){ if(!e){
@@ -1307,7 +1354,8 @@ function play(e,enc=e.enclosures.find(isPlayable)){
audio.src=`/media/${enc.id}`; audio.src=`/media/${enc.id}`;
audio.currentTime=0; audio.currentTime=0;
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true}); if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
$('#partwrap').innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title); // Initials, if it comes to that, are the feed's: the episode's read as "SE" beside the feed's art.
$('#partwrap').innerHTML=artHTML(e.image||feedArt(e.feed_id),feedName(e.feed_id));
$('#ptitle').textContent=e.title||'(untitled)'; $('#ptitle').textContent=e.title||'(untitled)';
const f=S.feeds.find(x=>x.id===e.feed_id); const f=S.feeds.find(x=>x.id===e.feed_id);
$('#pfeed').textContent=f?(f.title||f.id):''; $('#pfeed').textContent=f?(f.title||f.id):'';
@@ -1347,8 +1395,9 @@ function savePos(){
} }
audio.addEventListener('pause',savePos); audio.addEventListener('pause',savePos);
audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').innerHTML=ICON.play}); audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').innerHTML=ICON.play});
audio.addEventListener('play',()=>$('#pplay').innerHTML=ICON.pause); // body.playing is what sets the EQ bars moving.
audio.addEventListener('pause',()=>$('#pplay').innerHTML=ICON.play); audio.addEventListener('play',()=>{ $('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
audio.addEventListener('pause',()=>{ $('#pplay').innerHTML=ICON.play; document.body.classList.remove('playing'); });
window.addEventListener('beforeunload',savePos); window.addEventListener('beforeunload',savePos);
$('#pplay').onclick=()=>audio.paused?audio.play():audio.pause(); $('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
$('#pback').onclick=()=>audio.currentTime-=15; $('#pback').onclick=()=>audio.currentTime-=15;
@@ -1626,7 +1675,7 @@ async function prefsModal(){
put article images in enclosures, and those are not worth keeping. Empty takes everything.</span></div> put article images in enclosures, and those are not worth keeping. Empty takes everything.</span></div>
<div class="field"><label>Disk quota (GB, 0 = unlimited)</label> <div class="field"><label>Disk quota (GB, 0 = unlimited)</label>
<input type="number" id="gquota" min="0" step="0.5" value="${g.max_total_gb}"> <input type="number" id="gquota" min="0" step="0.5" value="${g.max_total_gb}">
<span class="hint">Over this, the oldest played items are deleted first. Starred <span class="hint">Over this, the oldest played items are deleted first. Kept
items are never touched.</span></div> items are never touched.</span></div>
<div class="field"><label>Delete items older than (days, 0 = keep)</label> <div class="field"><label>Delete items older than (days, 0 = keep)</label>
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div> <input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
@@ -1634,8 +1683,9 @@ async function prefsModal(){
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div> <span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
<div class="field"><label>Subscriptions</label> <div class="field"><label>Subscriptions</label>
<div class="inline"> <div class="inline">
<a class="btn ico" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save}</a> <!-- Words as well as icons: a floppy disk and a plus mean nothing on their own here. -->
<button class="btn ico" id="gopml" title="Import OPML" aria-label="Import OPML">${ICON.plus}</button> <a class="btn" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save} Export</a>
<button class="btn" id="gopml" title="Import OPML…" aria-label="Import OPML">${ICON.plus} Import…</button>
</div> </div>
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import <span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
subscribes you to every feed in one.</span></div> subscribes you to every feed in one.</span></div>
@@ -1666,7 +1716,7 @@ async function usersModal(){
openModal(`<h3>Users</h3> openModal(`<h3>Users</h3>
${users.map(u=>`<div class="inline" data-id="${u.id}" style="margin-bottom:8px"> ${users.map(u=>`<div class="inline" data-id="${u.id}" style="margin-bottom:8px">
<b style="flex:1;overflow-wrap:anywhere">${esc(u.name)}</b> <b style="flex:1;overflow-wrap:anywhere">${esc(u.name)}</b>
${u.password?'':'<span class="tag" title="No password: signs in through the proxy">proxy</span>'} ${u.password?'':'<span class="tag" title="No password: signs in through the proxy">Proxy</span>'}
<label class="check" style="margin:0"><input type="checkbox" data-a="admin" ${u.admin?'checked':''}> Admin</label> <label class="check" style="margin:0"><input type="checkbox" data-a="admin" ${u.admin?'checked':''}> Admin</label>
<button class="btn ico danger" data-a="rm" title="Remove ${esc(u.name)}" aria-label="Remove ${esc(u.name)}">${ICON.trash}</button></div>`).join('')} <button class="btn ico danger" data-a="rm" title="Remove ${esc(u.name)}" aria-label="Remove ${esc(u.name)}">${ICON.trash}</button></div>`).join('')}
<div class="field" style="margin-top:16px"><label>Add someone</label> <div class="field" style="margin-top:16px"><label>Add someone</label>
@@ -1840,6 +1890,17 @@ api('/api/me').then(u=>{
}).catch(()=>{}); }).catch(()=>{});
on('#logs','onclick',logsModal); on('#logs','onclick',logsModal);
$('#feedFilter').oninput=renderFeeds; $('#feedFilter').oninput=renderFeeds;
// The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a
// folder. Handled keys stop here, or the player's own Space and arrows would act on them too.
$('#feedlist').onkeydown=ev=>{
const row=ev.target.closest('[data-id]'); if(!row) return;
const id=row.dataset.id;
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
else if(row.classList.contains('group')&&
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
else return;
ev.preventDefault(); ev.stopPropagation();
};
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open')); $('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
$('#scrim').onclick=()=>nav(false); $('#scrim').onclick=()=>nav(false);
// Dark, light, and the 2004 Mac app. The button steps through them; the choice is remembered. // Dark, light, and the 2004 Mac app. The button steps through them; the choice is remembered.

View File

@@ -46,9 +46,10 @@ form{
width:min(360px,100%);background:var(--panel);border:1px solid var(--line); width:min(360px,100%);background:var(--panel);border:1px solid var(--line);
border-radius:14px;padding:22px;box-shadow:var(--shadow); border-radius:14px;padding:22px;box-shadow:var(--shadow);
} }
.brand{display:flex;align-items:center;gap:10px;margin-bottom:18px} /* The one place the 2004 icon is shown at the size it was drawn for. */
.brand img{width:34px;height:34px;object-fit:contain} .brand{display:flex;flex-direction:column;align-items:center;gap:6px;margin-bottom:20px}
h1{font-size:17px;margin:0;font-weight:650} .brand img{width:96px;height:auto}
h1{font-size:21px;margin:0;font-weight:650;letter-spacing:-.01em}
label{display:block;font-size:12px;color:var(--dim);margin:0 0 4px} label{display:block;font-size:12px;color:var(--dim);margin:0 0 4px}
input{ input{
width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg); width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);
@@ -60,6 +61,7 @@ button{
background:var(--accent);border:0;border-radius:8px;padding:10px; background:var(--accent);border:0;border-radius:8px;padding:10px;
} }
.msg{color:var(--bad);font-size:13px;min-height:19px;margin:10px 0 0;text-align:center} .msg{color:var(--bad);font-size:13px;min-height:19px;margin:10px 0 0;text-align:center}
button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
</style> </style>
<form id="f"> <form id="f">