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>
This commit is contained in:
2026-10-18 01:05:15 +02:00
co-authored by Claude Opus 5.5
parent c91264c253
commit 2a19515c27
19 changed files with 1639 additions and 1279 deletions
+27 -5
View File
@@ -245,7 +245,9 @@ class _Handler(BaseHTTPRequestHandler):
p = parsed.path
if p == '/':
self._html()
self._ui('index.html')
elif p.startswith('/ui/'):
self._ui(unquote(p[len('/ui/'):]))
elif p == '/api/files':
self._api_files()
elif p == '/api/analyze':
@@ -267,8 +269,21 @@ class _Handler(BaseHTTPRequestHandler):
else:
self._send(404, b'Not found', 'text/plain')
def _html(self):
self._send(200, _HTML.encode('utf-8'), 'text/html; charset=utf-8')
def _ui(self, name: str):
"""Serve a file of the browser UI from webui/. Read per request: it is
a handful of small files, and edits show up on reload without a server
restart. Only the listed types are served, and the path must stay
inside webui/ (same guard as _safe_path, but for the UI folder)."""
try:
path = (WEBUI_DIR / name).resolve()
path.relative_to(WEBUI_DIR)
except ValueError:
path = None
ctype = _UI_TYPES.get(path.suffix) if path else None
if ctype is None or not path.is_file():
self._send(404, b'Not found', 'text/plain')
return
self._send(200, path.read_bytes(), ctype)
def _api_files(self):
files = list_files(self.recordings_dir)
@@ -738,10 +753,15 @@ class _Server(ThreadingHTTPServer):
# ---------------------------------------------------------------------------
# UI page — single-page HTML/CSS/JS, loaded once at startup
# Browser UI — static files in webui/ (index.html, style.css, js/*.js modules)
# ---------------------------------------------------------------------------
_HTML = (Path(__file__).resolve().parent / 'webui.html').read_text(encoding='utf-8')
WEBUI_DIR = Path(__file__).resolve().parent / 'webui'
_UI_TYPES = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
}
# ---------------------------------------------------------------------------
@@ -773,6 +793,8 @@ def main():
if not rec_dir.exists():
print(f"Warning: recordings directory '{rec_dir}' does not exist yet.")
if not (WEBUI_DIR / 'index.html').is_file():
sys.exit(f"Error: browser UI not found at {WEBUI_DIR} — keep webui/ next to web.py.")
prune_orphan_analyses(analyses_dir, rec_dir)