Gate the scrim-covers-viewport rule, and say what was not verified
Some checks are pending
build-gate / build (push) Waiting to run
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:
parent
9533cca050
commit
04eccdfa7a
5 changed files with 269 additions and 50 deletions
|
|
@ -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)
|
||||
|
|
|
|||
149
internal/check-scrim-covers-viewport.mjs
Normal file
149
internal/check-scrim-covers-viewport.mjs
Normal 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).`)
|
||||
1
internal/fixtures/facet-one-field-rows.json
Normal file
1
internal/fixtures/facet-one-field-rows.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue