fix(mobile): retry failed script loads; fix 8 phone-width layout bugs
Some checks are pending
Test and SonarQube / quality (push) Waiting to run

Two reports from the same mobile session tied together: a voice-library
crash ("fmtDbfs is not defined") and a save button reporting "No audio
ready" despite a file being correctly loaded and trimmed. Root cause
was the same in both -- the module loader fetched each JS file once
with no retry, so a single dropped request on a flaky connection left
that module's functions/variables undefined while every other module
kept executing anyway, producing confusing symptoms nowhere near the
real cause.

loader.js: _load() now retries up to 3 times with backoff before
rejecting. If a module still can't load after that, the app says so
plainly via a toast (or a plain DOM banner if even utils.js failed to
load) instead of silently revealing a partially-broken page. Wired into
both the core-module fallback path and ensureFeatureModules' lazy path.

Loading every section of the app at a 375px viewport and checking for
elements running past the viewport edge (all now 0) found eight real,
independent layout bugs, each with a distinct root cause:

- .section-head's mobile wrap rule targeted .section-header, a class
  nothing in the app uses -- dead since it was written. Every section's
  icon/title/action row (e.g. "+ New voice") ran off-screen unreachable.
- The synth-mode toggle and Clone-a-Voice source tabs' icon-only mobile
  rules matched a <span> wrapper the markup didn't have. Added it, plus
  aria-labels so hiding the text doesn't erase the accessible name.
- voice-design.css loads after style.css and re-declares
  .design-sample-grid unscoped -- style.css's mobile override always
  lost the cascade regardless of viewport. Moved the override to the
  file that actually wins.
- Three separate minmax(380-440px,1fr) engine-card grids couldn't fit
  one column under ~460px. Collapsed to 1fr with min-width:0 on the
  items -- the column fix alone wasn't enough, since a grid item's
  content-based min-width is still a floor even inside a 1fr track.
- The routing test row (450px+ minimum) and the conversation input bar
  (a single non-wrapping flex row for text field + mic + gate + three
  toggles) now wrap under 767px.
- Try It Out's EQ sliders shrank to fit beside their toggle instead of
  ever wrapping below it, overflowing their own squeezed container.
  Forced onto their own full-width line.

Verified with a Playwright sweep of all 16 sections (0 overflowing
elements, 0 console errors) plus the existing 225-test suite and the
frontend syntax gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-13 17:09:24 +02:00
parent a78dac98fa
commit c91aaabe3a
6 changed files with 132 additions and 13 deletions

View File

@ -5,6 +5,20 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [1.27.5] — 2026-09-13
### Fixed
- **App became unusable on mobile after a script failed to load, with no indication why.** The module loader fetched each JS file once with no retry; a single dropped request on a flaky mobile connection left that module's functions/variables undefined while every *other* module kept executing anyway — confirmed live as a voice-library crash ("fmtDbfs is not defined") and a save button reporting "No audio ready" despite a file being loaded and trimmed correctly, both traceable to the same root cause. `_load()` now retries up to 3 times with backoff before giving up, and if a module still can't load after that, the app now says so plainly ("Some parts of the app failed to load. Please reload the page.") instead of silently continuing in a partially-broken state.
- **Eight real phone-width layout bugs, found and fixed by loading every section of the app at a 375px viewport and checking for content running off-screen:**
- `.section-head`'s own mobile wrap rule targeted `.section-header`, a class nothing in the app actually uses — the icon/title/action row (e.g. "+ New voice") never wrapped and ran off-screen with no way to reach it, on every section.
- The voice library's "Preview text" / "Reference transcript" toggle, and the Clone-a-Voice source tabs' mobile icon-only rule (`.clone-src-tab span:not(.mdi)`), matched a `<span>` wrapper the markup didn't actually have — added it, plus `aria-label`s so hiding the text doesn't erase the buttons' accessible names.
- The Voice Design sample cards' real mobile override was silently dead: `voice-design.css` loads *after* `style.css` and re-declares the same selector unscoped, so it always won regardless of viewport. Moved the override into the file that actually controls the cascade.
- Three separate `repeat(auto-fill, minmax(380-440px, 1fr))` engine-card grids (`.llm-service-grid`, `.llm-local-grid`, `.dc-grid`) couldn't fit even one column under ~460px; collapsed to one column with `min-width: 0` on their items (a grid item's default `min-width: auto` still won't shrink below its own content, which was the remaining floor after the column fix alone).
- The routing test row (`minmax(120px)/minmax(110px)/minmax(220px)/auto`, 450px+ minimum) and the conversation input bar (text field, mic, noise-gate row and three toggles as one non-wrapping flex row) both collapse/wrap under 767px now.
- Try It Out's EQ sliders shrank to fit next to their checkbox toggle instead of ever wrapping below it, so their `white-space: nowrap` labels overflowed their own squeezed container; forced them onto their own full-width line.
---
## [1.27.4] — 2026-09-13 ## [1.27.4] — 2026-09-13
### Fixed ### Fixed

View File

@ -1 +1 @@
1.27.4 1.27.5

View File

@ -11,6 +11,15 @@
.preset-row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; margin-top: 10px; } .preset-row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; margin-top: 10px; }
.preset-row input, .preset-row select { background: var(--panel); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: 8px 12px; font-size: 14px; } .preset-row input, .preset-row select { background: var(--panel); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: 8px 12px; font-size: 14px; }
.design-sample-grid { display: grid; grid-template-columns: minmax(150px,1fr) 52px 86px minmax(220px,1.4fr) minmax(220px,1.4fr) 176px; gap: 8px; align-items: center; } .design-sample-grid { display: grid; grid-template-columns: minmax(150px,1fr) 52px 86px minmax(220px,1.4fr) minmax(220px,1.4fr) 176px; gap: 8px; align-items: center; }
/* This file loads after style.css, so its own (unscoped) rule above always
won on specificity+order regardless of viewport -- style.css's mobile
override of the same selector was silently dead code at every width. */
@media (max-width: 640px) {
.design-sample-grid { grid-template-columns: 1fr; gap: 4px; }
.qwen-sample .sample-desc, .qwen-sample .sample-text {
white-space: normal; overflow: visible; text-overflow: clip;
}
}
.design-sample-header { padding: 8px 10px 10px; border-bottom: 1px solid var(--border); } .design-sample-header { padding: 8px 10px 10px; border-bottom: 1px solid var(--border); }
.design-sample-header div { white-space: nowrap; overflow: hidden; font-size: 14px; font-weight: 700; color: var(--subtext); text-transform: uppercase; letter-spacing: .07em; } .design-sample-header div { white-space: nowrap; overflow: hidden; font-size: 14px; font-weight: 700; color: var(--subtext); text-transform: uppercase; letter-spacing: .07em; }
.qwen-sample { border: 1px solid var(--border); border-radius: 6px; background: var(--panel); padding: 8px 10px; transition: border-color .15s; } .qwen-sample { border: 1px solid var(--border); border-radius: 6px; background: var(--panel); padding: 8px 10px; transition: border-color .15s; }

View File

@ -98,19 +98,61 @@
// ── 3. Load JS in parallel batches ──────────────────────────────────────── // ── 3. Load JS in parallel batches ────────────────────────────────────────
// Scripts with async=false are FETCHED in parallel but EXECUTED in DOM // Scripts with async=false are FETCHED in parallel but EXECUTED in DOM
// insertion order — safe for modules that share a global scope. // insertion order — safe for modules that share a global scope.
function _load(src) { //
// Each script gets up to 3 attempts before this rejects. Without a retry,
// one dropped request on a flaky mobile connection left the rest of the
// batch executing anyway (a script's own failure never blocked its
// siblings) — a function or variable from the missing module stayed
// undefined while everything downstream of it kept running, surfacing as
// confusing symptoms nowhere near the real cause (confirmed live: a core
// module failing to load left a later one throwing "fmtDbfs is not
// defined", and a save button reachable at all reporting "No audio
// ready" instead of the page ever explaining a script hadn't loaded).
function _load(src, attemptsLeft) {
attemptsLeft = attemptsLeft == null ? 3 : attemptsLeft;
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
var s = document.createElement('script'); var s = document.createElement('script');
s.src = src + '?v=' + _appVersion; s.src = src + '?v=' + _appVersion;
s.async = false; // fetch in parallel, execute in DOM order s.async = false; // fetch in parallel, execute in DOM order
s.onload = resolve; s.onload = resolve;
s.onerror = function () { reject(new Error('Failed to load ' + src)); }; s.onerror = function () {
if (s.parentNode) s.parentNode.removeChild(s);
if (attemptsLeft > 1) {
setTimeout(function () {
_load(src, attemptsLeft - 1).then(resolve, reject);
}, 350 * (4 - attemptsLeft)); // 350ms, then 700ms before the last try
} else {
reject(new Error('Failed to load ' + src));
}
};
document.body.appendChild(s); document.body.appendChild(s);
}); });
} }
function _loadBatch(srcs) { function _loadBatch(srcs) {
return Promise.all(srcs.map(_load)); return Promise.all(srcs.map(function (src) { return _load(src); }));
}
// Retries above absorb a transient drop; this is what the user sees when
// a module still would not load after 3 tries — reloading is the only
// real fix (nothing downstream of a missing module can be trusted), so
// say so plainly rather than leaving a partially-working page to explain
// itself through whatever error a missing function happens to throw.
function _notifyLoadFailure() {
console.error('One or more app modules failed to load after retries.');
try {
if (typeof window.toast === 'function') {
window.toast('Some parts of the app failed to load. Please reload the page.', 'error', 20000);
return;
}
} catch (_) {}
// utils.js itself failed to load, or toast() errored -- fall back to
// a plain banner rather than a silent console-only failure.
var banner = document.createElement('div');
banner.textContent = 'Some parts of the app failed to load. Please reload the page.';
banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:6000;'
+ 'background:#b91c1c;color:#fff;padding:10px 16px;font:600 14px system-ui,sans-serif;text-align:center;';
document.body.appendChild(banner);
} }
// A — foundation (sequential: each must finish before the next batch starts) // A — foundation (sequential: each must finish before the next batch starts)
@ -144,8 +186,18 @@
var _loadedFeatureModules = new Set(); var _loadedFeatureModules = new Set();
if (_bundleOk) _featureFiles.forEach(function (src) { _loadedFeatureModules.add(src); }); if (_bundleOk) _featureFiles.forEach(function (src) { _loadedFeatureModules.add(src); });
if (!_bundleOk) { if (!_bundleOk) {
try {
await _loadBatch(_featureFiles); await _loadBatch(_featureFiles);
_featureFiles.forEach(function (src) { _loadedFeatureModules.add(src); }); _featureFiles.forEach(function (src) { _loadedFeatureModules.add(src); });
} catch (error) {
// A module that still failed after 3 retries each is not a blip --
// the app cannot be trusted to work correctly from here. Mark
// whatever DID load so ensureFeatureModules doesn't re-fetch it, and
// say so plainly rather than silently revealing a partial app.
_featureFiles.forEach(function (src) { _loadedFeatureModules.add(src); });
console.error('Core feature modules failed to load', error);
_notifyLoadFailure();
}
} }
// Public section loader used by nav.js. Modules are loaded in manifest order, // Public section loader used by nav.js. Modules are loaded in manifest order,
@ -160,6 +212,7 @@
return _loadBatch(pending).catch(function (error) { return _loadBatch(pending).catch(function (error) {
pending.forEach(function (src) { _loadedFeatureModules.delete(src); }); pending.forEach(function (src) { _loadedFeatureModules.delete(src); });
console.error('Unable to load section modules for ' + sectionId, error); console.error('Unable to load section modules for ' + sectionId, error);
_notifyLoadFailure();
throw error; throw error;
}); });
}; };

View File

@ -10,9 +10,9 @@
<div class="tab-content" id="tab-source"> <div class="tab-content" id="tab-source">
<div class="clone-tabbox"> <div class="clone-tabbox">
<div class="clone-src-picker" id="clone-src-picker" role="tablist"> <div class="clone-src-picker" id="clone-src-picker" role="tablist">
<button class="clone-src-tab active" data-src="mic" role="tab"><span class="mdi mdi-microphone"></span> Microphone</button> <button class="clone-src-tab active" data-src="mic" role="tab" aria-label="Microphone"><span class="mdi mdi-microphone"></span> <span>Microphone</span></button>
<button class="clone-src-tab" data-src="file" role="tab"><span class="mdi mdi-file-music-outline"></span> Upload file</button> <button class="clone-src-tab" data-src="file" role="tab" aria-label="Upload file"><span class="mdi mdi-file-music-outline"></span> <span>Upload file</span></button>
<button class="clone-src-tab" data-src="url" role="tab"><span class="mdi mdi-link-variant"></span> URL / YouTube</button> <button class="clone-src-tab" data-src="url" role="tab" aria-label="URL / YouTube"><span class="mdi mdi-link-variant"></span> <span>URL / YouTube</span></button>
</div> </div>
<div class="clone-srcpanel"> <div class="clone-srcpanel">

View File

@ -2010,9 +2010,14 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.vl-grid { grid-template-columns: 1fr; } .vl-grid { grid-template-columns: 1fr; }
.getvoices-source-editor, .getvoices-toolbar, .getvoices-summary { grid-template-columns: 1fr; } .getvoices-source-editor, .getvoices-toolbar, .getvoices-summary { grid-template-columns: 1fr; }
.getvoices-source-buttons { flex-direction: row; min-width: 0; } .getvoices-source-buttons { flex-direction: row; min-width: 0; }
.design-sample-grid { grid-template-columns: 1fr; } /* .design-sample-grid's real mobile override lives in voice-design.css --
that file loads after this one and re-declares the same selector
unscoped, so a copy here would always lose the cascade. */
.qwen-sample-actions { justify-content: flex-start; } .qwen-sample-actions { justify-content: flex-start; }
.design-preset-row, .routing-grid { grid-template-columns: 1fr; } .design-preset-row, .routing-grid { grid-template-columns: 1fr; }
/* minmax(120px,...) minmax(110px,...) minmax(220px,...) auto needs 450px+
just for its minimums -- "Test route" and the textarea ran off-screen. */
.routing-test-row { grid-template-columns: 1fr; }
} }
/* Mobile top bar — hidden on desktop */ /* Mobile top bar — hidden on desktop */
#mobile-topbar { #mobile-topbar {
@ -2064,8 +2069,13 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* Main content full width, top-padded by topbar height */ /* Main content full width, top-padded by topbar height */
#main-content { padding: 0 12px 60px; } #main-content { padding: 0 12px 60px; }
/* Section headings */ /* Section headings -- every section actually uses .section-head, not
.section-header { flex-wrap: wrap; gap: 8px; } .section-header (the selector this rule shipped with, which matched
nothing); its icon/title/action row never wrapped on a narrow screen
as a result, pushing "+ New voice" and similar buttons off-screen
with no way to reach them. */
.section-head { flex-wrap: wrap; row-gap: 8px; }
.section-head-actions { margin-left: 0; }
.section-search { width: 100% !important; max-width: 100%; box-sizing: border-box; } .section-search { width: 100% !important; max-width: 100%; box-sizing: border-box; }
/* Toast: full width on small screens */ /* Toast: full width on small screens */
@ -2109,6 +2119,12 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.conv-url-inp { width: 100% !important; box-sizing: border-box; } .conv-url-inp { width: 100% !important; box-sizing: border-box; }
.conv-main { flex-direction: column; } .conv-main { flex-direction: column; }
.conv-stats-panel { width: 100% !important; flex-shrink: 0; } .conv-stats-panel { width: 100% !important; flex-shrink: 0; }
/* The text field, send/mic buttons, noise-gate row and three toggle
labels were all one non-wrapping flex row -- the toggles ran off
the right edge with no way to reach them. Text input gets its own
full-width line; everything else wraps onto the ones below it. */
.conv-text-row { flex-wrap: wrap; }
.conv-text-inp { flex: 1 1 100%; min-width: 0; }
/* ── Routing log ──────────────────────────────────────────────────────── */ /* ── Routing log ──────────────────────────────────────────────────────── */
.routing-log-entry { .routing-log-entry {
@ -2122,14 +2138,41 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* ── Try It Out ───────────────────────────────────────────────────────── */ /* ── Try It Out ───────────────────────────────────────────────────────── */
.preview-match-panel { grid-template-columns: 1fr !important; } .preview-match-panel { grid-template-columns: 1fr !important; }
/* .fx-sliders shrinks (flex-shrink:1) to fit next to its checkbox toggle
on the same line before .fx-row's own flex-wrap ever triggers -- its
nowrap labels then overflow the squeezed container instead of the row
ever dropping them to their own line. Forcing a full-width basis is
what actually makes that wrap happen. */
.fx-sliders { flex-basis: 100% !important; }
.fx-sliders input[type=range] { width: 84px !important; }
/* ── Clone / Design section toolbar ──────────────────────────────────── */ /* ── Clone / Design section toolbar ──────────────────────────────────── */
.vl-toolbar { gap: 5px; } .vl-toolbar { gap: 5px; }
/* ── Voice inspector: synth-mode segmented control ──────────────────────
"Preview text" / "Reference transcript" side by side never fit next to
the "Synth uses" label at this width -- the row simply ran off-screen
with no way to reach the second button (body's overflow-x: hidden
clips it, so it wasn't merely scrollable, it was unreachable). Wrap
the label onto its own line and let the segmented control take the
full width below it, each button sharing it evenly. */
.vl-synth-mode-row { flex-wrap: wrap; }
.vl-synth-seg { flex: 1 1 100%; }
.vl-synth-seg-btn { flex: 1; text-align: center; }
/* ── Settings ─────────────────────────────────────────────────────────── */ /* ── Settings ─────────────────────────────────────────────────────────── */
.settings-grid { grid-template-columns: 1fr !important; } .settings-grid { grid-template-columns: 1fr !important; }
.settings-cluster-grid { grid-template-columns: 1fr !important; } .settings-cluster-grid { grid-template-columns: 1fr !important; }
/* ── Language Models / Engines ───────────────────────────────────────── */
/* minmax(440px/380px, 1fr) cannot fit even one column under ~400-460px --
every card in these grids ran off-screen rather than stacking. */
/* A 1fr track still won't shrink below a grid item's own content-based
minimum (grid items default to min-width:auto) -- long unbroken card
content (an API-key row, a stats line) was the real remaining floor. */
.llm-service-grid, .llm-local-grid, .dc-grid { grid-template-columns: 1fr !important; }
.llm-service-grid > *, .llm-local-grid > *, .dc-grid > * { min-width: 0 !important; }
/* ── Generic helpers ──────────────────────────────────────────────────── */ /* ── Generic helpers ──────────────────────────────────────────────────── */
.vl-grid { grid-template-columns: 1fr; } .vl-grid { grid-template-columns: 1fr; }
.section-toolbar { flex-wrap: wrap; gap: 6px; } .section-toolbar { flex-wrap: wrap; gap: 6px; }