diff --git a/CHANGELOG.md b/CHANGELOG.md index cbcb313..7aad6d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 +- 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 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. diff --git a/todo.md b/TODO.md similarity index 82% rename from todo.md rename to TODO.md index a4b0ee0..e2f2cf7 100644 --- a/todo.md +++ b/TODO.md @@ -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 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 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), `.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 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 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 600–700, 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 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. -- [ ] **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 `--listh` from 40% to about 60% (:187), and hide `#files` (:192) when nothing is selected or the item 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 "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). - "saved" in the sidebar (:793–794), "downloaded" in headers and on the Downloaded tab. Use "downloaded". - 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". -- [ ] **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 reads as a window's minimise control. Give it the same round frame and put it with the others (`.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`. -- [ ] **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 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. -- [ ] **Settings export and import.** A floppy disk and a plus, which only make sense with the hint text +- [x] **Sign-in.** A plain centred card. It is the one place the original icon could be shown large. +- [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. ### 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 right, and so is the quarter turn when you click. How it is drawn is not. -- [ ] **Keyboard.** The triangle is a `` and a feed row a `
`, with no `tabindex` or +- [x] **Keyboard.** The triangle is a `` and a feed row a `
`, with no `tabindex` or `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 ``:'')+ + (mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id))+ `
${esc(f.title||f.id)}`+ - (mine.length?`${mine.length} feed${mine.length===1?'':'s'} · ${saved} saved` - :`${eps} items · ${saved} saved`)+ + `${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+ `
`+ - (f.orphaned?'gone':'')+ + (f.orphaned?'Gone':'')+ `${unread>999?'999+':unread}`; el.onclick=()=>{ selectFeed(f.id); nav(false); }; if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); }; list.appendChild(el); } - list.scrollTop=top; + done(); } function selectFeed(id){ S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value=''; @@ -826,8 +873,8 @@ function renderFeed(){ ${artHTML(f.image,name)}

${esc(name)}

-
${f.entries} items · ${f.downloaded} downloaded · checked ${ago(f.last_checked)} - · 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'}`:''}
${f.last_error?`
${esc(f.last_error)}
`:''} ${f.orphaned?`
This feed is no longer listed in its @@ -839,7 +886,7 @@ function renderFeed(){ - +
` : `
@@ -856,8 +903,8 @@ function renderFeed(){
`) + `
- ${['all','unread','downloaded','flagged'].map(t=> - ``).join('')} + ${[['all','All'],['unread','Unread'],['downloaded','Downloaded'],['flagged','Kept']].map(([t,label])=> + ``).join('')}
`; @@ -868,7 +915,7 @@ function renderFeed(){ '
'; $$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort)); box.appendChild(pane); - pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '40%'); + pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '60%'); dragSplit(pane); 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. $('#content').innerHTML = `
- ${artHTML(f.image,f.title||f.id)} + ${folderArt(f,kids)}

${esc(f.title||f.id)}

-
${isPatreon(f)?'Patreon creator':'OPML subscription'} · ${kids.length} feed${kids.length===1?'':'s'} - · ${unread} unread · ${saved} downloaded · checked ${ago(f.last_checked)} - · every ${everyText(f.every_mins)}
+
${isPatreon(f)?'Patreon creator':'OPML subscription'} + · ${plural(kids.length,'feed')}, ${unread} unread, ${saved} downloaded · checked ${ago(f.last_checked)}
${f.last_error?`
${esc(f.last_error)}
`:''} ${gone?`
${gone} feed${gone===1?' is':'s are'} no longer listed but kept because ${gone===1?'it has':'they have'} downloads.
`:''} @@ -921,7 +967,7 @@ function renderGroup(f,kids){ - +
@@ -940,9 +986,9 @@ function renderGroup(f,kids){ el.className='childrow'; el.innerHTML = artHTML(c.image,c.title||c.id)+ `
${esc(c.title||c.id)}`+ - `${c.entries} items · ${c.downloaded} downloaded`+ + `${plural(c.entries,'item')} · ${c.downloaded} downloaded`+ (c.last_error?` · error`:'')+`
`+ - (c.orphaned?'gone':'')+ + (c.orphaned?'Gone':'')+ `${c.unread}`; el.onclick=()=>selectFeed(c.id); box.appendChild(el); @@ -1016,16 +1062,15 @@ function epEl(e){ const playable=isPlayable(enc); const others=e.enclosures.length-1; const el=document.createElement('div'); - el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':'')+ - (player.guid===e.guid?' playing':''); + el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':''); el.dataset.guid=e.guid; 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):''); el.innerHTML=` + player.guid===e.guid?EQ:(e.read?'':'●')} + e.flagged?'Stop keeping':'Keep, so it is never deleted'}">${e.flagged?ICON.flagOn:ICON.flag}
${esc(e.title||'(untitled)')}
${[ @@ -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. function showDetail(e){ 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); syncTools(e); if(!e){ @@ -1307,7 +1354,8 @@ function play(e,enc=e.enclosures.find(isPlayable)){ audio.src=`/media/${enc.id}`; audio.currentTime=0; 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)'; const f=S.feeds.find(x=>x.id===e.feed_id); $('#pfeed').textContent=f?(f.title||f.id):''; @@ -1347,8 +1395,9 @@ function savePos(){ } audio.addEventListener('pause',savePos); audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').innerHTML=ICON.play}); -audio.addEventListener('play',()=>$('#pplay').innerHTML=ICON.pause); -audio.addEventListener('pause',()=>$('#pplay').innerHTML=ICON.play); +// body.playing is what sets the EQ bars moving. +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); $('#pplay').onclick=()=>audio.paused?audio.play():audio.pause(); $('#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.
- Over this, the oldest played items are deleted first. Starred + Over this, the oldest played items are deleted first. Kept items are never touched.
@@ -1634,8 +1683,9 @@ async function prefsModal(){ ${esc(g.download_dir)}
- ${ICON.save} - + + ${ICON.save} Export +
Export saves your subscriptions as OPML for another podcast app. Import subscribes you to every feed in one.
@@ -1666,7 +1716,7 @@ async function usersModal(){ openModal(`

Users

${users.map(u=>`
${esc(u.name)} - ${u.password?'':'proxy'} + ${u.password?'':'Proxy'}
`).join('')}
@@ -1840,6 +1890,17 @@ api('/api/me').then(u=>{ }).catch(()=>{}); on('#logs','onclick',logsModal); $('#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')); $('#scrim').onclick=()=>nav(false); // Dark, light, and the 2004 Mac app. The button steps through them; the choice is remembered. diff --git a/web/login.html b/web/login.html index 28536e4..8caccc3 100644 --- a/web/login.html +++ b/web/login.html @@ -46,9 +46,10 @@ form{ width:min(360px,100%);background:var(--panel);border:1px solid var(--line); border-radius:14px;padding:22px;box-shadow:var(--shadow); } -.brand{display:flex;align-items:center;gap:10px;margin-bottom:18px} -.brand img{width:34px;height:34px;object-fit:contain} -h1{font-size:17px;margin:0;font-weight:650} +/* The one place the 2004 icon is shown at the size it was drawn for. */ +.brand{display:flex;flex-direction:column;align-items:center;gap:6px;margin-bottom:20px} +.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} input{ 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; } .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}