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:
parent
1061e9c5c4
commit
9fddcb9901
5 changed files with 141 additions and 4 deletions
98
internal/check-one-scroll-container.mjs
Normal file
98
internal/check-one-scroll-container.mjs
Normal 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.`)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in a new issue