fix(mobile): retry failed script loads; fix 8 phone-width layout bugs
Some checks are pending
Test and SonarQube / quality (push) Waiting to run
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:
parent
a78dac98fa
commit
c91aaabe3a
14
CHANGELOG.md
14
CHANGELOG.md
@ -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
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -11,6 +11,15 @@
|
||||
.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; }
|
||||
.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 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; }
|
||||
|
||||
@ -98,19 +98,61 @@
|
||||
// ── 3. Load JS in parallel batches ────────────────────────────────────────
|
||||
// Scripts with async=false are FETCHED in parallel but EXECUTED in DOM
|
||||
// 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) {
|
||||
var s = document.createElement('script');
|
||||
s.src = src + '?v=' + _appVersion;
|
||||
s.async = false; // fetch in parallel, execute in DOM order
|
||||
s.onload = resolve;
|
||||
s.onerror = function () { reject(new Error('Failed to load ' + src)); };
|
||||
s.onload = resolve;
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
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)
|
||||
@ -144,8 +186,18 @@
|
||||
var _loadedFeatureModules = new Set();
|
||||
if (_bundleOk) _featureFiles.forEach(function (src) { _loadedFeatureModules.add(src); });
|
||||
if (!_bundleOk) {
|
||||
await _loadBatch(_featureFiles);
|
||||
_featureFiles.forEach(function (src) { _loadedFeatureModules.add(src); });
|
||||
try {
|
||||
await _loadBatch(_featureFiles);
|
||||
_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,
|
||||
@ -160,6 +212,7 @@
|
||||
return _loadBatch(pending).catch(function (error) {
|
||||
pending.forEach(function (src) { _loadedFeatureModules.delete(src); });
|
||||
console.error('Unable to load section modules for ' + sectionId, error);
|
||||
_notifyLoadFailure();
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
@ -10,9 +10,9 @@
|
||||
<div class="tab-content" id="tab-source">
|
||||
<div class="clone-tabbox">
|
||||
<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" data-src="file" role="tab"><span class="mdi mdi-file-music-outline"></span> Upload file</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 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" 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" aria-label="URL / YouTube"><span class="mdi mdi-link-variant"></span> <span>URL / YouTube</span></button>
|
||||
</div>
|
||||
<div class="clone-srcpanel">
|
||||
|
||||
|
||||
@ -2010,9 +2010,14 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.vl-grid { grid-template-columns: 1fr; }
|
||||
.getvoices-source-editor, .getvoices-toolbar, .getvoices-summary { grid-template-columns: 1fr; }
|
||||
.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; }
|
||||
.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-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 { padding: 0 12px 60px; }
|
||||
|
||||
/* Section headings */
|
||||
.section-header { flex-wrap: wrap; gap: 8px; }
|
||||
/* Section headings -- every section actually uses .section-head, not
|
||||
.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; }
|
||||
|
||||
/* 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-main { flex-direction: column; }
|
||||
.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-entry {
|
||||
@ -2122,14 +2138,41 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
|
||||
/* ── Try It Out ───────────────────────────────────────────────────────── */
|
||||
.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 ──────────────────────────────────── */
|
||||
.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-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 ──────────────────────────────────────────────────── */
|
||||
.vl-grid { grid-template-columns: 1fr; }
|
||||
.section-toolbar { flex-wrap: wrap; gap: 6px; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user