From 572fae892d6e09d9f0fe5e42d50a9a3bc0230444 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sat, 3 Oct 2026 14:46:31 +0200 Subject: [PATCH] fix(magazine): fit driving-mode text inside the frame and offer less text Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 3 + Manual.md | 3 +- static/js/magazine.js | 110 ++++++++++++-- static/sections/s-magazine.html | 11 +- static/style.css | 25 +++- tests/magazine.spec.js | 245 +++++++++++++++++++++++++++++++- 6 files changed, 371 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84fcb93..1f94187 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,10 +16,13 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi - **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. +- **Driving mode: choose how much text is shown.** New menu section *Text in Split view*: **Sentence** (default; only the current sentence, large), **Context** (previous, current, next: the former three lines) or **Paragraph** (the whole paragraph, current sentence bold and underlined, spoken word marked). Stored in the driving preferences (invalid values fall back to Sentence); the Text view still shows the whole text. Tests: `tests/magazine.spec.js` ("Driving mode text fit and text amount"). + ### Changed - Prompt wording that pulled every face and head toward one look was removed: "consistent facial construction" (concept style) became "individually distinct faces"; the hair locks no longer force "reaching at least to the ears or collar" / "past the shoulders" when the face clause states the hair; the dwarf body lock no longer says "a thick, full beard" (the beard now comes from the face clause, men only, so dwarf women get none); the Subject limit in the compact render prompts is 900 instead of 700 characters to make room for the clause. - `vision_ocr_page` routes its request through the cache layer; the default `live` mode behaves exactly as before. ### Fixed +- **Driving mode text ran out of its frame.** A very long sentence (300+ characters) in the Split view was cut off at the bottom. The text frame now takes the space between picture and controls (flex, `min-height: 0`), the current text fits automatically (1.5 rem down to 1 rem, in rem so text scaling and a 200% root font count), the picture shrinks first, and text that still does not fit scrolls inside the frame and follows the spoken word instead of being clipped. Verified at 320x568, 375x667, 414x896 and 667x375 (also with a 200% root font) in all three amounts. - **"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`. - **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`. diff --git a/Manual.md b/Manual.md index 2992716..f95e0fd 100644 --- a/Manual.md +++ b/Manual.md @@ -389,7 +389,8 @@ turned into a podcast discussion between two or three voices you pick. 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 in four groups. **View** (radio options with icon and name): **Picture** (the whole picture, no text), **Text** (the whole text, lyrics-style, no picture) and **Split** (picture plus three lines, the default; in landscape side by side). **Show** (switches, label left and switch right; on = thumb right with a tick, off = thumb left with a cross): *Show text* and *Show pictures* hide just that part in the views that contain it (so with Split you can drop either), and *Minimize text and picture*. **Playback:** *Play next article automatically* (see below). **Theme:** one control with *Light*, *Auto* and *Dark*; the chosen one carries a tick. Each control is at least 44 px high and the flyout fits phones from 320 px wide. Keyboard: Enter or Arrow Down opens the menu; Up/Down/Left/Right move over every control, Home/End jump to the first/last, Space or Enter switches or selects, Escape or a tap outside closes it and returns focus to the menu button. Choosing a view closes the flyout; switches and the theme keep it open so you see the effect. **Automatic next article:** when an article ends it is marked finished and the next article that has audio starts (articles without audio are skipped, the speed stays); title, picture, text and the index follow and a screen reader hears "Now playing: ..." once. After the last article it stops and shows *End of magazine* (it never starts over). Switch *Play next article automatically* off to stop after each article. An article whose audio cannot be loaded is skipped with a short message. Older saved views are converted (Lyrics to Text, Compact to Split). **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. **Theme (night driving):** the *Theme* control at the end of the menu offers **Auto** (follows the 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. **Lyrics** in the older wording is now the **Text** view. + **How much text (Split view):** the menu section *Text in Split view* sets the amount, remembered on this device. **Sentence** (default) shows only the sentence being read, large; **Context** shows the previous sentence (small), the current one (large) and the next (small); **Paragraph** shows the whole paragraph the sentence belongs to with the current sentence bold and underlined and the spoken word marked. The text frame always stays between the picture and the controls: the current text shrinks from 1.5 rem down to 1 rem (it follows your device's text size and browser zoom) until it fits, the picture gives way before the text is squeezed, and whatever still does not fit scrolls inside its frame while following the spoken word (scroll by hand to look around; it follows again at the next sentence). In landscape the picture and the text sit side by side. The *Text* view always shows the whole text. Tap or press Enter on a dimmed line (Context, Paragraph) to jump to it; the text is not announced sentence by sentence. + **Context lines:** with *Context*, 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. **Lyrics** in the older wording is now the **Text** view. **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. **Share a link:** the *Share* button in the toolbar (the whole magazine), the share button in the playlist header and the one at the top left of driving mode (the current article) share a link of the form `/#s-magazine?doc=&article=
&mode=drive`. Where the browser has a share sheet (phones, and any browser on https) it opens; otherwise the link is copied and a confirmation is announced (on a plain-http address a box with the link is shown to copy by hand). Opening the link goes straight to that article in driving mode with audio, pictures and text; if the browser refuses to start audio without a tap, a large *Tap to play* button appears. A link for the whole magazine opens at its first unfinished article. **Privacy and access:** the link contains only ids, no password or key. It is just an address: whoever opens it needs to reach this app the same way as for any other page (this app has no user accounts; access is controlled by your network or reverse proxy), and an unknown or removed article simply shows a message. Only articles that already have audio can be shared. A link whose parameters are not in the expected form is ignored. diff --git a/static/js/magazine.js b/static/js/magazine.js index d7b39ed..6649770 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -4558,7 +4558,7 @@ async function magAcrResolve() { // in the reflowed view can drive the page shown in the PDF view. function splitSentences(blocks) { const out = []; - blocks.forEach((b) => { + blocks.forEach((b, blockIndex) => { const parts = String(b.text || '') .split(/(?<=[.!?…])\s+(?=[A-ZÀ-Ü»„"'‘])/) .map((s) => s.trim()).filter(Boolean); @@ -4595,7 +4595,7 @@ function splitSentences(blocks) { }); } out.push({ - seams, + seams, para: blockIndex, text: text.trim(), blockId: origin.id, page: origin.page, bbox: origin.bbox, type: b.type, spokenText: spokenParts[partIndex] || text.trim(), // Used to pick "the image reached so far" during playback (see @@ -5911,6 +5911,8 @@ 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']; +// How much text the Split view shows at once: just the sentence being read (default), it with its neighbours, or its whole paragraph. +const MAG_DRIVING_AMOUNTS = ['sentence', 'context', 'paragraph']; const MAG_THEME_COLOR = { dark: '#000000', light: '#F5F3EE' }; const MAG_DRIVING_PREFS_KEY = 'ttsvc_mag_driving_prefs'; @@ -5925,6 +5927,7 @@ function magDrivingPrefs() { collapsed: saved.collapsed === true, autonext: saved.autonext !== false, theme: MAG_DRIVING_THEMES.includes(saved.theme) ? saved.theme : 'auto', + amount: MAG_DRIVING_AMOUNTS.includes(saved.amount) ? saved.amount : 'sentence', }; } return state.drivingPrefs; @@ -6065,29 +6068,44 @@ function magDrivingSync(audio, duration, pct, sentenceIndex, local, timeline) { } const slots = (timeline && timeline.slots) || []; const p = timeline && timeline.pos ? timeline.pos[sentenceIndex] : undefined; - const prev = p > 0 ? slots[p - 1] : -1, next = p >= 0 && p < slots.length - 1 ? slots[p + 1] : -1; - const windowKey = prev + ',' + sentenceIndex + ',' + next; + const amount = prefs.amount; + const spoken = (i) => !timeline || !timeline.pos || timeline.pos[i] !== undefined; + // [index, class, label] for every line to show: only the current one, it with its neighbours, or its whole paragraph. + let win; + if (amount === 'paragraph') { + const para = sentences[sentenceIndex] ? sentences[sentenceIndex].para : undefined; + win = []; + sentences.forEach((sent, i) => { if (sent.para === para && (i === sentenceIndex || spoken(i))) win.push([i, i < sentenceIndex ? 'is-prev' : i > sentenceIndex ? 'is-next' : 'is-current']); }); + } else { + const prev = amount === 'context' && p > 0 ? slots[p - 1] : -1, next = amount === 'context' && p >= 0 && p < slots.length - 1 ? slots[p + 1] : -1; + win = [[prev, 'is-prev'], [sentenceIndex, 'is-current'], [next, 'is-next']].filter((w) => w[0] >= 0); + } + const windowKey = amount + ':' + win.map((w) => w[0]).join(',') + '@' + sentenceIndex; + lyrics.dataset.amount = amount; if (lyrics.dataset.window !== windowKey) { - (lyrics.dataset.window || '').split(',').forEach((i) => { - const el = lyrics.querySelector('.mag-driving-line[data-i="' + i + '"]'); - if (el) { el.hidden = true; el.classList.remove('is-prev', 'is-current', 'is-next'); el.removeAttribute('tabindex'); el.removeAttribute('role'); el.removeAttribute('aria-label'); } + lyrics.querySelectorAll('.mag-driving-line:not([hidden])').forEach((el) => { + el.hidden = true; el.classList.remove('is-prev', 'is-current', 'is-next'); el.removeAttribute('tabindex'); el.removeAttribute('role'); el.removeAttribute('aria-label'); }); lyrics.dataset.window = windowKey; - [[prev, 'is-prev', 'Jump to the previous sentence'], [sentenceIndex, 'is-current', ''], [next, 'is-next', 'Jump to the next sentence']].forEach(([i, cls, label]) => { - const el = i >= 0 ? lyrics.querySelector('.mag-driving-line[data-i="' + i + '"]') : null; + lyrics.dataset.manual = ''; + win.forEach(([i, cls]) => { + const el = lyrics.querySelector('.mag-driving-line[data-i="' + i + '"]'); if (!el) return; el.hidden = false; el.classList.add(cls); - if (label) { el.setAttribute('tabindex', '0'); el.setAttribute('role', 'button'); el.setAttribute('aria-label', label + ': ' + (sentences[i].text || '')); } + if (cls !== 'is-current') { + el.setAttribute('tabindex', '0'); el.setAttribute('role', 'button'); + el.setAttribute('aria-label', (cls === 'is-prev' ? 'Jump to the previous sentence' : 'Jump to the next sentence') + ': ' + (sentences[i].text || '')); + } }); - // A very long sentence can outgrow the box: keep its start in view (the box itself scrolls, not the page). - const cur = lyrics.querySelector('.mag-driving-line.is-current'); - if (cur && lyrics.scrollHeight > lyrics.clientHeight) lyrics.scrollTo({ top: cur.offsetTop - 8, behavior: prefersReducedMotion() ? 'auto' : 'smooth' }); + magDrivingFitText(); + magDrivingFollowText(); } const cur = lyrics.querySelector('.mag-driving-line.is-current'); lyrics.querySelectorAll('.mag-dw.is-current').forEach((w) => w.classList.remove('is-current')); const words = cur ? cur.querySelectorAll('.mag-dw') : []; if (words[wi]) words[wi].classList.add('is-current'); + if (lyrics.dataset.wi !== String(wi)) { lyrics.dataset.wi = String(wi); magDrivingFollowText(); } } } const visual = $('mag-driving-visual'); @@ -6109,6 +6127,67 @@ function magDrivingSync(audio, duration, pct, sentenceIndex, local, timeline) { magDrivingSyncFull(sentences, sentenceIndex, wi); } +// Fit: the current line is as large as the frame allows (1.5rem down to 1rem, in rem so the user's text size counts). +// What still does not fit scrolls inside the frame, and the frame follows the word being spoken, so nothing is cut off silently. +function magDrivingFitText() { + const box = $('mag-driving-lyrics'); + if (!box || box.hidden || !box.clientHeight) return; + const max = magDrivingPrefs().amount === 'paragraph' ? 1.25 : 1.5, min = 1; + const set = (v) => box.style.setProperty('--drv-fs', v + 'rem'); + const fits = () => box.scrollHeight <= box.clientHeight + 1; + set(max); + if (fits()) return; + set(min); + if (!fits()) return; + let lo = min, hi = max; + for (let k = 0; k < 6; k++) { const mid = (lo + hi) / 2; set(mid); if (fits()) lo = mid; else hi = mid; } + set(lo); +} +// Keeps the current sentence (on a new sentence) or the spoken word (as it moves) inside the scrolling frame. +function magDrivingFollowText() { + const box = $('mag-driving-lyrics'); + if (!box || box.hidden || !box.clientHeight) return; + const cur = box.querySelector('.mag-driving-line.is-current'); + const fresh = box.dataset.followed !== box.dataset.window; // a new sentence/paragraph window: start at its beginning + box.dataset.followed = box.dataset.window || ''; + if (box.scrollHeight <= box.clientHeight + 1) { box.scrollTop = 0; return; } + if (fresh && cur) { + const para = box.dataset.amount === 'paragraph'; + box.scrollTop = Math.max(0, cur.offsetTop - (para ? box.clientHeight * 0.25 : 8)); + } else if (box.dataset.manual) return; // the listener scrolled away on purpose until the next sentence + const w = box.querySelector('.mag-dw.is-current'); + if (!w) return; + const b = box.getBoundingClientRect(), r = w.getBoundingClientRect(), pad = Math.min(24, box.clientHeight / 4); + if (r.top < b.top + pad || r.bottom > b.bottom - pad) { + box.scrollTo({ top: Math.max(0, box.scrollTop + (r.top - b.top) - box.clientHeight / 2 + r.height / 2), behavior: prefersReducedMotion() ? 'auto' : 'smooth' }); + } +} +function magDrivingTextWire() { + const box = $('mag-driving-lyrics'); + if (!box || box.dataset.fitWired) return; + box.dataset.fitWired = '1'; + ['wheel', 'touchmove'].forEach((ev) => box.addEventListener(ev, () => { box.dataset.manual = '1'; }, { passive: true })); + if (typeof ResizeObserver === 'function') { + let queued = false; + new ResizeObserver(() => { + if (queued) return; queued = true; + requestAnimationFrame(() => { queued = false; magDrivingFitText(); magDrivingFollowText(); }); + }).observe(box); + } +} +function magDrivingSetAmount(amount) { + if (!MAG_DRIVING_AMOUNTS.includes(amount)) return; + magDrivingPrefs().amount = amount; + magDrivingSavePrefs(); + magDrivingApplyAmount(); + magPlaylistSync(); +} +function magDrivingApplyAmount() { + const amount = magDrivingPrefs().amount, overlay = $('mag-driving'); + if (overlay) overlay.dataset.amount = amount; + document.querySelectorAll('#mag-driving-menu [data-amount]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.amount === amount))); +} + // ── Views, whole-text "lyrics", picture zoom ────────────────────────────────────────────────────── function magDrivingApplyView() { const prefs = magDrivingPrefs(); @@ -6127,6 +6206,8 @@ function magDrivingApplyView() { } const c = $('mag-driving-collapse'); if (c) c.setAttribute('aria-checked', String(!!prefs.collapsed)); + magDrivingApplyAmount(); + magDrivingTextWire(); } function magDrivingSetView(view) { @@ -6332,6 +6413,8 @@ function magDrivingViewsWire() { menu.addEventListener('click', (e) => { const th = e.target.closest('button[data-theme-choice]'); if (th) { magDrivingSetTheme(th.dataset.themeChoice); return; } + const am = e.target.closest('button[data-amount]'); + if (am) { magDrivingSetAmount(am.dataset.amount); return; } const v = e.target.closest('button[data-view]'); if (v) { magDrivingSetView(v.dataset.view); magDrivingMenuClose(); } }); @@ -6762,6 +6845,7 @@ root.magDrivingIndexToggle = magDrivingIndexToggle; root.magDrivingIndexOpen = magDrivingIndexOpen; root.magDrivingIndexClose = magDrivingIndexClose; root.magDrivingSetView = magDrivingSetView; +root.magDrivingSetAmount = magDrivingSetAmount; root.magDrivingSeek = magDrivingSeek; root.magDrivingCycleSpeed = magDrivingCycleSpeed; root.magDrivingToggleText = magDrivingToggleText; diff --git a/static/sections/s-magazine.html b/static/sections/s-magazine.html index 7fda92d..3516c65 100644 --- a/static/sections/s-magazine.html +++ b/static/sections/s-magazine.html @@ -340,7 +340,14 @@
- + +
+
+
Text in Split view
+
+ + +

Show
@@ -404,7 +411,7 @@ - +
diff --git a/static/style.css b/static/style.css index 415d9d1..96131fc 100644 --- a/static/style.css +++ b/static/style.css @@ -5468,12 +5468,12 @@ input[list="lang-codes"] { .mag-driving-lyrics { height: clamp(210px, 40vh, 360px); max-height: none; overflow-y: auto; overscroll-behavior: contain; display: flex; flex-direction: column; justify-content: safe center; gap: 12px; text-align: left; padding: 8px 14px; border-radius: 12px; background: color-mix(in srgb, var(--surface) 70%, transparent); scroll-behavior: auto; } -.mag-driving-lyrics .mag-driving-line { margin: 0; flex: none; font-size: clamp(15px, 4vw, 20px); line-height: 1.35; +.mag-driving-lyrics .mag-driving-line { margin: 0; flex: none; font-size: max(.875rem, calc(var(--drv-fs, 1.375rem) * .72)); line-height: 1.35; opacity: .6; color: var(--text); font-weight: 400; cursor: pointer; display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .mag-driving-lyrics .mag-driving-line[hidden] { display: none; } .mag-driving-lyrics .mag-driving-line:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } -.mag-driving-lyrics .mag-driving-line.is-current { opacity: 1; font-weight: 700; font-size: clamp(21px, 6vw, 32px); cursor: default; +.mag-driving-lyrics .mag-driving-line.is-current { opacity: 1; font-weight: 700; font-size: var(--drv-fs, 1.375rem); cursor: default; display: block; overflow: visible; -webkit-line-clamp: unset; line-clamp: unset; border-left: 4px solid var(--accent); padding-left: 10px; } @media (prefers-reduced-motion: no-preference) { @@ -5849,8 +5849,19 @@ button[aria-checked="true"] > .mag-radio::after { content: ""; width: 10px; heig .mag-driving-noimg { margin: auto; text-align: center; padding: 12px; } /* the three-line view: fixed height so nothing moves as lines change */ -.mag-driving-lyrics { flex: none; box-sizing: border-box; margin: 0; max-width: none; width: 100%; - height: clamp(110px, 26vh, 220px); height: clamp(110px, 26dvh, 220px); } +.mag-driving-lyrics { flex: 1.5 1 0; box-sizing: border-box; margin: 0; max-width: none; width: 100%; height: auto; min-height: 4.5rem; } +.mag-driving-stagewrap { min-height: 64px; } + +/* paragraph: the sentences flow as one paragraph; the current one is bold and underlined (the word highlight stays visible inside it) */ +.mag-driving-lyrics[data-amount="paragraph"] { display: block; padding-block: 10px; } +.mag-driving-lyrics[data-amount="paragraph"] .mag-driving-line { display: inline; overflow: visible; -webkit-line-clamp: unset; line-clamp: unset; opacity: 1; color: var(--subtext); + font-size: var(--drv-fs, 1.125rem); } +.mag-driving-lyrics[data-amount="paragraph"] .mag-driving-line.is-current { color: var(--text); border-left: 0; padding-left: 0; text-decoration: underline; + text-decoration-color: var(--accent); text-decoration-thickness: 3px; text-underline-offset: 4px; } +.mag-driving-lyrics[data-amount="paragraph"] .mag-driving-line.is-current .mag-dw.is-current { text-decoration: none; } +.mag-driving-lyrics[data-amount="paragraph"] .mag-driving-line { animation: none !important; } +.mag-driving-lyrics[data-amount="sentence"] .mag-driving-line { overflow-wrap: anywhere; } +.mag-seg-amount button { font-size: 14px; } /* the whole text */ .mag-driving-full-wrap { flex: 1 1 0; min-height: 0; position: relative; display: none; } @@ -5910,7 +5921,7 @@ button[aria-checked="true"] > .mag-radio::after { content: ""; width: 10px; heig .mag-driving[data-view="split"] .mag-driving-start { grid-column: 1 / -1; grid-row: 1; min-height: 44px; } .mag-driving[data-view="split"] .mag-driving-head { grid-column: 1 / -1; grid-row: 2; } .mag-driving[data-view="split"] .mag-driving-stagewrap { grid-column: 1; grid-row: 3; } - .mag-driving[data-view="split"] .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; flex: none; } } /* minimized: just the current line above the dock */ @@ -5918,7 +5929,7 @@ button[aria-checked="true"] > .mag-radio::after { content: ""; width: 10px; heig .mag-driving[data-collapsed="true"] .mag-driving-head p, .mag-driving[data-collapsed="true"] .mag-driving-head .mag-driving-queue { display: none; } .mag-driving[data-collapsed="true"] .mag-driving-content { flex: 0 1 auto; display: flex; } .mag-driving[data-collapsed="true"] .mag-driving-head h2 { font-size: 15px; -webkit-line-clamp: 1; line-clamp: 1; } -.mag-driving[data-collapsed="true"] .mag-driving-lyrics { display: flex; height: auto; max-height: 30vh; max-height: 30dvh; min-height: 0; grid-column: auto; grid-row: auto; } +.mag-driving[data-collapsed="true"] .mag-driving-lyrics { display: flex; flex: 0 1 auto; height: auto; max-height: 30vh; max-height: 30dvh; min-height: 0; grid-column: auto; grid-row: auto; } .mag-driving[data-collapsed="true"] .mag-driving-lyrics .is-prev, .mag-driving[data-collapsed="true"] .mag-driving-lyrics .is-next { display: none; } @media (prefers-reduced-motion: reduce) { @@ -5949,6 +5960,8 @@ html[data-mag-night] #toast.success { color: var(--green); } html[data-mag-night /* 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-lyrics[data-amount="paragraph"] .mag-driving-line { color: var(--dim); } +.mag-driving[data-dtheme="dark"] .mag-driving-lyrics[data-amount="paragraph"] .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); } diff --git a/tests/magazine.spec.js b/tests/magazine.spec.js index ea53c85..49ed99a 100644 --- a/tests/magazine.spec.js +++ b/tests/magazine.spec.js @@ -60,7 +60,9 @@ async function navTo(page, section) { await page.evaluate((s) => window.navTo(s), section); } -async function mockBackend(page, { articleStatus = 'detected', hasAudio = false, progress = {} } = {}) { +async function mockBackend(page, { articleStatus = 'detected', hasAudio = false, progress = {}, textAmount = 'context' } = {}) { + // Driving mode defaults to the current sentence alone; the older specs assert the three-line window, so they start there. + if (textAmount) await page.addInitScript((amount) => { try { if (!localStorage.getItem('ttsvc_mag_driving_prefs')) localStorage.setItem('ttsvc_mag_driving_prefs', JSON.stringify({ amount })); } catch (_) {} }, textAmount); // Each test starts from the article list. A previous test may deliberately // switch to page thumbnails, and that preference is persisted by the app. await page.addInitScript(() => localStorage.removeItem('ttsvc_mag_contents_view')); @@ -3034,7 +3036,7 @@ test.describe('Driving mode layout and views', () => { await page.setViewportSize({ width: 375, height: 667 }); await open(page); await openMenu(page); - const seg = page.locator('#mag-driving-menu .mag-seg'); + const seg = page.locator('#mag-driving-menu .mag-seg:not(.mag-seg-amount)'); 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'); @@ -3116,8 +3118,8 @@ test.describe('Driving mode layout and views', () => { 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; }); + const order = ['picture', 'text', 'split', 'sentence', 'context', 'paragraph', '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.amount || 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); } @@ -3845,3 +3847,238 @@ test.describe('Driving mode: measured word times drive the word highlight', () = await expect.poll(async () => (await lit(page)).compact?.[1]).toBeGreaterThanOrEqual(2); }); }); + +// ── Driving mode: text always fits its frame, and how much text is shown is a setting ────────────────── +test.describe('Driving mode text fit and text amount', () => { + const LONG = 'Gefährliches Halbwissen: Wenn Berater GPT-4 für komplexe Managementaufgaben einsetzen und dabei die Grenzen des Modells ignorieren, ' + + 'entstehen Entscheidungsvorlagen, die überzeugend klingen, aber auf frei erfundenen Zahlen beruhen, und niemand im Raum merkt es, bevor das Geld ausgegeben ist'; + const TEXTS = ['Erster kurzer Satz.', LONG + '.', 'Dritter kurzer Satz.', 'Vierter kurzer Satz.']; + const AT = 5.9; // late inside sentence 1, so the spoken word is near the end of the long sentence + + async function setup(page, prefs) { + if (prefs) await page.addInitScript((p) => { try { if (!localStorage.getItem('ttsvc_mag_driving_prefs')) localStorage.setItem('ttsvc_mag_driving_prefs', JSON.stringify(p)); } catch (_) {} }, prefs); + await mockBackend(page, { articleStatus: 'approved', hasAudio: true, textAmount: null }); + const article = { ...ARTICLE_APPROVED, has_audio: true, audio_seconds: 15 }; + await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: [article], progress: {}, default_voice: '', podcast: { hosts: [] } } })); + await page.route(`**/api/magazine/${DOC_ID}/articles/article-001`, route => route.fulfill({ json: article })); + const blocks = [ + { id: 'b1', page: 3, bbox: [0, 0, 1, 1], type: 'body', text: TEXTS.join(' '), reading_order: 0 }, + { id: 'b2', page: 3, bbox: [0, 0, 1, 1], type: 'body', text: 'Fünfter Satz im nächsten Absatz.', reading_order: 1 }, + ]; + await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/text*`, route => route.fulfill({ json: { article, blocks } })); + await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/timings`, route => route.fulfill({ json: { available: true, quality: 'exact', + sentences: [0, 3, 6, 9, 12].map((s, i) => ({ text: 'Satz ' + i, block: i < 4 ? 0 : 1, start: s, end: s + 3, speech: s + 0.7 })) } })); + await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/audio`, route => { + const wav = silentWav(15000), 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}` } }); + }); + } + async function open(page, { rootFont } = {}) { + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); + if (rootFont) await page.addStyleTag({ content: `html { font-size: ${rootFont} !important; }` }); + await navTo(page, 's-magazine'); + const card = page.locator('#mag-library-list .mag-card').first(); + await page.locator('#mag-library-list .mag-card:visible, button:has-text("Playlist"):visible').first().waitFor(); + if (await card.isVisible()) await card.click(); + await page.getByRole('button', { name: 'Playlist' }).click(); + await page.locator('.mag-playlist-track-play').first().click(); + await expect(page.locator('#mag-playlist-transcript [data-playlist-sentence]')).toHaveCount(5); + await page.evaluate(() => window.magOpenDriving()); + await expect(page.locator('#mag-driving')).toBeVisible(); + await expect.poll(() => page.evaluate(() => document.getElementById('mag-playlist-audio').duration)).toBeGreaterThan(14); + await page.evaluate((t) => { const a = document.getElementById('mag-playlist-audio'); a.pause(); a.currentTime = t; }, AT); + await expect.poll(() => page.evaluate(() => document.querySelector('#mag-driving-lyrics .mag-driving-line.is-current')?.dataset.i)).toBe('1'); + } + const shown = (page) => page.evaluate(() => [...document.querySelectorAll('#mag-driving-lyrics .mag-driving-line:not([hidden])')].map((e) => Number(e.dataset.i))); + const setAmount = async (page, a) => { + await page.evaluate((x) => window.magDrivingSetAmount(x), a); + await expect(page.locator('#mag-driving')).toHaveAttribute('data-amount', a); + }; + + async function fitReport(page) { + return page.evaluate(() => { + const r = (el) => el.getBoundingClientRect(); + const box = document.getElementById('mag-driving-lyrics'), b = r(box); + const dock = r(document.querySelector('.mag-driving-dock')), top = r(document.querySelector('.mag-driving-top')); + const stage = r(document.querySelector('.mag-driving-stagewrap')); + const w = box.querySelector('.mag-dw.is-current'), wr = w && r(w); + const overflows = box.scrollHeight > box.clientHeight + 1; + const side = innerWidth > innerHeight && innerHeight <= 500; + return { + vh: innerHeight, vw: innerWidth, box: { t: b.top, b: b.bottom, l: b.left, r: b.right, h: b.height }, dock: { t: dock.top }, top: { b: top.bottom }, + overflows, fits: !overflows, + wordInside: !!wr && wr.top >= b.top - 1 && wr.bottom <= b.bottom + 1, + scrollsHorizontally: box.scrollWidth > box.clientWidth + 1, + belowPicture: side ? b.left >= stage.right - 0.5 : b.top >= stage.bottom - 0.5, + contentBelowDock: r(document.querySelector('.mag-driving-content')).bottom <= dock.top + 0.5, + pageScrollX: document.documentElement.scrollWidth, + fontSize: parseFloat(getComputedStyle(box.querySelector('.mag-driving-line.is-current')).fontSize), + root: parseFloat(getComputedStyle(document.documentElement).fontSize), + }; + }); + } + + for (const rootFont of [undefined, '200%']) { + for (const [w, h] of [[320, 568], [375, 667], [414, 896], [667, 375]]) { + for (const amount of ['sentence', 'context', 'paragraph']) { + test(`${w}x${h}${rootFont ? ', root font 200%' : ''}, ${amount}: a 300+ character sentence stays inside its frame`, async ({ page }) => { + await page.setViewportSize({ width: w, height: h }); + await setup(page); + await open(page, { rootFont }); + await setAmount(page, amount); + await expect.poll(async () => (await shown(page)).length).toBeGreaterThan(0); + await page.waitForTimeout(500); // fit + follow-scroll settle + const m = await fitReport(page); + expect(m.box.h, 'the frame has room').toBeGreaterThan(40); + expect(m.box.t).toBeGreaterThanOrEqual(m.top.b - 0.5); // below the top bar + expect(m.box.b).toBeLessThanOrEqual(m.dock.t + 0.5); // above the dock + expect(m.box.b).toBeLessThanOrEqual(m.vh + 0.5); + expect(m.box.r).toBeLessThanOrEqual(m.vw + 0.5); + expect(m.belowPicture, 'the text does not overlap the picture').toBe(true); + expect(m.contentBelowDock).toBe(true); + expect(m.scrollsHorizontally).toBe(false); + expect(m.pageScrollX).toBeLessThanOrEqual(m.vw); + // never clipped silently: it fits, or the frame scrolls and the spoken word is inside the visible frame + expect(m.fits || m.wordInside, JSON.stringify(m)).toBe(true); + // font is in rem, so it follows the user's text size and stays within 1rem-1.5rem + expect(m.fontSize).toBeGreaterThanOrEqual(m.root * 0.99); + expect(m.fontSize).toBeLessThanOrEqual(m.root * 1.5 + 0.5); + }); + } + } + } + + test('a sentence that fits is shown at the largest size, a long one shrinks first', async ({ page }) => { + await page.setViewportSize({ width: 414, height: 896 }); + await setup(page); + await open(page); + await page.evaluate(() => { document.getElementById('mag-playlist-audio').currentTime = 1; }); + await expect.poll(() => page.evaluate(() => document.querySelector('#mag-driving-lyrics .mag-driving-line.is-current')?.dataset.i)).toBe('0'); + await page.waitForTimeout(300); + const short = await fitReport(page); + await page.evaluate((t) => { document.getElementById('mag-playlist-audio').currentTime = t; }, AT); + await expect.poll(() => page.evaluate(() => document.querySelector('#mag-driving-lyrics .mag-driving-line.is-current')?.dataset.i)).toBe('1'); + await page.waitForTimeout(300); + const long = await fitReport(page); + expect(short.fontSize).toBeCloseTo(short.root * 1.5, 0); + expect(long.fontSize).toBeLessThan(short.fontSize); + }); + + test('each text amount renders the expected lines; the default is the current sentence', async ({ page }) => { + await page.setViewportSize({ width: 414, height: 896 }); + await setup(page); + await open(page); + await expect(page.locator('#mag-driving')).toHaveAttribute('data-amount', 'sentence'); + expect(await shown(page)).toEqual([1]); + await setAmount(page, 'context'); + expect(await shown(page)).toEqual([0, 1, 2]); + await expect(page.locator('#mag-driving-lyrics .is-prev')).toHaveCount(1); + await expect(page.locator('#mag-driving-lyrics .is-next')).toHaveCount(1); + await setAmount(page, 'paragraph'); + expect(await shown(page)).toEqual([0, 1, 2, 3]); // the whole first paragraph, not the second one + await expect(page.locator('#mag-driving-lyrics .mag-driving-line.is-current')).toHaveCount(1); + expect(await page.locator('#mag-driving-lyrics .mag-dw.is-current').count()).toBe(1); + // the next paragraph starts a new window + await page.evaluate(() => { document.getElementById('mag-playlist-audio').currentTime = 13; }); + await expect.poll(() => shown(page)).toEqual([4]); + // text is read out by the audio, not announced sentence by sentence + expect(await page.locator('#mag-driving-lyrics').getAttribute('aria-live')).toBe('off'); + }); + + test('tapping a neighbouring line seeks to it in context and paragraph amounts', async ({ page }) => { + await page.setViewportSize({ width: 414, height: 896 }); + await setup(page); + await open(page); + for (const amount of ['context', 'paragraph']) { + await page.evaluate((t) => { document.getElementById('mag-playlist-audio').currentTime = t; }, AT); + await setAmount(page, amount); + await expect.poll(() => page.evaluate(() => document.querySelector('#mag-driving-lyrics .mag-driving-line.is-current')?.dataset.i)).toBe('1'); + await page.locator('#mag-driving-lyrics .mag-driving-line.is-next').first().click(); + await expect.poll(() => page.evaluate(() => document.querySelector('#mag-driving-lyrics .mag-driving-line.is-current')?.dataset.i)).toBe('2'); + } + }); + + test('the amount is a radio group in the menu, persists across reloads and the menu fits the screen', async ({ page }) => { + await page.setViewportSize({ width: 320, height: 568 }); + await setup(page); + await open(page); + await page.locator('#mag-driving-menubtn').click(); + const group = page.locator('#mag-driving-menu [role="radiogroup"][aria-labelledby="mag-menu-sec-amount"]'); + await expect(group.locator('[role="radio"]')).toHaveCount(3); + await expect(group.locator('[data-amount="sentence"]')).toHaveAttribute('aria-checked', 'true'); + await group.locator('[data-amount="paragraph"]').click(); + await expect(group.locator('[data-amount="paragraph"]')).toHaveAttribute('aria-checked', 'true'); + await expect(group.locator('[data-amount="sentence"]')).toHaveAttribute('aria-checked', 'false'); + await expect(page.locator('#mag-driving-menu')).toBeVisible(); // stays open so the effect is seen + const fit = await page.evaluate(() => { + const m = document.getElementById('mag-driving-menu').getBoundingClientRect(), dock = document.querySelector('.mag-driving-dock').getBoundingClientRect(); + return { l: m.left, r: m.right, t: m.top, b: m.bottom, vw: innerWidth, dockTop: dock.top, minH: Math.min(...[...document.querySelectorAll('#mag-driving-menu button')].map((x) => x.getBoundingClientRect().height)) }; + }); + expect(fit.l).toBeGreaterThanOrEqual(0); expect(fit.r).toBeLessThanOrEqual(fit.vw + 0.5); + expect(fit.t).toBeGreaterThanOrEqual(0); expect(fit.b).toBeLessThanOrEqual(fit.dockTop + 0.5); + expect(fit.minH).toBeGreaterThanOrEqual(43.5); + expect(await page.evaluate(() => JSON.parse(localStorage.getItem('ttsvc_mag_driving_prefs')).amount)).toBe('paragraph'); + // keyboard: arrows move over the new radios too + await group.locator('[data-amount="paragraph"]').focus(); + await page.keyboard.press('ArrowRight'); + await expect(page.locator('#mag-driving-menu [data-theme-choice="light"], #mag-driving-menu #mag-driving-text-toggle').first()).toBeDefined(); + await page.keyboard.press('Escape'); + await page.reload({ waitUntil: 'domcontentloaded' }); + await open(page); + await expect(page.locator('#mag-driving')).toHaveAttribute('data-amount', 'paragraph'); + expect(await shown(page)).toEqual([0, 1, 2, 3]); + }); + + test('a broken or unknown stored amount falls back to the current sentence', async ({ page }) => { + await setup(page, { amount: 'everything', view: 'split' }); + await page.setViewportSize({ width: 414, height: 896 }); + await open(page); + await expect(page.locator('#mag-driving')).toHaveAttribute('data-amount', 'sentence'); + expect(await shown(page)).toEqual([1]); + }); + + test('the Text view stays the whole text whatever the amount is', async ({ page }) => { + await setup(page, { view: 'text', amount: 'sentence' }); + await page.setViewportSize({ width: 414, height: 896 }); + await open(page); + await expect(page.locator('#mag-driving-full .mag-driving-fline')).toHaveCount(5); + await expect(page.locator('#mag-driving-lyrics')).toBeHidden(); + }); + + for (const theme of ['light', 'dark']) { + test(`${theme} theme: paragraph text and the current sentence keep 4.5:1 contrast`, async ({ page }) => { + await page.setViewportSize({ width: 414, height: 896 }); + await setup(page, { theme, amount: 'paragraph' }); + await open(page); + const res = await page.evaluate(() => { + const rgb = (c) => (c.match(/[\d.]+/g) || []).slice(0, 3).map(Number); + const lum = ([r, g, b]) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); }; + const ratio = (a, b) => { const x = lum(rgb(a)), y = lum(rgb(b)); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); }; + const o = document.getElementById('mag-driving'), cs = getComputedStyle(o); + const out = {}; + for (const [name, sel] of [['dim', '.is-next'], ['current', '.is-current']]) { + const el = document.querySelector('#mag-driving-lyrics .mag-driving-line' + sel), c = getComputedStyle(el); + out[name] = { opacity: c.opacity, worst: Math.min(ratio(c.color, cs.getPropertyValue('--surface').trim() ? getComputedStyle(o).getPropertyValue('--surface').trim().replace(/^#(..)(..)(..)$/, (m, a, b2, d) => `rgb(${parseInt(a, 16)},${parseInt(b2, 16)},${parseInt(d, 16)})`) : cs.backgroundColor), ratio(c.color, cs.backgroundColor)) }; + } + return out; + }); + expect(res.dim.opacity).toBe('1'); + expect(res.dim.worst).toBeGreaterThanOrEqual(4.5); + expect(res.current.worst).toBeGreaterThanOrEqual(4.5); + }); + } + + test('reduced motion: no animation on the current line while following the spoken word', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.setViewportSize({ width: 320, height: 568 }); + await setup(page, { amount: 'sentence' }); + await open(page, { rootFont: '200%' }); + expect(await page.locator('#mag-driving-lyrics .mag-driving-line.is-current').evaluate((e) => getComputedStyle(e).animationName)).toBe('none'); + const m = await fitReport(page); + expect(m.fits || m.wordInside).toBe(true); + }); +});