From c91aaabe3adc6bb095f21279c37ec008568c8831 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sun, 13 Sep 2026 17:09:24 +0200 Subject: [PATCH] fix(mobile): retry failed script loads; fix 8 phone-width layout bugs 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 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 --- CHANGELOG.md | 14 ++++++++ VERSION | 2 +- static/css/voice-design.css | 9 +++++ static/loader.js | 65 ++++++++++++++++++++++++++++++++---- static/sections/s-clone.html | 6 ++-- static/style.css | 49 +++++++++++++++++++++++++-- 6 files changed, 132 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f158457..7f500a2 100644 --- a/CHANGELOG.md +++ b/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 `` 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 diff --git a/VERSION b/VERSION index d8bc596..694a1ca 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.27.4 \ No newline at end of file +1.27.5 \ No newline at end of file diff --git a/static/css/voice-design.css b/static/css/voice-design.css index beb4781..c159e21 100644 --- a/static/css/voice-design.css +++ b/static/css/voice-design.css @@ -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; } diff --git a/static/loader.js b/static/loader.js index 048eda0..cb41fff 100644 --- a/static/loader.js +++ b/static/loader.js @@ -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; }); }; diff --git a/static/sections/s-clone.html b/static/sections/s-clone.html index 62740c6..ea60a58 100644 --- a/static/sections/s-clone.html +++ b/static/sections/s-clone.html @@ -10,9 +10,9 @@
- - - + + +
diff --git a/static/style.css b/static/style.css index 8965a07..988716d 100644 --- a/static/style.css +++ b/static/style.css @@ -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; }