perf(ui): lazy-render big sections to stay within the DOM node budget
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
084a99af2c
commit
560b82b0ef
@ -16,6 +16,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
- **Driving mode has a new layout that always keeps the controls on screen.** The overlay is now one full-height column: a top bar (share, a *Picture / Lyrics / Compact* switch, minimize, close), a content area that shrinks and scrolls inside itself, and a controls dock pinned to the bottom (position slider, time, previous / -15 / play / +15 / next, speed, text, images). On an iPhone SE (375x667) the controls used to fall below the screen, the share button covered the article title and the top of the title was cut off; now the dock is always fully visible from 320x568 up to tablets, in landscape the picture and the three lines sit side by side, and long titles wrap to two lines. *Picture* fills the area (double-tap, pinch, wheel, +/- keys or the zoom buttons to zoom, drag to pan, Escape or the reset button to reset); *Lyrics* shows the whole text, the spoken sentence large and centered, tap a sentence to jump there, and scrolling by hand pauses following until *Back to current*; *Compact* (default) is the picture plus previous / current / next line. The minimize button shrinks everything to just the current line. The choice is remembered. Transport buttons stay perfect circles.
|
||||
- **Driving mode shows three lines of text instead of the whole article.** The previous sentence (small), the current one (large, with the spoken word marked) and the next one (small) sit in a fixed-height box, so the controls below never jump. Lines fade in smoothly (no animation when your device asks for reduced motion), the text is not a live region so a screen reader does not read out every sentence, it is readable at 375 px, and tapping the previous or next line jumps to that sentence. Pictures work as before.
|
||||
|
||||
### Performance
|
||||
- **Big sections render lazily, so the page stays inside its DOM-node budget.** The Magazine section's markup is now fetched and injected the first time you open it (or deep-link into it) instead of at startup; the Performance background-jobs history is fetched and drawn only while Performance is shown (it used to add about 470 nodes at startup); and the language pickers keep a single option in the hidden native `<select>` while the searchable popup renders the full list on demand (setting `select.value` programmatically still works). Together this removes roughly 1,000 DOM nodes from the idle page and fixes the failing `tests/constrained.spec.js` node-budget test (5,862 nodes against a 5,000 limit before, about 4,750 now). Deep links, keyboard and screen-reader behavior are unchanged.
|
||||
|
||||
### Fixed
|
||||
- **Character image runs are isolated per character.** The profile + reference-sheet job (`POST /api/character-generate-consistent-images`) could hand back an earlier character's job when the same `Idempotency-Key` was reused for a different character; the key is now bound to a fingerprint of the character's name, book, identity fields, style and prompts, and a mismatch is rejected with 409 instead of returning the other character's images. The fingerprint is not exposed in the status payload. New regression tests (`tests/test_character_image_isolation.py`) generate a dwarf and an elf back to back and concurrently and check that each ComfyUI request carries only its own prompts, props, identity image, seed family and result, and that the request data is not mutated.
|
||||
- **Characters table on phones: the rating stars can be tapped again.** At 320-414 px the pinned checkbox, picture and name columns (328 px) covered the stars of the voice rating; on phones they now scroll with the table, the rating column is wide enough for five 44 px stars, and each star has a *Rate n of 5* label. Browser tests check at 320, 375 and 414 px that no star touches a pinned column, is hit-testable, is at least 44 px and that the page itself does not scroll sideways; the phone case of the profile/rating test is no longer skipped.
|
||||
|
||||
4
static/dist/main.min.js
vendored
4
static/dist/main.min.js
vendored
File diff suppressed because one or more lines are too long
@ -198,8 +198,10 @@
|
||||
}
|
||||
refresh.addEventListener('click', load);
|
||||
diagnosticsRefresh?.addEventListener('click', loadDiagnostics);
|
||||
load();
|
||||
loadDiagnostics();
|
||||
// Lazy: the history list (hundreds of DOM nodes) is only fetched/rendered
|
||||
// while Performance is shown, never at startup (DOM-node budget).
|
||||
window.jobsOnShow = function () { load(); loadDiagnostics(); };
|
||||
if (document.getElementById('s-performance')?.classList.contains('is-active')) window.jobsOnShow();
|
||||
setInterval(() => {
|
||||
if (document.getElementById('s-performance')?.classList.contains('is-active')) { load(); loadDiagnostics(); }
|
||||
}, 10000);
|
||||
|
||||
@ -1298,10 +1298,10 @@ function langFlag(code) {
|
||||
// listener keeps working untouched. That matters here because routing reads
|
||||
// `row.querySelector('.route-lang').value` when saving — swapping in a custom
|
||||
// element would have silently broken saving rather than looking broken.
|
||||
function enhanceLangSelect(sel) {
|
||||
function enhanceLangSelect(sel, fullOptions) {
|
||||
if (!sel || sel.dataset.enhanced === '1') return;
|
||||
sel.dataset.enhanced = '1';
|
||||
const opts = [...sel.options].map(o => ({ value: o.value, label: o.textContent.trim() }));
|
||||
const opts = Array.isArray(fullOptions) ? fullOptions : [...sel.options].map(o => ({ value: o.value, label: o.textContent.trim() }));
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'langsel';
|
||||
const btn = document.createElement('button');
|
||||
@ -1378,10 +1378,29 @@ function initLangSelects(root) {
|
||||
scope.querySelectorAll('select[data-langsel]').forEach(sel => {
|
||||
if (sel.dataset.enhanced === '1') return;
|
||||
const cur = sel.value || sel.getAttribute('data-default') || 'EN';
|
||||
sel.innerHTML = LANG_CODE_OPTIONS
|
||||
.map(([c, n]) => `<option value="${c}"${c === cur ? ' selected' : ''}>${escHtml(n)}</option>`).join('');
|
||||
if ([...sel.options].some(o => o.value === cur)) sel.value = cur;
|
||||
enhanceLangSelect(sel);
|
||||
// DOM-node budget: the hidden native <select> holds only the current
|
||||
// option (the searchable popup renders the full list on demand), and a
|
||||
// `value` setter adds any option that is assigned later, so programmatic
|
||||
// `sel.value = 'DE'` keeps working exactly as with a full list.
|
||||
const full = LANG_CODE_OPTIONS.map(([c, n]) => ({ value: c, label: n }));
|
||||
const labelOf = c => (full.find(o => o.value === c) || { label: c }).label;
|
||||
const ensure = c => {
|
||||
if (c != null && c !== '' && ![...sel.options].some(o => o.value === c) && full.some(o => o.value === c)) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c; opt.textContent = labelOf(c);
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
};
|
||||
sel.innerHTML = '';
|
||||
ensure(cur);
|
||||
const native = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value');
|
||||
Object.defineProperty(sel, 'value', {
|
||||
configurable: true,
|
||||
get() { return native.get.call(this); },
|
||||
set(v) { ensure(v); native.set.call(this, v); },
|
||||
});
|
||||
sel.value = cur;
|
||||
enhanceLangSelect(sel, full);
|
||||
});
|
||||
scope.querySelectorAll('select[data-langsel-keep]').forEach(enhanceLangSelect);
|
||||
}
|
||||
|
||||
@ -56,6 +56,13 @@
|
||||
// JS/CSS use the version string so the service worker cache stays valid across reloads.
|
||||
var _ts = Date.now();
|
||||
|
||||
// Sections whose only consumer is a lazy feature group: their markup is not
|
||||
// fetched/injected at startup (DOM-node budget, tests/constrained.spec.js);
|
||||
// it is injected on first navigation, before that group's modules run.
|
||||
var LAZY_HTML_SECTIONS = ['s-magazine'];
|
||||
var _uiReady = false;
|
||||
var _sectionHtml = {};
|
||||
|
||||
function _inject(id, html) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.innerHTML = html;
|
||||
@ -80,7 +87,7 @@
|
||||
// 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; })
|
||||
SECTIONS.filter(function (id) { return id !== _activeSection && LAZY_HTML_SECTIONS.indexOf(id) === -1; })
|
||||
.map(async function (id) {
|
||||
try {
|
||||
var res = await fetch('/static/sections/' + id + '.html?v=' + _ts);
|
||||
@ -203,7 +210,31 @@
|
||||
// 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.
|
||||
function _ensureSectionHtml(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (LAZY_HTML_SECTIONS.indexOf(id) === -1 || !el || el.firstElementChild) return Promise.resolve();
|
||||
if (_sectionHtml[id]) return _sectionHtml[id];
|
||||
_sectionHtml[id] = fetch('/static/sections/' + id + '.html?v=' + _ts)
|
||||
.then(function (res) {
|
||||
if (!res.ok) throw new Error(res.status + ' ' + res.statusText);
|
||||
return res.text();
|
||||
})
|
||||
.then(function (html) {
|
||||
_inject(id, html);
|
||||
if (_uiReady && window.applyI18n) window.applyI18n(el);
|
||||
})
|
||||
.catch(function (e) {
|
||||
delete _sectionHtml[id];
|
||||
el.innerHTML = '<p style="color:var(--red);padding:24px">Failed to load section <b>' + id + '</b>: ' + e.message + '</p>';
|
||||
});
|
||||
return _sectionHtml[id];
|
||||
}
|
||||
|
||||
window.ensureFeatureModules = function (sectionId) {
|
||||
return _ensureSectionHtml(sectionId).then(function () { return _ensureModules(sectionId); });
|
||||
};
|
||||
|
||||
function _ensureModules(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); });
|
||||
@ -215,7 +246,7 @@
|
||||
_notifyLoadFailure();
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// D — navigation (must run before init/reveal so routing is interactive before
|
||||
// async settings or voice-library refreshes can occupy the backend).
|
||||
@ -251,6 +282,7 @@
|
||||
if (window.initLangPicker) window.initLangPicker();
|
||||
if (window.applyI18n) window.applyI18n(document);
|
||||
if (window.initAccessibilityEnhancements) window.initAccessibilityEnhancements();
|
||||
_uiReady = true;
|
||||
|
||||
// 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.
|
||||
|
||||
@ -191,6 +191,7 @@
|
||||
if (sectionId === 's-library' && typeof window.libraryRender === 'function') window.libraryRender(window._libraryView || 'books');
|
||||
if (sectionId === 's-caststudio' && typeof window.studioOnShow === 'function') window.studioOnShow();
|
||||
if (sectionId === 's-magazine' && typeof window.magOnShow === 'function') window.magOnShow();
|
||||
if (sectionId === 's-performance' && typeof window.jobsOnShow === 'function') window.jobsOnShow();
|
||||
if (typeof window.initCollapsibleCards === 'function') window.initCollapsibleCards();
|
||||
setStoredSection(sectionId);
|
||||
setSectionHash(sectionId);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user