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

@@ -11,6 +11,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- An item opens from the keyboard and with a screen reader: its title is a button. The feed list's rows and the Directory's shows are links to a screen reader, the open one marked current (#298).
- An item's Play, Download and Delete buttons show while the keyboard is on one, as they do under the pointer (#299).
- A screen reader names an item's read button for what it does, Mark read or Mark unread, not "●" (#300).
- The player's position slider has a name, and a screen reader reads where you are as a time, "12:30 of 45:00" (#301).
- Placeholder text is in the theme's own hint colour, readable in every theme, and the search and feed filter boxes have names of their own (#302).
- A screen reader reads toasts out, and an error stays until you dismiss it with its x; no more than four show at once (#303).
- Error messages say what to do, for a server error, a lost connection, a file that would not play or a download that failed, in place of the browser's or the server's own words (#304).
- Text boxes, selects and switches have an edge you can see in every theme, and a box with the keyboard in it has the same ring as every other control (#305, #308).
- Dialogs are dialogs to a screen reader, named by their heading. The page behind one is out of reach while it is open, and closing it puts the keyboard back where it was (#306).
- The boxes in Add a feed, Settings, Download latest and OPML are named by their labels, and clicking a label puts the cursor in its box (#307).
- Settings shows iPX's and the page's versions at its foot: the logo's tooltip was out of reach of a keyboard and of a phone (#309).
## [1.2.0] - 2026-10-19
### Added

View File

@@ -31,7 +31,7 @@
<section id="directory" hidden></section>
<section id="log" hidden></section>
</main>
<div id="toasts"></div>
<div id="toasts" role="status"></div>
<script data-src="web/src"></script>
</body>

View File

@@ -20,6 +20,12 @@
--warn:#f5a524; /* amber, apart from the needle's orange, for pending */
--bad:#ff6b7a; /* pushed towards pink, apart from the needle */
--shadow:0 8px 28px rgba(4,10,20,.55);
/* The edge of a text box or a switch: the theme's own text mixed into its ground, as far as it
takes to clear 3:1 on every surface one sits on (#305, #308). --line is for rules between
things, which need not, and a box drawn in it all but vanished. A theme whose grounds sit
further apart takes more; tests/contrast.js holds every palette to it. */
--fieldmix:60%;
--field:color-mix(in oklab,var(--fg) var(--fieldmix),var(--bg));
}
/* Every other theme overrides the same variables, under data-theme and data-mode. The page's
script sets data-mode to light or dark, working Auto out from the system, so each theme has
@@ -132,6 +138,7 @@
--shadow:0 8px 28px rgba(0,0,0,.5);
}
:root[data-theme="adwaita"][data-mode="light"] {
--fieldmix:65%;
--bg:#ffffff;
--panel:#ebebed;
--panel2:#fafafb;
@@ -206,6 +213,7 @@
/* Nordic: the Nord palette. Polar Night for the dark half, Snow Storm for the light, Frost for
the accent and Aurora for the rest. Aurora red and Frost blue are shifted until they read. */
:root[data-theme="nordic"] {
--fieldmix:65%;
--bg:#2e3440; /* nord0 */
--panel:#3b4252; /* nord1 */
--panel2:#434c5e; /* nord2 */
@@ -243,6 +251,7 @@
mantle and the surfaces for the grounds, text and subtext for type, mauve (its default accent)
and peach for the rest. */
:root[data-theme="catppuccin"] {
--fieldmix:70%;
--bg:#1e1e2e; /* base */
--panel:#181825; /* mantle */
--panel2:#313244; /* surface0 */
@@ -260,6 +269,7 @@
--shadow:0 8px 28px rgba(0,0,0,.45);
}
:root[data-theme="catppuccin"][data-mode="light"] {
--fieldmix:80%;
--bg:#eff1f5; /* base */
--panel:#e6e9ef; /* mantle */
--panel2:#dce0e8; /* crust */
@@ -296,6 +306,7 @@
--shadow:0 8px 28px rgba(0,0,0,.45);
}
:root[data-theme="gruvbox"][data-mode="light"] {
--fieldmix:70%;
--bg:#fbf1c7; /* bg0 */
--panel:#f2e5bc; /* bg0_s */
--panel2:#ebdbb2; /* bg1 */
@@ -332,6 +343,7 @@
--shadow:0 8px 28px rgba(0,0,0,.45);
}
:root[data-theme="solarized"][data-mode="light"] {
--fieldmix:65%;
--bg:#fdf6e3; /* base3 */
--panel:#eee8d5; /* base2 */
--panel2:#e6dfca;
@@ -562,11 +574,13 @@ a{color:var(--accent)}
.sidefoot button{display:inline-flex;align-items:center;justify-content:center;gap:6px}
.sidefoot i{font-style:normal;opacity:.75}
.searchwrap{padding:12px 12px 8px}
/* The browser's own grey fell short of AA in most themes, and a placeholder is all some boxes say (#302). */
::placeholder{color:var(--faint);opacity:1}
input[type=search],input[type=text],input[type=password],input[type=number],select{
width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);
width:100%;background:var(--bg);border:1px solid var(--field);color:var(--fg);
border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px;
}
input:focus,select:focus{outline:0;border-color:var(--accent)}
input:focus,select:focus{border-color:var(--accent)}
/* A search box and the x that clears it, inside its right end, there only once something is typed
(#291). The browser's own x, in some browsers and not others, gives way to it. */
.clearable{position:relative;display:flex;align-items:center;min-width:0}
@@ -992,7 +1006,7 @@ 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 .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;width:100%;padding:0;text-align:start;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* An item with no title shows its opening words: set as text, not as a heading. */
.ep .t.notitle{font-weight:400}
.ep.read .t{color:var(--dim);font-weight:500}
@@ -1009,7 +1023,8 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s}
#split.one .h-fd,#split.one .ep .fd{display:none}
.dot{width:3px;height:3px;border-radius:50%;background:var(--faint);flex:none}
.ep .rowacts{display:flex;gap:2px;align-items:flex-start;opacity:0;transition:opacity .12s}
.ep:hover .rowacts{opacity:1}
/* Focus shows them as hover does: Tab reached Play and Delete while they were still invisible (#299). */
.ep:hover .rowacts,.ep:focus-within .rowacts{opacity:1}
.dlbar{height:3px;background:transparent;border-radius:2px;overflow:hidden;margin-top:7px}
.dlbar.live{background:var(--raise)}
.dlbar i{display:block;height:100%;width:0;background:var(--accent2);transition:width .25s}
@@ -1067,6 +1082,8 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
place-items:center;z-index:50;padding:20px;
}
#modal.on{display:grid}
/* Focus lands on the card itself as a dialog opens (#306): a place to start from, not a control. */
#modalCard:focus{outline:none}
.card{
background:var(--panel);border:1px solid var(--line);border-radius:14px;
padding:20px;width:min(460px,100%);box-shadow:var(--shadow);max-height:88vh;overflow:auto;
@@ -1138,10 +1155,12 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
.switch.lead{font-weight:600}
.sw{position:relative;flex:none;width:42px;height:26px}
.sw input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:pointer}
.sw i{position:absolute;inset:0;border-radius:999px;background:var(--raise);border:1px solid var(--line);pointer-events:none;transition:background-color .18s,border-color .18s}
.sw i{position:absolute;inset:0;border-radius:999px;background:var(--raise);border:1px solid var(--field);pointer-events:none;transition:background-color .18s,border-color .18s}
/* White, as iOS has it, in every theme: in --ink, the dark themes' knob was a black hole in the track. */
.sw i::after{content:"";position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.35);transition:transform .18s}
.sw input:checked+i{background:var(--accent);border-color:var(--accent)}
/* White on a light track did not show where the knob was, or so whether it was on (#308). */
:root[data-mode="light"] .sw i::after{box-shadow:0 0 0 1px var(--field),0 1px 3px rgba(0,0,0,.35)}
.sw input:checked+i::after{transform:translateX(16px)}
.sw input:focus-visible+i{outline:2px solid var(--accent);outline-offset:2px}
/* What only matters while it downloads on its own folds away when it is off: the rows in a grid of
@@ -1196,7 +1215,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
.flabel{font-size:14px;font-weight:500}
.cap{margin:0;font-size:12.5px;line-height:1.45;color:var(--dim)}
/* Words and phrases as chips: in one comma-separated box, one ran into the next. */
.chips{display:flex;flex-wrap:wrap;gap:6px;align-items:center;min-height:38px;padding:5px;border:1px solid var(--line);border-radius:9px;background:var(--bg);cursor:text}
.chips{display:flex;flex-wrap:wrap;gap:6px;align-items:center;min-height:38px;padding:5px;border:1px solid var(--field);border-radius:9px;background:var(--bg);cursor:text}
.chips:focus-within{border-color:var(--accent)}
.chip{display:inline-flex;align-items:center;gap:2px;max-width:100%;padding:3px 3px 3px 10px;border-radius:999px;background:var(--panel2);color:var(--fg);font-size:13px;line-height:1.3;overflow-wrap:anywhere}
.chipx{display:grid;place-items:center;flex:none;width:20px;height:20px;padding:0;border-radius:50%;color:var(--dim)}
@@ -1209,7 +1228,9 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px;
}
/* On --panel, not a toast's --raise: the error colours are tuned to read on the page's grounds. */
.toast.bad{border-color:var(--bad);color:var(--bad);background:var(--panel)}
.toast.bad{border-color:var(--bad);color:var(--bad);background:var(--panel);display:flex;gap:10px;align-items:flex-start}
.toastx{flex:none;display:grid;place-items:center;width:24px;height:24px;margin:-3px -7px -3px 0;border-radius:6px;color:inherit}
.toastx svg{width:11px;height:11px}
@keyframes in{from{opacity:0;transform:translateY(6px)}}
#burger,#dback{display:none}
/* Totals for what is showing, along the bottom, as the original's status bar. */
@@ -1368,7 +1389,7 @@ body:has(#player.on) #status{padding-bottom:3px}
/* Text boxes filled, and at 16px or more: Safari zooms the page in on one any smaller. */
input[type=search],input[type=text],input[type=password],input[type=number],select{
font-size:17px;background:var(--panel2);border-color:var(--edge);border-radius:10px;padding:9px 12px;
font-size:17px;background:var(--panel2);border-color:var(--field);border-radius:10px;padding:9px 12px;
}
#topbar #epSearch{font-size:16px;border-radius:999px;padding:9px 34px 9px 14px}
input:focus,select:focus{border-color:var(--accent)}
@@ -1436,7 +1457,7 @@ body:has(#player.on) #status{padding-bottom:3px}
.dpends{font-size:14px}
/* 16px or more, or Safari zooms the page in on it. */
.wcap input[type=number]{font-size:16px;padding:3px 4px}
.chips{background:var(--panel2);border-color:var(--edge);border-radius:10px}
.chips{background:var(--panel2);border-color:var(--field);border-radius:10px}
.chip{background:var(--panel);font-size:15px;padding:5px 4px 5px 12px}
.chipx{width:26px;height:26px}
.field.boxed{border-radius:14px}

View File

@@ -28,7 +28,7 @@
<button id="tbFlag" title="Pin the selected item, so it is never deleted" aria-label="Pin" data-icon="pin" disabled></button>
</div>
<span class="grow"></span>
<span class="clearable" id="epBox"><input type="search" id="epSearch" placeholder="Search items…"><button type="button" class="clearx" title="Clear" aria-label="Clear" data-icon="close"></button></span>
<span class="clearable" id="epBox"><input type="search" id="epSearch" placeholder="Search items…" aria-label="Search"><button type="button" class="clearx" title="Clear" aria-label="Clear" data-icon="close"></button></span>
<div class="tgroup" id="admintools">
<button id="prefs" title="Settings" aria-label="Settings" data-icon="settings"></button>
<a id="admin" href="/admin" title="Admin: the server's settings, accounts and the log" aria-label="Admin" data-icon="admin"></a>
@@ -36,7 +36,7 @@
</header>
<div id="shell">
<aside id="sidebar">
<div class="searchwrap"><span class="clearable"><input type="search" id="feedFilter" placeholder="Filter feeds…"><button type="button" class="clearx" title="Clear" aria-label="Clear" data-icon="close"></button></span></div>
<div class="searchwrap"><span class="clearable"><input type="search" id="feedFilter" placeholder="Filter feeds…" aria-label="Filter feeds"><button type="button" class="clearx" title="Clear" aria-label="Clear" data-icon="close"></button></span></div>
<div id="feedlist"></div>
<div class="sidefoot">
<div class="who"><span id="who"></span><button id="signout" title="Sign out" aria-label="Sign out" data-icon="signout"></button></div>
@@ -62,7 +62,7 @@
</div>
<div id="seekrow">
<span id="pcur">0:00</span>
<input type="range" id="seek" min="0" max="1000" value="0">
<input type="range" id="seek" min="0" max="1000" value="0" aria-label="Position">
<span id="pdur">0:00</span>
</div>
</div>
@@ -78,8 +78,8 @@
</div>
</div>
<div id="modal"><div class="card" id="modalCard"></div></div>
<div id="toasts"></div>
<div id="modal"><div class="card" id="modalCard" role="dialog" aria-modal="true" tabindex="-1"></div></div>
<div id="toasts" role="status"></div>
<!-- One element for both: a <video> plays an audio-only file exactly like <audio> does (same
HTMLMediaElement API), and it is the only tag that can also show a picture. Hidden unless
the current file is video -- see body.has-video below. -->

View File

@@ -1,21 +1,43 @@
/* ---------------- modals ---------------- */
/// `cls` names a dialog with a frame of its own. Set whole each time, so it never outlives its dialog.
// A dialog is a dialog to a screen reader, named by its heading, and the page behind it is out of
// reach until it closes: Tab used to walk out of it into the toolbar, and closing it left focus
// nowhere (#306). Not a <dialog>: its top layer would cover the toasts, and an error's Dismiss
// with them.
let modalFrom: HTMLElement|null=null, benched: HTMLElement[]=[];
function openModal(html: string, wide?: boolean, cls?: string){
$('#modalCard').innerHTML=html;
$('#modalCard').className='card'+(wide?' wide':'')+(cls?' '+cls:'');
$('#modal').classList.add('on');
const m=$('#modal'), card=$('#modalCard');
card.innerHTML=html;
card.className='card'+(wide?' wide':'')+(cls?' '+cls:'');
const h=card.querySelector('h2,h3');
if(h){ h.id=h.id||'modalTitle'; card.setAttribute('aria-labelledby',h.id); } else card.removeAttribute('aria-labelledby');
if(!m.classList.contains('on')){
modalFrom=document.activeElement as HTMLElement|null;
// Not what is inert already (#dpeek), which must stay so.
benched=([...document.body.children] as HTMLElement[]).filter(el=>el!==m&&el.id!=='toasts'&&!el.inert);
for(const el of benched) el.inert=true;
m.classList.add('on');
}
// Into the dialog; a dialog that wants the cursor in a box puts it there after this.
if(!card.contains(document.activeElement)) card.focus();
}
function closeModal(){
$('#modal').classList.remove('on');
const m=$('#modal');
if(!m.classList.contains('on')) return;
m.classList.remove('on');
for(const el of benched) el.inert=false;
benched=[];
if(modalFrom?.isConnected) modalFrom.focus();
modalFrom=null;
}
$('#modal').onclick=(e: MouseEvent)=>{ if((e.target as Element).id==='modal') closeModal(); };
$<HTMLButtonElement>('#addFeed').onclick=()=>{
openModal(`<h3>Add a feed</h3>
<div class="field"><label>Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss">
<div class="field"><label for="nurl">Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss">
<span class="hint">A Patreon token on its own adds every show from that creator.</span></div>
<div class="field"><label>Keywords (optional, comma separated)</label>
<div class="field"><label for="nkw">Keywords (optional, comma separated)</label>
<input type="text" id="nkw"><span class="hint">Only items matching a keyword are downloaded.</span></div>
<label class="check"><input type="checkbox" id="nexp"> Allow items marked explicit</label>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
@@ -60,6 +82,7 @@ function listedFeed(p: DirFeed,cls: string,rank?: number){
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`);
asLink(el,false);
// Yours has an id here as well as the directory's, and opens by it.
if(p.subscribed){ el.onclick=()=>selectFeed(p.feed||p.id); return el; }
el.onclick=()=>{ dirShow=p.id; $('#content').scrollTop=0; drawDirectory(undefined,'.dback'); };
@@ -418,10 +441,10 @@ async function prefsModal(){
const g = await api('/api/settings');
const admin = !!(S.me&&S.me.admin);
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Settings</h3>
<div class="field"><label>Theme</label>
<div class="field"><label for="stheme">Theme</label>
<select id="stheme">${Object.entries(THEMES).map(([k,t])=>
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
<div class="field" id="smodefield"${THEMES[theme.name].modes?'':' hidden'}><label>Light or dark</label>
<div class="field" id="smodefield"${THEMES[theme.name].modes?'':' hidden'}><label for="smode">Light or dark</label>
<select id="smode">${Object.entries(MODES).map(([k,t])=>
`<option value="${k}"${theme.mode===k?' selected':''}>${esc(t)}</option>`).join('')}</select>
<span class="hint">Auto follows your system's light/dark setting.</span></div>
@@ -461,7 +484,8 @@ async function prefsModal(){
Anyone holding one is you here, so revoke one you no longer use.</span></div>
<div class="field"><label>Feeds are checked every</label>
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>`);
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>
<div class="field"><span class="hint" id="sversion">${S.me?`iPX ${esc(S.me.version)}, page __WEB_VERSION__`:''}</span></div>`);
$<HTMLSelectElement>('#stheme').onchange=(e: Event)=>setTheme((e.target as HTMLSelectElement).value,undefined,true);
$<HTMLSelectElement>('#smode').onchange=(e: Event)=>setTheme(undefined,(e.target as HTMLSelectElement).value,true);
$<HTMLButtonElement>('#gopml').onclick=opmlModal;
@@ -745,7 +769,7 @@ function settingsModal(f: Feed, newUrl?: string){
function downloadLatestModal(f: Feed){
openModal(`<h3>Download latest items</h3>
<div class="field"><label>How many of the newest undownloaded items?</label>
<div class="field"><label for="dcount">How many of the newest undownloaded items?</label>
<input type="number" id="dcount" min="1" max="100" value="5">
<span class="hint">Queued immediately, ignoring the per-scan limit.</span></div>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
@@ -781,9 +805,9 @@ function opmlModal(){
<div class="inline">
<a class="btn ico" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save}</a>
</div></div>
<div class="field" style="margin-top:16px"><label>Import: choose a file, or paste OPML</label>
<div class="field" style="margin-top:16px"><label for="opmlFile">Import: choose a file, or paste OPML</label>
<input type="file" id="opmlFile" accept=".opml,.xml,text/x-opml,text/xml,application/xml" style="margin-bottom:8px">
<textarea id="opmlText" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div>
<textarea id="opmlText" aria-label="OPML to import" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--field);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
<button class="btn ico primary" id="oimp" title="Import: subscribe to every feed in it" aria-label="Import">${ICON.plus}</button></div>`);
$<HTMLButtonElement>('#oimp').onclick=async()=>{
@@ -818,6 +842,7 @@ api('/api/me').then(u=>{
S.me=u;
$('#who').textContent=u.name+(u.admin?' · admin':'');
// ipx's version and the page's, each released on its own.
// Also at the foot of Settings: the tooltip alone was out of reach of a keyboard and of a phone (#309).
$('#applogo').title=`iPX ${u.version}, page __WEB_VERSION__`;
// Whether the link to Admin shows, now and before the first frame of the next visit.
document.documentElement.toggleAttribute('data-admin',!!u.admin);

View File

@@ -50,7 +50,9 @@ function connect(){
else if(ev.ev==='download_error'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
if(bar) bar.classList.remove('live');
toast('Download failed: '+ev.msg,true); refreshEntries();
// Which item, and what to do: the reason (ev.msg) is in the daemon's log (#304).
const e=S.entries.find(x=>x.enclosures.some(n=>n.id===ev.enclosure));
toast(`Unable to download ${e?`"${entryName(e).text}"`:'a file'}. Try again with its download button.`,true); refreshEntries();
}
// Jev picked items out for someone's Signals: theirs are fetched again, and the one open.
else if(ev.ev==='signal_hits'){

View File

@@ -105,6 +105,7 @@ function renderFeeds(){
el.tabIndex=0; el.dataset.id=id;
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>`:'');
asLink(el,S.feed===id);
el.onclick=()=>{ selectFeed(id); nav(false); };
el.onpointerenter=el.ontouchstart=()=>prefetch(id);
places.appendChild(el);
@@ -168,6 +169,7 @@ function renderFeeds(){
`</small></div>`+
(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>`;
asLink(el,S.feed===f.id);
el.onclick=()=>{ selectFeed(f.id); nav(false); };
// Fetched as the pointer or a finger reaches it, so the click finds it here (#247).
el.onpointerenter=el.ontouchstart=()=>prefetch(f.id);

View File

@@ -51,7 +51,7 @@ function refreshFeed(){
document.body.append(spin);
// New items arrive by the event stream when the scan finishes, as they do for a button press.
const asked = api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
.catch(e => toast(e.message, true));
.catch(e => toast(why(e), true));
loadEntries();
// Up for two seconds at least, so it is seen even when the server answers at once.
Promise.all([asked, new Promise(r => setTimeout(r, 2000))]).then(() => { spin.remove(); refreshing = false; });

View File

@@ -162,13 +162,15 @@ function epEl(e: Entry){
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 name=entryName(e);
// The title is a button, its click the row's, so Tab and a screen reader can open the item:
// the row alone was a click target only a mouse could reach (#298).
el.innerHTML=`
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
player.guid===e.guid?EQ:(e.read?'':'●')}</button>
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}" aria-label="Mark ${e.read?'unread':'read'}">${
player.guid===e.guid?EQ:(e.read?'':'<span aria-hidden="true">●</span>')}</button>
<button class="fl${e.flagged?' on':''}" data-a="flag" aria-pressed="${!!e.flagged}" title="${
e.flagged?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
<div class="body">
<span class="t${name.derived?' notitle':''}">${esc(name.text)}</span>
<button type="button" class="t${name.derived?' notitle':''}">${esc(name.text)}</button>
<div class="line">${[
num&&`<span>${num}</span>`,
left&&`<span>${left}</span>`,
@@ -283,7 +285,7 @@ function setRead(e: Entry,read: boolean){
/// reloaded, so an item does not vanish from under the pointer on the Unread tab.
function markRead(e: Entry){
if(e.read) return;
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(why(err),true); });
}
/// On the Unread tab, the items read while turning from one to the next (a swipe, j and k), kept

View File

@@ -165,7 +165,8 @@ function installNativePlayback(){
case 'error':
N.paused = true;
fire('pause');
toast('Playback failed' + (m.message ? ': ' + m.message : ''), true);
report('player_error', {msg: `native: ${m.message || 'error'}`, ...playing()});
toast('Unable to play this file. Try again, or download it again.', true);
break;
}
},

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;

View File

@@ -30,6 +30,7 @@ function feedRow(id: string, cls: string, html: string, open: ()=>void){
const el=document.createElement('div');
el.className='feed '+cls+(S.feed===id?' sel':'');
el.tabIndex=0; el.dataset.id=id; el.innerHTML=html;
asLink(el,S.feed===id);
el.onclick=()=>{ open(); nav(false); };
return el;
}

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));