fix(magazine): fit driving-mode text inside the frame and offer less text
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
48af2b3ccb
commit
572fae892d
@ -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`.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -340,7 +340,14 @@
|
||||
<div role="radiogroup" aria-labelledby="mag-menu-sec-view" class="mag-menu-views">
|
||||
<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>
|
||||
<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 text, amount below</small></span><span class="mag-radio" aria-hidden="true"></span></button>
|
||||
</div>
|
||||
<hr role="separator">
|
||||
<div class="mag-menu-sec" id="mag-menu-sec-amount">Text in Split view</div>
|
||||
<div role="radiogroup" aria-labelledby="mag-menu-sec-amount" class="mag-seg mag-seg-amount">
|
||||
<button type="button" role="radio" aria-checked="true" tabindex="-1" data-amount="sentence" title="Only the sentence being read, large"><span>Sentence</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-amount="context" title="The sentence with the previous and next one dimmed"><span>Context</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-amount="paragraph" title="The whole paragraph with the current sentence marked"><span>Paragraph</span><span class="mdi mdi-check mag-seg-check" aria-hidden="true"></span></button>
|
||||
</div>
|
||||
<hr role="separator">
|
||||
<div class="mag-menu-sec" id="mag-menu-sec-show">Show</div>
|
||||
@ -404,7 +411,7 @@
|
||||
</figure>
|
||||
<p id="mag-driving-noimg" class="mag-driving-noimg muted" hidden>No picture for this part of the article.</p>
|
||||
</div>
|
||||
<div id="mag-driving-lyrics" class="mag-driving-lyrics" role="group" aria-live="off" aria-label="Article text: previous, current and next sentence" hidden></div>
|
||||
<div id="mag-driving-lyrics" class="mag-driving-lyrics" role="group" aria-live="off" aria-label="Article text" hidden></div>
|
||||
<div class="mag-driving-full-wrap">
|
||||
<div id="mag-driving-full" class="mag-driving-full" role="group" aria-live="off" tabindex="0"
|
||||
aria-label="Whole article text. Arrow keys move between sentences, Enter plays from the sentence."></div>
|
||||
|
||||
@ -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); }
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user