Offline shell (precache, offline notice) and accessibility layer (skip link, button names, sheet focus and Escape)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 07:46:10 -04:00
1 parent df8c6ce586
commit 4625848048
5 files changed
+81 -7

No files matched your search

+1
View File
@@ -56,5 +56,6 @@
<script src="/links.js"></script>
<script src="/palette.js"></script>
<script src="/app.js"></script>
<script src="/shell.js"></script>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
// App-shell niceties that do not belong to any one tab: offline notice and an accessibility layer
// (names for icon-only buttons, a skip link, keyboard handling and focus for sheets).
(function () {
// ---- Offline notice
const bar = document.createElement('div');
bar.id = 'offline'; bar.setAttribute('role', 'status'); bar.hidden = true;
bar.textContent = 'You are offline. Showing what was saved on this device; changes will not sync until you are back online.';
document.body.appendChild(bar);
const sync = () => { bar.hidden = navigator.onLine; };
addEventListener('online', sync); addEventListener('offline', sync); sync();
// ---- Skip link and landmarks
const skip = document.createElement('a');
skip.href = '#view'; skip.className = 'skip'; skip.textContent = 'Skip to content';
skip.addEventListener('click', e => { e.preventDefault(); const v = document.getElementById('view'); if (v) { v.setAttribute('tabindex', '-1'); v.focus(); } });
document.body.insertBefore(skip, document.body.firstChild);
const nav = document.getElementById('nav'); if (nav && !nav.getAttribute('aria-label')) nav.setAttribute('aria-label', 'Main');
// ---- Give every button or link that has only an icon a name (from its title, tooltip, id or class)
const NAMES = { more: 'More', navcol: 'Collapse the menu', sx: 'Cancel', x: 'Close', close: 'Close', back: 'Back', fwd: 'Forward', menu: 'Menu', play: 'Play', pause: 'Pause', next: 'Next', prev: 'Previous', shuffle: 'Shuffle', repeat: 'Repeat', send: 'Send', clip: 'Attach a file', lyrics: 'Lyrics', full: 'Full screen', refresh: 'Refresh', ext: 'Open in a new tab' };
const named = el => (el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || el.textContent.trim() || el.getAttribute('title') || '');
function label(root) {
root.querySelectorAll('button, a[href], [role="button"]').forEach(el => {
if (named(el)) { if (!el.getAttribute('aria-label') && !el.textContent.trim()) el.setAttribute('aria-label', el.getAttribute('title')); return; }
const key = (el.id || '').replace(/^(btn|b)-?/, '') || [...el.classList].find(c => NAMES[c]) || '';
const l = NAMES[key] || NAMES[el.id] || (el.querySelector('img[alt]') || {}).alt;
if (l) el.setAttribute('aria-label', l);
});
root.querySelectorAll('input:not([type=hidden]):not([type=checkbox]):not([type=radio]), select, textarea').forEach(el => {
if (el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || (el.id && document.querySelector(`label[for="${CSS.escape(el.id)}"]`)) || el.closest('label')) return;
const l = el.getAttribute('placeholder') || el.getAttribute('title'); if (l) el.setAttribute('aria-label', l);
});
}
let pending = false;
new MutationObserver(() => { if (pending) return; pending = true; requestAnimationFrame(() => { pending = false; label(document.body); }); })
.observe(document.body, { childList: true, subtree: true });
label(document.body);
// ---- Sheets (bottom sheets and dialogs): Escape closes, focus moves in and returns, Tab stays inside
let lastFocus = null;
new MutationObserver(() => {
const s = document.getElementById('sheet');
if (s && !s._a11y) { s._a11y = true; lastFocus = document.activeElement; setTimeout(() => { if (!s.contains(document.activeElement)) { const f = s.querySelector('input:not([type=hidden]), textarea, select, button'); if (f) f.focus(); } }, 50); }
if (!s && lastFocus) { try { lastFocus.focus(); } catch {} lastFocus = null; }
}).observe(document.body, { childList: true });
addEventListener('keydown', e => {
const s = document.getElementById('sheet'); if (!s) return;
if (e.key === 'Escape' && typeof closeSheet === 'function') { e.preventDefault(); closeSheet(); return; }
if (e.key !== 'Tab') return;
const f = [...s.querySelectorAll('a[href], button:not([disabled]), input:not([type=hidden]):not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])')].filter(x => x.offsetParent !== null);
if (!f.length) return;
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && (document.activeElement === last || !s.contains(document.activeElement))) { e.preventDefault(); first.focus(); }
});
})();
+6
View File
@@ -495,3 +495,9 @@ body.lyropen { overflow: hidden; }
.palrow.on { background: var(--panel2); }
.palrow svg { width: 20px; height: 20px; color: var(--muted); }
@media (prefers-reduced-motion: reduce) { * { animation-duration: .01ms !important; transition-duration: .01ms !important; } }
/* Offline notice, skip link, keyboard focus */
#offline { position: fixed; left: 0; right: 0; top: 0; z-index: 70; padding: calc(var(--sat) + 6px) 16px 6px; text-align: center; font-size: 13px; background: #b26a00; color: #fff; }
.skip { position: fixed; left: 8px; top: -60px; z-index: 80; padding: 10px 14px; border-radius: 10px; background: var(--accent); color: var(--accent-t); }
.skip:focus { top: calc(var(--sat) + 8px); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+16 -5
View File
@@ -1,11 +1,22 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v6';
const CACHE = 'hub-v7';
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL)));
self.skipWaiting();
});
// Install: keep the icons, and (when signed in) the page itself with the scripts and styles it names, so the app opens offline.
async function precache() {
const c = await caches.open(CACHE);
await c.addAll(SHELL);
try {
const r = await fetch('/', { credentials: 'same-origin', redirect: 'manual' });
if (r.ok) {
const html = await r.clone().text();
await c.put('/', r);
const urls = [...html.matchAll(/(?:src|href)="(\/[^"]+\.(?:js|css)(?:\?[^"]*)?)"/g)].map(m => m[1]);
await Promise.all(urls.map(u => c.add(u).catch(() => {})));
}
} catch { /* signed out or offline: it is cached the first time it is used */ }
}
self.addEventListener('install', e => { e.waitUntil(precache()); self.skipWaiting(); });
self.addEventListener('activate', e => {
e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k)))));
self.clients.claim();
+2 -2
View File
@@ -99,7 +99,7 @@ function serveStatic(req, res) {
if (err) { res.writeHead(404); return res.end('Not found'); }
// The pages link to /app.js?v=<build> etc. A new release changes the address, so no proxy cache ("Cache Assets" in
// Nginx Proxy Manager, a CDN, the browser) can hand out an old copy.
if (p === '/index.html' || p === '/login.html') data = Buffer.from(String(data).replace(/(src|href)="(\/(?:app|webauthn|login|links|palette)\.js|\/style\.css)"/g, `$1="$2?v=${BUILD}"`));
if (p === '/index.html' || p === '/login.html') data = Buffer.from(String(data).replace(/(src|href)="(\/(?:app|webauthn|login|links|palette|shell)\.js|\/style\.css)"/g, `$1="$2?v=${BUILD}"`));
const headers = { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' };
if (p.startsWith('/icons/')) headers['cache-control'] = 'public, max-age=604800'; // icons and splash screens rarely change
else { // everything else: the browser may keep a copy but must check with us first, so a new version is never missed
@@ -113,7 +113,7 @@ function serveStatic(req, res) {
}
// Changes whenever the app's files change, so an open page can tell that a newer version is available.
const BUILD = (() => { try { return crypto.createHash('sha1').update(['app.js', 'links.js', 'palette.js', 'index.html', 'style.css', 'webauthn.js', 'login.js', 'login.html', 'sw.js'].map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })();
const BUILD = (() => { try { return crypto.createHash('sha1').update(['app.js', 'links.js', 'palette.js', 'shell.js', 'index.html', 'style.css', 'webauthn.js', 'login.js', 'login.html', 'sw.js'].map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })();
// ---- OnlyOffice in-app editing (JWT-signed editor config + save-back callback to Seafile)
const JWT_SECRET = process.env.ONLYOFFICE_JWT_SECRET || '';