Studio -> Magazines: library, contents tree grouped by section with
confidence/pages/listening-time/audio status, the Stage D review
screen (tick/untick layout blocks, correct title/author, approve or
reject), a reflowed article view with click-to-seek sentence
highlighting, a linked original-page view showing the source bounding
box, and a podcast panel (2-3 host pickers, script generation,
turn-by-turn playback).
Wired into the existing lazy-module system (module-manifest.json,
loader.js, nav.js) rather than a separate app. Reuses /api/tts-preview
for both narration and podcast turns -- no new audio pipeline.
Fixes found building this:
- The pane toggle used role="tab" without the full ARIA tabs pattern
(aria-selected, roving tabindex, arrow-key nav) -- fails WCAG 2.2 AA
and hid the buttons from role-based tooling. Replaced with a button
group + aria-pressed, matching the plain-click interaction it
actually has.
- Print layout cues ("(continued on page 8)") were being sent to TTS
verbatim -- narration would have spoken the parenthetical aloud.
Stripped from the narration copy only; layout.json keeps the
original for the review screen.
- has_podcast now comes from the articles listing so opening an
article without a generated script skips a doomed GET instead of a
routine 404 on every open.
Verified against the real API contract with Playwright (library,
review pre-selection, approval, sentence highlighting + PDF-page
linking, podcast approval gate, zero console errors across the flow)
and manually end-to-end against a real ingested fixture, screenshots
included. Full suite: 94 Python tests, 33/34 desktop Playwright tests
(one unrelated pre-existing flaky DOM-count assertion).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
210 lines
9.7 KiB
JavaScript
210 lines
9.7 KiB
JavaScript
(async function () {
|
|
'use strict';
|
|
|
|
// Hard safety net: never show skeleton longer than 8 s regardless of what
|
|
// fails below. A partial UI beats an indefinite spinner.
|
|
var _skTimeout = setTimeout(function () {
|
|
var sk = document.getElementById('app-skeleton');
|
|
if (sk) {
|
|
sk.classList.add('sk-hidden');
|
|
// Fully remove from DOM — otherwise the (display:none) skeleton still sits
|
|
// in the document and earlier reveal logic would skip removal.
|
|
setTimeout(function () { if (sk.parentNode) sk.parentNode.removeChild(sk); }, 300);
|
|
document.querySelectorAll('.page-section').forEach(function (s) { s.style.display = ''; });
|
|
}
|
|
}, 8000);
|
|
|
|
const SECTIONS = [
|
|
's-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser',
|
|
's-reader', 's-caststudio', 's-performance', 's-routing', 's-connect', 's-settings',
|
|
's-llms', 's-conversation', 's-library', 's-magazine',
|
|
];
|
|
|
|
function _storedSection() {
|
|
try { return localStorage.getItem('ttsvc_section') || ''; } catch (_) { return ''; }
|
|
}
|
|
function _hashSection() {
|
|
var raw = String(location.hash || '').replace(/^#/, '').trim();
|
|
if (!raw) return '';
|
|
if (raw.indexOf('section=') === 0) raw = raw.slice(8);
|
|
raw = decodeURIComponent(raw).replace(/^\/?/, '');
|
|
return SECTIONS.indexOf(raw) !== -1 ? raw : '';
|
|
}
|
|
|
|
// Determine which section to show first (same logic as nav.js): hash > stored > My Voices.
|
|
var _savedSection = _storedSection();
|
|
var _activeSection = _hashSection() || ((_savedSection && SECTIONS.indexOf(_savedSection) !== -1) ? _savedSection : 's-voices');
|
|
|
|
// ── App version for cache-busting ────────────────────────────────────────
|
|
// Read from the already-loaded style.css href — no extra network round trip.
|
|
// Falls back to fetching /api/version only if the tag isn't found.
|
|
var _appVersion = 'dev';
|
|
var _styleHref = (document.querySelector('link[href*="style.css"]') || {}).getAttribute ? document.querySelector('link[href*="style.css"]').getAttribute('href') : '';
|
|
var _styleMatch = _styleHref && _styleHref.match(/[?&]v=([^&]+)/);
|
|
if (_styleMatch) {
|
|
_appVersion = _styleMatch[1];
|
|
} else {
|
|
// Fallback: fetch from API (only on first ever load before style tag exists)
|
|
try {
|
|
var _vr = await fetch('/api/version');
|
|
var _vd = await _vr.json();
|
|
_appVersion = _vd.version || _appVersion;
|
|
} catch (_) {}
|
|
}
|
|
|
|
// Session timestamp used ONLY for section HTML files (never cached by SW anyway).
|
|
// JS/CSS use the version string so the service worker cache stays valid across reloads.
|
|
var _ts = Date.now();
|
|
|
|
function _inject(id, html) {
|
|
var el = document.getElementById(id);
|
|
if (el) el.innerHTML = html;
|
|
}
|
|
|
|
// ── 1. Load ONLY the active section before anything else ─────────────────
|
|
// Feature modules set up DOM event listeners — the active section's elements
|
|
// must exist when JS runs. Other sections are loaded in step 2.
|
|
try {
|
|
var _ar = await fetch('/static/sections/' + _activeSection + '.html?v=' + _ts);
|
|
if (!_ar.ok) throw new Error(_ar.status + ' ' + _ar.statusText);
|
|
_inject(_activeSection, await _ar.text());
|
|
} catch (e) {
|
|
var _ael = document.getElementById(_activeSection);
|
|
if (_ael) _ael.innerHTML =
|
|
'<p style="color:var(--red);padding:24px">Failed to load section <b>' +
|
|
_activeSection + '</b>: ' + e.message + '</p>';
|
|
}
|
|
|
|
// ── 2. Fetch remaining sections in background (parallel with JS loading) ──
|
|
// These run concurrently with utils + settings below. Because section HTML
|
|
// files are much smaller than JS, they finish before the feature-module batch
|
|
// needs them.
|
|
var _bgSections = Promise.all(
|
|
SECTIONS.filter(function (id) { return id !== _activeSection; })
|
|
.map(async function (id) {
|
|
try {
|
|
var res = await fetch('/static/sections/' + id + '.html?v=' + _ts);
|
|
if (!res.ok) throw new Error(res.status + ' ' + res.statusText);
|
|
_inject(id, await res.text());
|
|
} catch (e) {
|
|
var el = document.getElementById(id);
|
|
if (el) el.innerHTML =
|
|
'<p style="color:var(--red);padding:24px">Failed to load section <b>' +
|
|
id + '</b>: ' + e.message + '</p>';
|
|
}
|
|
})
|
|
);
|
|
|
|
// ── 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) {
|
|
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)); };
|
|
document.body.appendChild(s);
|
|
});
|
|
}
|
|
|
|
function _loadBatch(srcs) {
|
|
return Promise.all(srcs.map(_load));
|
|
}
|
|
|
|
// A — foundation (sequential: each must finish before the next batch starts)
|
|
await _load('/static/js/utils.js');
|
|
await _load('/static/js/i18n.js'); // defines window.t / applyI18n before features run
|
|
await _load('/static/js/settings.js');
|
|
|
|
// Wait for background sections BEFORE feature modules run.
|
|
// Feature modules set up event listeners on DOM elements; all section HTML
|
|
// must already be injected. On a local server the sections finish well before
|
|
// settings.js completes, so this await is typically instant.
|
|
await _bgSections;
|
|
|
|
// C — feature modules: always try the bundle first (one request instead of 23).
|
|
// Falls back to individual files if the bundle is missing or fails to load.
|
|
var _featureFiles = [];
|
|
var _lazyFeatureGroups = {};
|
|
try {
|
|
var _manifestResponse = await fetch('/static/module-manifest.json?v=' + _appVersion, { cache: 'no-store' });
|
|
if (!_manifestResponse.ok) throw new Error('module manifest ' + _manifestResponse.status);
|
|
var _manifest = await _manifestResponse.json();
|
|
if (!Array.isArray(_manifest.core) || !_manifest.core.length) throw new Error('empty core module manifest');
|
|
_featureFiles = _manifest.core.map(function (name) { return '/static/js/' + name + '.js'; });
|
|
_lazyFeatureGroups = _manifest.lazy && typeof _manifest.lazy === 'object' ? _manifest.lazy : {};
|
|
} catch (_) {
|
|
console.error('Unable to load the feature module manifest');
|
|
}
|
|
var _bundleOk = false;
|
|
try { await _load('/static/dist/main.min.js'); _bundleOk = true; }
|
|
catch (_) { _bundleOk = false; }
|
|
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); });
|
|
}
|
|
|
|
// Public section loader used by nav.js. Modules are loaded in manifest order,
|
|
// exactly once, and a failed group leaves the existing section visible rather
|
|
// than turning navigation into a blank screen.
|
|
window.ensureFeatureModules = function (sectionId) {
|
|
var names = Array.isArray(_lazyFeatureGroups[sectionId]) ? _lazyFeatureGroups[sectionId] : [];
|
|
var pending = names.map(function (name) { return '/static/js/' + name + '.js'; })
|
|
.filter(function (src) { return !_loadedFeatureModules.has(src); });
|
|
if (!pending.length) return Promise.resolve();
|
|
pending.forEach(function (src) { _loadedFeatureModules.add(src); });
|
|
return _loadBatch(pending).catch(function (error) {
|
|
pending.forEach(function (src) { _loadedFeatureModules.delete(src); });
|
|
console.error('Unable to load section modules for ' + sectionId, error);
|
|
throw error;
|
|
});
|
|
};
|
|
|
|
// D — navigation (must run before init/reveal so routing is interactive before
|
|
// async settings or voice-library refreshes can occupy the backend).
|
|
try { await _load('/static/nav.js'); } catch (_) {}
|
|
|
|
// A deep link or restored section must have its feature code before init and
|
|
// before the first visible section is activated.
|
|
try { if (window.ensureFeatureModules) await window.ensureFeatureModules(_activeSection); } catch (_) {}
|
|
|
|
// E — init (needs feature modules and nav globals to be defined)
|
|
try { await _load('/static/js/init.js'); } catch (_) {}
|
|
|
|
// ── Remove skeleton, reveal sections ────────────────────────────────────
|
|
// Always runs — even if a script above failed to load, showing a partial UI
|
|
// is far better than leaving the skeleton on screen indefinitely.
|
|
_revealApp();
|
|
|
|
function _revealApp() {
|
|
clearTimeout(_skTimeout);
|
|
var sk = document.getElementById('app-skeleton');
|
|
// Always remove the skeleton if it's still in the DOM — never gate on
|
|
// sk-hidden (the 8s fallback may have added that class without removing it).
|
|
if (sk) {
|
|
sk.classList.add('sk-hidden');
|
|
setTimeout(function () { if (sk.parentNode) sk.parentNode.removeChild(sk); }, 300);
|
|
}
|
|
document.querySelectorAll('.page-section').forEach(function (s) {
|
|
s.style.display = '';
|
|
});
|
|
}
|
|
|
|
// ── i18n: add the language picker and translate the (now fully-rendered) UI ──
|
|
if (window.initLangPicker) window.initLangPicker();
|
|
if (window.applyI18n) window.applyI18n(document);
|
|
if (window.initAccessibilityEnhancements) window.initAccessibilityEnhancements();
|
|
|
|
// E — post-init modules load in background AFTER the UI is already visible.
|
|
// Engines, AI backends, and conversation load while the user browses other tabs.
|
|
_loadBatch([
|
|
'/static/js/engines.js',
|
|
'/static/js/ai-backends.js',
|
|
'/static/js/conversation.js',
|
|
]);
|
|
})();
|