doxa-maps/internal/check-one-scroll-container.mjs
Doxa Maps 00e505404c The gallery pane was ported from a working one, two lines out of three
rega's census refutes the min-height diagnosis I gave, and the arithmetic is what
settles it. Inside section.dms-pane the CONTENT measures exactly the viewport: 900 in a
794px pane at 1440x900, 844 in a 712px pane at 390x844. The overflow is not "about the
DX-life header", it IS the header -- 106 and 132 px -- and it happens on 12 of 16 tabs
INCLUDING plugins that assert no floor of their own, which is what proves the floor is
not theirs.

library/ProfileLoader.vue:336 floors EVERY profile at
`min-height: var(--profile-min-h, 100dvh)`. That measures against the VIEWPORT while the
pane measures against its HOST, so wherever anything sits above the gallery the profile
is taller than the pane by exactly that chrome, and .dms-pane's overflow-y:auto scrolls
the whole plugin -- tab strip included. ProfileLoader documents the trap and documents
the only lever: the floor is inside the child's SHADOW ROOT, where no host-page selector
reaches, so an inherited custom property is all a host has. `--profile-min-h: 0`.

IT IS TWO THIRDS OF A FIX, NOT A NEW DEFECT. .vtl-host and plugin-studio's .ps-pane
already set it. .dms-pane was copied from .vtl-host -- its own comment says "porting the
same proven shape" -- and took the first two lines: fill the host, own the overflow. The
third, release the child's viewport floor, is the one that makes the other two safe.

So the gate now asserts the THREE AS ONE SHAPE: a rule that is absolutely-filling AND
scrolling must release the floor. It also FAILS if it cannot find such a rule at all,
because a gate that silently stops finding its subject is the failure I spent this
morning removing from check:i18n-keys-exist -- a check that matches nothing reports the
same green as a check that matches everything.

Negative-tested: removing the opt-out from either pane fails it, and changing the pane's
shape so the gate cannot recognise it fails it too rather than passing blind.

MY DIAGNOSIS WAS SECOND-ORDER AND I SHOULD SAY SO PLAINLY. The min-height floors were
real and worth fixing, but they only bite when a plugin's own floor exceeds the pane.
This bites on every tab at every size. I reasoned from source to the first mechanism
that explained the one symptom I had been shown, and the tall-desktop arm -- the one I
asked rega to run precisely because it would refute me -- is what separated them.
2026-09-28 16:49:01 +00:00

139 lines
9.3 KiB
JavaScript

#!/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.',
)
}
}
}
// ── ★ THE HOST-SIZED SCROLLER IS A THREE-LINE SHAPE, AND IT MUST BE PORTED WHOLE ────────────────
// A pane that fills its host absolutely AND turns on overflow is also, necessarily, the thing that
// scrolls whatever its child overflows by. library/ProfileLoader.vue floors EVERY profile at
// `min-height: var(--profile-min-h, 100dvh)` -- a VIEWPORT measurement -- while such a pane measures
// against its HOST. So on any page with chrome above it, the child is taller than the pane by exactly
// the height of that chrome, and the pane scrolls the whole plugin, tab strip and all.
//
// That floor lives inside the child's SHADOW ROOT, where no host-page selector reaches. The single
// lever is the inherited custom property, which ProfileLoader documents as the opt-out. So the three
// belong together: fill the host, own the overflow, and release the child's viewport floor.
//
// ★ THIS IS GATED BECAUSE IT WAS ALREADY PORTED ONCE AND ARRIVED SHORT. doxa-maps-spa's .dms-pane was
// copied from VerticalTabsLayout's .vtl-host -- its own comment says "porting the same proven shape" --
// and took the first two lines. rega measured the result: 12 of 16 gallery tabs overflowing by exactly
// the DX-life header height, on plugins with no floor of their own. Two thirds of a fix reads as a
// whole one in review, which is precisely why a reviewer cannot be the check.
for (const [file, label] of [
['plugin-designs/doxa-maps-spa/doxa-maps-spa.vue', '.dms-pane'],
['library/layouts/VerticalTabsLayout.vue', '.vtl-host'],
]) {
const p = join(ROOT, file)
if (!existsSync(p)) { fails.push(`${file} is gone — this gate names it by path and now checks nothing`); continue }
const src = readFileSync(p, 'utf8')
// The rule for each pane: any rule that is BOTH absolutely-filling AND scrolling must release the floor.
const rules = src.match(/[^{}\n]*\{[^{}]*\}/g) || []
const scrollers = rules.filter((r) => /position:\s*absolute/.test(r) && /inset:\s*0/.test(r) && /overflow(-y)?:\s*auto/.test(r))
if (!scrollers.length) {
fails.push(`${file}: no absolutely-filling scroller found for ${label} — the shape moved and this gate is now blind to it`)
continue
}
for (const r of scrollers) {
if (!/--profile-min-h:\s*0/.test(r)) {
const sel = r.slice(0, r.indexOf('{')).trim().split('\n').pop().trim()
fails.push(`${file}: "${sel}" fills its host and owns the overflow but does NOT set --profile-min-h:0.\n`
+ ' ProfileLoader floors every child at 100dvh (the VIEWPORT), so on a page with a header the\n'
+ ' child is taller than this pane by the header height and this pane scrolls the whole plugin,\n'
+ ' tab strip included. The three lines are one shape: fill, scroll, release 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, and every host-sized scroller releases ProfileLoader's viewport floor.`)