Keyboard, screen reader and contrast fixes from the interface review (#298 #299 #300 #301 #302 #303 #304 #305 #306 #307 #308 #309)

Found reviewing the main window, its dialogs and the Directory in all 25 palettes (13 themes,
light and dark), in Playwright against the test daemon.

- #298: an item's title is a button, so Tab and a screen reader can open it; the feed list's
  rows, places, Signals and the Directory's shows are links to a screen reader (asLink), named
  by what they show, the open one aria-current. Not role=button, which would flatten a folder's
  triangle inside it.
- #299: .ep:focus-within shows the row's actions; Tab reached Play and Delete at opacity 0.
- #300: the read toggle is named Mark read / Mark unread, the dot hidden from screen readers.
- #301: the seek slider is named Position, its value read as "12:30 of 45:00".
- #302: ::placeholder in --faint; the browser's grey was 2.7-4.4:1 in 20 palettes. The search
  and filter boxes have names that do not depend on their placeholder.
- #303: #toasts is role=status; an error toast stays until dismissed, at most four at once.
- #304: api() says what to do when iPX cannot be reached or answers 500 (whose text is the
  daemon's internals, already in its log); play() failures are put in words and reported, an
  AbortError ignored; a failed download names its item.
- #305, #308: --field, the theme's --fg mixed into --bg in OKLab (--fieldmix, 60% and up to 80%
  for Catppuccin light), edges text boxes, selects, chips, the OPML box and switch tracks,
  phones included, and rings a light theme's switch knob. tests/contrast.js holds it to 3:1 on
  bg, panel, panel2 and raise in every palette. --line stays for rules. Fields lose outline:0,
  so they take the same focus ring as everything else.
- #306: openModal makes the card role=dialog aria-modal, labelled by its heading, focuses it,
  and makes the rest of the body inert until closeModal, which gives focus back to what opened
  it. Not <dialog>: its top layer would cover the toasts and their Dismiss.
- #307: the labels in Add a feed, Settings, Download latest and OPML point at their boxes.
- #309: Settings shows iPX's and the page's versions at its foot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 20:12:34 +00:00
parent c8afcec73d
commit 6ec2c35291
16 changed files with 218 additions and 47 deletions

View File

@@ -5,7 +5,18 @@ const $ = <T extends HTMLElement = HTMLElement>(s: string, r: ParentNode = docum
const $$ = <T extends HTMLElement = HTMLElement>(s: string, r: ParentNode = document): T[] => [...r.querySelectorAll<T>(s)];
/// What went wrong, from whatever a catch caught: the page throws Errors, but a catch cannot know.
const why = (e: unknown): string => e instanceof Error ? e.message : String(e);
const ESCAPED: Record<string,string> = {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'};
/// A row that opens a feed, a place or a show (the feed list, the Directory): a link to a screen
/// reader, named by what it shows, the open one marked current. Not a button, whose contents a
/// screen reader flattens, which would hide a folder's triangle inside it; as a bare div it read as
/// text, nothing to press (#298).
function asLink(el: HTMLElement, current: boolean){
const said=(s: string)=>el.querySelector(s)?.textContent?.trim();
const marks=$$('[role=img][aria-label],.subbed[aria-label]',el).map(m=>m.getAttribute('aria-label'));
el.setAttribute('role','link');
el.setAttribute('aria-label',[said('b'),said('small'),...marks,el.querySelector<HTMLElement>('.badge:not(.zero)')?.title].filter(Boolean).join(', '));
if(current) el.setAttribute('aria-current','page');
}
const ESCAPED: Record<string,string> ={'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'};
const esc = (s: string|null|undefined) => (s??'').replace(/[&<>"']/g,c=>ESCAPED[c]);
// Icons: Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com
// License - https://fontawesome.com/license/free (Icons: CC BY 4.0). Embedded as SVG, only the
@@ -116,11 +127,18 @@ window.addEventListener('error',e=>report('page_error',{msg:String(e.message||e.
window.addEventListener('unhandledrejection',e=>report('rejection',{msg:String(e.reason?.message||e.reason)}));
async function api(url: string, opts?: RequestInit): Promise<any>{
const r = await fetch(url,{headers:{'Content-Type':'application/json',traceparent:traceparent()},...opts});
// The browser's "Failed to fetch" said nothing anyone could act on (#304).
const r = await fetch(url,{headers:{'Content-Type':'application/json',traceparent:traceparent()},...opts})
.catch(()=>{ throw new Error('Unable to reach iPX. Check your connection and try again.'); });
if(r.status===401){ location.href='/login'; throw new Error('signed out'); }
// A page in place of ipx's words is Cloudflare or Caddy answering for it: the message became a
// whole 502 page (#219).
if(!r.ok) throw new Error((!/html/.test(r.headers.get('Content-Type')||'')&&await r.text().catch(()=>''))||`${r.status} ${r.statusText}`.trim());
if(!r.ok){
// A page in place of ipx's words is Cloudflare or Caddy answering for it: the message became a
// whole 502 page (#219). A 500's words are the daemon's own workings, which its log already
// has; they are not something to show the person who pressed the button (#304).
const said=!/html/.test(r.headers.get('Content-Type')||'')&&await r.text().catch(()=>'');
throw new Error(r.status===500 ? 'iPX could not do that just now. Try again in a minute.'
: said || 'Unable to reach iPX just now. Try again in a minute.');
}
return r.status===204?null:r.json().catch((): null=>null);
}
// navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN
@@ -175,10 +193,19 @@ async function share(title: string, url: string, btn?: HTMLElement){
}
function toast(msg: string, bad?: boolean){
const t=document.createElement('div');
const t=document.createElement('div'), box=$('#toasts');
t.className='toast'+(bad?' bad':''); t.textContent=msg;
$('#toasts').appendChild(t);
setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)},bad?6000:3200);
const go=()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)};
// An error stays until it is put away: gone after six seconds, it was easy to miss and could not
// be read again (#303).
if(bad){
const x=document.createElement('button');
x.type='button'; x.className='toastx'; x.title='Dismiss'; x.setAttribute('aria-label','Dismiss'); x.innerHTML=ICON.close;
x.onclick=go; t.appendChild(x);
}else setTimeout(go,3200);
box.appendChild(t);
// A run of failed downloads would otherwise stack errors up the screen.
while(box.children.length>4) box.firstElementChild!.remove();
}
const clock = (s: number) => {
s=Math.max(0,Math.floor(s||0));