min-height clamps height, so the pane could not hold the plugin it was pinning

The Driver's least favourite bug: on DX Life's research page a capability-4 plugin
with tabs at the top had a second, element-level scrollbar, and scrolling it scrolled
the TAB HEADER out of view.

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 never granted
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 it was meant to fill, .dms-slot grew with it, and .dms-pane's overflow-y:auto
became a real scroll container wrapping the WHOLE plugin, tab strip included. That is
the second scrollbar: not the plugin's content scroller, the PANE scrolling the plugin
bodily. The invariant was written as a description and was only ever a hope.

The shell now pins with min-height:0. An inline style outranks a child's :host rule,
so that one property restores the invariant for every fixed-viewport plugin at once
without editing any of them. It is NOT added to the scroll-stack branch, where
natural height is the design.

A floor set INSIDE a child's shadow root is out of reach from the shell, and two of
mine had one. research-dashboard's .rd-shell and big-picture's .bp-root now say
min-height:inherit, deferring to :host -- which the gallery pins to 0, and which a
naive page leaves at its own floor, so the collapse guard those floors exist for is
kept rather than deleted. big-picture's 480px MOVED UP to :host rather than being
dropped: :host had no floor there, so inheriting would have quietly resolved to 0
everywhere and thrown away the black-screen guard its own comment calls load-bearing.

check-one-scroll-container asserts both halves, because either alone rots: the shell
still pins with min-height:0, and no plugin grows a NEW floor inside its shadow root.
The second half is what matters later -- a fixed shell plus a plugin that adds
min-height:700px next month is this bug again and nobody would connect it.

The rule is a CONTRADICTION IN ONE DECLARATION, not a large min-height by itself: an
element saying height:100% ("I fill whatever holds me") while refusing to be shorter
than N. My first version flagged any big min-height and caught gospel-resources'
.gd-map, a map deliberately sized height:80svh that scrolls inside its root's own
content scroller -- the one scrollbar a plugin is allowed. Flagging it would have sent
its owner after a non-bug, which is worse than not having the gate.

Negative-tested: removing min-height:0 from the shell, and reverting either .rd-shell
or .bp-root to a constant, each add a failure; restored, only the known one remains.

RED ON PURPOSE, and deliberately NOT in gates:pre. gospel-internet's .gb still carries
min-height:640px and that fix is its owner's. Wiring a red gate into gates:pre would
block the fleet on someone else's file, so the gates:pre entry belongs in the same
landing that fixes .gb -- until then this gate is the work item, afterwards the guard.

Source only: doxa-maps-spa, research-dashboard and big-picture need rebuilding, which
is the build owner's to do.
This commit is contained in:
Doxa Maps 2026-09-28 14:38:06 +00:00
parent 1061e9c5c4
commit 9fddcb9901
5 changed files with 141 additions and 4 deletions

View file

@ -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.`)

View file

@ -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",

View file

@ -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;
}

View file

@ -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) {

View file

@ -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";