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:
@@ -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> = {'&':'&','<':'<','>':'>','"':'"',"'":'''};
|
||||
/// 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> ={'&':'&','<':'<','>':'>','"':'"',"'":'''};
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user