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

@@ -31,7 +31,7 @@ function setSrc(url: string): Promise<void>|null{
return ready.then(()=>{
// Its playlist and audio requests join the trace of the tap that played them (#192).
hls=new w.Hls({xhrSetup:(xhr: XMLHttpRequest)=>xhr.setRequestHeader('traceparent',traceparent())});
hls.on(w.Hls.Events.ERROR,(_: string,d: {fatal: boolean, type: string, details: string})=>{ if(d.fatal){ toast('Playback failed: '+d.details,true);
hls.on(w.Hls.Events.ERROR,(_: string,d: {fatal: boolean, type: string, details: string})=>{ if(d.fatal){ toast('Unable to play the DeadAir version. Try again, or turn DeadAir off for this show.',true);
report('player_error',{msg:`hls.js: ${d.type} ${d.details}`,feed:player.feed,enclosure:player.enc}); } });
hls.loadSource(url); hls.attachMedia(audio);
});
@@ -48,10 +48,18 @@ function markPlayed(){
setRead(e,true).catch(()=>{});
}
/// What a failed play() means to whoever pressed play; the browser's own words go to the log (#304).
function playFailed(err: Error){
// A newer load took its place: nothing failed.
if(err.name==='AbortError') return;
report('player_error',{msg:`play(): ${err.name}: ${err.message}`,...playing()});
toast(err.name==='NotAllowedError' ? 'The browser held playback back. Press play to start it.'
: 'Unable to play this file. Try again, or download it again.', true);
}
/// Plays one of the item's files in the player bar: the one asked for, or its first playable one.
function play(e: Entry,enc=e.enclosures.find(isPlayable)){
if(!enc||!isPlayable(enc)){
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet. Download it first, with its button on the item.', true);
return;
}
// The same file carries on where it was; another of the item's files starts from its top. So
@@ -76,8 +84,8 @@ function play(e: Entry,enc=e.enclosures.find(isPlayable)){
player.marked=false;
paintDead();
}
const go=()=>audio.play().catch((err: Error)=>toast('Playback failed: '+err.message,true));
if(wait) wait.then(go,err=>toast('Playback failed: '+err.message,true)); else go();
const go=()=>audio.play().catch(playFailed);
if(wait) wait.then(go,playFailed); else go();
renderEntries();
}
function mediaSession(e: Entry,f: Feed|undefined){
@@ -95,6 +103,8 @@ audio.addEventListener('timeupdate',()=>{
$('#pcur').textContent=clock(audio.currentTime);
$('#pdur').textContent=clock(d);
if(d) $<HTMLInputElement>('#seek').value=String(Math.round(audio.currentTime/d*1000));
// Its value is thousandths, which a screen reader would read out as they are (#301).
$('#seek').setAttribute('aria-valuetext',`${clock(audio.currentTime)} of ${clock(d)}`);
// Only playing counts as moving: the seek to where you left off happens paused, and saving
// that would write back whatever the list said, however old.
if(!audio.paused) player.moved=true;