Gate the scrim-covers-viewport rule, and say what was not verified
Some checks are pending
build-gate / build (push) Waiting to run

A `position:fixed; inset:0` scrim is only viewport-relative while nothing
establishes a containing block for fixed descendants. VerticalTabsLayout sets
`contain:layout` on .vtl-content, so inside the cap-5 studio every such scrim
covered the CONTENT PANE instead. Measured at a 1280x900 viewport:

    .dt-scrim / .modal-scrim / .lt-overlay ... 1024x793 @ (256,107)
    captured by div.vtl-content [contain:layout]

256 is the rail width, 107 the header height — left uncovered and CLICKABLE
behind what still looks like a modal. The fix (library/utils/viewportScrim.js)
measures the real origin and subtracts it, rather than removing the shell's
contain, which is load-bearing for a different reason.

This adds the gate that keeps it fixed. A source grep cannot: every one of these
scrims greps as a correct inset:0, because the variable is the ANCESTOR, not the
declaration — and which ancestors exist depends on the HOST, not the component.

check:scrim-viewport:self-test reverts each scrim to plain inset:0 and requires
EVERY case to fail; a gate that cannot fail is worse than no gate, and this one
measures a property that looks fine in a screenshot either way. The self-test
also surfaced that .lt-overlay is captured TWICE — div.lt-root [transform] as
well — collapsing it to 233x30 rather than merely to the pane.

SemanticTreeLegend's .stl-export-backdrop is fixed the same way but marked as
NOT verified through the UI: it sits behind SHOW_EXPORT_BUTTON = false, so the
menu that owns it cannot be opened. Correct-if-enabled, not observed.
This commit is contained in:
doxa-maps-88655c70 2026-09-22 21:54:39 +00:00
parent 9533cca050
commit 04eccdfa7a
5 changed files with 269 additions and 50 deletions

View file

@ -42,9 +42,17 @@
* search matches "South Asian" rather than 'A012'. Adding it to a control list would
* reintroduce the exact duplicate axis this card removed, so that is asserted explicitly.
*
* ── DATA ────────────────────────────────────────────────────────────────────────────────────
* Rows come from internal/fixtures/facet-one-field-rows.json — 114 rows sampled from the live
* pray-tools data, committed so the assertion runs identically on a fresh clone with no network
* (regenerate: `node internal/check-facet-one-field.mjs --refresh-fixture`). A MISSING fixture is
* a HARD FAILURE, never a fallback to generated rows: see the long note at loadRows() for the
* three different verdicts this gate gave about one unchanged config while it had a synthetic
* corpus. Hermetic — no network, no browser, no server.
*
* Run: node internal/check-facet-one-field.mjs (--self-test for the NEGATIVE test)
*/
import { readFileSync } from 'node:fs'
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
@ -98,62 +106,116 @@ function loadConfig() {
return make(RELIGION_FAMILIES, canonicalFamilyName, langFamilyByIso, lbl)
}
// ── the rows: the live fixture the other gates use, else a synthetic stand-in ────────────────
// ── THE ROWS MUST BE REAL, AND A MISSING FIXTURE IS A HARD FAILURE ──────────────────────────
// ★ THIS GATE SHIPPED TWICE ON SYNTHETIC DATA AND WAS WRONG BOTH TIMES, IN OPPOSITE DIRECTIONS.
// The recorded history, because the second symptom is the dangerous one:
//
// v1 every field indexed by `i % 6` -> religion, country and language moved in LOCKSTEP, so
// they induced identical partitions and the gate reported 15 DUPLICATE AXES in a config
// that has none.
// v2 distinct co-prime cycles -> down to three pairs, but THE PAIRS CHANGED BETWEEN RUNS on
// unchanged source (region-vs-cluster one minute, language-vs-people-bloc the next). A real
// duplicate-axis finding is ONE pair, not a rotating clique; a verdict that moves while the
// subject is still is a verdict about the DATA, not the config.
// v3 the same corpus then reported PASS. Same arithmetic coincidences, opposite answer.
//
// A PASS ON GENERATED DATA IS WORSE THAN THE FALSE POSITIVES WERE. Obvious nonsense gets ignored;
// a green line gets believed and acted on. In both directions the gate's answer was decided by its
// INPUT rather than by its SUBJECT, which makes it untrustworthy either way — the precise failure
// shape this repo keeps paying for.
//
// So the synthetic corpus is DELETED, not repaired. Rows come from a committed sample of the live
// pray-tools data, exactly as check-facet-cascade does, and a missing fixture EXITS 1 naming the
// regeneration command. It is never a fallback: on a fresh clone with no network, degrading to
// generated rows is precisely how a gate goes green without having measured anything.
const FIXTURE = resolve(ROOT, 'internal/fixtures/facet-one-field-rows.json')
function loadRows() {
for (const p of ['data/research-rows.json', 'data-private/research-rows.json',
'_qa-cycle/_fixtures/research-rows.json']) {
try {
const j = JSON.parse(readFileSync(resolve(ROOT, p), 'utf8'))
const rows = Array.isArray(j) ? j : (j.rows || j.data || j.results)
if (Array.isArray(rows) && rows.length) { note(`rows: ${rows.length} from ${p}`); return rows }
} catch (_) { /* try the next candidate */ }
try {
const j = JSON.parse(readFileSync(FIXTURE, 'utf8'))
const rows = Array.isArray(j) ? j : (j.rows || j.data || j.results)
if (Array.isArray(rows) && rows.length) return rows
} catch (_) { /* fall through to the hard failure below */ }
console.error('FACET-ONE-FIELD: FAIL — missing fixture\n' +
` ${FIXTURE}\n` +
' This gate asserts on the PARTITION each facet induces over REAL rows. It has no synthetic\n' +
' fallback on purpose: generated rows made it report 15 duplicate axes, then 3 rotating\n' +
' pairs, then PASS — three different answers about one unchanged config.\n' +
' Regenerate with: node internal/check-facet-one-field.mjs --refresh-fixture')
process.exit(1)
}
/**
* --refresh-fixture: rebuild internal/fixtures/facet-one-field-rows.json from the live API.
*
* ★ SAMPLED FOR BREADTH, NOT DEPTH — AND THAT IS WHY IT IS NOT check-facet-cascade's FIXTURE.
* The obvious move is to reuse internal/fixtures/facet-cascade-rows.json (same source, same row
* shape). MEASURED, IT WOULD SILENTLY SKIP HALF THIS GATE: that fixture is slimmed to the nine
* fields cascade needs, and `religion`, `engagement_status` and `imb_language_family` are NOT
* among them — 0 distinct values for each across its 73 rows. religion_family and language_family
* are exactly the derived facets this gate most needs to see, so they would project to '' on every
* row and be excluded from comparison entirely. Two gates, two different sampling requirements.
*
* Cascade needs DEPTH (rows spread across the children of a few parents, so a child count is
* non-zero). This gate needs BREADTH: the partition test is only meaningful when each axis shows
* its real spread of distinct values, so an under-sampled axis could look identical to another by
* accident — which is the artifact this fixture exists to remove. Sampling therefore walks the
* rows keeping any row that introduces a NEW value on ANY axis, which maximises distinct values
* per axis at a small row count.
*/
async function refreshFixture() {
const base = process.env.VITE_API_BASE_URL || 'https://pray.doxa.life'
const FIELDS = 'slug,name,wagf_region,wagf_block,country_code,rop1,imb_reg_of_people_2,' +
'imb_reg_of_people_25,primary_language,imb_language_family,religion,engagement_status,people_praying'
const res = await fetch(`${base}/api/people-groups/list?fields=${FIELDS}&lang=en`, {
headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64)' },
})
if (!res.ok) throw new Error(`list HTTP ${res.status}`)
const json = await res.json()
const all = Array.isArray(json) ? json : (json.posts || json.data || json.results || [])
if (!all.length) throw new Error('live list returned 0 rows')
const AXES = ['rop1', 'imb_reg_of_people_2', 'imb_reg_of_people_25', 'wagf_region', 'wagf_block',
'country_code', 'primary_language', 'imb_language_family', 'religion', 'engagement_status']
const val = (v) => (v && typeof v === 'object' ? (v.value ?? v.label ?? '') : (v ?? ''))
// ★ PER-AXIS CAP, because uncapped "novel on any axis" is not a sample — it is the dataset.
// Measured: with no cap this kept 1,932 of 2,107 rows (1.4 MB), because people_group alone has
// 1,736 distinct values and every row introduces a new one. A 1.4 MB committed blob is not a
// fixture, and fetching it defeats the point of committing one.
//
// The cap is safe because of what the assertion actually compares. Two axes can only be mistaken
// for one another when they induce the SAME partition, which requires the same GROUP COUNT. The
// deep axes do not need exhaustive coverage to be distinguishable — they need enough values to be
// unmistakably finer-grained than the coarse ones. 40 per axis keeps every coarse axis COMPLETE
// (rop1 16, wagf_region 7, wagf_block 25, imb_language_family 11, religion 31, engagement 2 —
// all under the cap, so nothing is lost where confusion is actually possible) while truncating
// only the three deep axes, which stay far above any coarse axis's group count.
const CAP = 40
const seen = Object.fromEntries(AXES.map((a) => [a, new Set()]))
const keep = []
for (const r of all) {
let novel = false
for (const a of AXES) {
const v = String(val(r[a]))
if (v && !seen[a].has(v) && seen[a].size < CAP) { seen[a].add(v); novel = true }
}
if (novel) keep.push(r)
}
return null
const F = ['slug', 'name', ...AXES, 'people_praying']
const slim = keep.map((r) => { const o = {}; for (const f of F) if (r[f] != null) o[f] = r[f]; return o })
mkdirSync(dirname(FIXTURE), { recursive: true })
writeFileSync(FIXTURE, JSON.stringify(slim))
console.log(`fixture refreshed: ${slim.length} rows -> ${FIXTURE}`)
for (const a of AXES) console.log(` ${a.padEnd(24)} distinct=${seen[a].size}`)
}
const cfg = loadConfig()
const facets = cfg.facets || {}
let rows = loadRows()
if (!rows) {
// No fixture on disk. Synthesize rows that exercise the REAL accessors over the REAL field
// shapes ({value,label} pairs), which is all assertion A needs: it compares PARTITIONS, and a
// partition is a property of the accessors, not of the live API's exact contents.
//
// ★ THE CORPUS MUST MAKE INDEPENDENT AXES INDEPENDENT, AND THE FIRST ONE DID NOT.
// The first version of this fixture indexed every field with `i % 6`. That made religion,
// country and language PERFECTLY CORRELATED — each had 6 values changing in lockstep — so
// they induced the IDENTICAL partition and the gate reported 15 duplicate axes in a config
// that has none. The logic was right and the DATA was a fiction, which is the more dangerous
// of the two failures: a gate that always fails gets muted, and a muted gate protects nothing.
//
// So each axis now cycles on a DISTINCT PRIME (7, 11, 13, 17, 19, 23, 29). Distinct primes
// share no common period below their product, so no two axes can drift into lockstep across
// the 400 rows sampled here — any equal partition this gate now reports is a property of the
// ACCESSORS, which is exactly what it claims to measure.
note('rows: no fixture found — using a synthetic {value,label} corpus (co-prime cycles)')
const PEOPLE = ['South Asian Peoples','East Asian Peoples','Sub-Saharan Peoples','Arab Peoples','Turkic Peoples','Malay Peoples','Latin Peoples']
const REGION = ['Asia','Africa','Europe','Americas','Oceania','Middle East','Eurasia','Caribbean','Arctic','Levant','Sahel']
const COUNTRY = [['IN','India'],['CN','China'],['NG','Nigeria'],['EG','Egypt'],['TR','Turkey'],['MY','Malaysia'],['BR','Brazil'],['FR','France'],['KE','Kenya'],['ID','Indonesia'],['PK','Pakistan'],['MX','Mexico'],['VN','Vietnam']]
const LANG = [['hin','Hindi'],['cmn','Mandarin'],['yor','Yoruba'],['arb','Arabic'],['tur','Turkish'],['zlm','Malay'],['por','Portuguese'],['fra','French'],['swh','Swahili'],['ind','Indonesian'],['urd','Urdu'],['spa','Spanish'],['vie','Vietnamese'],['ben','Bengali'],['tam','Tamil'],['tel','Telugu'],['mar','Marathi']]
// religion CODES whose FIRST LETTER is the family — the real shape religion_family parses.
const RELIGION = [['H1','Hinduism'],['M2','Islam - Sunni'],['C3','Christianity'],['B4','Buddhism'],['E5','Ethnoreligion'],['N6','Non-Religious'],['H7','Hinduism - Vaishnavite'],['M8','Islam - Shia'],['C9','Christianity - Orthodox'],['B10','Buddhism - Mahayana'],['A11','Atheist'],['O12','Other'],['S13','Sikhism'],['J14','Judaism'],['D15','Daoism'],['U16','Unknown'],['P17','Parsee'],['Z18','Zoroastrian'],['L19','Larger'] ]
const mk = (i) => ({
rop1: { value: 'A0' + (10 + (i % 7)), label: PEOPLE[i % 7] },
imb_reg_of_people_2: { value: 'C' + (100 + (i % 31)), label: 'Cluster ' + (i % 31) },
imb_reg_of_people_25: { value: '' + (300000 + (i % 29)), label: 'PG ' + (i % 29) },
wagf_region: { value: 'r' + (i % 11), label: REGION[i % 11] },
wagf_block: { value: 'b' + (i % 13), label: 'Bloc ' + (i % 13) },
country_code: { value: COUNTRY[i % 37 % 13][0] + (i % 37), label: COUNTRY[i % 37 % 13][1] + ' ' + (i % 37) },
engagement_status: { value: 'e' + (i % 3), label: (i % 3 === 0) ? 'Engaged' : 'Unengaged' },
primary_language: { value: LANG[i % 17][0], label: LANG[i % 17][1] },
// sparse, exactly as live (populated on a minority of rows) so the ISO fallback is exercised
imb_language_family: (i % 23 === 0) ? { value: 'ie', label: 'Indo-European' } : null,
religion: { value: RELIGION[i % 19][0], label: RELIGION[i % 19][1] },
population: 1000 + i, people_praying: i, adopted_by_churches: i % 2, name: 'Group ' + i,
})
rows = Array.from({ length: 400 }, (_, i) => mk(i))
}
if (process.argv.includes('--refresh-fixture')) { await refreshFixture(); process.exit(0) }
const rows = loadRows()
note(`rows: ${rows.length} from ${FIXTURE.replace(ROOT + '/', '')} (live sample, committed)`)
// ── project every facet over the rows ───────────────────────────────────────────────────────
const projections = new Map() // facetKey -> array of per-row output values (strings)

View file

@ -0,0 +1,149 @@
#!/usr/bin/env node
/**
* check-scrim-covers-viewport.mjs — a full-screen SCRIM must cover the VIEWPORT in every host,
* not just the content pane it happens to be rendered into.
*
* THE DEFECT THIS CATCHES. A modal scrim is written `position:fixed; inset:0` on the assumption
* that fixed coordinates are viewport coordinates. That holds only while nothing in the ancestor
* chain establishes a containing block for fixed descendants. VerticalTabsLayout — the cap-5 shell
* every plugin is hosted in as a tab — sets `contain:layout` on .vtl-content. So the identical
* scrim read, at a 1280x900 viewport:
* STANDALONE leaf page .............. 1280x900 @ (0,0)
* INSIDE the plugin studio .......... 1024x793 @ (256,107)
* 256 is the rail width and 107 the header height. The scrim still LOOKS like a modal — it dims
* the pane it sits in — while leaving the rail and header UNCOVERED AND CLICKABLE behind it. A
* modal that does not capture input is not modal, which is why this is a correctness bug and not
* a cosmetic one.
*
* ★ WHY A SOURCE GREP CANNOT ANSWER THIS. Every one of these scrims greps as a correct
* `position:fixed;inset:0`. The bug is not in the declaration, it is in the ANCESTOR — and which
* ancestors exist depends on the HOST, not on the component. The variable that matters is not the
* CSS and not the viewport width, it is where the component is mounted. So this gate measures the
* shipped element's rect inside the real shell and compares it against the viewport.
*
* ★ AND WHY THE SHADOW-ROOT REASSURANCE IS A TRAP. poster-wall.vue carried a comment saying
* "position:fixed inside a shadow root is still viewport-relative, so this covers the whole app".
* TRUE, and IRRELEVANT: a shadow boundary scopes styles and selectors, it does not create a
* containing block. transform / filter / perspective / backdrop-filter / will-change of those —
* or contain:layout|paint|strict|content — do. Reasoning about the shadow boundary answers a
* question nobody asked, and it reads as a reason not to check.
*
* WHAT IT ASSERTS. For each (tab, trigger, scrim) case: open the scrim and require its rect to
* equal the viewport rect exactly. On failure it names the capturing ancestor from a live
* computed-style walk, so the report says `div.vtl-content [contain:layout]` rather than
* "misplaced".
*
* PRECONDITIONS: the DEV server (`bun run dev` on :5173) — the studio is the host under test.
* No Mapbox token needed: this measures DOM geometry and the scrims render without a basemap.
* Browser gate, so NOT in build:unlocked and NOT in CI.
*
* NEGATIVE-TESTED: `--self-test` re-measures each scrim with the util's geometry stripped back to
* a plain `inset:0` and requires EVERY case to fail. A gate that cannot fail is worse than no
* gate, and this one is measuring a property that "looks fine" in a screenshot either way.
*
* Exit: 0 all pass · 1 any fail · 2 dev server unreachable (a precondition, not a defect).
*/
import { chromium } from 'playwright'
const BASE = (process.env.DEV_URL || 'http://localhost:5173').replace(/\/+$/, '')
const SELF_TEST = process.argv.includes('--self-test')
const VW = 1280, VH = 900
// Shadow DOM does not pierce: recursive walk with a seen-Set. A single-level querySelector
// reports 0 elements for every one of these components and reads as "no bug".
const DEEP = `(sel)=>{const out=[],seen=new Set();const w=(r)=>{if(!r||seen.has(r))return;seen.add(r);try{out.push(...r.querySelectorAll(sel))}catch(e){};for(const el of r.querySelectorAll('*'))if(el.shadowRoot)w(el.shadowRoot)};w(document);return out}`
// Buttons inside the CONTENT PANE only — the rail tabs are buttons too, and matching one of those
// by label silently "clicks the trigger" while opening nothing.
const PANE_BUTTONS = `(()=>{const pane=(${DEEP})('.vtl-content')[0];const seen=new Set();const out=[];const w=(r)=>{if(!r||seen.has(r))return;seen.add(r);for(const el of r.querySelectorAll('button'))out.push(el);for(const el of r.querySelectorAll('*'))if(el.shadowRoot)w(el.shadowRoot)};w(pane);return out})()`
const CASES = [
{ name: 'DataTable .dt-scrim', tab: 'data-tables<cities', scrim: '.dt-scrim', rowTrigger: '.tabulator-row, .dt-row, tbody tr' },
{ name: 'DataTable .dt-scrim (gospel)', tab: 'data-tables<gospel', scrim: '.dt-scrim', rowTrigger: '.tabulator-row, .dt-row, tbody tr' },
{ name: 'upg poster-wall .modal-scrim', tab: 'poster-wall<my-upg', scrim: '.modal-scrim', buttonRe: 'Prayer List' },
{ name: 'LegendTools .lt-overlay', tab: 'countries-map<country-post', scrim: '.lt-overlay', buttonRe: '^⚙️$' },
]
const browser = await chromium.launch().catch(() => null)
if (!browser) { console.error('[scrim-covers-viewport] could not launch chromium'); process.exit(2) }
const page = await browser.newPage({ viewport: { width: VW, height: VH } })
page.on('pageerror', () => {})
const probe = await page.goto(BASE + '/', { waitUntil: 'domcontentloaded' }).catch(() => null)
if (!probe) {
console.error(`[scrim-covers-viewport] cannot reach ${BASE} — is \`bun run dev\` running?`)
await browser.close(); process.exit(2)
}
let pass = 0, fail = 0, skip = 0
const failures = []
console.log(`SCRIM-VIEWPORT: viewport ${VW}x${VH}${SELF_TEST ? ' [--self-test: every case MUST fail]' : ''}`)
for (const c of CASES) {
await page.goto(BASE + '/', { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(4000)
// Heavy tabs lazy-mount ON ACTIVATION — click the tab, then wait, or every query returns 0.
const onTab = await page.evaluate(`(()=>{const t=(${DEEP})('.vtl-tab').find(t=>(t.textContent||'').includes(${JSON.stringify(c.tab)})); if(!t) return false; t.click(); return true})()`)
if (!onTab) { console.log(`? ${c.name}: tab not found (${c.tab})`); skip++; continue }
await page.waitForTimeout(7000)
const opened = c.rowTrigger
? await page.evaluate(`(()=>{const r=(${DEEP})(${JSON.stringify(c.rowTrigger)}); if(!r.length) return false; r[0].click(); return true})()`)
: await page.evaluate(`(()=>{const re=new RegExp(${JSON.stringify(c.buttonRe)},'i'); const b=${PANE_BUTTONS}.find(e=>re.test((e.textContent||'').trim())); if(!b) return false; b.click(); return true})()`)
if (!opened) { console.log(`? ${c.name}: trigger not found`); skip++; continue }
await page.waitForTimeout(1800)
const r = await page.evaluate(`(()=>{
const s = (${DEEP})(${JSON.stringify(c.scrim)})[0];
if (!s) return { open:false };
${SELF_TEST ? `s.setAttribute('style','position:fixed;inset:0');` : ''}
const b = s.getBoundingClientRect();
const cap = []; let el = s;
while (el) {
if (el.nodeType === 1) {
const cs = getComputedStyle(el); const why = [];
if (cs.transform && cs.transform !== 'none') why.push('transform');
if (cs.filter && cs.filter !== 'none') why.push('filter');
if (/paint|layout|strict|content/.test(cs.contain||'')) why.push('contain:'+cs.contain);
if (why.length) cap.push((el.tagName||'').toLowerCase()+(typeof el.className==='string'&&el.className?'.'+el.className.trim().split(/\\s+/)[0]:'')+' ['+why.join(',')+']');
}
el = el.parentElement || (el.getRootNode() && el.getRootNode().host) || null;
}
return { open:true, x:Math.round(b.left), y:Math.round(b.top), w:Math.round(b.width), h:Math.round(b.height), cap };
})()`)
if (!r.open) { console.log(`? ${c.name}: scrim did not open`); skip++; continue }
const ok = r.x === 0 && r.y === 0 && r.w === VW && r.h === VH
if (ok) { pass++; console.log(` ✓ ${c.name} — ${r.w}x${r.h} @ (${r.x},${r.y})`) }
else {
fail++
const why = r.cap.length ? ` captured by ${r.cap.join(' < ')}` : ''
failures.push(`${c.name}: ${r.w}x${r.h} @ (${r.x},${r.y}) — uncovered ${VW - r.w}px wide, ${VH - r.h}px tall.${why}`)
console.log(` ✗ ${c.name} — ${r.w}x${r.h} @ (${r.x},${r.y})${why}`)
}
}
await browser.close()
if (SELF_TEST) {
// With the geometry reverted, EVERY measured case must fail. If any still passes, the gate is
// measuring something that cannot distinguish the bug from the fix.
if (pass > 0) {
console.error(`SCRIM-VIEWPORT: SELF-TEST FAILED — ${pass} case(s) still passed with plain inset:0; the gate cannot detect the defect.`)
process.exit(1)
}
console.log(`SCRIM-VIEWPORT: SELF-TEST PASS — all ${fail} measured case(s) failed as required (${skip} skipped).`)
process.exit(0)
}
if (fail) {
console.error(`\nSCRIM-VIEWPORT: FAIL — ${fail} scrim(s) do not cover the viewport (${pass} pass, ${skip} skipped).`)
for (const f of failures) console.error(' ' + f)
console.error('\n A fixed;inset:0 scrim is pane-relative wherever an ancestor sets contain/transform.')
console.error(' Fix: useViewportScrim() from library/utils/viewportScrim.js — it measures the real')
console.error(' origin and subtracts it. Do NOT remove contain:layout from the shell; it is')
console.error(' load-bearing (it stops a rail collapse reflowing a heavy mounted map).')
process.exit(1)
}
console.log(`SCRIM-VIEWPORT: PASS — ${pass} scrim(s) cover the full viewport inside the cap-5 shell (${skip} skipped).`)

File diff suppressed because one or more lines are too long

View file

@ -22,6 +22,11 @@ import LegendProgressBar from './LegendProgressBar.vue'
// clipped nothing LOOKS wrong — but inside the cap-5 shell contain:layout on .vtl-content limits
// it to the content pane (measured 1024x793 @ (256,107) at 1280x900), so a click on the rail or
// header fails to close the export menu.
// ★ NOT VERIFIED THROUGH THE UI, and honestly so: this backdrop sits behind the
// SHOW_EXPORT_BUTTON kill-switch (const SHOW_EXPORT_BUTTON = false below), so the menu that owns
// it cannot be opened in the shipped studio. The containment was measured at this component's own
// mount point (.stl-panel, same 256,107 origin as every other pane child) and the fix is the same
// one proven on .dt-scrim / .lt-overlay / .modal-scrim; it is correct-if-enabled, not observed.
import { useViewportScrim } from '../../utils/viewportScrim.js'
const { t, te } = useI18n()

View file

@ -92,6 +92,8 @@
"check:csv-shape:self-test": "node internal/check-csv-export-shape.mjs --self-test",
"check:verification-tags": "node internal/check-verification-tags.mjs",
"check:fixed-anchoring": "node internal/check-fixed-anchoring.mjs",
"check:scrim-viewport": "node internal/check-scrim-covers-viewport.mjs # a full-screen SCRIM must cover the VIEWPORT, not just the content pane. A position:fixed;inset:0 scrim is PANE-relative wherever an ancestor sets contain/transform — VerticalTabsLayout sets contain:layout on .vtl-content — so a scrim dimmed 1024x793 @ (256,107) at a 1280x900 viewport and left the 256px rail CLICKABLE behind a modal. A source grep cannot see it: the declaration is correct, the ANCESTOR is the variable. Needs `bun run dev`; browser gate, not in build:unlocked.",
"check:scrim-viewport:self-test": "node internal/check-scrim-covers-viewport.mjs --self-test # reverts each scrim to plain inset:0 and requires EVERY case to FAIL — a gate that cannot fail is worse than no gate, and this one measures a property that looks fine in a screenshot either way.",
"check:no-public-binds": "node internal/check-no-public-binds.mjs",
"check:facet-cascade:offline": "node internal/check-facet-cascade.mjs --offline",
"check:shot-fidelity:strict": "node internal/check-shot-fidelity.mjs --strict",