diff --git a/internal/check-one-scroll-container.mjs b/internal/check-one-scroll-container.mjs new file mode 100644 index 000000000..bbfdf885c --- /dev/null +++ b/internal/check-one-scroll-container.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/** + * check-one-scroll-container — a plugin pinned into the gallery's pane must not assert a height FLOOR + * the pane cannot honour. + * + * THE DEFECT THIS EXISTS FOR (Driver, 2026-09-28, "one of my least favorite bugs"): on DX Life's + * research page a capability-4 plugin with tabs at the top had an EXTRA element-level scrollbar, and + * scrolling it scrolled the TAB HEADER out of view — on top of the plugin's own content scroll. + * + * WHY IT HAPPENED, AND WHY A COMMENT WAS NOT ENOUGH. doxa-maps-spa pins each fixed-viewport plugin + * with position:absolute;inset:0;height:100% and its own comment states the invariant: "fills the pane + * exactly, so scrollHeight===clientHeight and overflow:auto paints NO scrollbar." CSS does not grant + * that. A used height is CLAMPED between min-height and max-height, so a child's own min-height beats + * the height:100% the shell sets — it is a floor, not a preference. Wherever the pane was shorter than + * that floor the pinned element grew taller than the pane, .dms-slot grew with it, and .dms-pane's + * overflow-y:auto became a real scroll container wrapping the WHOLE plugin, tab strip included. + * + * SO THIS GATE ASSERTS BOTH HALVES, because either alone rots: + * 1. the shell still pins with min-height:0 — the one property that defeats a :host floor; + * 2. no plugin grows a NEW floor on an element inside its own shadow root, where (1) cannot reach. + * (2) is the half that matters over time: a fixed shell plus a plugin that adds .foo{min-height:700px} + * next month is the same bug again, and nobody would connect it to this. + * + * Source-level and headless BY DESIGN. Only rega runs a browser here, and the browser proof is rega's + * scroll-container count. This is the cheap guard that keeps the class from coming back between runs. + * ★ THIS GATE IS RED ON PURPOSE AT LANDING, and is NOT wired into gates:pre yet. gospel-internet's + * .gb still carries min-height:640px; that fix belongs to its owner, not to me, and wiring a red gate + * into gates:pre would block the whole fleet on someone else's file. Add the gates:pre entry in the + * same landing that fixes .gb -- until then this is the work item, afterwards it is the guard. + * + * Exit 0 pass · 1 fail. + */ +import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs' +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const TAG = 'ONE-SCROLL-CONTAINER' +const fails = [] + +// ── 1. the shell pins with a defeated floor ──────────────────────────────────────────────────── +const SHELL = join(ROOT, 'plugin-designs/doxa-maps-spa/doxa-maps-spa.vue') +const shell = readFileSync(SHELL, 'utf8') +const pinned = shell.match(/'display:block;position:absolute;inset:0;[^']*'/) +if (!pinned) { + fails.push('doxa-maps-spa: could not find the pinned cssText at all — it was rewritten; re-read this gate') +} else if (!/min-height:\s*0/.test(pinned[0])) { + fails.push(`doxa-maps-spa pins a plugin WITHOUT min-height:0, so any child floor wins over height:100%:\n ${pinned[0]}`) +} + +// ── 2. no plugin asserts a floor the shell cannot defeat ─────────────────────────────────────── +// A floor on :host is FINE: the shell's inline min-height:0 outranks it. A floor on an inner selector +// is not reachable from outside the shadow root, so it must be relative (inherit / %) rather than a +// constant taller than a short pane. THRESHOLD: 400px. Below that a floor cannot exceed a realistic +// pane, and small min-heights (rows, buttons, toolbars) are everywhere and are not this bug. +const THRESHOLD = 400 +const DESIGNS = join(ROOT, 'plugin-designs') +for (const bundle of readdirSync(DESIGNS)) { + const dir = join(DESIGNS, bundle) + if (!existsSync(dir) || !statSync(dir).isDirectory()) continue + if (!readdirSync(dir).some((f) => f.endsWith('.vue'))) continue + for (const file of readdirSync(dir).filter((f) => f.endsWith('.vue'))) { + // Join CSS built by JS string concatenation ('…' + \n '…') so a rule split across lines is seen. + const src = readFileSync(join(dir, file), 'utf8').replace(/'\s*\+\s*\n\s*'/g, '') + for (const rule of src.match(/[^;{}\n]*\{[^{}]*min-height:\s*\d+px[^{}]*\}/g) || []) { + const px = Number((rule.match(/min-height:\s*(\d+)px/) || [])[1]) + if (!Number.isFinite(px) || px < THRESHOLD) continue + // ★ THE DEFECT IS A CONTRADICTION IN ONE RULE, not a large min-height on its own. The flagged + // shape is an element that says height:100% -- "I fill whatever box holds me" -- and in the same + // breath refuses to be shorter than N. Only that element is trying to be the pane and failing. + // A deliberately TALL inner region is not this bug: gospel-resources' .gd-map is + // height:80svh;min-height:420px, a map sized to the viewport on purpose, and it scrolls inside + // its own root's content scroller, which is the ONE scrollbar the plugin is allowed. Flagging it + // would have sent its owner after a non-bug, which is worse than not having this gate. + if (!/height:\s*100%/.test(rule)) continue + // The CSS is often a JS string, so a selector can arrive carrying the opening quote and the + // tail of a concatenation. Strip those before testing, or a :host rule reads as ":host and + // reports itself as an inner selector — which it is not, and the shell already defeats it. + const selector = rule.slice(0, rule.indexOf('{')).trim().split('\n').pop() + .replace(/^[\s'"+,]+/, '').trim() + // :host floors are defeated by the shell; anything else is not. + if (/^:host/.test(selector)) continue + fails.push( + `${bundle}/${file}: "${selector}" sets min-height:${px}px inside the shadow root.\n` + + ' The shell cannot defeat this from outside, so in a pane shorter than ' + px + 'px it\n' + + ' grows a second scroll container that scrolls the tab strip away. Use min-height:inherit\n' + + ' (defer to :host, which the shell pins to 0) or drop the floor.', + ) + } + } +} + +if (fails.length) { + console.error(`${TAG}: FAIL`) + for (const f of fails) console.error(` ✗ ${f}`) + process.exit(1) +} +console.log(`${TAG}: PASS — the gallery pins plugins with min-height:0, and no plugin asserts a floor inside its shadow root that a short pane could not honour.`) diff --git a/package.json b/package.json index 34923b2bc..a830555a9 100644 --- a/package.json +++ b/package.json @@ -193,6 +193,7 @@ "check:parse": "node internal/inline-parse-check.mjs", "check:pinned-umd": "node internal/check-pinned-umd.mjs", "check:popup-escaping": "node internal/check-popup-escaping.mjs", + "check:one-scroll-container": "node internal/check-one-scroll-container.mjs", "check:pin-selection": "node internal/check-pin-selection.mjs", "check:poster-action-print": "node internal/check-poster-action-print.mjs", "check:poster-card-fits": "node internal/check-poster-card-fits.mjs", diff --git a/plugin-designs/big-picture/big-picture.vue b/plugin-designs/big-picture/big-picture.vue index a5f68dfa7..4725253f1 100644 --- a/plugin-designs/big-picture/big-picture.vue +++ b/plugin-designs/big-picture/big-picture.vue @@ -226,11 +226,22 @@ function onGroupsFiltered(payload) { .bp-root's height:100% resolve against nothing → 0-height collapse (the black-screen class). Declaring :host here means this element fills its slot even if the mounter forces no display (or the wrong one). See BUG-EMBEDDED-PLUGIN-STUDIO-BLACK-SCREEN-DISPLAY-BLOCK. */ -:host { display: block; width: 100%; height: 100%; } +:host { display: block; width: 100%; height: 100%; min-height: 480px; } .bp-root { --bg: #f3f3f1; --line: #d1d4cd; --ink: #3b463d; --mut: #6d766e; display: flex; flex-direction: column; - width: 100%; height: 100%; min-height: 480px; overflow: hidden; + /* min-height:INHERIT, not 480px — the same fix as research-dashboard's .rd-shell. The floor keeps a + host that supplies no height from collapsing this to nothing, which is worth keeping; stated as a + constant it ALSO applied inside the gallery's height-bounded pane, where an element claiming + height:100% and simultaneously refusing to be shorter than 480px is a contradiction the pane pays + for. It grows past .dms-slot and .dms-pane's overflow-y:auto starts scrolling the whole plugin, + tab strip included. Inheriting defers to :host: the gallery pins the element with an inline + min-height:0 so this resolves to 0 and fills the pane, while a naive page keeps the guard. + The 480px itself MOVED UP to :host rather than being deleted: :host had no floor, so inheriting + from it would have quietly resolved to 0 everywhere and dropped the collapse guard the comment + above calls load-bearing. On :host it is still a real floor for a naive mounter AND reachable by + the shell's inline style, which is the whole point. */ + width: 100%; height: 100%; min-height: inherit; overflow: hidden; background: var(--bg); color: var(--ink); box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; } diff --git a/plugin-designs/doxa-maps-spa/doxa-maps-spa.vue b/plugin-designs/doxa-maps-spa/doxa-maps-spa.vue index 749eb9631..38a760d38 100644 --- a/plugin-designs/doxa-maps-spa/doxa-maps-spa.vue +++ b/plugin-designs/doxa-maps-spa/doxa-maps-spa.vue @@ -1380,9 +1380,27 @@ export default defineComponent({ // them back to exactly one viewport regardless of real content height — the trap this fixes // (see SCROLL_ELEMENTS above). static+height:auto lets the element grow the pane's // scrollHeight past its clientHeight, which is what gives .dms-pane something to scroll. + // ★ min-height:0 IS LOAD-BEARING, AND height:100% DOES NOT IMPLY IT. CSS resolves a used + // height by CLAMPING the computed height between min-height and max-height, so a child's own + // min-height WINS over the height:100% we set here — it is a floor, not a preference. Nine + // bundles ship a root floor (research-dashboard 560px, gospel-internet 640px, + // wall-of-people-groups 600px, big-picture 480px, country-posters / my-upg-100-list 420px), + // and wherever the pane is shorter than that floor the pinned element grew TALLER than the + // pane it was supposed to fill. .dms-slot then grew with it and .dms-pane's overflow-y:auto + // (below) turned into a real scroll container — one that contains the WHOLE plugin, tab strip + // and all, so scrolling it scrolled the tab header out of view. That is the second scrollbar + // the Driver has been looking at: not the plugin's content scroller, the PANE scrolling the + // plugin bodily. The invariant two comments below ("fills the pane exactly, so + // scrollHeight===clientHeight") was written as a description and was only ever a hope. + // An inline style outranks the child's own :host rule, so this one property restores the + // invariant for every fixed-viewport plugin at once, without editing any of them. A floor set + // on an element INSIDE the child's shadow root is out of reach from here and stays its + // owner's to fix (research-dashboard's .rd-shell, gospel-internet's .gb). + // NOT added to the scroll-stack branch: those are natural-height by design and a floor is + // exactly what they are entitled to. el.style.cssText = t.scroll ? 'display:block;position:static;width:100%;height:auto;' - : 'display:block;position:absolute;inset:0;width:100%;height:100%;' + : 'display:block;position:absolute;inset:0;width:100%;height:100%;min-height:0;' slot.appendChild(el) t.state = 'ready' } catch (e) { diff --git a/plugin-designs/research-dashboard/research-dashboard.vue b/plugin-designs/research-dashboard/research-dashboard.vue index ef1d48c47..c44458833 100644 --- a/plugin-designs/research-dashboard/research-dashboard.vue +++ b/plugin-designs/research-dashboard/research-dashboard.vue @@ -380,7 +380,16 @@ useShadowStyles(` --fs-xs:10px; --fs-sm:11px; --fs-md:12px; --fs-lg:13px; --fs-xl:16px; --r-lg:10px; --r-md:6px; --r-pill:999px; } .profile-loader{ width:100%; height:100%; } - .rd-shell{ position:relative; display:grid; width:100%; height:100%; min-height:560px; box-sizing:border-box; + /* ★ min-height:INHERIT, NOT 560px. The floor exists so a host that gives the element no height at + all still renders a usable dashboard instead of collapsing to nothing — a real guard, worth + keeping. But stated as a constant it also applied inside a height-BOUNDED host, where it made this + grid taller than the pane holding it and handed the gallery's .dms-pane a second scrollbar that + scrolled the tab strip out of view (doxa-maps-spa.vue, the pinned cssText). Inheriting defers the + decision to :host, which is the only box that knows: the gallery pins the element with an inline + min-height:0 and this resolves to 0, so the grid fills the pane exactly; a naive page leaves :host + at its own 560px and the guard is unchanged. Inheritance crosses the shadow boundary, so this + reads the host's computed value and needs no attribute or class to switch on. */ + .rd-shell{ position:relative; display:grid; width:100%; height:100%; min-height:inherit; box-sizing:border-box; grid-template-columns: var(--facet-w,296px) minmax(0,1fr) var(--rail-w,296px); grid-template-rows: var(--rd-topbar-h,38px) auto var(--rd-stats-h,22px) 1fr var(--table-h,34%); grid-template-areas: "topbar topbar topbar" "filters filters filters" "stats stats stats" "facets map rail" "facets table rail";