feat(magazine): clearer driving-mode menu with switches and theme selector

Menu is grouped into View (Picture/Text/Split radios), Show and Playback
(real switches) and Theme (Light|Auto|Dark segmented control). Fixes the
Dark option looking disabled: its data-theme=dark attribute matched the
app-wide dark palette selector. Adds automatic advance to the next article
with audio (switch, default on) with End of magazine state.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-03 14:24:05 +02:00
parent 1f812d2faf
commit 488e57e609
6 changed files with 571 additions and 81 deletions

View File

@ -10,9 +10,13 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- **Character info is translated to English before image prompts are built** (`core/prompt_translation.py`, `_english_prompt_inputs`). Free-text sheet fields (species, appearance, clothing, age, props, story-moment quote) and the book setting are translated in one batched, low-temperature call through the app's existing LLM, validated per field (non-empty, no error echo, length, names preserved, not still German), and cached in the new `prompt_translation_cache` table keyed by source hash, language, model and prompt version. Records and the displayed German text never change; species/age/dwarf/period checks now run on English. If the LLM is down or output is rejected the original text is used, a warning is logged, `prompt_translation.used_untranslated_text` is reported and generation continues. `PROMPT_TRANSLATION=off` disables it. New read-only dry run `POST /api/character-prompt-preview`. See architecture/character-visual-identity.md. - **Character info is translated to English before image prompts are built** (`core/prompt_translation.py`, `_english_prompt_inputs`). Free-text sheet fields (species, appearance, clothing, age, props, story-moment quote) and the book setting are translated in one batched, low-temperature call through the app's existing LLM, validated per field (non-empty, no error echo, length, names preserved, not still German), and cached in the new `prompt_translation_cache` table keyed by source hash, language, model and prompt version. Records and the displayed German text never change; species/age/dwarf/period checks now run on English. If the LLM is down or output is rejected the original text is used, a warning is logged, `prompt_translation.used_untranslated_text` is reported and generation continues. `PROMPT_TRANSLATION=off` disables it. New read-only dry run `POST /api/character-prompt-preview`. See architecture/character-visual-identity.md.
- **Record/replay cache for vision OCR responses** (`core/vision_cache.py`, `MAGAZINE_VISION_CACHE=live|record|replay`). Freezes the raw model response per (model, prompt, image, parameters) in a git-ignored cache directory so OCR/layout/post-processing changes can be compared on identical vision output; replay fails loudly on a miss. `bench_ocr.py --vision-cache` and `compare_magazines.py` (`--baseline-vision-run`, `--new-vision-run`) report mode and models and say whether a comparison is frozen. See architecture/magazine-ingestion.md. - **Record/replay cache for vision OCR responses** (`core/vision_cache.py`, `MAGAZINE_VISION_CACHE=live|record|replay`). Freezes the raw model response per (model, prompt, image, parameters) in a git-ignored cache directory so OCR/layout/post-processing changes can be compared on identical vision output; replay fails loudly on a miss. `bench_ocr.py --vision-cache` and `compare_magazines.py` (`--baseline-vision-run`, `--new-vision-run`) report mode and models and say whether a comparison is frozen. See architecture/magazine-ingestion.md.
- **Driving-mode menu redesigned.** Sections *View*, *Show*, *Playback* and *Theme*. *View* is a radio group with icon, name and indicator: **Picture** (whole picture, no text), **Text** (whole text, lyrics-style scrolling, no picture) and **Split** (picture plus three lines; the former Compact). The on/off options (*Show text*, *Show pictures*, *Minimize text and picture*) are real switches (label left, switch right, thumb position and a tick/cross inside the thumb as well as colour; `role="switch"`). The theme is one segmented control **Light | Auto | Dark** with icons and a tick on the selected segment. Controls are 44 px high, the flyout fits 320 px, text keeps 4.5:1 and switch, segment and radio boundaries and the focus ring 3:1 in both themes; the thumb does not slide with reduced motion. Keyboard: arrows (Up/Down/Left/Right) move over every control, Home/End jump, Space/Enter activate, Escape closes and returns focus to the menu button. Views stored by older builds migrate (image to picture, lyrics to text, compact to split).
- **Driving mode plays on automatically.** When an article ends it is marked finished (saved like the playlist does) and the next article that has audio starts (rows without audio are skipped, speed carries over, title, picture, text, index highlight and lock-screen metadata follow, "Now playing: ..." is announced once). At the last article it stops with an *End of magazine* state, no wrap-around. New switch *Play next article automatically* (default on, remembered in the driving preferences); off stops after the article as before. A next article whose audio cannot be loaded is skipped with a brief message. Manual Next, seeks and duplicate `ended` events never advance twice.
### Changed ### Changed
- `vision_ocr_page` routes its request through the cache layer; the default `live` mode behaves exactly as before. - `vision_ocr_page` routes its request through the cache layer; the default `live` mode behaves exactly as before.
### Fixed ### Fixed
- **"Colors: Dark (night)" looked disabled in the light theme.** The menu item carried `data-theme="dark"`, which is also the hook of the app-wide dark palette (`[data-theme="dark"] {...}`), so the button itself got the dark palette's light text colour on the light menu. The theme buttons now use `data-theme-choice`, so no app theme selector can match them.
- **A boy rendered as an adult:** `_character_age_category` read `age`, but sheets store `age_estimate`. - **A boy rendered as an adult:** `_character_age_category` read `age`, but sheets store `age_estimate`.
- **Characters' data no longer bleeds into each other through shared labels or names.** A real-library audit (270 records) traced Arthag's (book 02) copy of Kolon's species and relationships and 16 whole-sheet copies (Zwerg/Kolon = Arthag, Mann = Fremder, Alchimist = Promos ...) to identity matching on any shared token ("Zwerg", "der Zwerg", "Mann") with "longer text wins" merging, and to `csRosterCanonicalName` renaming a sheet to the first cast name found among its aliases instead of its own name. Now only a real name links two profiles (`static/js/character-identity.js`, `core/character_records.py`), the own name always wins (`csFindMergeKey`, `csRosterCanonicalName`, `clUpsert` writes to the exact-name record before any alias scan, no seed rename onto a name that has its own record). Tests: `tests/character-identity.test.mjs`. - **Characters' data no longer bleeds into each other through shared labels or names.** A real-library audit (270 records) traced Arthag's (book 02) copy of Kolon's species and relationships and 16 whole-sheet copies (Zwerg/Kolon = Arthag, Mann = Fremder, Alchimist = Promos ...) to identity matching on any shared token ("Zwerg", "der Zwerg", "Mann") with "longer text wins" merging, and to `csRosterCanonicalName` renaming a sheet to the first cast name found among its aliases instead of its own name. Now only a real name links two profiles (`static/js/character-identity.js`, `core/character_records.py`), the own name always wins (`csFindMergeKey`, `csRosterCanonicalName`, `clUpsert` writes to the exact-name record before any alias scan, no seed rename onto a name that has its own record). Tests: `tests/character-identity.test.mjs`.
- **Other cast members' names are no longer stored as a character's alias or real name** (Kolon carried "Sharraz Garthai"/"Sharraz"): `_charsheets_parse` drops them in cast-target mode, keeping relations such as "Tochter des Promos". - **Other cast members' names are no longer stored as a character's alias or real name** (Kolon carried "Sharraz Garthai"/"Sharraz"): `_charsheets_parse` drops them in cast-target mode, keeping relations such as "Tochter des Promos".

File diff suppressed because one or more lines are too long

View File

@ -5402,6 +5402,9 @@ function magPlaylistPlay(articleId, options) {
state.playlistCurrent = articleId; state.playlistCurrent = articleId;
state.article = a; state.article = a;
state.playlistLastSaved = 0; state.playlistLastSaved = 0;
state.playlistEndedHandled = false;
state.playlistAuto = !!options?.auto; // only automatic advances are skipped when their audio cannot load
magDrivingEndState(false);
const audio = $('mag-playlist-audio'); const audio = $('mag-playlist-audio');
if (audio) { if (audio) {
state.playlistSwitching = true; state.playlistSwitching = true;
@ -5435,15 +5438,57 @@ function magPlaylistPlay(articleId, options) {
renderPlaylistTracks(); renderPlaylistTracks();
loadPlaylistCompanion(a); loadPlaylistCompanion(a);
if (state.driving) { renderDriving(); magDrivingApplyToggles(); magApplyPlaybackSpeed(); } if (state.driving) { renderDriving(); magDrivingApplyToggles(); magApplyPlaybackSpeed(); }
if (options?.auto && state.driving) magDrivingAnnounce('Now playing: ' + (a.title || 'Untitled'));
} }
// Polite live-region message, once per article (never per sentence).
function magDrivingAnnounce(msg) {
const el = $('mag-driving-announce');
if (!el) return;
el.textContent = '';
setTimeout(() => { el.textContent = msg; }, 50);
}
function magDrivingEndState(on) {
const el = $('mag-driving-endstate');
if (el) el.hidden = !on;
}
// Called for the media element's 'ended' event and, with `false`, for the manual Next button.
function _magPlaylistNext(eventOrCompleted) { function _magPlaylistNext(eventOrCompleted) {
const isEnded = typeof eventOrCompleted === 'object' && eventOrCompleted !== null;
const completed = eventOrCompleted !== false; const completed = eventOrCompleted !== false;
const audio = $('mag-playlist-audio');
if (isEnded) {
// A stale or duplicate 'ended' must not advance twice (the browser drops events queued for a replaced source; the flag covers repeats).
if (!audio || state.playlistEndedHandled || audio.dataset.article !== state.playlistCurrent) return;
if (!audio.ended && !audio.paused && Number.isFinite(audio.duration) && audio.currentTime < audio.duration - 1) return; // demonstrably mid-track
state.playlistEndedHandled = true;
}
savePlaylistProgress(completed, true); savePlaylistProgress(completed, true);
const order = _magPlaylistOrder(); const order = _magPlaylistOrder();
const i = order.findIndex((a) => a.id === state.playlistCurrent); const i = order.findIndex((a) => a.id === state.playlistCurrent);
if (i >= 0 && i < order.length - 1) magPlaylistPlay(order[i + 1].id, { skipCurrentSave: true }); const auto = isEnded && state.driving;
else syncDrivingPlayButton(); if (auto && !magDrivingPrefs().autonext) { syncDrivingPlayButton(); return; } // switch off: stop, as before
if (i >= 0 && i < order.length - 1) {
magPlaylistPlay(order[i + 1].id, { skipCurrentSave: true, auto });
} else {
if (state.driving && isEnded) { magDrivingEndState(true); magDrivingAnnounce('End of magazine'); }
syncDrivingPlayButton();
}
}
// A next article that cannot be loaded during automatic advance is skipped with a brief message (no wrap-around,
// and a run of failures ends at the last article instead of looping).
function magPlaylistAudioError() {
const audio = $('mag-playlist-audio');
if (!audio || !audio.getAttribute('src') || !state.driving || !state.playlistAuto) return;
const order = _magPlaylistOrder();
const i = order.findIndex((a) => a.id === state.playlistCurrent);
const failed = state.articles.find((x) => x.id === state.playlistCurrent);
toast('Could not play "' + ((failed && failed.title) || 'article') + '", skipped', 'error', 4000);
if (i >= 0 && i < order.length - 1) {
magPlaylistPlay(order[i + 1].id, { skipCurrentSave: true, auto: true });
} else { magDrivingEndState(true); magDrivingAnnounce('End of magazine'); state.playlistAuto = false; syncDrivingPlayButton(); }
} }
function magPlaylistPrevious() { function magPlaylistPrevious() {
@ -5692,7 +5737,7 @@ function magToggleDriving() {
magDrivingApplyTheme(); // before the overlay is shown, so it never flashes the wrong palette magDrivingApplyTheme(); // before the overlay is shown, so it never flashes the wrong palette
if (overlay) overlay.hidden = !state.driving; if (overlay) overlay.hidden = !state.driving;
if (!state.driving) { magDrivingMenuClose(false); magDrivingIndexClose(false); } if (!state.driving) { magDrivingMenuClose(false); magDrivingIndexClose(false); }
if (state.driving) { magDrivingViewsWire(); magDrivingZoomWire(); magDrivingApplyView(); renderDriving(); } if (state.driving) { magDrivingViewsWire(); magDrivingZoomWire(); magDrivingApplyView(); magDrivingApplyToggles(); renderDriving(); }
magDrivingWake(); magDrivingWake();
} }
@ -5862,7 +5907,9 @@ function magDrivingTogglePlayback() {
// ── Driving mode extras: +/-15 s, speed, text and image toggles, seek ──────── // ── Driving mode extras: +/-15 s, speed, text and image toggles, seek ────────
const MAG_DRIVING_SPEEDS = [0.75, 1, 1.25, 1.5, 1.75, 2]; const MAG_DRIVING_SPEEDS = [0.75, 1, 1.25, 1.5, 1.75, 2];
const MAG_DRIVING_VIEWS = ['image', 'lyrics', 'compact']; const MAG_DRIVING_VIEWS = ['picture', 'text', 'split'];
// Older builds stored image / lyrics / compact; they map to picture / text / split so saved choices keep working.
const MAG_DRIVING_VIEW_LEGACY = { image: 'picture', lyrics: 'text', compact: 'split' };
const MAG_DRIVING_THEMES = ['auto', 'light', 'dark']; const MAG_DRIVING_THEMES = ['auto', 'light', 'dark'];
const MAG_THEME_COLOR = { dark: '#000000', light: '#F5F3EE' }; const MAG_THEME_COLOR = { dark: '#000000', light: '#F5F3EE' };
const MAG_DRIVING_PREFS_KEY = 'ttsvc_mag_driving_prefs'; const MAG_DRIVING_PREFS_KEY = 'ttsvc_mag_driving_prefs';
@ -5874,8 +5921,9 @@ function magDrivingPrefs() {
state.drivingPrefs = { state.drivingPrefs = {
speed: MAG_DRIVING_SPEEDS.includes(saved.speed) ? saved.speed : 1, speed: MAG_DRIVING_SPEEDS.includes(saved.speed) ? saved.speed : 1,
text: saved.text !== false, images: saved.images !== false, text: saved.text !== false, images: saved.images !== false,
view: MAG_DRIVING_VIEWS.includes(saved.view) ? saved.view : 'compact', view: MAG_DRIVING_VIEWS.includes(saved.view) ? saved.view : (MAG_DRIVING_VIEW_LEGACY[saved.view] || 'split'),
collapsed: saved.collapsed === true, collapsed: saved.collapsed === true,
autonext: saved.autonext !== false,
theme: MAG_DRIVING_THEMES.includes(saved.theme) ? saved.theme : 'auto', theme: MAG_DRIVING_THEMES.includes(saved.theme) ? saved.theme : 'auto',
}; };
} }
@ -5894,8 +5942,8 @@ function magDrivingApplyTheme() {
const overlay = $('mag-driving'), theme = magDrivingResolvedTheme(), root = document.documentElement; const overlay = $('mag-driving'), theme = magDrivingResolvedTheme(), root = document.documentElement;
const meta = document.querySelector('meta[name="theme-color"]'); const meta = document.querySelector('meta[name="theme-color"]');
if (overlay) overlay.dataset.dtheme = theme; if (overlay) overlay.dataset.dtheme = theme;
document.querySelectorAll('#mag-driving-menu [role="menuitemradio"][data-theme]').forEach((b) => { document.querySelectorAll('#mag-driving-menu [data-theme-choice]').forEach((b) => {
b.setAttribute('aria-checked', String(b.dataset.theme === magDrivingPrefs().theme)); b.setAttribute('aria-checked', String(b.dataset.themeChoice === magDrivingPrefs().theme));
}); });
if (state.driving) { if (state.driving) {
if (theme === 'dark') root.setAttribute('data-mag-night', ''); else root.removeAttribute('data-mag-night'); if (theme === 'dark') root.setAttribute('data-mag-night', ''); else root.removeAttribute('data-mag-night');
@ -5957,6 +6005,13 @@ function magDrivingToggleText() {
magPlaylistSync(); magPlaylistSync();
} }
function magDrivingToggleAutonext() {
const prefs = magDrivingPrefs();
prefs.autonext = !prefs.autonext;
magDrivingSavePrefs();
magDrivingApplyToggles();
}
function magDrivingToggleImages() { function magDrivingToggleImages() {
const prefs = magDrivingPrefs(); const prefs = magDrivingPrefs();
prefs.images = !prefs.images; prefs.images = !prefs.images;
@ -5970,6 +6025,8 @@ function magDrivingApplyToggles() {
const text = $('mag-driving-text-toggle'), images = $('mag-driving-image-toggle'); const text = $('mag-driving-text-toggle'), images = $('mag-driving-image-toggle');
if (text) text.setAttribute('aria-checked', String(prefs.text)); if (text) text.setAttribute('aria-checked', String(prefs.text));
if (images) images.setAttribute('aria-checked', String(prefs.images)); if (images) images.setAttribute('aria-checked', String(prefs.images));
const an = $('mag-driving-autonext');
if (an) an.setAttribute('aria-checked', String(prefs.autonext));
const lyrics = $('mag-driving-lyrics'), visual = $('mag-driving-visual'); const lyrics = $('mag-driving-lyrics'), visual = $('mag-driving-visual');
if (lyrics && !prefs.text) lyrics.hidden = true; if (lyrics && !prefs.text) lyrics.hidden = true;
if (visual && !prefs.images) visual.hidden = true; if (visual && !prefs.images) visual.hidden = true;
@ -6059,7 +6116,7 @@ function magDrivingApplyView() {
if (!overlay) return; if (!overlay) return;
overlay.dataset.view = prefs.view; overlay.dataset.view = prefs.view;
overlay.dataset.collapsed = prefs.collapsed ? 'true' : 'false'; overlay.dataset.collapsed = prefs.collapsed ? 'true' : 'false';
document.querySelectorAll('#mag-driving-menu [role="menuitemradio"][data-view]').forEach((b) => { document.querySelectorAll('#mag-driving-menu [data-view]').forEach((b) => {
b.setAttribute('aria-checked', String(b.dataset.view === prefs.view)); b.setAttribute('aria-checked', String(b.dataset.view === prefs.view));
}); });
const mb = $('mag-driving-menubtn'), meta = MAG_VIEW_META[prefs.view]; const mb = $('mag-driving-menubtn'), meta = MAG_VIEW_META[prefs.view];
@ -6080,7 +6137,7 @@ function magDrivingSetView(view) {
magDrivingSavePrefs(); magDrivingSavePrefs();
magDrivingApplyView(); magDrivingApplyView();
magPlaylistSync(); magPlaylistSync();
if (view === 'lyrics') magDrivingBackToCurrent(); if (view === 'text') magDrivingBackToCurrent();
} }
function magDrivingToggleCollapsed() { function magDrivingToggleCollapsed() {
@ -6088,7 +6145,7 @@ function magDrivingToggleCollapsed() {
prefs.collapsed = !prefs.collapsed; prefs.collapsed = !prefs.collapsed;
magDrivingSavePrefs(); magDrivingSavePrefs();
magDrivingApplyView(); magDrivingApplyView();
if (!prefs.collapsed && prefs.view === 'lyrics') magDrivingBackToCurrent(); if (!prefs.collapsed && prefs.view === 'text') magDrivingBackToCurrent();
} }
// Whole text, Spotify style: every sentence is a line; the current one is large and kept centered. // Whole text, Spotify style: every sentence is a line; the current one is large and kept centered.
@ -6112,7 +6169,7 @@ function magDrivingBackToCurrent() {
function magDrivingSyncFull(sentences, index, wi) { function magDrivingSyncFull(sentences, index, wi) {
const box = $('mag-driving-full'); const box = $('mag-driving-full');
const prefs = magDrivingPrefs(); const prefs = magDrivingPrefs();
if (!box || prefs.view !== 'lyrics' || !prefs.text) return; if (!box || prefs.view !== 'text' || !prefs.text) return;
const key = state.playlistCurrent + ':' + sentences.length; const key = state.playlistCurrent + ':' + sentences.length;
if (_drvFull.key !== key) { if (_drvFull.key !== key) {
_drvFull.key = key; _drvFull.index = -1; _drvFull.follow = true; _drvFull.key = key; _drvFull.index = -1; _drvFull.follow = true;
@ -6230,11 +6287,11 @@ function magDrivingZoomWire() {
window.addEventListener('resize', magDrivingZoomApply); window.addEventListener('resize', magDrivingZoomApply);
} }
const MAG_VIEW_META = { image: ['mdi-image-outline', 'Picture'], lyrics: ['mdi-text-long', 'Lyrics'], compact: ['mdi-view-split-horizontal', 'Compact'] }; const MAG_VIEW_META = { picture: ['mdi-image-outline', 'Picture'], text: ['mdi-text-long', 'Text'], split: ['mdi-view-split-horizontal', 'Split'] };
// One menu button opens a flyout (view choice and minimize). It is placed under the button, clamped to the // One menu button opens a flyout (view choice and minimize). It is placed under the button, clamped to the
// viewport and never taller than the room above the dock. // viewport and never taller than the room above the dock.
function magDrivingMenuItems() { return [...document.querySelectorAll('#mag-driving-menu [role^="menuitem"]')]; } function magDrivingMenuItems() { return [...document.querySelectorAll('#mag-driving-menu button')]; }
function magDrivingMenuPlace() { function magDrivingMenuPlace() {
const menu = $('mag-driving-menu'), btn = $('mag-driving-menubtn'), dock = document.querySelector('.mag-driving-dock'); const menu = $('mag-driving-menu'), btn = $('mag-driving-menubtn'), dock = document.querySelector('.mag-driving-dock');
if (!menu || menu.hidden) return; if (!menu || menu.hidden) return;
@ -6271,17 +6328,20 @@ function magDrivingViewsWire() {
btn.addEventListener('keydown', (e) => { btn.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); magDrivingMenuOpen(e.key === 'ArrowUp' ? 'last' : undefined); } if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); magDrivingMenuOpen(e.key === 'ArrowUp' ? 'last' : undefined); }
}); });
// Views close the flyout (the change is the answer); the theme and the switches keep it open so the effect is seen.
menu.addEventListener('click', (e) => { menu.addEventListener('click', (e) => {
const it = e.target.closest('[role="menuitemradio"]'); const th = e.target.closest('button[data-theme-choice]');
if (it && it.dataset.theme) { magDrivingSetTheme(it.dataset.theme); magDrivingMenuClose(); return; } if (th) { magDrivingSetTheme(th.dataset.themeChoice); return; }
if (it) { magDrivingSetView(it.dataset.view); magDrivingMenuClose(); return; } const v = e.target.closest('button[data-view]');
if (e.target.closest('#mag-driving-collapse, #mag-driving-text-toggle, #mag-driving-image-toggle')) magDrivingMenuClose(); if (v) { magDrivingSetView(v.dataset.view); magDrivingMenuClose(); }
}); });
// One roving tab stop over all controls: Down/Right = next, Up/Left = previous (wrapping), Home/End = first/last.
// Enter and Space activate the focused button natively. Escape closes and returns focus to the menu button.
menu.addEventListener('keydown', (e) => { menu.addEventListener('keydown', (e) => {
const items = magDrivingMenuItems(), i = items.indexOf(document.activeElement); const items = magDrivingMenuItems(), i = items.indexOf(document.activeElement);
const go = (n) => { e.preventDefault(); items.forEach((x) => { x.tabIndex = -1; }); items[n].tabIndex = 0; items[n].focus(); }; const go = (n) => { e.preventDefault(); items.forEach((x) => { x.tabIndex = -1; }); items[n].tabIndex = 0; items[n].focus(); };
if (e.key === 'ArrowDown') go((i + 1) % items.length); if (e.key === 'ArrowDown' || e.key === 'ArrowRight') go((i + 1) % items.length);
else if (e.key === 'ArrowUp') go((i - 1 + items.length) % items.length); else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') go((i - 1 + items.length) % items.length);
else if (e.key === 'Home') go(0); else if (e.key === 'Home') go(0);
else if (e.key === 'End') go(items.length - 1); else if (e.key === 'End') go(items.length - 1);
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); magDrivingMenuClose(); } else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); magDrivingMenuClose(); }
@ -6503,6 +6563,7 @@ function wirePlaylistAudio() {
if (!audio || audio.dataset.wired) return; if (!audio || audio.dataset.wired) return;
audio.dataset.wired = '1'; audio.dataset.wired = '1';
audio.addEventListener('ended', _magPlaylistNext); audio.addEventListener('ended', _magPlaylistNext);
audio.addEventListener('error', magPlaylistAudioError);
audio.addEventListener('playing', () => magDrivingStartPrompt(false)); audio.addEventListener('playing', () => magDrivingStartPrompt(false));
audio.addEventListener('timeupdate', () => { magPlaylistSync(); savePlaylistProgress(false, false); magMediaSessionPosition(); }); audio.addEventListener('timeupdate', () => { magPlaylistSync(); savePlaylistProgress(false, false); magMediaSessionPosition(); });
// The loop covers smooth playback; these cover every way the position can change without it noticing. // The loop covers smooth playback; these cover every way the position can change without it noticing.
@ -6704,6 +6765,7 @@ root.magDrivingSetView = magDrivingSetView;
root.magDrivingSeek = magDrivingSeek; root.magDrivingSeek = magDrivingSeek;
root.magDrivingCycleSpeed = magDrivingCycleSpeed; root.magDrivingCycleSpeed = magDrivingCycleSpeed;
root.magDrivingToggleText = magDrivingToggleText; root.magDrivingToggleText = magDrivingToggleText;
root.magDrivingToggleAutonext = magDrivingToggleAutonext;
root.magDrivingToggleImages = magDrivingToggleImages; root.magDrivingToggleImages = magDrivingToggleImages;
root.magDrivingPrevious = magDrivingPrevious; root.magDrivingPrevious = magDrivingPrevious;
root.magHistoryStep = magHistoryStep; root.magHistoryStep = magHistoryStep;

View File

@ -324,33 +324,46 @@
<!-- Large controls, minimal text, no interaction required beyond play/ <!-- Large controls, minimal text, no interaction required beyond play/
pause/skip -- reuses whatever is already playing rather than its pause/skip -- reuses whatever is already playing rather than its
own playback path. --> own playback path. -->
<div id="mag-driving" class="mag-driving" hidden role="dialog" aria-label="Driving mode" data-view="compact"> <div id="mag-driving" class="mag-driving" hidden role="dialog" aria-label="Driving mode" data-view="split">
<!-- Layout: top bar / flexible content / controls dock. The dock is never scrolled or overlapped. --> <!-- Layout: top bar / flexible content / controls dock. The dock is never scrolled or overlapped. -->
<div class="mag-driving-top"> <div class="mag-driving-top">
<button type="button" class="mag-driving-share" onclick="magShareCurrent()" aria-label="Share a link to this article" title="Share a link to this article"> <button type="button" class="mag-driving-share" onclick="magShareCurrent()" aria-label="Share a link to this article" title="Share a link to this article">
<span class="mdi mdi-share-variant-outline" aria-hidden="true"></span> <span class="mdi mdi-share-variant-outline" aria-hidden="true"></span>
</button> </button>
<div class="mag-driving-menuwrap"> <div class="mag-driving-menuwrap">
<button type="button" class="mag-driving-menubtn" id="mag-driving-menubtn" aria-haspopup="menu" aria-expanded="false" aria-controls="mag-driving-menu" <button type="button" class="mag-driving-menubtn" id="mag-driving-menubtn" aria-expanded="false" aria-controls="mag-driving-menu"
aria-label="Display menu: Compact" title="Display menu"> aria-label="Display menu: Split" title="Display menu">
<span class="mdi mdi-view-split-horizontal" aria-hidden="true"></span><span class="mag-driving-view-label" id="mag-driving-menulabel">Compact</span> <span class="mdi mdi-view-split-horizontal" aria-hidden="true"></span><span class="mag-driving-view-label" id="mag-driving-menulabel">Split</span>
</button> </button>
<div class="mag-driving-menu" id="mag-driving-menu" role="menu" aria-label="Display" hidden> <div class="mag-driving-menu" id="mag-driving-menu" role="group" aria-label="Display options" hidden>
<button type="button" role="menuitemradio" aria-checked="false" tabindex="-1" data-view="image"><span class="mdi mdi-image-outline" aria-hidden="true"></span><span>Picture</span></button> <div class="mag-menu-sec" id="mag-menu-sec-view">View</div>
<button type="button" role="menuitemradio" aria-checked="false" tabindex="-1" data-view="lyrics"><span class="mdi mdi-text-long" aria-hidden="true"></span><span>Lyrics: whole text</span></button> <div role="radiogroup" aria-labelledby="mag-menu-sec-view" class="mag-menu-views">
<button type="button" role="menuitemradio" aria-checked="true" tabindex="-1" data-view="compact"><span class="mdi mdi-view-split-horizontal" aria-hidden="true"></span><span>Compact: picture and three lines</span></button> <button type="button" role="radio" aria-checked="false" tabindex="-1" data-view="picture"><span class="mdi mdi-image-outline" aria-hidden="true"></span><span class="mag-menu-label">Picture<small>whole picture, no text</small></span><span class="mag-radio" aria-hidden="true"></span></button>
<button type="button" role="radio" aria-checked="false" tabindex="-1" data-view="text"><span class="mdi mdi-text-long" aria-hidden="true"></span><span class="mag-menu-label">Text<small>whole text, no picture</small></span><span class="mag-radio" aria-hidden="true"></span></button>
<button type="button" role="radio" aria-checked="true" tabindex="-1" data-view="split"><span class="mdi mdi-view-split-horizontal" aria-hidden="true"></span><span class="mag-menu-label">Split<small>picture and three lines</small></span><span class="mag-radio" aria-hidden="true"></span></button>
</div>
<hr role="separator"> <hr role="separator">
<button type="button" role="menuitemcheckbox" aria-checked="true" tabindex="-1" id="mag-driving-text-toggle" onclick="magDrivingToggleText()" <div class="mag-menu-sec" id="mag-menu-sec-show">Show</div>
title="Show or hide the text being read"><span class="mdi mdi-text-long" aria-hidden="true"></span><span>Show text</span></button> <div role="group" aria-labelledby="mag-menu-sec-show">
<button type="button" role="menuitemcheckbox" aria-checked="true" tabindex="-1" id="mag-driving-image-toggle" onclick="magDrivingToggleImages()" <button type="button" role="switch" aria-checked="true" tabindex="-1" class="mag-menu-switch" id="mag-driving-text-toggle" onclick="magDrivingToggleText()"
title="Show or hide the article's pictures"><span class="mdi mdi-image-outline" aria-hidden="true"></span><span>Show pictures</span></button> title="Show or hide the text being read"><span class="mag-menu-label">Show text</span><span class="mag-sw" aria-hidden="true"><span class="mag-sw-thumb"><span class="mdi mdi-check mag-sw-on"></span><span class="mdi mdi-close mag-sw-off"></span></span></span></button>
<button type="button" role="menuitemcheckbox" aria-checked="false" tabindex="-1" id="mag-driving-collapse" aria-controls="mag-driving-content" <button type="button" role="switch" aria-checked="true" tabindex="-1" class="mag-menu-switch" id="mag-driving-image-toggle" onclick="magDrivingToggleImages()"
onclick="magDrivingToggleCollapsed()"><span class="mdi mdi-arrow-collapse-vertical" aria-hidden="true"></span><span>Minimize text and picture</span></button> title="Show or hide the article's pictures"><span class="mag-menu-label">Show pictures</span><span class="mag-sw" aria-hidden="true"><span class="mag-sw-thumb"><span class="mdi mdi-check mag-sw-on"></span><span class="mdi mdi-close mag-sw-off"></span></span></span></button>
<button type="button" role="switch" aria-checked="false" tabindex="-1" class="mag-menu-switch" id="mag-driving-collapse" aria-controls="mag-driving-content"
onclick="magDrivingToggleCollapsed()"><span class="mag-menu-label">Minimize text and picture</span><span class="mag-sw" aria-hidden="true"><span class="mag-sw-thumb"><span class="mdi mdi-check mag-sw-on"></span><span class="mdi mdi-close mag-sw-off"></span></span></span></button>
</div>
<hr role="separator"> <hr role="separator">
<div role="group" aria-label="Colors"> <div class="mag-menu-sec" id="mag-menu-sec-play">Playback</div>
<button type="button" role="menuitemradio" aria-checked="true" tabindex="-1" data-theme="auto"><span class="mdi mdi-theme-light-dark" aria-hidden="true"></span><span>Colors: Auto (follow system)</span></button> <div role="group" aria-labelledby="mag-menu-sec-play">
<button type="button" role="menuitemradio" aria-checked="false" tabindex="-1" data-theme="light"><span class="mdi mdi-white-balance-sunny" aria-hidden="true"></span><span>Colors: Light</span></button> <button type="button" role="switch" aria-checked="true" tabindex="-1" class="mag-menu-switch" id="mag-driving-autonext" onclick="magDrivingToggleAutonext()"
<button type="button" role="menuitemradio" aria-checked="false" tabindex="-1" data-theme="dark"><span class="mdi mdi-weather-night" aria-hidden="true"></span><span>Colors: Dark (night)</span></button> title="When an article ends, continue with the next one that has audio"><span class="mag-menu-label">Play next article automatically</span><span class="mag-sw" aria-hidden="true"><span class="mag-sw-thumb"><span class="mdi mdi-check mag-sw-on"></span><span class="mdi mdi-close mag-sw-off"></span></span></span></button>
</div>
<hr role="separator">
<div class="mag-menu-sec" id="mag-menu-sec-theme">Theme</div>
<div role="radiogroup" aria-labelledby="mag-menu-sec-theme" class="mag-seg">
<button type="button" role="radio" aria-checked="false" tabindex="-1" data-theme-choice="light"><span class="mdi mdi-white-balance-sunny" aria-hidden="true"></span><span>Light</span><span class="mdi mdi-check mag-seg-check" aria-hidden="true"></span></button>
<button type="button" role="radio" aria-checked="true" tabindex="-1" data-theme-choice="auto" title="Follow the system setting"><span class="mdi mdi-theme-light-dark" aria-hidden="true"></span><span>Auto</span><span class="mdi mdi-check mag-seg-check" aria-hidden="true"></span></button>
<button type="button" role="radio" aria-checked="false" tabindex="-1" data-theme-choice="dark"><span class="mdi mdi-weather-night" aria-hidden="true"></span><span>Dark</span><span class="mdi mdi-check mag-seg-check" aria-hidden="true"></span></button>
</div> </div>
</div> </div>
</div> </div>
@ -370,6 +383,8 @@
<div class="mag-driving-head"> <div class="mag-driving-head">
<h2 id="mag-driving-title">Article</h2> <h2 id="mag-driving-title">Article</h2>
<p id="mag-driving-by" class="muted"></p> <p id="mag-driving-by" class="muted"></p>
<p id="mag-driving-endstate" class="mag-driving-endstate" hidden><span class="mdi mdi-flag-checkered" aria-hidden="true"></span> End of magazine</p>
<div id="mag-driving-announce" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></div>
<div id="mag-driving-queue" class="mag-driving-queue" hidden> <div id="mag-driving-queue" class="mag-driving-queue" hidden>
<span id="mag-queue-progress-2" class="muted"></span> <span id="mag-queue-progress-2" class="muted"></span>
</div> </div>

View File

@ -5751,14 +5751,44 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; }
.mag-driving-topspacer { flex: 1 1 auto; min-width: 0; } .mag-driving-topspacer { flex: 1 1 auto; min-width: 0; }
.mag-driving-menubtn:focus-visible, .mag-driving-indexbtn:focus-visible, .mag-driving-menu button:focus-visible, .mag-driving-menubtn:focus-visible, .mag-driving-indexbtn:focus-visible, .mag-driving-menu button:focus-visible,
.mag-driving-index button:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } .mag-driving-index button:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
.mag-driving-menu { position: absolute; top: calc(100% + 6px); left: 0; z-index: 3; width: max-content; max-width: calc(100vw - 16px); min-width: 220px; .mag-driving-menu { position: absolute; top: calc(100% + 6px); left: 0; z-index: 3; width: max-content; max-width: calc(100vw - 16px); min-width: 280px;
overflow-y: auto; padding: 6px; box-sizing: border-box; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); overflow-y: auto; padding: 6px; box-sizing: border-box; border: 1px solid var(--border); border-radius: 14px; background: var(--surface);
box-shadow: 0 8px 24px rgba(0,0,0,.35); } box-shadow: 0 8px 24px rgba(0,0,0,.35); }
/* Flyout controls: View = radio rows, Show = switches, Theme = segmented control. Boundaries use --ctl (>= 3:1 on the
surface, set per driving theme); the selected state is shown by a mark (dot / thumb position + tick / tick) as well as colour. */
.mag-driving[data-dtheme="light"], .mag-driving:not([data-dtheme]) { --ctl: #6B7280; }
.mag-driving[data-dtheme="dark"] { --ctl: #8D96A3; }
.mag-driving-menu button { width: 100%; min-height: 44px; padding: 0 12px; display: flex; align-items: center; gap: 10px; text-align: left; .mag-driving-menu button { width: 100%; min-height: 44px; padding: 0 12px; display: flex; align-items: center; gap: 10px; text-align: left;
border: 0; border-radius: 10px; background: transparent; color: var(--text); font-size: 16px; font-weight: 600; cursor: pointer; } border: 0; border-radius: 10px; background: transparent; color: var(--text); font-size: 16px; font-weight: 600; cursor: pointer; font-family: inherit; }
.mag-driving-menu button .mdi { font-size: 22px; flex: none; } .mag-driving-menu button .mdi { font-size: 22px; flex: none; }
.mag-driving-menu button[aria-checked="true"] { background: var(--cur-bg, var(--accent)); color: var(--cur-fg, var(--on-accent, #fff)); box-shadow: inset 4px 0 0 var(--cur-bar, transparent); }
.mag-driving-menu hr { border: 0; border-top: 1px solid var(--border); margin: 4px 0; } .mag-driving-menu hr { border: 0; border-top: 1px solid var(--border); margin: 4px 0; }
.mag-menu-sec { padding: 8px 12px 2px; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--subtext); }
.mag-menu-label { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; line-height: 1.2; }
.mag-menu-label small { font-size: 12px; font-weight: 500; color: var(--subtext); }
.mag-menu-views button[aria-checked="true"] { background: var(--panel); box-shadow: inset 0 0 0 2px var(--ctl); }
.mag-radio { flex: none; width: 22px; height: 22px; box-sizing: border-box; border: 2px solid var(--ctl); border-radius: 50%; display: flex; align-items: center; justify-content: center; }
button[aria-checked="true"] > .mag-radio { border-color: var(--accent); }
button[aria-checked="true"] > .mag-radio::after { content: ""; width: 10px; height: 10px; border-radius: 50%; background: var(--accent); }
.mag-sw { flex: none; position: relative; width: 48px; height: 28px; box-sizing: border-box; border: 2px solid var(--ctl); border-radius: 14px; background: var(--panel); }
.mag-sw-thumb { position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; border-radius: 50%; background: var(--ctl); color: var(--surface);
display: flex; align-items: center; justify-content: center; transition: transform .15s ease; }
.mag-sw-thumb .mdi { font-size: 14px; line-height: 1; }
.mag-sw-on { display: none; }
.mag-menu-switch[aria-checked="true"] .mag-sw { background: var(--accent); border-color: var(--accent); }
.mag-menu-switch[aria-checked="true"] .mag-sw-thumb { transform: translateX(20px); background: var(--on-accent); color: var(--accent); }
.mag-menu-switch[aria-checked="true"] .mag-sw-on { display: block; }
.mag-menu-switch[aria-checked="true"] .mag-sw-off { display: none; }
.mag-seg { display: flex; gap: 0; margin: 4px 0 6px; border: 2px solid var(--ctl); border-radius: 12px; overflow: hidden; }
.mag-driving-menu .mag-seg button { flex: 1 1 0; min-width: 0; width: auto; justify-content: center; gap: 4px; padding: 0 4px; border-radius: 0; font-size: 15px; }
.mag-seg button + button { border-left: 2px solid var(--ctl); }
.mag-driving-menu .mag-seg button .mdi { font-size: 20px; }
.mag-seg-check { display: none; }
.mag-driving-menu .mag-seg button[aria-checked="true"] { background: var(--cur-bg, var(--accent)); color: var(--cur-fg, var(--on-accent, #fff)); font-weight: 800; }
.mag-seg button[aria-checked="true"] .mag-seg-check { display: inline-block; }
.mag-driving-menu .mag-seg button:focus-visible { outline: 3px solid var(--text); outline-offset: -5px; }
#mag-driving .mag-driving-endstate { margin: 6px 0 0; padding: 6px 12px; display: inline-flex; align-items: center; gap: 6px; border: 2px solid var(--ctl); border-radius: 12px; background: var(--panel); color: var(--text); font-weight: 700; }
#mag-driving .mag-driving-endstate[hidden] { display: none; }
@media (prefers-reduced-motion: reduce) { .mag-sw-thumb { transition: none; } }
.mag-driving-index { position: absolute; left: 0; right: 0; z-index: 2; display: flex; flex-direction: column; box-sizing: border-box; .mag-driving-index { position: absolute; left: 0; right: 0; z-index: 2; display: flex; flex-direction: column; box-sizing: border-box;
background: var(--surface); border-top: 1px solid var(--border); } background: var(--surface); border-top: 1px solid var(--border); }
.mag-driving-index[hidden] { display: none; } .mag-driving-index[hidden] { display: none; }
@ -5836,11 +5866,11 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; }
display: flex; align-items: center; gap: 8px; box-shadow: 0 2px 10px rgba(0,0,0,.3); } display: flex; align-items: center; gap: 8px; box-shadow: 0 2px 10px rgba(0,0,0,.3); }
/* views */ /* views */
.mag-driving[data-view="image"] .mag-driving-lyrics, .mag-driving[data-view="lyrics"] .mag-driving-lyrics, .mag-driving[data-view="picture"] .mag-driving-lyrics, .mag-driving[data-view="text"] .mag-driving-lyrics,
.mag-driving[data-view="lyrics"] .mag-driving-stagewrap, .mag-driving[data-view="compact"] .mag-driving-full-wrap, .mag-driving[data-view="text"] .mag-driving-stagewrap, .mag-driving[data-view="split"] .mag-driving-full-wrap,
.mag-driving[data-view="image"] .mag-driving-full-wrap { display: none; } .mag-driving[data-view="picture"] .mag-driving-full-wrap { display: none; }
.mag-driving[data-view="lyrics"] .mag-driving-full-wrap { display: block; } .mag-driving[data-view="text"] .mag-driving-full-wrap { display: block; }
.mag-driving[data-view="compact"] .mag-driving-stagewrap { flex: 1 1 0; } .mag-driving[data-view="split"] .mag-driving-stagewrap { flex: 1 1 0; }
/* the dock */ /* the dock */
.mag-driving-dock { flex: none; box-sizing: border-box; width: 100%; padding: 8px 12px 10px; border-top: 1px solid var(--border); .mag-driving-dock { flex: none; box-sizing: border-box; width: 100%; padding: 8px 12px 10px; border-top: 1px solid var(--border);
@ -5875,12 +5905,12 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; }
.mag-driving-btn.mag-driving-play { width: 60px; height: 60px; font-size: 30px; } .mag-driving-btn.mag-driving-play { width: 60px; height: 60px; font-size: 30px; }
.mag-driving-dock { padding: 4px 12px 6px; gap: 2px; } .mag-driving-dock { padding: 4px 12px 6px; gap: 2px; }
.mag-driving-dock .mag-driving-seek input[type="range"] { height: 24px; } .mag-driving-dock .mag-driving-seek input[type="range"] { height: 24px; }
.mag-driving[data-view="compact"] .mag-driving-content { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); .mag-driving[data-view="split"] .mag-driving-content { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-rows: auto auto minmax(0, 1fr); column-gap: 12px; row-gap: 4px; max-width: none; } grid-template-rows: auto auto minmax(0, 1fr); column-gap: 12px; row-gap: 4px; max-width: none; }
.mag-driving[data-view="compact"] .mag-driving-start { grid-column: 1 / -1; grid-row: 1; min-height: 44px; } .mag-driving[data-view="split"] .mag-driving-start { grid-column: 1 / -1; grid-row: 1; min-height: 44px; }
.mag-driving[data-view="compact"] .mag-driving-head { grid-column: 1 / -1; grid-row: 2; } .mag-driving[data-view="split"] .mag-driving-head { grid-column: 1 / -1; grid-row: 2; }
.mag-driving[data-view="compact"] .mag-driving-stagewrap { grid-column: 1; grid-row: 3; } .mag-driving[data-view="split"] .mag-driving-stagewrap { grid-column: 1; grid-row: 3; }
.mag-driving[data-view="compact"] .mag-driving-lyrics { grid-column: 2; grid-row: 3; height: auto; min-height: 0; } .mag-driving[data-view="split"] .mag-driving-lyrics { grid-column: 2; grid-row: 3; height: auto; min-height: 0; }
} }
/* minimized: just the current line above the dock */ /* minimized: just the current line above the dock */

View File

@ -2810,7 +2810,7 @@ test.describe('Driving mode layout and views', () => {
const sizes = [[320, 568], [375, 667], [414, 896], [667, 375], [1280, 720]]; const sizes = [[320, 568], [375, 667], [414, 896], [667, 375], [1280, 720]];
for (const [w, h] of sizes) { for (const [w, h] of sizes) {
for (const v of ['compact', 'image', 'lyrics']) { for (const v of ['split', 'picture', 'text']) {
test(`${w}x${h}, ${v} view: dock fully visible, round buttons, nothing overlaps`, async ({ page }) => { test(`${w}x${h}, ${v} view: dock fully visible, round buttons, nothing overlaps`, async ({ page }) => {
await setup(page); await setup(page);
await page.setViewportSize({ width: w, height: h }); await page.setViewportSize({ width: w, height: h });
@ -2843,15 +2843,15 @@ test.describe('Driving mode layout and views', () => {
await setup(page); await setup(page);
await page.setViewportSize({ width: 375, height: 667 }); await page.setViewportSize({ width: 375, height: 667 });
await open(page); await open(page);
await expect(view(page, 'compact')).toHaveAttribute('aria-checked', 'true'); // the default await expect(view(page, 'split')).toHaveAttribute('aria-checked', 'true'); // the default
await expect(page.locator('#mag-driving-visual')).toBeVisible(); await expect(page.locator('#mag-driving-visual')).toBeVisible();
await expect(page.locator('#mag-driving-lyrics')).toBeVisible(); await expect(page.locator('#mag-driving-lyrics')).toBeVisible();
await expect(page.locator('#mag-driving-full')).toBeHidden(); await expect(page.locator('#mag-driving-full')).toBeHidden();
await pick(page, 'image'); await pick(page, 'picture');
await expect(page.locator('#mag-driving-visual')).toBeVisible(); await expect(page.locator('#mag-driving-visual')).toBeVisible();
await expect(page.locator('#mag-driving-lyrics')).toBeHidden(); await expect(page.locator('#mag-driving-lyrics')).toBeHidden();
await expect(page.locator('#mag-driving-full')).toBeHidden(); await expect(page.locator('#mag-driving-full')).toBeHidden();
await pick(page, 'lyrics'); await pick(page, 'text');
await expect(page.locator('#mag-driving-full .mag-driving-fline')).toHaveCount(4); await expect(page.locator('#mag-driving-full .mag-driving-fline')).toHaveCount(4);
await expect(page.locator('#mag-driving-visual')).toBeHidden(); await expect(page.locator('#mag-driving-visual')).toBeHidden();
await expect(page.locator('#mag-driving-lyrics')).toBeHidden(); await expect(page.locator('#mag-driving-lyrics')).toBeHidden();
@ -2861,7 +2861,7 @@ test.describe('Driving mode layout and views', () => {
await expect(page.locator('#mag-driving-menu')).toBeVisible(); await expect(page.locator('#mag-driving-menu')).toBeVisible();
await page.keyboard.press('ArrowDown'); await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'compact'); // lyrics -> next item (compact) await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'split'); // lyrics -> next item (compact)
await expect(page.locator('#mag-driving-menu')).toBeHidden(); await expect(page.locator('#mag-driving-menu')).toBeHidden();
// no live region spam // no live region spam
expect(await page.locator('#mag-driving-full').getAttribute('aria-live')).toBe('off'); expect(await page.locator('#mag-driving-full').getAttribute('aria-live')).toBe('off');
@ -2870,11 +2870,11 @@ test.describe('Driving mode layout and views', () => {
test('the chosen view and the minimized state survive a reload', async ({ page }) => { test('the chosen view and the minimized state survive a reload', async ({ page }) => {
await setup(page); await setup(page);
await open(page); await open(page);
await pick(page, 'lyrics'); await pick(page, 'text');
await page.reload(); await page.reload();
await open(page); await open(page);
await expect(view(page, 'lyrics')).toHaveAttribute('aria-checked', 'true'); await expect(view(page, 'text')).toHaveAttribute('aria-checked', 'true');
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'lyrics'); await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'text');
await toggleCollapse(page); await toggleCollapse(page);
await page.reload(); await page.reload();
await open(page); await open(page);
@ -2890,16 +2890,16 @@ test.describe('Driving mode layout and views', () => {
await open(page); await open(page);
const btn = page.locator('#mag-driving-menubtn'), menu = page.locator('#mag-driving-menu'); const btn = page.locator('#mag-driving-menubtn'), menu = page.locator('#mag-driving-menu');
await expect(page.locator('#mag-driving-views')).toHaveCount(0); // the three-icon switch is gone await expect(page.locator('#mag-driving-views')).toHaveCount(0); // the three-icon switch is gone
await expect(btn).toHaveAttribute('aria-haspopup', 'menu'); await expect(btn).not.toHaveAttribute('aria-haspopup', /.+/);
await expect(btn).toHaveAttribute('aria-expanded', 'false'); await expect(btn).toHaveAttribute('aria-expanded', 'false');
await expect(menu).toBeHidden(); await expect(menu).toBeHidden();
await btn.click(); await btn.click();
await expect(menu).toBeVisible(); await expect(menu).toBeVisible();
await expect(btn).toHaveAttribute('aria-expanded', 'true'); await expect(btn).toHaveAttribute('aria-expanded', 'true');
await expect(menu).toHaveAttribute('role', 'menu'); await expect(menu).toHaveAttribute('role', 'group');
expect(await page.locator('#mag-driving-menu [role="menuitemradio"][data-view]').count()).toBe(3); expect(await page.locator('#mag-driving-menu [data-view]').count()).toBe(3);
for (const id of ['#mag-driving-text-toggle', '#mag-driving-image-toggle', '#mag-driving-collapse']) for (const id of ['#mag-driving-text-toggle', '#mag-driving-image-toggle', '#mag-driving-collapse', '#mag-driving-autonext'])
await expect(page.locator(id)).toHaveAttribute('role', 'menuitemcheckbox'); await expect(page.locator(id)).toHaveAttribute('role', 'switch');
await expect(page.locator('#mag-driving-dock .x, .mag-driving-dock #mag-driving-text-toggle, .mag-driving-dock #mag-driving-image-toggle')).toHaveCount(0); await expect(page.locator('#mag-driving-dock .x, .mag-driving-dock #mag-driving-text-toggle, .mag-driving-dock #mag-driving-image-toggle')).toHaveCount(0);
for (const b of await page.locator('#mag-driving-menu button').all()) { for (const b of await page.locator('#mag-driving-menu button').all()) {
const box = await b.boundingBox(); expect(Math.min(box.width, box.height)).toBeGreaterThanOrEqual(43.5); const box = await b.boundingBox(); expect(Math.min(box.width, box.height)).toBeGreaterThanOrEqual(43.5);
@ -2909,14 +2909,14 @@ test.describe('Driving mode layout and views', () => {
await expect(btn).toBeFocused(); await expect(btn).toBeFocused();
await expect(page.locator('#mag-driving')).toBeVisible(); await expect(page.locator('#mag-driving')).toBeVisible();
await btn.click(); await expect(menu).toBeVisible(); await btn.click(); await expect(menu).toBeVisible();
await page.mouse.click(190, 400); // outside tap await page.mouse.click(373, 300); // outside tap
await expect(menu).toBeHidden(); await expect(menu).toBeHidden();
await btn.click(); await btn.click();
await view(page, 'lyrics').click(); // choosing closes it and applies the view await view(page, 'text').click(); // choosing closes it and applies the view
await expect(menu).toBeHidden(); await expect(menu).toBeHidden();
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'lyrics'); await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'text');
await expect(btn).toHaveAttribute('aria-label', /Lyrics/); await expect(btn).toHaveAttribute('aria-label', /Text/);
for (const v of ['image', 'compact', 'lyrics']) { for (const v of ['picture', 'split', 'text']) {
await pick(page, v); await pick(page, v);
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', v); await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', v);
await expect(view(page, v)).toHaveAttribute('aria-checked', 'true'); await expect(view(page, v)).toHaveAttribute('aria-checked', 'true');
@ -2924,21 +2924,21 @@ test.describe('Driving mode layout and views', () => {
// keyboard: arrows, Home/End, Enter // keyboard: arrows, Home/End, Enter
await btn.focus(); await page.keyboard.press('ArrowDown'); await btn.focus(); await page.keyboard.press('ArrowDown');
await expect(menu).toBeVisible(); await expect(menu).toBeVisible();
await expect(page.locator('#mag-driving-menu :focus')).toHaveAttribute('data-view', 'lyrics'); await expect(page.locator('#mag-driving-menu :focus')).toHaveAttribute('data-view', 'text');
await page.keyboard.press('ArrowDown'); await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowUp'); await page.keyboard.press('ArrowUp'); await page.keyboard.press('ArrowUp'); await page.keyboard.press('ArrowUp');
await expect(page.locator('#mag-driving-menu :focus')).toHaveAttribute('data-view', 'image'); await expect(page.locator('#mag-driving-menu :focus')).toHaveAttribute('data-view', 'picture');
await page.keyboard.press('End'); await page.keyboard.press('End');
await expect(page.locator('#mag-driving-menu :focus')).toHaveAttribute('data-theme', 'dark'); await expect(page.locator('#mag-driving-menu :focus')).toHaveAttribute('data-theme-choice', 'dark');
await page.keyboard.press('Home'); await page.keyboard.press('Enter'); await page.keyboard.press('Home'); await page.keyboard.press('Enter');
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'image'); await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'picture');
}); });
test('flyout: text and pictures toggles live in the menu and keep their state; speed sits after the time in the slider row', async ({ page }) => { test('flyout: text and pictures toggles live in the menu and keep their state; speed sits after the time in the slider row', async ({ page }) => {
await setup(page); await setup(page);
await page.setViewportSize({ width: 375, height: 667 }); await page.setViewportSize({ width: 375, height: 667 });
await open(page); await open(page);
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'compact'); await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'split');
await expect(page.locator('#mag-driving-lyrics')).toBeVisible(); await expect(page.locator('#mag-driving-lyrics')).toBeVisible();
await openMenu(page); await openMenu(page);
await expect(page.locator('#mag-driving-text-toggle')).toHaveAttribute('aria-checked', 'true'); await expect(page.locator('#mag-driving-text-toggle')).toHaveAttribute('aria-checked', 'true');
@ -2971,6 +2971,385 @@ test.describe('Driving mode layout and views', () => {
await expect(page.locator('#mag-driving-speed')).toHaveText('1×'); await expect(page.locator('#mag-driving-speed')).toHaveText('1×');
}); });
// ── Redesigned flyout: switches, segmented theme control, view options, contrast ──
const PREFS_KEY = 'ttsvc_mag_driving_prefs';
const prefsOf = (page) => page.evaluate((k) => { try { return JSON.parse(localStorage.getItem(k) || '{}'); } catch (_) { return null; } }, PREFS_KEY);
// WCAG contrast helpers, run in the page against computed styles (alpha and ancestors' backgrounds composited).
const CONTRAST_JS = `(() => {
const parse = (c) => { const m = c.match(/[\\d.]+/g).map(Number); return [m[0], m[1], m[2], m.length > 3 ? m[3] : 1]; };
const over = (t, u) => { const a = t[3]; return [0, 1, 2].map((i) => t[i] * a + u[i] * (1 - a)).concat([1]); };
const bgOf = (el) => { const st = []; for (let e = el; e; e = e.parentElement) st.push(parse(getComputedStyle(e).backgroundColor)); let c = [0, 0, 0, 1]; for (let i = st.length - 1; i >= 0; i--) c = over(st[i], c); return c; };
const lum = (c) => { const f = c.slice(0, 3).map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2]; };
const ratio = (a, b) => { const x = lum(a), y = lum(b); return Math.round((Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05) * 100) / 100; };
return { parse, over, bgOf, ratio };
})()`;
test('switches: role=switch, label left / switch right, aria-checked toggles by click and Space, persists across reload', async ({ page }) => {
await setup(page);
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
await openMenu(page);
const ids = { text: '#mag-driving-text-toggle', images: '#mag-driving-image-toggle', collapsed: '#mag-driving-collapse', autonext: '#mag-driving-autonext' };
const geo = await page.evaluate((ids) => Object.fromEntries(Object.entries(ids).map(([k, s]) => {
const b = document.querySelector(s), l = b.querySelector('.mag-menu-label').getBoundingClientRect(), t = b.querySelector('.mag-sw').getBoundingClientRect();
const on = b.getAttribute('aria-checked') === 'true';
return [k, { role: b.getAttribute('role'), labelLeft: l.right <= t.left + 0.5, track: [t.width, t.height], h: b.getBoundingClientRect().height, on,
thumb: b.querySelector('.mag-sw-thumb').getBoundingClientRect().left - t.left }];
})), ids);
for (const g of Object.values(geo)) { expect(g.role).toBe('switch'); expect(g.labelLeft).toBe(true); expect(g.h).toBeGreaterThanOrEqual(43.5); expect(g.track[0]).toBeGreaterThan(g.track[1]); }
expect(geo.text.on).toBe(true); expect(geo.images.on).toBe(true); expect(geo.collapsed.on).toBe(false); expect(geo.autonext.on).toBe(true);
expect(geo.text.thumb).toBeGreaterThan(geo.collapsed.thumb + 10); // on = thumb on the right, off = on the left (not colour alone)
// click flips; the menu stays open so the effect is visible
for (const k of ['text', 'images', 'collapsed', 'autonext']) {
const before = await page.locator(ids[k]).getAttribute('aria-checked');
await page.locator(ids[k]).click();
await expect(page.locator(ids[k])).toHaveAttribute('aria-checked', before === 'true' ? 'false' : 'true');
await expect(page.locator('#mag-driving-menu')).toBeVisible();
}
await expect(page.locator('#mag-driving')).toHaveAttribute('data-collapsed', 'true');
await expect(page.locator('#mag-driving-lyrics')).toBeHidden();
// Space toggles from the keyboard
await page.locator(ids.autonext).focus();
await page.keyboard.press('Space');
await expect(page.locator(ids.autonext)).toHaveAttribute('aria-checked', 'true');
await page.keyboard.press('Space');
await expect(page.locator(ids.autonext)).toHaveAttribute('aria-checked', 'false');
await page.keyboard.press('Enter');
await expect(page.locator(ids.autonext)).toHaveAttribute('aria-checked', 'true');
await page.keyboard.press('Space'); // leave it off to prove persistence
const saved = await prefsOf(page);
expect(saved).toMatchObject({ text: false, images: false, collapsed: true, autonext: false });
await page.reload();
await open(page);
await openMenu(page);
await expect(page.locator(ids.text)).toHaveAttribute('aria-checked', 'false');
await expect(page.locator(ids.images)).toHaveAttribute('aria-checked', 'false');
await expect(page.locator(ids.collapsed)).toHaveAttribute('aria-checked', 'true');
await expect(page.locator(ids.autonext)).toHaveAttribute('aria-checked', 'false');
});
test('theme: one segmented Light | Auto | Dark control; Dark is plainly visible and works from the light theme (regression: it looked disabled)', async ({ page }) => {
await setup(page);
await page.emulateMedia({ colorScheme: 'light' });
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
await openMenu(page);
const seg = page.locator('#mag-driving-menu .mag-seg');
await expect(seg).toHaveAttribute('role', 'radiogroup');
await expect(seg).toHaveAttribute('aria-labelledby', 'mag-menu-sec-theme');
await expect(page.locator('#mag-menu-sec-theme')).toHaveText('Theme');
await expect(seg.locator('button')).toHaveText(['Light', 'Auto', 'Dark']);
const r = await page.evaluate((js) => {
const { bgOf, parse, ratio } = eval(js);
const btns = [...document.querySelectorAll('#mag-driving-menu .mag-seg button')];
const info = btns.map((b) => { const cs = getComputedStyle(b), bg = bgOf(b);
return { t: b.dataset.themeChoice, color: cs.color, opacity: cs.opacity, disabled: b.disabled || b.hasAttribute('aria-disabled'), pe: cs.pointerEvents,
ratio: ratio(parse(cs.color), bg), h: b.getBoundingClientRect().height, w: b.getBoundingClientRect().width,
check: getComputedStyle(b.querySelector('.mag-seg-check')).display, checked: b.getAttribute('aria-checked') }; });
const sg = document.querySelector('#mag-driving-menu .mag-seg').getBoundingClientRect(), mn = document.getElementById('mag-driving-menu').getBoundingClientRect();
return { info, within: sg.left >= mn.left && sg.right <= mn.right + 0.5 };
}, CONTRAST_JS);
expect(r.within).toBe(true);
const dark = r.info.find((i) => i.t === 'dark'), light = r.info.find((i) => i.t === 'light');
expect(dark.opacity).toBe('1'); expect(dark.disabled).toBe(false); expect(dark.pe).not.toBe('none');
expect(dark.color).toBe(light.color); // same text colour as its siblings (it used to get the dark palette's)
for (const i of r.info) { expect(i.ratio, i.t).toBeGreaterThanOrEqual(4.5); expect(i.h).toBeGreaterThanOrEqual(43.5); }
const auto = r.info.find((i) => i.t === 'auto');
expect(auto.checked).toBe('true'); expect(auto.check).not.toBe('none'); // selected: tick mark, not colour only
expect(dark.check).toBe('none');
await seg.locator('[data-theme-choice="dark"]').click();
await expect(page.locator('#mag-driving')).toHaveAttribute('data-dtheme', 'dark');
expect(await page.evaluate(() => getComputedStyle(document.getElementById('mag-driving')).backgroundColor)).toBe('rgb(0, 0, 0)');
await expect(page.locator('#mag-driving-menu')).toBeVisible();
await expect(seg.locator('[data-theme-choice="dark"]')).toHaveAttribute('aria-checked', 'true');
await expect(seg.locator('[data-theme-choice="dark"] .mag-seg-check')).toBeVisible();
await expect(seg.locator('[data-theme-choice="auto"]')).toHaveAttribute('aria-checked', 'false');
await seg.locator('[data-theme-choice="light"]').click();
await expect(page.locator('#mag-driving')).toHaveAttribute('data-dtheme', 'light');
// keyboard: arrows move over the segments, Space chooses
await seg.locator('[data-theme-choice="light"]').focus();
await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight'); await page.keyboard.press('Space');
await expect(page.locator('#mag-driving')).toHaveAttribute('data-dtheme', 'dark');
});
test('views: Picture / Text / Split radio options with icon, label and indicator; old stored values migrate', async ({ page }) => {
await setup(page);
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
await openMenu(page);
const group = page.locator('#mag-driving-menu [role="radiogroup"][aria-labelledby="mag-menu-sec-view"]');
await expect(page.locator('#mag-menu-sec-view')).toHaveText('View');
await expect(group.locator('[role="radio"]')).toHaveCount(3);
await expect(group.locator('[role="radio"] .mag-menu-label')).toHaveText([/^Picture/, /^Text/, /^Split/]);
for (const b of await group.locator('[role="radio"]').all()) {
await expect(b.locator('.mdi').first()).toBeVisible();
await expect(b.locator('.mag-radio')).toBeVisible();
}
const dot = (v) => page.evaluate((v) => getComputedStyle(document.querySelector(`#mag-driving-menu [data-view="${v}"] .mag-radio`), '::after').content, v);
expect(await dot('split')).toBe('""'); expect(await dot('picture')).toBe('none'); // selected indicator is a dot, not only a fill
// mapping of the three options
await view(page, 'picture').click();
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'picture');
await expect(page.locator('#mag-driving-visual')).toBeVisible(); await expect(page.locator('#mag-driving-lyrics')).toBeHidden(); await expect(page.locator('#mag-driving-full')).toBeHidden();
await expect(page.locator('#mag-driving-menubtn')).toHaveAttribute('aria-label', /Picture/);
await pick(page, 'text');
await expect(page.locator('#mag-driving-full')).toBeVisible(); await expect(page.locator('#mag-driving-visual')).toBeHidden();
await expect(page.locator('#mag-driving-menulabel')).toHaveText('Text');
await pick(page, 'split');
await expect(page.locator('#mag-driving-visual')).toBeVisible(); await expect(page.locator('#mag-driving-lyrics')).toBeVisible();
expect(await page.locator('#mag-driving-lyrics .mag-driving-line:not([hidden])').count()).toBeGreaterThanOrEqual(2); // previous (if any), current, next
expect((await prefsOf(page)).view).toBe('split');
// migration of values stored by older builds
for (const [old, now] of [['image', 'picture'], ['lyrics', 'text'], ['compact', 'split'], ['picture', 'picture'], ['text', 'text'], ['split', 'split'], ['bogus', 'split']]) {
await page.evaluate(([k, v]) => localStorage.setItem(k, JSON.stringify({ view: v })), [PREFS_KEY, old]);
await page.reload();
await open(page);
await expect(page.locator('#mag-driving'), old + ' -> ' + now).toHaveAttribute('data-view', now);
await openMenu(page);
await expect(view(page, now)).toHaveAttribute('aria-checked', 'true');
}
});
test('keyboard: arrows move over every control, Space/Enter activate, Escape closes and returns focus to the menu button', async ({ page }) => {
await setup(page);
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
const btn = page.locator('#mag-driving-menubtn');
await btn.focus(); await page.keyboard.press('Enter');
const order = ['picture', 'text', 'split', 'mag-driving-text-toggle', 'mag-driving-image-toggle', 'mag-driving-collapse', 'mag-driving-autonext', 'light', 'auto', 'dark'];
const cur = () => page.evaluate(() => { const a = document.activeElement; return a.dataset.view || a.dataset.themeChoice || a.id; });
expect(await cur()).toBe('split'); // focus starts on the selected view
await page.keyboard.press('Home'); expect(await cur()).toBe('picture');
for (const next of order.slice(1)) { await page.keyboard.press('ArrowDown'); expect(await cur()).toBe(next); }
await page.keyboard.press('ArrowDown'); expect(await cur()).toBe('picture'); // wraps
await page.keyboard.press('ArrowUp'); expect(await cur()).toBe('dark');
await page.keyboard.press('ArrowLeft'); expect(await cur()).toBe('auto');
await page.keyboard.press('End'); expect(await cur()).toBe('dark');
await page.keyboard.press('Space');
await expect(page.locator('#mag-driving')).toHaveAttribute('data-dtheme', 'dark');
await page.keyboard.press('Escape');
await expect(page.locator('#mag-driving-menu')).toBeHidden();
await expect(btn).toBeFocused();
await expect(page.locator('#mag-driving')).toBeVisible();
});
for (const theme of ['light', 'dark']) {
test(`contrast: flyout controls meet WCAG AA in the ${theme} theme (text 4.5:1, switch / segment / radio boundaries and focus ring 3:1)`, async ({ page }) => {
await setup(page);
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
await openMenu(page);
await page.locator(`#mag-driving-menu [data-theme-choice="${theme}"]`).click();
await page.emulateMedia({ reducedMotion: 'reduce' });
const res = await page.evaluate((js) => {
const { bgOf, parse, over, ratio } = eval(js);
const q = (s) => document.querySelector(s), menu = q('#mag-driving-menu'), surface = bgOf(menu);
const text = (el) => ratio(parse(getComputedStyle(el).color), bgOf(el));
const edge = (c, under) => ratio(over(parse(c), under), under);
const out = { sections: ['#mag-menu-sec-view', '#mag-menu-sec-show', '#mag-menu-sec-play', '#mag-menu-sec-theme'].map((s) => text(q(s))),
labels: [...menu.querySelectorAll('.mag-menu-label')].map(text), hints: [...menu.querySelectorAll('.mag-menu-label small')].map(text) };
const off = q('#mag-driving-collapse'), on = q('#mag-driving-text-toggle'); // collapse is off by default, text is on
const offTrack = off.querySelector('.mag-sw'), onTrack = on.querySelector('.mag-sw');
const panelOff = bgOf(offTrack);
out.offTrackEdge = edge(getComputedStyle(offTrack).borderTopColor, surface);
out.offThumb = ratio(parse(getComputedStyle(off.querySelector('.mag-sw-thumb')).backgroundColor), panelOff);
out.onTrackEdge = ratio(parse(getComputedStyle(onTrack).backgroundColor), surface);
out.onThumb = ratio(parse(getComputedStyle(on.querySelector('.mag-sw-thumb')).backgroundColor), parse(getComputedStyle(onTrack).backgroundColor));
out.onIcon = ratio(parse(getComputedStyle(on.querySelector('.mag-sw-on')).color), parse(getComputedStyle(on.querySelector('.mag-sw-thumb')).backgroundColor));
const seg = q('#mag-driving-menu .mag-seg'), sel = seg.querySelector('[aria-checked="true"]'), un = seg.querySelector('[aria-checked="false"]');
out.segEdge = edge(getComputedStyle(seg).borderTopColor, surface);
out.segDivider = edge(getComputedStyle(seg.querySelectorAll('button')[1]).borderLeftColor, surface);
out.segSelected = text(sel); out.segUnselected = text(un);
const radio = q('#mag-driving-menu [data-view]:not([aria-checked="true"]) .mag-radio');
out.radioEdge = edge(getComputedStyle(radio).borderTopColor, bgOf(radio));
out.radioOn = ratio(parse(getComputedStyle(q('#mag-driving-menu [data-view][aria-checked="true"] .mag-radio')).borderTopColor), bgOf(q('#mag-driving-menu [data-view][aria-checked="true"] .mag-radio')));
return out;
}, CONTRAST_JS);
for (const [k, v] of Object.entries(res)) {
const vals = Array.isArray(v) ? v : [v];
const min = /Edge|Thumb|Divider|radio|Icon/.test(k) ? 3 : 4.5;
for (const x of vals) expect(x, `${theme} ${k}`).toBeGreaterThanOrEqual(min);
}
// focus ring: visible outline of >= 3:1 against what it sits on (list switch and segment)
for (const sel of ['#mag-driving-autonext', '#mag-driving-menu [data-view="split"]', `#mag-driving-menu [data-theme-choice="${theme}"]`, '#mag-driving-menu [data-theme-choice="auto"]']) {
await page.locator(sel).focus();
await page.keyboard.press('ArrowDown'); await page.keyboard.press('ArrowUp'); // keyboard focus, so :focus-visible applies
const ring = await page.evaluate(([js, sel]) => {
const { bgOf, parse, ratio } = eval(js); const el = document.querySelector(sel), cs = getComputedStyle(el);
return { w: parseFloat(cs.outlineWidth), style: cs.outlineStyle, ratio: ratio(parse(cs.outlineColor), bgOf(el)) };
}, [CONTRAST_JS, sel]);
expect(ring.style).toBe('solid'); expect(ring.w).toBeGreaterThanOrEqual(2); expect(ring.ratio, `${theme} focus ring ${sel}`).toBeGreaterThanOrEqual(3);
}
});
}
test('reduced motion: the switch thumb does not slide', async ({ page }) => {
await setup(page);
await open(page);
await page.emulateMedia({ reducedMotion: 'reduce' });
expect(await page.evaluate(() => getComputedStyle(document.querySelector('.mag-sw-thumb')).transitionDuration)).toBe('0s');
await page.emulateMedia({ reducedMotion: 'no-preference' });
expect(await page.evaluate(() => getComputedStyle(document.querySelector('.mag-sw-thumb')).transitionDuration)).not.toBe('0s');
});
for (const [w, h] of [[320, 568], [375, 667], [414, 896], [667, 375]]) {
test(`${w}x${h}: new flyout stays inside the viewport and above the dock, controls reachable`, async ({ page }) => {
await setup(page);
await page.setViewportSize({ width: w, height: h });
await open(page);
await openMenu(page);
const g = await page.evaluate(() => {
const m = document.getElementById('mag-driving-menu').getBoundingClientRect(), d = document.querySelector('.mag-driving-dock').getBoundingClientRect();
const btns = [...document.querySelectorAll('#mag-driving-menu button')];
return { l: m.left, r: m.right, t: m.top, b: m.bottom, vw: document.documentElement.clientWidth, vh: innerHeight, dockTop: d.top,
minH: Math.min(...btns.map((b) => b.getBoundingClientRect().height)), minW: Math.min(...btns.map((b) => b.getBoundingClientRect().width)),
hOverflow: document.getElementById('mag-driving-menu').scrollWidth > document.getElementById('mag-driving-menu').clientWidth + 1 };
});
expect(g.l).toBeGreaterThanOrEqual(0); expect(g.r).toBeLessThanOrEqual(g.vw + 0.5); expect(g.t).toBeGreaterThanOrEqual(0);
expect(g.b).toBeLessThanOrEqual(g.dockTop + 0.5); expect(g.b).toBeLessThanOrEqual(g.vh);
expect(g.minH).toBeGreaterThanOrEqual(43.5); expect(g.minW).toBeGreaterThanOrEqual(43.5); expect(g.hOverflow).toBe(false);
await page.locator('#mag-driving-menu [data-theme-choice="dark"]').scrollIntoViewIfNeeded(); // the menu scrolls when short
await page.locator('#mag-driving-menu [data-theme-choice="dark"]').click();
await expect(page.locator('#mag-driving')).toHaveAttribute('data-dtheme', 'dark');
});
}
// ── Auto-advance to the next article ──
const CHAIN = (fourth) => [
{ id: 'article-001', title: 'First', pages: [1], has_audio: true, audio_seconds: 12 },
{ id: 'article-002', title: 'Silent', pages: [3], has_audio: false },
{ id: 'article-003', title: 'Third', pages: [5], has_audio: true, audio_seconds: 3 },
...(fourth ? [{ id: 'article-004', title: 'Fourth', pages: [7], has_audio: true, audio_seconds: 3 }] : []),
];
async function setupChain(page, { fourth = true, fourthAudio = 'ok' } = {}) {
await setup(page);
const base = { ...ARTICLE_APPROVED, status: 'approved', images: [] };
const arts = CHAIN(fourth).map((a) => ({ ...base, ...a }));
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: arts, progress: {}, default_voice: '', podcast: { hosts: [] } } }));
const hits = { audio: [], progress: [] };
await page.route(`**/api/magazine/${DOC_ID}/articles/article-00[234]/*`, route => route.fulfill({ status: 404, json: { error: 'none' } }));
for (const [id, secs] of [['article-003', 3000], ['article-004', 3000]]) {
await page.route(`**/api/magazine/${DOC_ID}/articles/${id}/audio`, (route) => {
hits.audio.push(id);
if (id === 'article-004' && fourthAudio === '404') return route.fulfill({ status: 404, json: { error: 'missing' } });
const wav = silentWav(secs), range = /bytes=(\d+)-(\d*)/.exec(route.request().headers().range || '');
if (!range) return route.fulfill({ body: wav, contentType: 'audio/wav', headers: { 'Accept-Ranges': 'bytes' } });
const from = Number(range[1]), to = range[2] ? Number(range[2]) : wav.length - 1;
return route.fulfill({ status: 206, body: wav.subarray(from, to + 1), contentType: 'audio/wav', headers: { 'Accept-Ranges': 'bytes', 'Content-Range': `bytes ${from}-${to}/${wav.length}` } });
});
}
await page.route(`**/api/magazine/${DOC_ID}/progress`, (route) => { hits.progress.push(route.request().postDataJSON()); return route.fulfill({ json: { ok: true } }); });
return hits;
}
const title = (page) => page.locator('#mag-driving-title');
// Jump close to the end of the current track and play it out.
const playOut = (page) => page.evaluate(async () => { const a = document.getElementById('mag-playlist-audio'); a.currentTime = Math.max(0, a.duration - 0.4); await a.play(); });
const playing = (page) => page.evaluate(() => { const a = document.getElementById('mag-playlist-audio'); return !a.paused && !a.ended; });
test('auto-advance: at the end of an article the next one with audio plays (no-audio rows skipped), speed and state carry over', async ({ page }) => {
const hits = await setupChain(page);
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
await expect(title(page)).toHaveText('First');
await page.locator('#mag-driving-speed').click(); // 1.25x
await expect(page.locator('#mag-driving-speed')).toHaveText('1.25×');
await playOut(page);
await expect(title(page)).toHaveText('Third'); // 'Silent' has no audio and is skipped
await expect.poll(() => playing(page)).toBe(true);
expect(await page.evaluate(() => document.getElementById('mag-playlist-audio').playbackRate)).toBe(1.25);
await expect(page.locator('#mag-driving-speed')).toHaveText('1.25×');
await expect(page.locator('#mag-driving-announce')).toHaveText('Now playing: Third');
expect(hits.audio).not.toContain('article-002');
expect(hits.progress.some((p) => p.article === 'article-001' && p.completed === true)).toBe(true); // finished is saved
await expect(page.locator('#mag-driving-endstate')).toBeHidden();
// the index highlights the new article
await page.locator('#mag-driving-indexbtn').click();
await expect(page.locator('#mag-driving-index-list [aria-current="true"] .ix-title')).toHaveText('Third');
// media session metadata follows
if (await page.evaluate(() => 'mediaSession' in navigator && !!navigator.mediaSession.metadata)) {
expect(await page.evaluate(() => navigator.mediaSession.metadata.title)).toBe('Third');
}
});
test('auto-advance: stops at the last article with an End of magazine state (no wrap-around)', async ({ page }) => {
const hits = await setupChain(page, { fourth: false });
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
await playOut(page);
await expect(title(page)).toHaveText('Third');
await expect.poll(() => playing(page)).toBe(true);
const before = hits.audio.length;
await playOut(page);
await expect(page.locator('#mag-driving-endstate')).toBeVisible();
await expect(page.locator('#mag-driving-endstate')).toContainText('End of magazine');
await expect(page.locator('#mag-driving-announce')).toHaveText('End of magazine');
await page.waitForTimeout(700);
await expect(title(page)).toHaveText('Third');
expect(await playing(page)).toBe(false);
expect(hits.audio.length).toBe(before + 0);
expect(hits.progress.some((p) => p.article === 'article-003' && p.completed === true)).toBe(true);
// choosing another article clears the state
await page.evaluate(() => window.magPlaylistPlay('article-001', { fromStart: true }));
await expect(page.locator('#mag-driving-endstate')).toBeHidden();
});
test('auto-advance: the switch turns it off (stops as before) and the choice persists', async ({ page }) => {
await setupChain(page);
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
await expect(page.locator('#mag-driving-autonext')).toHaveAttribute('aria-checked', 'true'); // default on
await expect(page.locator('#mag-driving-autonext .mag-menu-label')).toHaveText('Play next article automatically');
await openMenu(page);
await page.locator('#mag-driving-autonext').click();
await expect(page.locator('#mag-driving-autonext')).toHaveAttribute('aria-checked', 'false');
expect((await prefsOf(page)).autonext).toBe(false);
await page.keyboard.press('Escape');
await playOut(page);
await expect.poll(() => page.evaluate(() => document.getElementById('mag-playlist-audio').ended)).toBe(true);
await page.waitForTimeout(700);
await expect(title(page)).toHaveText('First');
expect(await playing(page)).toBe(false);
await page.reload();
await open(page);
await expect(page.locator('#mag-driving-autonext')).toHaveAttribute('aria-checked', 'false');
});
test('auto-advance: manual Next, a seek and duplicate ended events never advance twice', async ({ page }) => {
await setupChain(page);
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
// manual Next right before the natural end: exactly one step
await page.evaluate(() => { const a = document.getElementById('mag-playlist-audio'); a.currentTime = a.duration - 0.5; return a.play(); });
await page.evaluate(() => window.magDrivingNext());
await expect(title(page)).toHaveText('Third');
await page.waitForTimeout(900);
await expect(title(page)).toHaveText('Third');
// duplicate / stale ended events while the track is still playing are ignored
await page.evaluate(async () => { const a = document.getElementById('mag-playlist-audio'); a.currentTime = 0.3; await a.play(); a.dispatchEvent(new Event('ended')); a.dispatchEvent(new Event('ended')); });
await page.waitForTimeout(300);
await expect(title(page)).toHaveText('Third');
// a real end followed by duplicates: one advance only
await playOut(page);
await expect(title(page)).toHaveText('Fourth');
await page.evaluate(() => { const a = document.getElementById('mag-playlist-audio'); a.dispatchEvent(new Event('ended')); a.dispatchEvent(new Event('ended')); });
await page.waitForTimeout(400);
await expect(title(page)).toHaveText('Fourth');
});
test('auto-advance: a next article whose audio is missing is skipped with a brief message and playback never gets stuck', async ({ page }) => {
await setupChain(page, { fourthAudio: '404' });
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
await playOut(page);
await expect(title(page)).toHaveText('Third');
await expect.poll(() => playing(page)).toBe(true);
await playOut(page); // Fourth cannot be loaded: it is skipped, nothing follows
await expect(page.locator('#toast')).toContainText('Fourth');
await expect(page.locator('#toast')).toContainText('skipped');
await expect(page.locator('#mag-driving-endstate')).toBeVisible();
expect(await playing(page)).toBe(false);
});
const THREE = [ const THREE = [
{ id: 'article-001', title: 'The Future of Batteries', pages: [1], has_audio: true, audio_seconds: 12 }, { id: 'article-001', title: 'The Future of Batteries', pages: [1], has_audio: true, audio_seconds: 12 },
{ id: 'article-002', title: 'Second story with a very long headline that has to wrap over several lines on a small phone', pages: [3], has_audio: true, audio_seconds: 3725 }, { id: 'article-002', title: 'Second story with a very long headline that has to wrap over several lines on a small phone', pages: [3], has_audio: true, audio_seconds: 3725 },
@ -3103,7 +3482,7 @@ test.describe('Driving mode layout and views', () => {
}); });
// ── Driving mode colour theme ────────────────────────────────────────────────────────────────────── // ── Driving mode colour theme ──────────────────────────────────────────────────────────────────────
const themeItem = (page, t) => page.locator(`#mag-driving-menu [data-theme="${t}"]`); const themeItem = (page, t) => page.locator(`#mag-driving-menu [data-theme-choice="${t}"]`);
const chooseTheme = async (page, t) => { await openMenu(page); await themeItem(page, t).click(); }; const chooseTheme = async (page, t) => { await openMenu(page); await themeItem(page, t).click(); };
const dtheme = (page) => page.evaluate(() => document.getElementById('mag-driving').dataset.dtheme); const dtheme = (page) => page.evaluate(() => document.getElementById('mag-driving').dataset.dtheme);
const stored = (page) => page.evaluate(() => { try { return JSON.parse(localStorage.getItem('ttsvc_mag_driving_prefs') || '{}').theme; } catch (_) { return 'err'; } }); const stored = (page) => page.evaluate(() => { try { return JSON.parse(localStorage.getItem('ttsvc_mag_driving_prefs') || '{}').theme; } catch (_) { return 'err'; } });
@ -3304,7 +3683,7 @@ test.describe('Driving mode layout and views', () => {
await setup(page); await setup(page);
await page.setViewportSize({ width: 375, height: 667 }); await page.setViewportSize({ width: 375, height: 667 });
await open(page); await open(page);
await pick(page, 'lyrics'); await pick(page, 'text');
const current = () => page.evaluate(() => Number(document.querySelector('#mag-driving-full .mag-driving-fline.is-current')?.dataset.i ?? -1)); const current = () => page.evaluate(() => Number(document.querySelector('#mag-driving-full .mag-driving-fline.is-current')?.dataset.i ?? -1));
for (const t of [10.5, 2, 7.5, 3.6, 0.1]) { for (const t of [10.5, 2, 7.5, 3.6, 0.1]) {
await page.evaluate((x) => { document.getElementById('mag-playlist-audio').currentTime = x; }, t); await page.evaluate((x) => { document.getElementById('mag-playlist-audio').currentTime = x; }, t);
@ -3337,7 +3716,7 @@ test.describe('Driving mode layout and views', () => {
await setup(page); await setup(page);
await page.setViewportSize({ width: 375, height: 667 }); await page.setViewportSize({ width: 375, height: 667 });
await open(page); await open(page);
await pick(page, 'image'); await pick(page, 'picture');
const stage = page.locator('#mag-driving-stage'); const stage = page.locator('#mag-driving-stage');
await expect(stage).toBeVisible(); await expect(stage).toBeVisible();
await expect(stage.locator('img')).toHaveCount(1); await expect(stage.locator('img')).toHaveCount(1);
@ -3419,14 +3798,14 @@ test.describe('Driving mode: measured word times drive the word highlight', () =
test('word follows the measured times at several positions and seeks, in compact, lyrics and playlist views', async ({ page }) => { test('word follows the measured times at several positions and seeks, in compact, lyrics and playlist views', async ({ page }) => {
await openWords(page); await openWords(page);
await page.evaluate(() => window.magDrivingSetView('compact')); await page.evaluate(() => window.magDrivingSetView('split'));
for (const t of [0.3, 0.9, 1.35, 2.5, 3.8, 4.3, 5.0, 3.0, 8.4, 11.2, 0.8, 7.0]) { for (const t of [0.3, 0.9, 1.35, 2.5, 3.8, 4.3, 5.0, 3.0, 8.4, 11.2, 0.8, 7.0]) {
await seekPaused(page, t); await seekPaused(page, t);
const want = expectedWord(t); const want = expectedWord(t);
await expect.poll(async () => (await lit(page)).compact, { message: 'compact at ' + t }).toEqual(want); await expect.poll(async () => (await lit(page)).compact, { message: 'compact at ' + t }).toEqual(want);
await expect.poll(async () => (await lit(page)).list, { message: 'playlist at ' + t }).toEqual(want); await expect.poll(async () => (await lit(page)).list, { message: 'playlist at ' + t }).toEqual(want);
} }
await page.evaluate(() => window.magDrivingSetView('lyrics')); await page.evaluate(() => window.magDrivingSetView('text'));
for (const t of [1.0, 2.0, 4.0, 9.0, 6.0]) { for (const t of [1.0, 2.0, 4.0, 9.0, 6.0]) {
await seekPaused(page, t); await seekPaused(page, t);
await expect.poll(async () => (await lit(page)).lyrics, { message: 'lyrics at ' + t }).toEqual(expectedWord(t)); await expect.poll(async () => (await lit(page)).lyrics, { message: 'lyrics at ' + t }).toEqual(expectedWord(t));
@ -3459,7 +3838,7 @@ test.describe('Driving mode: measured word times drive the word highlight', () =
test('without measured words the highlight falls back to the interpolation and still moves', async ({ page }) => { test('without measured words the highlight falls back to the interpolation and still moves', async ({ page }) => {
await openWords(page, { withWords: false }); await openWords(page, { withWords: false });
await page.evaluate(() => window.magDrivingSetView('compact')); await page.evaluate(() => window.magDrivingSetView('split'));
await seekPaused(page, 1.0); await seekPaused(page, 1.0);
await expect.poll(async () => (await lit(page)).compact?.[1]).toBeGreaterThanOrEqual(0); await expect.poll(async () => (await lit(page)).compact?.[1]).toBeGreaterThanOrEqual(0);
await seekPaused(page, 2.9); await seekPaused(page, 2.9);