From 13cfa3545c823d1cc65f75857f1128943d4a65c4 Mon Sep 17 00:00:00 2001 From: Jeremy Andrews Date: Sun, 2 Aug 2026 11:07:02 +0200 Subject: [PATCH] Re-vendor the browser bundle: resize-aware summary clamp, and no stranded skeleton Picks up scolta-php#269, two follow-ups to the layout reservation. The clamp decision is a measurement, and it ran only on resolve and on a toggle click, so it froze at the width the summary landed in. Narrow the column afterwards and a summary that fitted reflows past the reserved height: still clipped, but with no fade and no control, so a sighted reader saw text cut off with no way to open it. A feature-detected ResizeObserver on the text region now recomputes it, one live at a time, disconnected on release and on expand, re-established on collapse, and never installed when the summary is off. The box measures the same at every width, so the reveal moves nothing. Separately, the un-awaited summarizeResults() call had nothing chained onto it, so a throw before its fetch became a rejected promise the expansion chain's catch never saw, stranding the reserved skeleton. It now carries its own version-guarded catch. No Python-side change. Copied byte-identically with scripts/vendor_assets.py, verified with cmp. --- CHANGELOG.md | 5 +++ src/scolta/assets/js/scolta.js | 81 +++++++++++++++++++++++++++++++--- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c57d06..0f8a0ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to scolta-python are documented here. ## [Unreleased] +### Fixed +- **Re-vendored the browser bundle (`src/scolta/assets/js/scolta.js`) from scolta-php: the AI summary's "Show more" control now follows the viewport width, and a summarize failure can no longer strand the loading skeleton** + ([tag1consulting/scolta-php#269](https://github.com/tag1consulting/scolta-php/pull/269)). + Two follow-ups to the layout reservation. **First**, whether to clamp the summary and offer the control is a measurement (`scrollHeight` against `clientHeight`), and it ran only on resolve and on a toggle click, so the decision froze at the width the summary landed in. Rotate a phone to portrait, or shrink a responsive column, and a summary that fitted reflows past the reserved height: the text is still clipped, but with no clamped class there is no fade and the control stays hidden, so **a sighted reader saw text cut off at the box edge with no on-screen way to open it** (the full text stays in the DOM, so find-in-page and assistive tech were never affected). Widening left a pointless control behind. A feature-detected `ResizeObserver` on the text region now recomputes it, with exactly one live at a time, disconnected when the slot is released and when the user expands the summary, re-established on collapse, and never installed when the AI summary is off. The recompute toggles a mask class and the control's `hidden` flag inside the fixed-height panel, and the reserved height derives from a line count rather than the width, so **the box measures the same at every width** (297.03px at both 1280px and 420px) and the reveal moves nothing. **Second**, the search flow's un-awaited `summarizeResults(...)` call had nothing chained onto it; the work before its fetch is outside its own error handling, and because the function is `async` a throw there became a rejected promise the expansion chain's `.catch` never saw. With the slot reserved from the result paint, that left a skeleton shimmering permanently. The call now carries its own `.catch`, guarded on the search version so an abandoned cycle cannot collapse a newer search's slot. Nothing on the Python side changed and no index rebuild is needed. Copied byte-identically from the canonical `scolta-php` assets, verified with `cmp`. + ### Fixed - **Re-vendored the browser bundle (`src/scolta/assets/js/scolta.js`, `src/scolta/assets/css/scolta.css`) from scolta-php: the AI summary no longer shoves the result list down when it arrives** ([tag1consulting/scolta-php#268](https://github.com/tag1consulting/scolta-php/pull/268)). diff --git a/src/scolta/assets/js/scolta.js b/src/scolta/assets/js/scolta.js index dabe007..4ce0b5a 100644 --- a/src/scolta/assets/js/scolta.js +++ b/src/scolta/assets/js/scolta.js @@ -387,6 +387,10 @@ let conversationMessages = []; let followUpCount = 0; let abortController = null; + // Watches the resolved summary's text region so the clamp decision follows + // the width instead of being frozen at the width it resolved in. Exactly one + // at a time; see observeSummaryClamp(). + let summaryClampObserver = null; let queryFacetCounts = {}; // { dimension: { value: count } } — per typed query, folded once when expansion lands let currentQuery = ""; let allHighlightTerms = []; @@ -1240,6 +1244,8 @@ function releaseSummarySlot() { const summaryEl = els && els.aiSummary; if (!summaryEl) return; + // The element it was watching is about to be emptied. + disconnectSummaryClamp(); summaryEl.style.display = 'none'; summaryEl.className = ''; summaryEl.innerHTML = ''; @@ -1272,6 +1278,46 @@ toggle.hidden = !overflows; } + /** + * Keep the clamp decision honest as the text region's width changes. + * + * updateSummaryClamp() measures, so its answer is only true for the width it + * measured at. It ran once on resolve and again on a toggle click, which + * froze the decision at resolve-time width: rotate a phone to portrait, or + * shrink a responsive column, and a summary that fitted reflows to more + * lines and overflows the reserved height. The text is still clipped — + * .scolta-ai-summary-text is overflow:hidden while reserved — but without + * the clamped class there is no fade and the control stays hidden, so a + * sighted reader sees text cut off at the box edge with no way to open it. + * (The full text is in the DOM throughout, so find-in-page and assistive + * tech were never affected; the visible affordance was.) Widening has the + * mirror problem: a pointless control on a summary that now fits. + * + * Recomputing costs no layout shift. It toggles a mask class and the + * control's hidden flag, and the control lives inside the fixed-height, + * overflow-hidden panel, so nothing outside the box can move. + */ + function observeSummaryClamp() { + // Feature-detected: older engines and JSDOM have no ResizeObserver, and + // the resolved path must not throw for want of it. Without one the + // behaviour is exactly what it was before this existed. + if (typeof ResizeObserver === 'undefined') return; + const summaryEl = els && els.aiSummary; + if (!summaryEl) return; + if (!getInstanceConfig().AI_SUMMARIZE) return; + const textEl = summaryEl.querySelector('.scolta-ai-summary-text'); + if (!textEl) return; + disconnectSummaryClamp(); + summaryClampObserver = new ResizeObserver(() => updateSummaryClamp()); + summaryClampObserver.observe(textEl); + } + + function disconnectSummaryClamp() { + if (!summaryClampObserver) return; + summaryClampObserver.disconnect(); + summaryClampObserver = null; + } + /** * Drop the reserved height so the whole summary (or a follow-up answer) is * visible. Always the result of a click or a keypress, so the resulting @@ -1280,6 +1326,11 @@ function expandSummarySlot() { const summaryEl = els && els.aiSummary; if (!summaryEl) return; + // The user has opened the summary. Stop watching rather than re-clamping + // against that choice: updateSummaryClamp() would no-op on an unreserved + // panel anyway, but an observer left running on an expanded summary is + // just work nobody asked for. + disconnectSummaryClamp(); summaryEl.classList.remove(SUMMARY_RESERVED_CLASS, SUMMARY_CLAMPED_CLASS); const toggle = summaryEl.querySelector('[data-scolta-summary-toggle]'); if (toggle) { @@ -1298,6 +1349,8 @@ toggle.textContent = 'Show more'; } updateSummaryClamp(); + // Reserved again, so width changes matter again. + observeSummaryClamp(); } function toggleSummaryExpanded() { @@ -1444,6 +1497,8 @@ ${disclaimerHtml}`; updateSummaryClamp(); + // The decision above is only true for the width it measured at. + observeSummaryClamp(); } else { // Nothing to show. Collapse to exactly what a deployment with the // summary disabled looks like rather than leaving an empty box. @@ -4414,12 +4469,28 @@ const expandedLabel = expandedTerms ? expandedTerms.filter(t => t.toLowerCase() !== query.toLowerCase()) : []; - summarizeResults(query, allScoredResults, expandedLabel, sortHint, filterHint, activeFilters); + // Deliberately not awaited — the summary is allowed to land after this + // chain settles — which is exactly why it needs its own catch. Nothing + // is chained onto the promise it returns, so a rejection from it does + // NOT reach the .catch below; it becomes an unhandled rejection and the + // reserved skeleton shimmers forever. summarizeResults() handles its own + // fetch failures, but the work before that fetch (candidate selection, + // context assembly) is outside them, and on a malformed result set a + // throw there used to strand the slot with no way back. + summarizeResults(query, allScoredResults, expandedLabel, sortHint, filterHint, activeFilters) + .catch(e => { + if (version !== searchVersion) return; + console.warn('[scolta:summarize] failed before the request:', e); + releaseSummarySlot(); + }); }).catch(e => { - // The slot is reserved from the result paint, so anything that throws - // between there and summarizeResults() now leaves a skeleton shimmering - // forever instead of failing silently. Collapse it and say why. Only - // this cycle's slot: a newer search owns the panel once it starts. + // The slot is reserved from the result paint, so anything that throws in + // the expansion phase — between that paint and the summarize call — now + // leaves a skeleton shimmering forever instead of failing silently. + // Collapse it and say why. This covers the awaited work above only; the + // un-awaited summarizeResults() call carries its own catch, for the + // reason given there. Only this cycle's slot: a newer search owns the + // panel once it starts. if (version !== searchVersion) return; console.warn('[scolta:search] expansion phase failed:', e); releaseSummarySlot();