feat(magazine): night theme for driving mode (Auto/Light/Dark)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-03 09:15:28 +02:00
parent f120f3d4f4
commit 5a69481f65
6 changed files with 227 additions and 7 deletions

View File

@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
### Added
- **Night theme for driving mode.** A *Colors* group at the end of the display menu: Auto (follows the system, live; default), Light, Dark; remembered in this browser (storage errors are tolerated) and independent of the app's own theme. Dark uses true black, text at about 85% white, a muted blue accent, dimmed pictures (brightness .85), a themed top bar, menu, index with rows, lyric lines and current-line highlight, spoken-word marker, slider, dock buttons (still perfect circles), zoom buttons, *Tap to play*, toasts and share box, and sets the browser theme colour while driving mode is open (restored on exit). All text pairs are at least 4.5:1 and borders 3:1, including *no audio* rows; status icons differ by shape, not just colour. The overlay is painted dark from its first frame. Tested with Playwright (persistence, Auto vs emulated colour scheme, computed-style contrast ratios, first-frame background, theme-color restore).
- **Driving mode index shows what the playlist shows.** Each row has a 44 px round play / resume / replay button, the author ("by Name", smaller and muted, ellipsized), and the three-state listening indicator (not played / partly played / played) with accessible text, updated live as articles play and finish. Shares one state helper (`magListenState`) with the playlist.
- **Article index in driving mode.** A new *Index* button in the top bar opens a sheet between the top bar and the controls dock that lists every article of the magazine with its title and length (m:ss or h:mm:ss; articles without audio are dimmed, show *no audio* and cannot be chosen). The playing article is highlighted and scrolled into view; tap or press Enter to jump to an article while driving mode keeps running. Escape, the close button or a tap outside closes it and returns focus to the button; Tab stays inside the sheet. Lengths come from the existing articles endpoint (`audio_seconds`), so there is no new backend call; a 113-article list is a plain list rendered at once.
- **Share an article or a magazine as a link that opens straight in driving mode.** A *Share* button in the magazine toolbar (whole magazine), one in the playlist header and one in driving mode (current article) build `/#s-magazine?doc=<id>&article=<id>&mode=drive`. They use the browser's share sheet where there is one (it needs https, so on a plain-http LAN address they copy the link instead, with a polite aria-live toast, or show a box with the link if even copying is refused). Opening such a link, also on a first visit and also pasted into an already-open tab, opens the magazine and then driving mode for that article with audio, pictures and text; when the browser blocks autoplay a large *Tap to play* button appears. The link carries ids only (no key or password) and does not change who may reach the app; the ids are validated against a strict pattern and never put into markup, and anything malformed is ignored. Tested with Playwright (desktop deep link, blocked autoplay, hostile parameters, share sheet at 375 px, copy fallback).

View File

@ -289,7 +289,7 @@ turned into a podcast discussion between two or three voices you pick.
large touch target and keyboard reachable; the speed also applies to the
playlist player.
**Hands-free:** while a track plays, the lock screen, notification shade, Bluetooth headset and car display show the article title, magazine and first-page artwork and offer play/pause, previous/next article and ±15 s (and seeking, where the device offers it). In driving mode the screen is kept awake while audio plays and allowed to sleep when it is paused or you leave. Both are optional browser features; where one is missing or refused, playback itself is unaffected. Opening driving mode resumes the first unfinished article at the position you left it.
**Layout and views:** driving mode is a top bar, a content area and a controls dock. The dock (position slider, time and playback speed, then previous, back 15 s, play/pause, forward 15 s, next) is always completely visible at the bottom and is never covered or pushed off screen, on phones from 320x568, tablets and in landscape. The top bar has *Share*, a *menu* button that shows the current view, *Index* and *Exit*. The menu opens a flyout: choose **Picture** (the picture fills the area), **Lyrics** (the whole text) or **Compact** (picture plus three lines, the default; in landscape side by side), and switch *Show text*, *Show pictures* and *Minimize text and picture* on or off. Keyboard: Enter or Arrow Down opens the menu, arrows/Home/End move, Enter chooses, Escape or a tap outside closes it and returns focus to the button. **Index:** the *Index* button opens a list of all articles of the magazine with their length (articles without audio are dimmed and marked *no audio*). The article that is playing is highlighted; tap it or press Enter on another to jump there while driving mode keeps playing. Every row is its own item, separated from the next by a thin divider line and generous space above and below (the playing row is highlighted instead), so long two- or three-line titles do not run into each other. Escape or the close button closes the list (Tab stays inside it while open); the controls below stay visible and usable by touch. Lengths come from the already generated audio, nothing is recomputed. Each row also has a round **play button** (Play for unplayed, *Resume* with a circular-arrow icon for partly played, *Play again* with the replay icon for finished articles; it starts that article in driving mode and continues from the saved position), the author as *by Name* in smaller muted text under the title (omitted when unknown, long names are shortened), and a status icon on the right (empty circle: not played, dashed circle: partly played, check: finished) that also has the text *Not played / Partly played / Finished-Played* for screen readers and updates live while you listen. **Speed** is the button after the time next to the slider. **Picture zoom:** double-tap or double-click, pinch, mouse wheel, the + and - keys, or the zoom in / zoom out / reset buttons on the picture; drag (or arrow keys) to move a zoomed picture; Escape or the reset button returns to full view. **Lyrics:** the spoken sentence is large and kept in the middle, earlier and later ones are dimmed; tap or press Enter on a sentence to play from it; scrolling by hand stops the automatic following and shows *Back to current*. With reduced motion switched on in your device the text jumps instead of gliding. **Minimize** (in the menu) reduces the area to the title and the current line so the dock keeps minimal text; choose it again to expand. Your view and the minimized state are remembered in this browser only. Titles wrap to two lines (with an ellipsis beyond that), picture captions to two lines.
**Layout and views:** driving mode is a top bar, a content area and a controls dock. The dock (position slider, time and playback speed, then previous, back 15 s, play/pause, forward 15 s, next) is always completely visible at the bottom and is never covered or pushed off screen, on phones from 320x568, tablets and in landscape. The top bar has *Share*, a *menu* button that shows the current view, *Index* and *Exit*. The menu opens a flyout: choose **Picture** (the picture fills the area), **Lyrics** (the whole text) or **Compact** (picture plus three lines, the default; in landscape side by side), and switch *Show text*, *Show pictures* and *Minimize text and picture* on or off. Keyboard: Enter or Arrow Down opens the menu, arrows/Home/End move, Enter chooses, Escape or a tap outside closes it and returns focus to the button. **Index:** the *Index* button opens a list of all articles of the magazine with their length (articles without audio are dimmed and marked *no audio*). The article that is playing is highlighted; tap it or press Enter on another to jump there while driving mode keeps playing. Every row is its own item, separated from the next by a thin divider line and generous space above and below (the playing row is highlighted instead), so long two- or three-line titles do not run into each other. Escape or the close button closes the list (Tab stays inside it while open); the controls below stay visible and usable by touch. Lengths come from the already generated audio, nothing is recomputed. Each row also has a round **play button** (Play for unplayed, *Resume* with a circular-arrow icon for partly played, *Play again* with the replay icon for finished articles; it starts that article in driving mode and continues from the saved position), the author as *by Name* in smaller muted text under the title (omitted when unknown, long names are shortened), and a status icon on the right (empty circle: not played, dashed circle: partly played, check: finished) that also has the text *Not played / Partly played / Finished-Played* for screen readers and updates live while you listen. **Speed** is the button after the time next to the slider. **Picture zoom:** double-tap or double-click, pinch, mouse wheel, the + and - keys, or the zoom in / zoom out / reset buttons on the picture; drag (or arrow keys) to move a zoomed picture; Escape or the reset button returns to full view. **Lyrics:** the spoken sentence is large and kept in the middle, earlier and later ones are dimmed; tap or press Enter on a sentence to play from it; scrolling by hand stops the automatic following and shows *Back to current*. With reduced motion switched on in your device the text jumps instead of gliding. **Minimize** (in the menu) reduces the area to the title and the current line so the dock keeps minimal text; choose it again to expand. Your view and the minimized state are remembered in this browser only. Titles wrap to two lines (with an ellipsis beyond that), picture captions to two lines. **Colors (night driving):** at the end of the menu, *Colors* offers **Auto (follow system)** (default), **Light** and **Dark (night)**. Dark is a night palette for the whole driving overlay (top bar, menu, index and rows, text lines, slider, dock, zoom buttons, the *Tap to play* button, toasts and the share box): true black background, text at about 85% white, a muted blue accent, pictures slightly dimmed, and the browser's address bar colour follows while driving mode is open and is restored when you leave. Text keeps at least 4.5:1 contrast and buttons/borders 3:1, also for rows without audio (shown dimmed with a dashed play button); the listening states stay distinguishable by icon, not only by colour, and the focus ring and spoken-word highlight stay clearly visible. Auto switches live when your phone changes between day and night mode. The choice is independent of the rest of the app and remembered in this browser only (if storage is blocked it applies until you leave the page). Nothing glides or fades in extra for the theme, so reduced-motion settings are unaffected.
**Three lines of text:** driving mode shows the previous sentence (small), the current one (large, the spoken word marked) and the next (small) in a box of fixed height, so nothing moves around it. Tap or press Enter on the previous or next line to jump to that sentence. The text is deliberately not announced by screen readers sentence by sentence; the play/pause button and the position slider stay the way to control it. With reduced motion switched on in your device, lines change without animation. On a phone (375 px wide) the sentences wrap and a very long current sentence scrolls inside its box. Turn the text off with *Show text* in the menu to see only the picture.
**Text and picture follow the voice:** for audio generated since sentence times were recorded, the highlighted sentence (playlist and driving mode) and the picture shown come from the exact time each sentence was spoken, so they stay right after the slider, the 15-second buttons, a tap on a sentence, the lock screen or a resume. Captions are shown but are not in the MP3, so they are never highlighted. The highlight lights up a sentence just before its first word is heard (each sentence starts with a short silence, which is not counted). Audio from before sentence times were recorded, and audio whose text was edited afterwards, has no usable record and is followed by an estimate from sentence length (less exact); regenerate the audio to get exact sync. Interview-voice audio is covered like any other.
**Word by word:** the first time an article is played, the app asks the forced-aligner service (the separate `tts-aligner` container, see below) to measure where each word starts and ends and stores that beside the audio. Playback never waits for it: until it is ready (usually well under a minute for a magazine article) the marked word is estimated from word length as before, and it switches to the measured word on its own. The marked word is the last one that has started, so it stays lit through a pause; the *Lyrics* view now marks the spoken word too (as do *Compact* and the playlist text). If the service is not running, nothing changes for you and the app tries again later (at most every 10 minutes per article). Prerequisites: `docker compose up -d tts-aligner` (first start downloads about 1.2 GB of model weights into the `aligner-models` volume; after that it needs no internet). `ALIGNER_URL` empty turns word timing off. Privacy: text and audio go only to your own container on the internal Docker network. Accessibility: the marking is visual only and adds no announcements; with reduced motion nothing animates. API: `POST /api/magazine/{doc}/articles/{id}/align-words` starts it explicitly, `GET` shows progress.

View File

@ -5660,6 +5660,7 @@ function magBackToContents() {
magPause();
magStopQueue();
state.driving = false;
magDrivingApplyTheme();
magShow('contents');
magRefreshArticles();
}
@ -5671,6 +5672,7 @@ async function magBackToLibrary() {
magPause();
magStopQueue();
state.driving = false;
magDrivingApplyTheme();
magForgetLastDoc();
state.doc = null;
state.article = null;
@ -5687,6 +5689,7 @@ async function magBackToLibrary() {
function magToggleDriving() {
state.driving = !state.driving;
const overlay = $('mag-driving');
magDrivingApplyTheme(); // before the overlay is shown, so it never flashes the wrong palette
if (overlay) overlay.hidden = !state.driving;
if (!state.driving) { magDrivingMenuClose(false); magDrivingIndexClose(false); }
if (state.driving) { magDrivingViewsWire(); magDrivingZoomWire(); magDrivingApplyView(); renderDriving(); }
@ -5860,6 +5863,8 @@ function magDrivingTogglePlayback() {
// ── 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_VIEWS = ['image', 'lyrics', 'compact'];
const MAG_DRIVING_THEMES = ['auto', 'light', 'dark'];
const MAG_THEME_COLOR = { dark: '#000000', light: '#F5F3EE' };
const MAG_DRIVING_PREFS_KEY = 'ttsvc_mag_driving_prefs';
function magDrivingPrefs() {
@ -5871,11 +5876,42 @@ function magDrivingPrefs() {
text: saved.text !== false, images: saved.images !== false,
view: MAG_DRIVING_VIEWS.includes(saved.view) ? saved.view : 'compact',
collapsed: saved.collapsed === true,
theme: MAG_DRIVING_THEMES.includes(saved.theme) ? saved.theme : 'auto',
};
}
return state.drivingPrefs;
}
// Colour theme of driving mode only (the app has its own). Auto follows the system setting live.
function magDrivingResolvedTheme() {
const t = magDrivingPrefs().theme;
if (t === 'light' || t === 'dark') return t;
try { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } catch (_) { return 'light'; }
}
// Sets the palette on the overlay, the page-level hook for toast/share sheet and <meta name="theme-color">;
// when driving mode is closed it removes the hook and restores the original theme-color.
function magDrivingApplyTheme() {
const overlay = $('mag-driving'), theme = magDrivingResolvedTheme(), root = document.documentElement;
const meta = document.querySelector('meta[name="theme-color"]');
if (overlay) overlay.dataset.dtheme = theme;
document.querySelectorAll('#mag-driving-menu [role="menuitemradio"][data-theme]').forEach((b) => {
b.setAttribute('aria-checked', String(b.dataset.theme === magDrivingPrefs().theme));
});
if (state.driving) {
if (theme === 'dark') root.setAttribute('data-mag-night', ''); else root.removeAttribute('data-mag-night');
if (meta) { if (state.themeColorOrig == null) state.themeColorOrig = meta.getAttribute('content') || ''; meta.setAttribute('content', MAG_THEME_COLOR[theme]); }
} else {
root.removeAttribute('data-mag-night');
if (meta && state.themeColorOrig != null) { meta.setAttribute('content', state.themeColorOrig); state.themeColorOrig = null; }
}
}
function magDrivingSetTheme(theme) {
if (!MAG_DRIVING_THEMES.includes(theme)) return;
magDrivingPrefs().theme = theme;
magDrivingSavePrefs();
magDrivingApplyTheme();
}
function magDrivingSavePrefs() {
try { localStorage.setItem(MAG_DRIVING_PREFS_KEY, JSON.stringify(magDrivingPrefs())); } catch (_) {}
}
@ -6023,7 +6059,7 @@ function magDrivingApplyView() {
if (!overlay) return;
overlay.dataset.view = prefs.view;
overlay.dataset.collapsed = prefs.collapsed ? 'true' : 'false';
document.querySelectorAll('#mag-driving-menu [role="menuitemradio"]').forEach((b) => {
document.querySelectorAll('#mag-driving-menu [role="menuitemradio"][data-view]').forEach((b) => {
b.setAttribute('aria-checked', String(b.dataset.view === prefs.view));
});
const mb = $('mag-driving-menubtn'), meta = MAG_VIEW_META[prefs.view];
@ -6237,6 +6273,7 @@ function magDrivingViewsWire() {
});
menu.addEventListener('click', (e) => {
const it = e.target.closest('[role="menuitemradio"]');
if (it && it.dataset.theme) { magDrivingSetTheme(it.dataset.theme); magDrivingMenuClose(); return; }
if (it) { magDrivingSetView(it.dataset.view); magDrivingMenuClose(); return; }
if (e.target.closest('#mag-driving-collapse, #mag-driving-text-toggle, #mag-driving-image-toggle')) magDrivingMenuClose();
});
@ -6256,6 +6293,11 @@ function magDrivingViewsWire() {
if (panel && !panel.hidden && !e.target.closest('#mag-driving-index, #mag-driving-indexbtn, .mag-driving-dock')) magDrivingIndexClose(false);
});
window.addEventListener('resize', () => { magDrivingMenuPlace(); magDrivingIndexPlace(); });
try {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => { if (state.driving && magDrivingPrefs().theme === 'auto') magDrivingApplyTheme(); };
if (mq.addEventListener) mq.addEventListener('change', onChange); else if (mq.addListener) mq.addListener(onChange);
} catch (_) {}
}
// ── Article index: every article of the magazine with its length, as a sheet between top bar and dock ──────────

View File

@ -346,6 +346,12 @@
title="Show or hide the article's pictures"><span class="mdi mdi-image-outline" aria-hidden="true"></span><span>Show pictures</span></button>
<button type="button" role="menuitemcheckbox" aria-checked="false" tabindex="-1" id="mag-driving-collapse" aria-controls="mag-driving-content"
onclick="magDrivingToggleCollapsed()"><span class="mdi mdi-arrow-collapse-vertical" aria-hidden="true"></span><span>Minimize text and picture</span></button>
<hr role="separator">
<div role="group" aria-label="Colors">
<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>
<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="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>
</div>
</div>
</div>
<button type="button" class="mag-driving-indexbtn" id="mag-driving-indexbtn" aria-haspopup="dialog" aria-expanded="false" aria-controls="mag-driving-index"

View File

@ -5757,7 +5757,7 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; }
.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; }
.mag-driving-menu button .mdi { font-size: 22px; flex: none; }
.mag-driving-menu button[aria-checked="true"] { background: var(--accent); color: var(--on-accent, #fff); }
.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-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); }
@ -5770,7 +5770,7 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; }
.mag-driving-index-list .ix-item { display: flex; align-items: center; gap: 6px; border-radius: 10px; padding: 13px 4px; border-bottom: 1px solid var(--border); }
.mag-driving-index-list .ix-item:last-child { border-bottom-color: transparent; }
.mag-driving-index-list .ix-item:has(.ix-row[aria-current="true"]) { border-bottom-color: transparent; }
.mag-driving-index-list .ix-item:has(.ix-row[aria-current="true"]) { background: var(--accent); color: var(--on-accent, #fff); }
.mag-driving-index-list .ix-item:has(.ix-row[aria-current="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-index-list button { min-height: 44px; border: 0; background: transparent; color: var(--text); font-size: 15px; cursor: pointer; }
.mag-driving-index-list .ix-row { flex: 1 1 auto; min-width: 0; min-height: 44px; padding: 0 8px 0 0; display: flex; align-items: center; gap: 8px; text-align: left; border-radius: 10px; }
.mag-driving-index-list .ix-play { flex: none; width: 44px; height: 44px; min-height: 44px; padding: 5px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
@ -5778,7 +5778,7 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; }
.mag-driving-index-list .ix-play .mdi { display: inline-flex; width: 34px; height: 34px; align-items: center; justify-content: center; border-radius: 50%; }
.mag-driving-index-list .ix-item:has(.ix-row[aria-current="true"]) .ix-play { background: var(--surface); color: var(--accent); border-color: var(--border); }
.mag-driving-index-list button:disabled .mdi { opacity: 1; }
.mag-driving-index-list .ix-row[aria-current="true"] { color: var(--on-accent, #fff); font-weight: 700; }
.mag-driving-index-list .ix-row[aria-current="true"] { color: var(--cur-fg, var(--on-accent, #fff)); font-weight: 700; }
.mag-driving-index-list button:disabled { opacity: .5; cursor: default; }
.mag-driving-index-list .mdi-num { flex: none; min-width: 2em; text-align: right; font-variant-numeric: tabular-nums; opacity: .7; }
.mag-driving-index-list .ix-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
@ -5895,3 +5895,37 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; }
.mag-driving-pz { transition: none; }
.mag-driving-backcur { transition: none; }
}
/* ── Driving mode colour theme (Auto / Light / Dark) ───────────────────────────────────────────────────────
Independent of the app theme: the overlay, the toast and the share sheet get their own palette while driving
mode is open. JS sets data-dtheme="dark|light" on #mag-driving (resolved from the setting and, for Auto, from
prefers-color-scheme) and data-mag-night on <html> while the dark palette is on, before the overlay is shown.
Dark is a night-drive palette: true black (OLED), text ~85% white, a muted blue accent, every text pair >= 4.5:1
and every UI boundary >= 3:1 (checked in tests/magazine.spec.js). */
.mag-driving[data-dtheme="light"] {
--bg: #F5F3EE; --surface: #FFFFFF; --panel: #F3F4F6; --border: #E5E7EB; --text: #111827; --subtext: #5C6470; --dim: #6B7280;
--accent: #2563EB; --green: #16A34A; --red: #DC2626; --yellow: #D97706; --on-accent: #FFFFFF;
--cur-bg: var(--accent); --cur-fg: var(--on-accent); --cur-bar: transparent; --img-filter: none; color-scheme: light; }
.mag-driving[data-dtheme="dark"], html[data-mag-night] #toast, html[data-mag-night] .mag-share-sheet {
--bg: #000000; --surface: #0D1015; --panel: #1A1F27; --border: #59616E; --text: #DADFE6; --subtext: #A0A9B6; --dim: #8D96A3;
--accent: #6F9BDA; --green: #7CC49A; --red: #E88E8E; --yellow: #D8A94A; --on-accent: #0A0F18;
--cur-bg: #16263F; --cur-fg: #DADFE6; --cur-bar: #6F9BDA; --img-filter: brightness(.85); color-scheme: dark; }
@media (prefers-color-scheme: dark) { .mag-driving:not([data-dtheme]) { background: #000; } }
.mag-driving[data-dtheme] { background: var(--bg); color: var(--text); }
html[data-mag-night] #toast { background: var(--surface); color: var(--text); box-shadow: 0 20px 70px rgba(0,0,0,.8); }
html[data-mag-night] #toast.success { color: var(--green); } html[data-mag-night] #toast.error { color: var(--red); } html[data-mag-night] #toast.info { color: var(--accent); }
.mag-driving[data-dtheme="dark"] img { filter: var(--img-filter); }
.mag-driving[data-dtheme="dark"] .mag-driving-menu, .mag-driving[data-dtheme="dark"] .mag-driving-backcur { box-shadow: 0 8px 24px rgba(0,0,0,.8); }
/* dimmed text on black: no opacity tricks (they would drop below 4.5:1), the hierarchy is size, weight and brightness */
.mag-driving[data-dtheme="dark"] .mag-driving-lyrics .mag-driving-line, .mag-driving[data-dtheme="dark"] .mag-driving-line { opacity: 1; color: var(--dim); }
.mag-driving[data-dtheme="dark"] .mag-driving-lyrics .mag-driving-line.is-current, .mag-driving[data-dtheme="dark"] .mag-driving-line.is-current { color: var(--text); }
.mag-driving[data-dtheme="dark"] .mag-driving-fline, .mag-driving[data-dtheme="dark"] .mag-driving-fline.is-past { opacity: 1; color: var(--dim); }
.mag-driving[data-dtheme="dark"] .mag-driving-fline.is-current { color: var(--text); }
.mag-driving[data-dtheme="dark"] .mag-driving-index-list .mdi-num, .mag-driving[data-dtheme="dark"] .mag-driving-index-list .ix-author { opacity: 1; color: var(--subtext); }
.mag-driving[data-dtheme="dark"] .mag-driving-index-list .ix-item:has(.ix-row[aria-current="true"]) :is(.mdi-num, .ix-author) { color: var(--cur-fg); }
.mag-driving[data-dtheme="dark"] .mag-driving-index-list button:disabled { opacity: 1; color: var(--dim); }
.mag-driving[data-dtheme="dark"] .mag-driving-index-list .ix-row:disabled :is(.mdi-num, .ix-author, .ix-dur) { color: inherit; }
.mag-driving[data-dtheme="dark"] .mag-driving-index-list .ix-play:disabled { color: var(--dim); border-style: dashed; }
.mag-driving[data-dtheme="dark"] .mag-driving-zoomctl button:disabled { opacity: 1; color: var(--dim); }
.mag-driving[data-dtheme="dark"] .mag-driving-zoomctl button { background: var(--surface); }
.mag-driving[data-dtheme="dark"] .mag-driving-seek input[type="range"] { accent-color: var(--accent); }

View File

@ -2897,7 +2897,7 @@ test.describe('Driving mode layout and views', () => {
await expect(menu).toBeVisible();
await expect(btn).toHaveAttribute('aria-expanded', 'true');
await expect(menu).toHaveAttribute('role', 'menu');
expect(await page.locator('#mag-driving-menu [role="menuitemradio"]').count()).toBe(3);
expect(await page.locator('#mag-driving-menu [role="menuitemradio"][data-view]').count()).toBe(3);
for (const id of ['#mag-driving-text-toggle', '#mag-driving-image-toggle', '#mag-driving-collapse'])
await expect(page.locator(id)).toHaveAttribute('role', 'menuitemcheckbox');
await expect(page.locator('#mag-driving-dock .x, .mag-driving-dock #mag-driving-text-toggle, .mag-driving-dock #mag-driving-image-toggle')).toHaveCount(0);
@ -2929,7 +2929,7 @@ test.describe('Driving mode layout and views', () => {
await page.keyboard.press('ArrowUp'); await page.keyboard.press('ArrowUp');
await expect(page.locator('#mag-driving-menu :focus')).toHaveAttribute('data-view', 'image');
await page.keyboard.press('End');
await expect(page.locator('#mag-driving-menu :focus')).toHaveAttribute('id', 'mag-driving-collapse');
await expect(page.locator('#mag-driving-menu :focus')).toHaveAttribute('data-theme', 'dark');
await page.keyboard.press('Home'); await page.keyboard.press('Enter');
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'image');
});
@ -3102,6 +3102,143 @@ test.describe('Driving mode layout and views', () => {
expect(g.find((it) => it.current).bc).toBe('rgba(0, 0, 0, 0)');
});
// ── Driving mode colour theme ──────────────────────────────────────────────────────────────────────
const themeItem = (page, t) => page.locator(`#mag-driving-menu [data-theme="${t}"]`);
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 stored = (page) => page.evaluate(() => { try { return JSON.parse(localStorage.getItem('ttsvc_mag_driving_prefs') || '{}').theme; } catch (_) { return 'err'; } });
test('theme: Auto (default) follows the emulated system colour scheme live; Light and Dark override it', async ({ page }) => {
await setupIndex(page);
await page.emulateMedia({ colorScheme: 'dark' });
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
expect(await dtheme(page)).toBe('dark');
await openMenu(page);
await expect(themeItem(page, 'auto')).toHaveAttribute('aria-checked', 'true');
await expect(themeItem(page, 'dark')).toHaveAttribute('aria-checked', 'false');
await page.keyboard.press('Escape');
await page.emulateMedia({ colorScheme: 'light' });
await expect.poll(() => dtheme(page)).toBe('light');
await page.emulateMedia({ colorScheme: 'dark' });
await expect.poll(() => dtheme(page)).toBe('dark');
await chooseTheme(page, 'light'); // explicit Light wins over a dark system
expect(await dtheme(page)).toBe('light');
expect(await page.evaluate(() => getComputedStyle(document.getElementById('mag-driving')).backgroundColor)).toBe('rgb(245, 243, 238)');
await page.emulateMedia({ colorScheme: 'light' });
await chooseTheme(page, 'dark'); // explicit Dark wins over a light system
expect(await dtheme(page)).toBe('dark');
await expect(page.locator('html')).toHaveAttribute('data-mag-night', '');
await chooseTheme(page, 'auto');
expect(await dtheme(page)).toBe('light');
await expect(page.locator('html')).not.toHaveAttribute('data-mag-night', '');
});
test('theme: the choice persists across a reload (localStorage) and survives unavailable storage', async ({ page }) => {
await setupIndex(page);
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
expect(await dtheme(page)).toBe('light');
await chooseTheme(page, 'dark');
expect(await stored(page)).toBe('dark');
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await open(page);
expect(await dtheme(page)).toBe('dark');
await openMenu(page);
await expect(themeItem(page, 'dark')).toHaveAttribute('aria-checked', 'true');
await expect(themeItem(page, 'auto')).toHaveAttribute('aria-checked', 'false');
// keyboard reachable: End goes to the last radio, Enter chooses it
await page.keyboard.press('Escape');
await page.locator('#mag-driving-menubtn').focus(); await page.keyboard.press('ArrowDown');
await page.keyboard.press('End'); await page.keyboard.press('ArrowUp'); await page.keyboard.press('Enter');
expect(await dtheme(page)).toBe('light');
// storage that throws must not break the setting for the session
await page.evaluate(() => { Storage.prototype.setItem = () => { throw new Error('blocked'); }; });
await chooseTheme(page, 'dark');
expect(await dtheme(page)).toBe('dark');
});
test('theme: dark overlay is dark at the first frame, theme-color follows and is restored, images are dimmed', async ({ page }) => {
await setupIndex(page);
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
await chooseTheme(page, 'dark');
await page.evaluate(() => window.magToggleDriving()); // close
await expect(page.locator('#mag-driving')).toBeHidden();
const r = await page.evaluate(() => {
const meta = () => document.querySelector('meta[name="theme-color"]').getAttribute('content');
const before = meta();
window.magToggleDriving(); // open, measured in the same task (no frame in between)
const o = document.getElementById('mag-driving');
const sync = { bg: getComputedStyle(o).backgroundColor, theme: meta(), visible: !o.hidden };
const img = document.createElement('img'); document.getElementById('mag-driving-pz').appendChild(img);
sync.filter = getComputedStyle(img).filter; img.remove();
window.magToggleDriving(); // close again
return { before, sync, after: meta(), night: document.documentElement.hasAttribute('data-mag-night') };
});
expect(r.sync.visible).toBe(true);
expect(r.sync.bg).toBe('rgb(0, 0, 0)');
expect(r.sync.theme.toLowerCase()).toBe('#000000');
expect(r.sync.filter).toBe('brightness(0.85)');
expect(r.after).toBe(r.before);
expect(r.night).toBe(false);
});
test('theme: dark text, icon and UI colours meet WCAG AA (4.5:1 text, 3:1 UI) incl. disabled rows and the word highlight', async ({ page }) => {
await setupIndex(page, 0, { authors: { 'article-001': 'Ada Author', 'article-002': 'Ben Writer' },
progress: { 'article-001': { seconds: 5, duration: 12, completed: false }, 'article-002': { seconds: 3725, duration: 3725, completed: true } } });
await page.emulateMedia({ reducedMotion: 'reduce' }); // no fade-in animation while measuring
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
await chooseTheme(page, 'dark');
await page.evaluate(() => { document.getElementById('mag-playlist-audio').currentTime = 4; });
await expect(page.locator('#mag-driving-lyrics .mag-driving-line.is-current')).toBeVisible();
await expect(page.locator('#mag-driving-lyrics .mag-dw.is-current')).toHaveCount(1);
await page.locator('#mag-driving-indexbtn').click();
await expect(page.locator('#mag-driving-index-list .ix-row').first()).toBeVisible();
const res = await page.evaluate(() => {
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 = (top, under) => { const a = top[3]; return [0, 1, 2].map((i) => top[i] * a + under[i] * (1 - a)).concat([1]); };
const bgOf = (el) => { const stack = []; for (let e = el; e; e = e.parentElement) { stack.push(parse(getComputedStyle(e).backgroundColor)); }
let c = [0, 0, 0, 1]; for (let i = stack.length - 1; i >= 0; i--) c = over(stack[i], c); return c; };
const opac = (el) => { let o = 1; for (let e = el; e; e = e.parentElement) o *= Number(getComputedStyle(e).opacity); return o; };
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.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
const textRatio = (el) => { const bg = bgOf(el), fgc = parse(getComputedStyle(el).color), o = opac(el);
const fg = over([fgc[0], fgc[1], fgc[2], fgc[3] * o], bg); return Math.round(ratio(fg, bg) * 100) / 100; };
const q = (s) => document.querySelector(s), items = [...document.querySelectorAll('#mag-driving-index-list .ix-item')];
const cur = items.find((li) => li.querySelector('[aria-current="true"]')), plain = items.find((li) => li !== cur && !li.querySelector('.ix-row:disabled'));
const dis = items.find((li) => li.querySelector('.ix-row:disabled'));
const border = (el) => { const bg = bgOf(el), b = parse(getComputedStyle(el).borderBottomColor); return Math.round(ratio(over(b, bg), bg) * 100) / 100; };
// the word highlight: a marked word on the current line
const w = document.createElement('span'); w.className = 'mag-fw is-current'; w.textContent = 'word'; q('#mag-driving-lyrics .mag-driving-line.is-current').appendChild(w);
const out = {
title: textRatio(q('#mag-driving-title')), by: textRatio(q('#mag-driving-by')) ,
rowTitle: textRatio(plain.querySelector('.ix-title')), rowAuthor: textRatio(plain.querySelector('.ix-author')), rowNum: textRatio(plain.querySelector('.mdi-num')),
rowDur: textRatio(plain.querySelector('.ix-dur')), rowPlayIcon: textRatio(plain.querySelector('.ix-play')),
curTitle: textRatio(cur.querySelector('.ix-title')), curAuthor: textRatio(cur.querySelector('.ix-author')), curDur: textRatio(cur.querySelector('.ix-dur')),
disTitle: textRatio(dis.querySelector('.ix-title')), disDur: textRatio(dis.querySelector('.ix-dur')), disPlay: textRatio(dis.querySelector('.ix-play')),
statusPlain: textRatio(plain.querySelector('.ix-status')),
currentLine: textRatio(q('#mag-driving-lyrics .mag-driving-line.is-current')), prevNext: textRatio(q('#mag-driving-lyrics .is-prev, #mag-driving-lyrics .is-next')), liveWord: textRatio(q('#mag-driving-lyrics .mag-dw.is-current')),
dockIcon: textRatio(q('.mag-driving-btn')), playIcon: textRatio(q('.mag-driving-play')), speed: textRatio(q('#mag-driving-speed')), time: textRatio(q('#mag-driving-time')),
word: textRatio(w), menuBtn: textRatio(q('#mag-driving-menubtn')), indexBtn: textRatio(q('#mag-driving-indexbtn')), exit: textRatio(q('.mag-driving-exit')),
divider: border(plain), dockEdge: border(q('.mag-driving-dock')) };
w.remove();
// three-state indicators stay distinguishable without colour: different icons
out.icons = items.slice(0, 3).map((li) => li.querySelector('.ix-status .mdi').className);
return out;
});
const { icons, divider, dockEdge, ...text } = res;
for (const [k, v] of Object.entries(text)) expect(v, k + ' = ' + v).toBeGreaterThanOrEqual(4.5);
expect(divider).toBeGreaterThanOrEqual(3); expect(dockEdge).toBeGreaterThanOrEqual(3);
expect(res.playIcon).toBeGreaterThanOrEqual(4.5);
expect(new Set(icons).size).toBe(3);
// the dock transport buttons stay perfect circles in the dark theme
const circles = await page.evaluate(() => [...document.querySelectorAll('.mag-driving-btn')].map((b) => { const r = b.getBoundingClientRect(); return [r.width, r.height, getComputedStyle(b).borderRadius]; }));
for (const [w, h, br] of circles) { expect(Math.abs(w - h)).toBeLessThan(0.5); expect(br).toBe('50%'); }
});
test('index with 113 articles renders quickly and scrolls the current one into view', async ({ page }) => {
await setupIndex(page, 113);
await page.setViewportSize({ width: 375, height: 667 });