diff --git a/tests/contrast.js b/tests/contrast.js index 72245e5..5574caa 100644 --- a/tests/contrast.js +++ b/tests/contrast.js @@ -11,6 +11,7 @@ const blocks = {}; for (const m of css.matchAll(/^(:root(?:\[[^\]]+\])*)\s*\{([^}]*)\}/gm)) { const vars = {}; for (const v of m[2].matchAll(/--(\w+):\s*(#[0-9a-f]{6})\b/gi)) vars[v[1]] = v[2].toLowerCase(); + const mix = m[2].match(/--fieldmix:\s*(\d+)%/); if (mix) vars.fieldmix = +mix[1] / 100; if (Object.keys(vars).length) blocks[m[1]] = vars; } @@ -49,6 +50,21 @@ for (const sel of Object.keys(blocks)) { } themes['modern'] = base; +// --field, a text box's and a switch's edge, is --fg mixed into --bg in OKLab, as color-mix() does. +const oklab = h => { + const [r, g, b] = [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255).map(c => c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4); + const [l, m, s] = [.4122214708 * r + .5363325363 * g + .0514459929 * b, .2119034982 * r + .6806995451 * g + .1073969566 * b, .0883024619 * r + .2817188376 * g + .6299787005 * b].map(Math.cbrt); + return [.2104542553 * l + .793617785 * m - .0040720468 * s, 1.9779984951 * l - 2.428592205 * m + .4505937099 * s, .0259040371 * l + .7827717662 * m - .808675766 * s]; +}; +const fromOklab = ([L, a, b]) => { + const [l, m, s] = [L + .3963377774 * a + .2158037573 * b, L - .1055613458 * a - .0638541728 * b, L - .0894841775 * a - 1.291485548 * b].map(x => x ** 3); + return '#' + [4.0767416621 * l - 3.3077115913 * m + .2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - .3413193965 * s, -.0041960863 * l - .7034186147 * m + 1.707614701 * s] + .map(c => Math.min(1, Math.max(0, c)) <= .0031308 ? 12.92 * Math.max(0, c) : 1.055 * Math.min(1, c) ** (1 / 2.4) - .055) + .map(c => Math.round(c * 255).toString(16).padStart(2, '0')).join(''); +}; +for (const p of Object.values(themes)) { const f = oklab(p.fg), g = oklab(p.bg), k = p.fieldmix; p.field = fromOklab(f.map((v, i) => v * k + g[i] * (1 - k))); } +RULES.push(['field', ['bg', 'panel', 'panel2', 'raise'], 3]); // a text box's or a switch's edge, wherever one sits + let bad = 0; for (const [name, p] of Object.entries(themes)) { for (const [fg, grounds, min] of RULES) for (const g of grounds) { diff --git a/tests/dom-stub.js b/tests/dom-stub.js index d317944..78c9222 100644 --- a/tests/dom-stub.js +++ b/tests/dom-stub.js @@ -16,7 +16,7 @@ function makeContext(html, script, { media = false } = {}) { const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } }, value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null, appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){}, - setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){}, + setAttribute(){}, getAttribute(){ return null; }, removeAttribute(){}, contains(){ return false; }, select(){}, setSelectionRange(){}, focus(){}, replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; }, play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } }, { get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) }); diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index f51d07d..32d2e8d 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -2291,3 +2291,53 @@ signalsTest('the server pays for Signals only when it has a key to pay with', as await page.request.patch('/api/settings', { data: { signals_on_server: false } }); } }); + +test('a dialog keeps the keyboard inside it and gives it back on closing', async ({ page }) => { + // #306: Tab walked out of a dialog into the toolbar, and closing one left focus on the page. + await page.locator('#addFeed').focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('#modalCard[role=dialog]')).toHaveAttribute('aria-labelledby', /.+/); + await expect(page.locator('#nurl')).toBeFocused(); + // Labels name their boxes (#307). + await expect(page.getByRole('textbox', { name: 'Feed URL' })).toHaveCount(1); + for (let i = 0; i < 8; i++) { + await page.keyboard.press('Tab'); + // Past the last control Tab goes to the browser's own toolbar (the body, from here), never the page behind. + expect(await page.evaluate(() => { const a = document.activeElement; return a === document.body || document.querySelector('#modalCard').contains(a); }), `Tab ${i + 1} stays out of the page behind`).toBe(true); + } + await page.keyboard.press('Escape'); + await expect(page.locator('#modal.on')).toBeHidden(); + await expect(page.locator('#addFeed')).toBeFocused(); + expect(await page.locator('#shell').evaluate(e => e.inert)).toBe(false); +}); + +test('an item opens from the keyboard, and its row actions show while focused', async ({ page }) => { + await page.locator('.feed', { hasText: 'Test Show' }).first().click(); + const row = page.locator('#eps .ep').first(); + // #298: the title is a button, so Tab reaches the item itself. + await row.locator('button.t').focus(); + await page.keyboard.press('Enter'); + await expect(row).toHaveClass(/\bsel\b/); + // #300: the read toggle is named for what it does, not its dot. + await expect(row.locator('.st')).toHaveAttribute('aria-label', /^Mark (read|unread)$/); + // #299: focus shows what hover shows. + await page.mouse.move(0, 0); + const act = row.locator('.rowacts button').first(); + if (await act.count()) { + await act.focus(); + await expect(row.locator('.rowacts')).toHaveCSS('opacity', '1'); + } + // #298: a feed in the list is a link to a screen reader, the open one current. + await expect(page.locator('#feedlist .feed[aria-current=page]')).toHaveAttribute('role', 'link'); +}); + +test('an error toast is announced and stays until it is dismissed', async ({ page }) => { + // #303: role=status, and no timer on an error. + await expect(page.locator('#toasts')).toHaveAttribute('role', 'status'); + await page.evaluate(() => toast('Unable to play this file. Try again, or download it again.', true)); + const t = page.locator('.toast.bad').last(); + await page.waitForTimeout(6500); + await expect(t).toBeVisible(); + await t.getByRole('button', { name: 'Dismiss' }).click(); + await expect(t).toHaveCount(0); +}); diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index fd31532..b8f467f 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -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 diff --git a/web/admin.html b/web/admin.html index caf3875..31769d7 100644 --- a/web/admin.html +++ b/web/admin.html @@ -31,7 +31,7 @@ -
+
diff --git a/web/app.css b/web/app.css index 7b9f62e..adca094 100644 --- a/web/app.css +++ b/web/app.css @@ -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} diff --git a/web/index.html b/web/index.html index d944f26..e483cd1 100644 --- a/web/index.html +++ b/web/index.html @@ -28,7 +28,7 @@ - +
@@ -36,7 +36,7 @@
@@ -78,8 +78,8 @@
- -
+ +
diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index 237c587..8bebe65 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -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 : 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(); }; $('#addFeed').onclick=()=>{ openModal(`

Add a feed

-
+
A Patreon token on its own adds every show from that creator.
-
+
Only items matching a keyword are downloaded.
@@ -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?`${ICON.subbed}` :``); + 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(`

Settings

-
+
-
+
Auto follows your system's light/dark setting.
@@ -461,7 +484,8 @@ async function prefsModal(){ Anyone holding one is you here, so revoke one you no longer use.
${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 admin page.':'Only an admin changes this.'}
`); + ${admin?'This and the rest of the server\'s settings are on the admin page.':'Only an admin changes this.'}
+
${S.me?`iPX ${esc(S.me.version)}, page __WEB_VERSION__`:''}
`); $('#stheme').onchange=(e: Event)=>setTheme((e.target as HTMLSelectElement).value,undefined,true); $('#smode').onchange=(e: Event)=>setTheme(undefined,(e.target as HTMLSelectElement).value,true); $('#gopml').onclick=opmlModal; @@ -745,7 +769,7 @@ function settingsModal(f: Feed, newUrl?: string){ function downloadLatestModal(f: Feed){ openModal(`

Download latest items

-
+
Queued immediately, ignoring the per-scan limit.
@@ -781,9 +805,9 @@ function opmlModal(){
-
+
-
+
`); $('#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); diff --git a/web/src/events.ts b/web/src/events.ts index 77e69f6..8626498 100644 --- a/web/src/events.ts +++ b/web/src/events.ts @@ -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'){ diff --git a/web/src/feeds.ts b/web/src/feeds.ts index dfc657e..d86a585 100644 --- a/web/src/feeds.ts +++ b/web/src/feeds.ts @@ -105,6 +105,7 @@ function renderFeeds(){ el.tabIndex=0; el.dataset.id=id; el.innerHTML=`${v.icon}${v.title}`+(id===':all' ?`${unreadAll>999?'999+':unreadAll}`:''); + 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(){ `
`+ (f.orphaned?'Gone':'')+ `${unread>999?'999+':unread}`; + 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); diff --git a/web/src/gestures.ts b/web/src/gestures.ts index 9c8cd34..89a04a6 100644 --- a/web/src/gestures.ts +++ b/web/src/gestures.ts @@ -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; }); diff --git a/web/src/items.ts b/web/src/items.ts index a393630..eefe6cc 100644 --- a/web/src/items.ts +++ b/web/src/items.ts @@ -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=` - +
- ${esc(name.text)} +
${[ num&&`${num}`, left&&`${left}`, @@ -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 diff --git a/web/src/native.ts b/web/src/native.ts index 2645302..12811fd 100644 --- a/web/src/native.ts +++ b/web/src/native.ts @@ -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; } }, diff --git a/web/src/player.ts b/web/src/player.ts index 490a93a..98b62eb 100644 --- a/web/src/player.ts +++ b/web/src/player.ts @@ -31,7 +31,7 @@ function setSrc(url: string): Promise|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) $('#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; diff --git a/web/src/signals.ts b/web/src/signals.ts index fee1443..33e96dc 100644 --- a/web/src/signals.ts +++ b/web/src/signals.ts @@ -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; } diff --git a/web/src/util.ts b/web/src/util.ts index 830df73..882473f 100644 --- a/web/src/util.ts +++ b/web/src/util.ts @@ -5,7 +5,18 @@ const $ = (s: string, r: ParentNode = docum const $$ = (s: string, r: ParentNode = document): T[] => [...r.querySelectorAll(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 = {'&':'&','<':'<','>':'>','"':'"',"'":'''}; +/// 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('.badge:not(.zero)')?.title].filter(Boolean).join(', ')); + if(current) el.setAttribute('aria-current','page'); +} +const ESCAPED: Record ={'&':'&','<':'<','>':'>','"':'"',"'":'''}; 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{ - 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));