Files
adminandClaude Opus 5.5 2a19515c27 feat: split the web UI into modules and rework it for screen readers
webui.html becomes webui/ (index.html, style.css, ES modules in js/, no
build step); web.py serves it under / and /ui/<path> with the same
containment check as recordings.

How it feels with a screen reader:
- Days read "Today, Saturday 17 October"; recordings are named by their
  wall-clock span ("22:00 to 23:00"), which is the row header and what
  every button says. The filename moves to the last column.
- Focus is never dropped: busy buttons stay focused (aria-disabled),
  finished analyses focus their first section, delete moves to the next
  row, closing the clip bar (x or Escape) returns to where you were.
- Reloads rebuild only the days whose content changed; DOM ids are stable
  per filename, so a rollover no longer resets the reading position.
- Only user-initiated actions are announced; highlights completion is
  spoken (the progress area was stuck aria-busy before).
- Media Session previous/next track step sections like J/K (AirPods,
  lock screen, media keys).
- Row layout: Time | Length | Loudness | Player | Options | File. The
  player opens inside its cell; Download/Cut/Delete are always there,
  and Cut toggles its start/end fields in place.
- Settings tucked behind a <details>; pre-roll and auto-advance are
  remembered per browser; no CSS uppercase on headers or badges.

Fixes the REC badge showing on every row: its inline-flex display beat
the UA [hidden] rule. One global [hidden]{display:none !important} now
covers every case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 01:05:15 +02:00

116 lines
4.5 KiB
JavaScript

// Formatting and screen-reader helpers shared by every module. A leaf: it
// imports nothing, so any module can use it without creating a cycle.
export const $ = id => document.getElementById(id);
export const esc = s => String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
export const pad = n => String(n).padStart(2, '0');
export const fmtDur = s => {
if (s == null) return '—';
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = Math.floor(s % 60);
return h ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
};
// "2 h 59 min" — for running text a screen reader speaks, where "2:59:59"
// reads like a clock time
export const fmtDurWords = s => {
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
if (h) return m ? `${h} h ${m} min` : `${h} h`;
return m ? `${m} min` : `${Math.round(s)} s`;
};
export const fmtSize = b => {
if (b < 1024) return b + ' B';
if (b < 1 << 20) return (b / 1024).toFixed(0) + ' KB';
if (b < 1 << 30) return (b / (1 << 20)).toFixed(1) + ' MB';
return (b / (1 << 30)).toFixed(2) + ' GB';
};
// Epoch seconds -> local wall-clock "HH:MM:SS"
export const fmtClock = ts => {
const d = new Date(ts * 1000);
return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
};
// Epoch seconds -> local "HH:MM"
export const fmtHM = ts => {
const d = new Date(ts * 1000);
return pad(d.getHours()) + ':' + pad(d.getMinutes());
};
// Listing `date` ("YYYY-MM-DD HH:MM:SS", the recording start parsed out of
// the filename server-side) -> epoch seconds, or null for unparseable values
export const fileStartEpoch = date => {
const t = Date.parse(String(date).replace(' ', 'T'));
return isNaN(t) ? null : t / 1000;
};
// "1:30", "0:01:30" or "90" -> seconds; null when unparseable
export function parseTime(s) {
if (!s || !s.trim()) return null;
const parts = s.trim().split(':').map(v => parseFloat(v));
if (parts.some(isNaN) || parts.length > 3) return null;
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
if (parts.length === 2) return parts[0] * 60 + parts[1];
return parts[0];
}
// "2026-10-17" -> "Today, Saturday 17 October". Spoken far better than the
// ISO date, which screen readers read digit group by digit group. en-GB to
// match the page's lang="en", so the speech language never switches mid-page.
export function dayLabel(day) {
const [y, m, d] = day.split('-').map(Number);
const date = new Date(y, m - 1, d);
if (isNaN(date)) return day;
const now = new Date();
const opts = {weekday: 'long', day: 'numeric', month: 'long'};
if (y !== now.getFullYear()) opts.year = 'numeric';
const text = date.toLocaleDateString('en-GB', opts);
// Math.round absorbs the 23/25 h days around DST switches
const ago = Math.round((new Date(now.getFullYear(), now.getMonth(), now.getDate()) - date) / 86400000);
return ago === 0 ? 'Today, ' + text : ago === 1 ? 'Yesterday, ' + text : text;
}
export function announce(msg) {
const el = $('sr-announce');
if (!el) return;
el.textContent = ''; // clear first so same text re-triggers
setTimeout(() => { el.textContent = msg; }, 50);
}
// A long chip list is a wall of buttons: hide it behind an aria-expanded
// toggle. Returns the toggle to insert before the chips, or null when the list
// is short enough to show as is. The separator space lives inside the
// aria-hidden arrow — a leading space in the text node reads as an indent.
export function chipToggle(chips, text) {
if (chips.children.length <= 12) return null;
chips.hidden = true;
const tog = document.createElement('button');
tog.className = 'chip-toggle';
tog.setAttribute('aria-expanded', 'false');
const arrow = document.createElement('span');
arrow.className = 'day-arrow';
arrow.setAttribute('aria-hidden', 'true');
arrow.textContent = '▸ ';
tog.append(arrow, text);
tog.addEventListener('click', () => {
const exp = chips.hidden;
chips.hidden = !exp;
tog.setAttribute('aria-expanded', exp);
arrow.textContent = exp ? '▾ ' : '▸ ';
});
return tog;
}
// localStorage for per-browser conveniences (pre-roll, auto-advance, whether
// the settings are open). It can be missing or throw (private mode, blocked
// storage), and nothing here may depend on it working.
export const remember = {
get(key) { try { return localStorage.getItem('isr.' + key); } catch { return null; } },
set(key, value) { try { localStorage.setItem('isr.' + key, value); } catch { /* not kept */ } },
};