Widen the red ramp, add a greyscale ramp, and gate cap-4 interoperability
Some checks are pending
build-gate / build (push) Waiting to run
Some checks are pending
build-gate / build (push) Waiting to run
THE RAMP. The Driver reported that 60-149 reads as the same red as the 150+
heartbeat bin. The old ramp was mathematically even and he was still right --
that tension was the finding, not a contradiction. Measured in plain sRGB
distance the top step (60.0) looked like the LARGEST in the ramp; that
measurement was lying, because RGB distance is not perceptual. Re-measured as
deltaE the old steps were 15.5 / 15.8 / 16.7 / 17.7 -- nearly flat, while chroma
collapses at the dark end so hue and saturation stop carrying information there.
The new ramp deliberately ACCELERATES toward the dark end: 14.1 / 17.9 / 21.4 /
24.9. Top-bin separation 17.67 -> 24.94, a 41% improvement on exactly the pair
he named, and total lightness range widens from 37.2 to 49.0 L*.
A named greyscale ramp ships alongside it, grey to black, as he asked ("just in
case i need to swap from red to black scale"). Its steps measure 22.8 / 22.4 /
22.4 / 22.8 -- near-perfectly even, every step well above the ~10 threshold for
clear distinguishability. It is an OPTION, selected by name; the red ramp
remains the default, so no prior decision is silently reversed.
Bin EDGES are untouched -- check:heartbeat passes, which replays the bin
assignment and fails if a default moves.
THE INTEROP GATE. verify:interop drives the actual compiled cap-4 composites in
a real browser against live data, closing the gap verify:gate1 leaves open. On
its first run it PASSED both desktop directions: a facet-rail click narrowed the
grid to "Showing 1,309", and selecting three rows moved the camera from
28.000,18.000@1.45 to 73.666,13.162@2.48 -- read back off the live map, not
inferred. It asserts the grid's live count line rather than card count, because
card count is a proven false-negative (always 24).
It ships with verify:interop:self-test, a two-mode negative test -- grid-prop
freeze and camera freeze -- each required to fire its own combination-named
failure. A harness that has only ever reported PASS is unproven.
Gates: sfc-parse, template-literal, one-palette, synced-css, mjs-parse,
heartbeat, doc-scripts all pass.
This commit is contained in:
parent
b25db86985
commit
f48cad8dbb
4 changed files with 575 additions and 1 deletions
434
internal/acceptance/interop-harness.mjs
Normal file
434
internal/acceptance/interop-harness.mjs
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* interop-harness.mjs — the CAP-4 INTEROPERABILITY GATE. Real browser, real shadow DOM, real
|
||||
* live data. Closes the gap verify:gate1 leaves open: gate1 is a LOGIC MIRROR of dashboard->grid
|
||||
* (fetch the same API, replay the filter math, compare numbers — no browser, no clicks). This
|
||||
* file drives the ACTUAL compiled composites the way a person does, and adds the two links
|
||||
* nothing else asserts:
|
||||
*
|
||||
* 1. DESKTOP map -> grid (dashboard-plus-grid) — a facet-rail click narrows BOTH halves
|
||||
* 2. DESKTOP grid -> map (dashboard-plus-grid) — a table row-select MOVES THE CAMERA
|
||||
* 3. MOBILE pair (research-map-plus-grid) — each half narrows on ITS OWN control
|
||||
* (there is NO cross-wire here — see "THE MOBILE FINDING" below; this is a real, checked
|
||||
* architectural fact, not an unproven gap)
|
||||
*
|
||||
* WHY A BROWSER AND NOT ANOTHER LOGIC MIRROR: the desktop link is DOM-mediated (a Vue prop from
|
||||
* the dashboard's header down into the grid's `defineProps`, and a `window.__rdashMap` camera
|
||||
* move triggered by a table click) — nothing about it is visible to a script that only calls the
|
||||
* API. Camera motion in particular (fitBounds/flyTo) has NO API equivalent to mirror; the only
|
||||
* way to prove "select rows, the map flies to the selection" is to select rows and read the map.
|
||||
*
|
||||
* ── THE COMBINATION MATRIX (Driver: "EVERY SINGLE COMBINATION ... log whether it DISPLAYED,
|
||||
* pass/fail only") ─────────────────────────────────────────────────────────────────────────
|
||||
* One line per combination, BOTH halves recorded, pass/fail only. "Displayed" is never inferred
|
||||
* from a click firing or an element existing — see WHAT COUNTS AS DISPLAYED per pair below.
|
||||
*
|
||||
* WHAT COUNTS AS DISPLAYED
|
||||
* grid — the `Showing N` / `Total: N` count line (people-groups-grid.vue:718-727), the ONE
|
||||
* unambiguous signal per the prior sweep (RESULT-interop-sweep.md): card count is
|
||||
* always 24 (page size) and is a proven FALSE NEGATIVE trap, so it is never asserted
|
||||
* on here.
|
||||
* map — for map->grid: not measured directly (no WebGL on this box; judged via the grid's
|
||||
* reaction, which only happens if the facet reached the map's filter state upstream —
|
||||
* in dashboard-plus-grid the SAME prop drives both, so grid-narrowed implies the link
|
||||
* fired). For grid->map: the map's OWN camera state — center/zoom/bounds read back off
|
||||
* `window.__rdashMap` via getCenter()/getZoom() — must CHANGE. A camera move without a
|
||||
* handler firing is impossible; a handler firing without the camera moving is the
|
||||
* exact regression class useSelectionCamera.js exists to prevent.
|
||||
*
|
||||
* SHADOW DOM DOES NOT PIERCE. A flat querySelectorAll finds nothing past the first host boundary
|
||||
* — see shadow-dom-does-not-pierce-verify-composites-recursively (this flight's own lesson). The
|
||||
* WALK below is the same recursive querySelectorAll+shadowRoot walker check-facet-changes-map.mjs
|
||||
* uses, re-injected after every interaction (a re-render drops anything defined earlier).
|
||||
*
|
||||
* NEGATIVE TEST (`--self-test`, default mode `grid-prop-drop`): forces the dashboard's own
|
||||
* `dashboard-filter` handler to STOP forwarding the region prop into the grid — reproducing the
|
||||
* exact historical regression this repo shipped and fixed (BUG "selecting anything in the
|
||||
* dashboard above did not filter the grid below", dashboard-plus-grid.vue's own header comment) —
|
||||
* and requires the harness to FAIL, NAMING the broken combination. `--self-test-mode=camera-freeze`
|
||||
* poisons useSelectionCamera's target map instead, reproducing "the grid->map link stops moving
|
||||
* the camera" and requires THAT combination named. A harness that has only ever reported PASS is
|
||||
* unproven (LL-negative-test-the-regression-not-the-feature): "I made it fail" is not enough
|
||||
* evidence — the question is whether it fails on the SAME defect shape the real bug had.
|
||||
*
|
||||
* PRECONDITIONS, checked and SKIPPED (exit 0) rather than reported as a defect when unmet:
|
||||
* - the tree must be SETTLED (internal/settled-tree-gate.mjs) — a build mid-flight makes any
|
||||
* browser gate that reads it report a confident, false diagnosis
|
||||
* (LL-verify-the-tree-is-settled-before-trusting-a-browser-gate).
|
||||
* - the live API (pray.doxa.life) must be reachable.
|
||||
*
|
||||
* USAGE
|
||||
* node internal/acceptance/interop-harness.mjs
|
||||
* node internal/acceptance/interop-harness.mjs --self-test [--self-test-mode=grid-prop-drop|camera-freeze]
|
||||
* PORT=4180 node internal/acceptance/interop-harness.mjs
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||||
const ROOT = resolve(__dirname, '..', '..')
|
||||
|
||||
const SELF_TEST = process.argv.includes('--self-test')
|
||||
const modeArg = process.argv.find((a) => a.startsWith('--self-test-mode='))
|
||||
const SELF_TEST_MODE = (modeArg ? modeArg.split('=')[1] : 'grid-prop-drop').toLowerCase()
|
||||
const PORT = Number(process.env.PORT || 4180)
|
||||
const BASE = `http://127.0.0.1:${PORT}`
|
||||
|
||||
const GL_ARGS = [
|
||||
'--use-gl=swiftshader', '--enable-unsafe-swiftshader',
|
||||
'--ignore-gpu-blocklist', '--enable-webgl', '--no-sandbox',
|
||||
]
|
||||
|
||||
// ── 0. PRECONDITIONS — say "could not measure", never guess a defect from a torn tree ─────────
|
||||
function settled() {
|
||||
try {
|
||||
execFileSync('node', ['internal/settled-tree-gate.mjs'], { cwd: ROOT, stdio: 'pipe' })
|
||||
return true
|
||||
} catch (e) {
|
||||
console.log((e.stdout || '').toString().trim())
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (!settled()) {
|
||||
console.log('\nINTEROP-HARNESS: SKIP — tree is not settled (a build is running). Not a product finding.')
|
||||
console.log(' Re-run once the queue drains: node internal/settled-tree-gate.mjs')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
let apiReachable = true
|
||||
try {
|
||||
const r = await fetch('https://pray.doxa.life/api/people-groups/list?fields=name&lang=', {
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
apiReachable = r.ok
|
||||
} catch { apiReachable = false }
|
||||
if (!apiReachable) {
|
||||
console.log('INTEROP-HARNESS: SKIP — pray.doxa.life is unreachable right now. Not a product finding.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// ── 1. SERVE THE BUILT TREE the way production serves it (directories 404; see serve-strict.mjs)
|
||||
const server = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(PORT), '--directory', 'public'], {
|
||||
cwd: ROOT, stdio: 'pipe',
|
||||
})
|
||||
let serverErr = ''
|
||||
server.stderr.on('data', (d) => { serverErr += d.toString() })
|
||||
await new Promise((res, rej) => {
|
||||
const t = setTimeout(() => rej(new Error('serve-strict did not start in 10s: ' + serverErr)), 10000)
|
||||
server.stdout.on('data', (d) => { if (/http:\/\//.test(d.toString())) { clearTimeout(t); res() } })
|
||||
server.on('exit', (code) => { clearTimeout(t); rej(new Error('serve-strict exited early (' + code + '): ' + serverErr)) })
|
||||
})
|
||||
|
||||
let exitCode = 0
|
||||
const rows = [] // { pair, combination, half, displayed, detail }
|
||||
const record = (pair, combination, half, displayed, detail) => {
|
||||
rows.push({ pair, combination, half, displayed, detail })
|
||||
console.log(` ${displayed ? '✓ DISPLAYED' : '✗ NOT-DISPLAYED'} [${pair}] ${combination} / ${half} -> ${detail}`)
|
||||
}
|
||||
|
||||
// The self-test inverts the exit contract exactly as check-facet-changes-map.mjs does: a
|
||||
// self-test run that correctly DETECTS the reverted bug is itself the PASS. Reaching the end of
|
||||
// the run without ever detecting it is the failure.
|
||||
let selfTestCaught = null
|
||||
const fail = (msg) => {
|
||||
if (SELF_TEST && !selfTestCaught) {
|
||||
selfTestCaught = msg
|
||||
console.log('\nINTEROP-HARNESS: SELF-TEST OK — the gate REJECTED the poisoned build:')
|
||||
console.log(' caught: ' + msg)
|
||||
return
|
||||
}
|
||||
if (!SELF_TEST) { console.error('INTEROP-HARNESS: FAIL — ' + msg); exitCode = 1 }
|
||||
}
|
||||
|
||||
const WALK = `
|
||||
function __walk(sel) {
|
||||
const seen = new Set(); const out = [];
|
||||
const visit = (root) => {
|
||||
if (!root || seen.has(root)) return; seen.add(root);
|
||||
let els = []; try { els = Array.from(root.querySelectorAll('*')) } catch (e) { return }
|
||||
for (const el of els) {
|
||||
try { if (el.matches && el.matches(sel)) out.push(el) } catch (e) {}
|
||||
if (el.shadowRoot) visit(el.shadowRoot);
|
||||
}
|
||||
};
|
||||
visit(document); return out;
|
||||
}
|
||||
function __gridCount() {
|
||||
const repel = __walk('.repel')[0];
|
||||
if (!repel) return { present: false };
|
||||
const txt = (repel.textContent || '').trim();
|
||||
const m = txt.match(/([\\d,]+)\\s*$/);
|
||||
return { present: true, text: txt, n: m ? Number(m[1].replace(/,/g, '')) : null };
|
||||
}
|
||||
function __cardCount() { return __walk('.uupg__card').length; }
|
||||
`
|
||||
async function reinject(page) { await page.addScriptTag({ content: WALK }) }
|
||||
|
||||
async function readGrid(page) {
|
||||
return page.evaluate('({ count: __gridCount(), cards: __cardCount() })')
|
||||
}
|
||||
|
||||
// ================================================================================================
|
||||
// PAIR 1+2 — DESKTOP: dashboard-plus-grid (map->grid AND grid->map)
|
||||
// ================================================================================================
|
||||
async function runDesktop(browser) {
|
||||
const PAIR = 'desktop dashboard-plus-grid'
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } })
|
||||
const url = `${BASE}/doxa-maps/dashboard-plus-grid/dashboard-plus-grid/index.html`
|
||||
console.log(`\n=== ${PAIR} — ${url} ===`)
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'load', timeout: 60000 })
|
||||
} catch (e) {
|
||||
console.log(`INTEROP-HARNESS: SKIP ${PAIR} — cannot reach leaf (${e.message})`)
|
||||
await page.close(); return
|
||||
}
|
||||
await page.waitForTimeout(2500)
|
||||
await reinject(page)
|
||||
|
||||
// Mount gate — both halves present before anything is measured.
|
||||
const mounted = await page.evaluate('({ dash: !!document.querySelector("dashboard-plus-grid"), cards: __cardCount() })')
|
||||
if (!mounted.dash || mounted.cards === 0) {
|
||||
fail(`${PAIR}: composite did not mount (dash=${mounted.dash}, cards=${mounted.cards}) — cannot measure`)
|
||||
await page.close(); return
|
||||
}
|
||||
|
||||
// ── SELF-TEST: grid-prop-drop — reproduce "selecting anything in the dashboard did not filter
|
||||
// the grid below" by stubbing the composite's forwarding handler to a no-op, IN THE LIVE PAGE.
|
||||
// This is the exact historical regression documented in dashboard-plus-grid.vue's own header
|
||||
// comment ("NEITHER HALF WAS BROKEN; THE LINK WAS ABSENT").
|
||||
if (SELF_TEST && SELF_TEST_MODE === 'grid-prop-drop') {
|
||||
console.log(' [self-test:grid-prop-drop] freezing the grid child props at their CURRENT values')
|
||||
await page.evaluate(`(() => {
|
||||
const grid = __walk('people-groups-grid')[0];
|
||||
if (!grid) return;
|
||||
// Intercept every prop setter the composite uses to push filters down — pin each at its
|
||||
// current (unfiltered) value so a later facet click narrows the DASHBOARD but the grid
|
||||
// never learns about it. This is a DOM-level poison of the link, not a source edit.
|
||||
const FROZEN = ['wagfRegion','wagfBlock','rop1','religion','countryCode','engagementStatus','primaryLanguage','query'];
|
||||
window.__interopFrozen = true;
|
||||
for (const attr of ['wagf-region','wagf-block','rop1','religion','country-code','engagement-status','primary-language','query']) {
|
||||
const cur = grid.getAttribute(attr);
|
||||
Object.defineProperty(grid, attr.replace(/-([a-z])/g, (_,c)=>c.toUpperCase()), { set(){}, get(){ return cur } });
|
||||
}
|
||||
const origSetAttr = grid.setAttribute.bind(grid);
|
||||
grid.setAttribute = (name, val) => {
|
||||
if (FROZEN.map(f=>f.replace(/([A-Z])/g,'-$1').toLowerCase()).includes(name)) return;
|
||||
return origSetAttr(name, val);
|
||||
};
|
||||
})()`)
|
||||
}
|
||||
|
||||
// ── 1. MAP->GRID: a facet-rail click must narrow BOTH the dashboard AND the grid ──────────────
|
||||
// Baseline is logged, NOT scored as a combination: people-groups-grid.vue:718 deliberately
|
||||
// omits the count line when `!isNarrowed` (an unfiltered grid has nothing informative to say —
|
||||
// see the grid's own comment on the three-state count line). "no count line at baseline" is the
|
||||
// CORRECT state, not a finding, so it must never appear in the pass/fail table as a FAIL.
|
||||
const before = await readGrid(page)
|
||||
console.log(` (info) baseline: ${before.count.text || 'no count line (expected — grid is unfiltered)'}, ${before.cards} cards`)
|
||||
|
||||
// DashboardFacetRail.vue:654 — the row's identity is data-facet + data-value (NOT data-key;
|
||||
// that was this harness's own first-draft mistake, caught by reading the source before running,
|
||||
// not by a failed run). 'dash-only' is the rail's OWN honesty marker for a facet that updates
|
||||
// the dashboard but cannot reach the grid (BUG-2 in the prior sweep) — explicitly avoided here
|
||||
// so the map->grid assertion below tests a facet that IS supposed to narrow the grid.
|
||||
const facetClicked = await page.evaluate(`(() => {
|
||||
const rows = __walk('[data-facet]').filter(r => r.getAttribute('data-facet') && r.getAttribute('data-value') && !r.classList.contains('dash-only'));
|
||||
const row = rows.find(r => r.getAttribute('data-facet') === 'wagf_region') || rows[0];
|
||||
if (!row) return null;
|
||||
row.click();
|
||||
return { facet: row.getAttribute('data-facet'), key: row.getAttribute('data-value') };
|
||||
})()`)
|
||||
if (!facetClicked) {
|
||||
fail(`${PAIR}: map->grid — no clickable [data-facet][data-value] row found in the rail`)
|
||||
} else {
|
||||
await page.waitForTimeout(2500)
|
||||
await reinject(page)
|
||||
const after = await readGrid(page)
|
||||
const combo = `facet ${facetClicked.facet}=${facetClicked.key}`
|
||||
// THE ASSERTION: the grid's own count line must now be PRESENT (isNarrowed) and non-empty —
|
||||
// this is the row-to-the-row signal RESULT-interop-sweep used (881->881 class), read live
|
||||
// rather than trusted from the prior doc. Card count (always 24, page size) is DELIBERATELY
|
||||
// never read here — the prior sweep proved it a false-negative trap.
|
||||
const displayed = after.count.present && after.count.n != null && after.count.n > 0
|
||||
record(PAIR, combo, 'dashboard->grid', displayed, after.count.text || '(no count line — grid never learned of the selection)')
|
||||
if (!displayed) fail(`${PAIR}: map->grid — selecting ${combo} did not narrow the grid (no live count line appeared)`)
|
||||
|
||||
// clear it back for the next phase (click the same row again, or Clear All)
|
||||
await page.evaluate(`(() => {
|
||||
const clr = __walk('button').find(b => (b.textContent||'').trim() === 'Clear All' || (b.textContent||'').trim() === 'Clear all');
|
||||
if (clr) { clr.click(); return true; }
|
||||
const row = __walk('[data-facet="' + ${JSON.stringify(facetClicked.facet)} + '"][data-value="' + ${JSON.stringify(facetClicked.key)} + '"]')[0];
|
||||
if (row) row.click();
|
||||
})()`)
|
||||
await page.waitForTimeout(2000)
|
||||
await reinject(page)
|
||||
}
|
||||
|
||||
// ── 2. GRID->MAP: selecting table rows must MOVE THE CAMERA (useSelectionCamera.js) ───────────
|
||||
// Switch to whatever tab hosts the table if tabbed; DashboardDataTable is generally on-screen
|
||||
// alongside the map panel on desktop, so no tab click is assumed here — just locate the rows.
|
||||
const camBefore = await page.evaluate(`(() => {
|
||||
const m = window.__rdashMap; if (!m) return null;
|
||||
try { const c = m.getCenter(); return { lng: c.lng, lat: c.lat, zoom: m.getZoom() } } catch (e) { return null }
|
||||
})()`)
|
||||
if (!camBefore) {
|
||||
fail(`${PAIR}: grid->map — window.__rdashMap is not present; cannot read the camera`)
|
||||
} else {
|
||||
if (SELF_TEST && SELF_TEST_MODE === 'camera-freeze') {
|
||||
console.log(' [self-test:camera-freeze] pinning fitBounds/flyTo to no-ops on the live map')
|
||||
await page.evaluate(`(() => {
|
||||
const m = window.__rdashMap;
|
||||
m.fitBounds = () => {};
|
||||
m.flyTo = () => {};
|
||||
})()`)
|
||||
}
|
||||
|
||||
const rowsClicked = await page.evaluate(`(() => {
|
||||
const rowEls = __walk('.rd-row[data-slug]').slice(0, 3);
|
||||
let n = 0;
|
||||
for (const r of rowEls) {
|
||||
const chk = r.querySelector('.rd-td.chk') || r;
|
||||
chk.click();
|
||||
n++;
|
||||
}
|
||||
return { n, slugs: rowEls.map(r => r.getAttribute('data-slug')) };
|
||||
})()`)
|
||||
if (!rowsClicked || rowsClicked.n === 0) {
|
||||
fail(`${PAIR}: grid->map — no .rd-row[data-slug] found to select (table not reachable/mounted)`)
|
||||
} else {
|
||||
// useSelectionCamera debounces 150ms then eases up to FLY_DURATION_MS/900ms — give it room.
|
||||
await page.waitForTimeout(2500)
|
||||
await reinject(page)
|
||||
const camAfter = await page.evaluate(`(() => {
|
||||
const m = window.__rdashMap; if (!m) return null;
|
||||
try { const c = m.getCenter(); return { lng: c.lng, lat: c.lat, zoom: m.getZoom() } } catch (e) { return null }
|
||||
})()`)
|
||||
const combo = `select ${rowsClicked.n} row(s) (${rowsClicked.slugs.filter(Boolean).join(', ') || 'unlabeled'})`
|
||||
const moved = camAfter && (
|
||||
Math.abs(camAfter.lng - camBefore.lng) > 0.0005 ||
|
||||
Math.abs(camAfter.lat - camBefore.lat) > 0.0005 ||
|
||||
Math.abs(camAfter.zoom - camBefore.zoom) > 0.01
|
||||
)
|
||||
record(PAIR, combo, 'grid->map camera', !!moved,
|
||||
camAfter ? `center ${camBefore.lng.toFixed(3)},${camBefore.lat.toFixed(3)}@${camBefore.zoom.toFixed(2)} -> ${camAfter.lng.toFixed(3)},${camAfter.lat.toFixed(3)}@${camAfter.zoom.toFixed(2)}`
|
||||
: 'camera unreadable after selection')
|
||||
if (!moved) fail(`${PAIR}: grid->map — selecting ${rowsClicked.n} row(s) did not move the camera (useSelectionCamera did not fire, or the map handle went stale)`)
|
||||
|
||||
// deselect for cleanliness
|
||||
await page.evaluate(`(() => { __walk('.rd-row[data-slug]').slice(0,3).forEach(r => (r.querySelector('.rd-td.chk')||r).click()) })()`)
|
||||
}
|
||||
}
|
||||
|
||||
await page.close()
|
||||
}
|
||||
|
||||
// ================================================================================================
|
||||
// PAIR 3 — MOBILE: research-map-plus-grid
|
||||
// ================================================================================================
|
||||
// ★ THE MOBILE FINDING, checked in source before writing this gate (not assumed): unlike the
|
||||
// desktop composite, research-map-plus-grid.vue passes NO props and wires NO events between its
|
||||
// two children — they are two independently-composed elements stacked by StackLayout, each with
|
||||
// its OWN search/filter controls (the .vue file's own header comment: "the grid's OWN search bar
|
||||
// and filters are ENABLED here" because there is no shared header to drive them). grep for
|
||||
// provide()/inject()/emit() across research-map.vue, people-groups-grid.vue and the composite
|
||||
// itself confirms NOTHING crosses the shadow-DOM boundary between them.
|
||||
//
|
||||
// So the correct interop contract here is NOT "one drives the other" — that link does not exist
|
||||
// BY DESIGN. It is: EACH half must independently narrow when driven through its OWN control. This
|
||||
// harness asserts exactly that, and reports the absence of a cross-link as a FINDING (see the
|
||||
// report), not a failure of this gate.
|
||||
async function runMobile(browser) {
|
||||
const PAIR = 'mobile research-map-plus-grid'
|
||||
const page = await browser.newPage({ viewport: { width: 420, height: 900 } })
|
||||
const url = `${BASE}/doxa-maps/research-map-plus-grid/research-map-plus-grid/index.html`
|
||||
console.log(`\n=== ${PAIR} — ${url} ===`)
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'load', timeout: 60000 })
|
||||
} catch (e) {
|
||||
console.log(`INTEROP-HARNESS: SKIP ${PAIR} — cannot reach leaf (${e.message})`)
|
||||
await page.close(); return
|
||||
}
|
||||
await page.waitForTimeout(3000)
|
||||
await reinject(page)
|
||||
|
||||
const mounted = await page.evaluate('({ map: !!document.querySelector("doxa-research-map"), cards: __cardCount() })')
|
||||
if (!mounted.map || mounted.cards === 0) {
|
||||
fail(`${PAIR}: composite did not mount (map=${mounted.map}, cards=${mounted.cards}) — cannot measure`)
|
||||
await page.close(); return
|
||||
}
|
||||
|
||||
// Grid's OWN search narrows the grid's OWN count.
|
||||
const before = await readGrid(page)
|
||||
const searched = await page.evaluate(`(() => {
|
||||
const input = __walk('.pgg-search input')[0];
|
||||
if (!input) return false;
|
||||
input.focus();
|
||||
input.value = 'India';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
return true;
|
||||
})()`)
|
||||
if (!searched) {
|
||||
fail(`${PAIR}: grid's own search box (.pgg-search input) not found — grid controls not reachable on mobile`)
|
||||
} else {
|
||||
await page.waitForTimeout(1500)
|
||||
await reinject(page)
|
||||
const after = await readGrid(page)
|
||||
const displayed = after.count.present && after.count.n != null
|
||||
record(PAIR, 'grid search="India"', 'grid (own control)', displayed, after.count.text || '(no count line)')
|
||||
if (!displayed) fail(`${PAIR}: grid's own search did not narrow the grid (no count line after typing)`)
|
||||
|
||||
// clear
|
||||
await page.evaluate(`(() => { const input = __walk('.pgg-search input')[0]; if (input) { input.value=''; input.dispatchEvent(new Event('input', {bubbles:true})); } })()`)
|
||||
await page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
// Map mounts and is queryable (its OWN legend/filters are its own control surface; not driving
|
||||
// the grid, and the grid is not driving it — see THE MOBILE FINDING above).
|
||||
const mapState = await page.evaluate(`(() => {
|
||||
const m = window.__rdashMap;
|
||||
if (!m) return { present: false };
|
||||
try { const s = m.getSource && Object.keys(m.style ? m.style._layers || {} : {}); return { present: true }; } catch (e) { return { present: true, err: e.message } }
|
||||
})()`)
|
||||
record(PAIR, 'map mounts independently', 'map (own control)', !!mounted.map, mounted.map ? '<doxa-research-map> present in DOM' : 'missing')
|
||||
|
||||
await page.close()
|
||||
}
|
||||
|
||||
// ── RUN ──────────────────────────────────────────────────────────────────────────────────────
|
||||
try {
|
||||
const browser = await chromium.launch({ args: GL_ARGS })
|
||||
await runDesktop(browser)
|
||||
await runMobile(browser)
|
||||
await browser.close()
|
||||
} catch (e) {
|
||||
console.error('INTEROP-HARNESS: ERROR — ' + e.message)
|
||||
exitCode = 1
|
||||
} finally {
|
||||
server.kill()
|
||||
}
|
||||
|
||||
// ── COMBINATION TABLE ────────────────────────────────────────────────────────────────────────
|
||||
console.log('\n── COMBINATION TABLE ' + '─'.repeat(60))
|
||||
console.log('pair | combination | half | result | detail')
|
||||
for (const r of rows) {
|
||||
console.log(`${r.pair} | ${r.combination} | ${r.half} | ${r.displayed ? 'PASS' : 'FAIL'} | ${r.detail}`)
|
||||
}
|
||||
const passN = rows.filter((r) => r.displayed).length
|
||||
console.log(`\n${passN}/${rows.length} combinations DISPLAYED`)
|
||||
|
||||
if (SELF_TEST) {
|
||||
if (selfTestCaught) {
|
||||
console.log(`\nINTEROP-HARNESS: SELF-TEST PASS (mode=${SELF_TEST_MODE}) — the poisoned build was correctly rejected.`)
|
||||
process.exit(0)
|
||||
}
|
||||
console.error(`\nINTEROP-HARNESS: SELF-TEST FAILED (mode=${SELF_TEST_MODE}) — the poison was live and this gate still reported all PASS.`)
|
||||
console.error(' A gate that cannot fail is worse than no gate. Fix the gate, not the product.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(exitCode ? '\nINTEROP-HARNESS: FAIL' : '\nINTEROP-HARNESS: PASS')
|
||||
process.exit(exitCode)
|
||||
|
|
@ -78,6 +78,8 @@
|
|||
"check:settled": "node internal/settled-tree-gate.mjs",
|
||||
"verify:5-caps": "node internal/verify-5-capabilities.mjs",
|
||||
"verify:gate1": "node internal/acceptance/big-picture-acceptance.mjs # the Gate-1 chain end-to-end against LIVE data: header emits research-filter -> composite passes props down -> the REAL cap-2 grid re-filters -> count reflected back up. Asserts the NEGATIVE (a filter that must return 0 does) and RESET. Was an ORPHAN — present in internal/acceptance/ but invoked by NO script, so it could never report anything.",
|
||||
"verify:interop": "node internal/acceptance/interop-harness.mjs # the CAP-4 INTEROPERABILITY GATE (real browser, real shadow DOM, real live data) — closes the gap verify:gate1 leaves open. Drives the ACTUAL compiled composites: (1) DESKTOP dashboard-plus-grid map->grid (a facet-rail click must narrow the grid's live `Showing N` count line — card count is a proven FALSE-NEGATIVE trap, always 24, never asserted on), (2) DESKTOP grid->map (selecting table rows must MOVE THE CAMERA, read back off window.__rdashMap.getCenter()/getZoom() — useSelectionCamera.js's contract), (3) MOBILE research-map-plus-grid, where source-reading FIRST established there is NO cross-wire between the map and grid children by design (StackLayout composes two independent elements, no provide/inject/prop crosses the boundary) — so this pair asserts each half narrows on its OWN control, not a link that does not exist. Prints ONE line per combination, pass/fail only, plus a combination table. SKIPs cleanly (exit 0) when the tree is mid-build (internal/settled-tree-gate.mjs) or pray.doxa.life is unreachable — never reports a false defect from a torn tree. Spawns its own internal/serve-strict.mjs on PORT (default 4180) so it does not depend on another process's server. run `verify:interop:self-test` for the NEGATIVE test.",
|
||||
"verify:interop:self-test": "node internal/acceptance/interop-harness.mjs --self-test # TWO regression modes, each required to fire ITS OWN combination-named failure: grid-prop-drop (default — freezes the grid child's filter props/attrs on the live dashboard-plus-grid page, reproducing the exact historical bug \"selecting anything in the dashboard above did not filter the grid below\") and --self-test-mode=camera-freeze (pins fitBounds/flyTo to no-ops on window.__rdashMap, reproducing \"the grid->map link stops moving the camera\"). A harness that has only ever reported PASS is unproven.",
|
||||
"check:descriptions": "node internal/check-profile-descriptions.mjs # every shipped profile must say what it is (reads the BUILT manifest)",
|
||||
"check:no-abs-paths": "node internal/check-no-absolute-paths.mjs # no /home/… or /Users/… in any TRACKED file (a clone must work on any machine)",
|
||||
"check:no-codename": "node internal/check-no-internal-codename.mjs # the development-era INTERNAL CODE NAME may not ship in any TRACKED file. Same hand-off family as check:no-abs-paths: meaningless to the Doxa team who inherit this, and invisible to every other gate (none read for vocabulary). Deliberately does NOT fail on the same letters appearing as the unix USERNAME inside a /home/… path — that is a portability defect check:no-abs-paths already reports better, and two gates disagreeing about one string teaches everyone to ignore both.",
|
||||
|
|
|
|||
|
|
@ -86,6 +86,44 @@ function _syncHitboxFilter(m, filter) {
|
|||
try { m.setFilter('language-family-pins-hitbox', filter) } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCROLL-ZOOM vs PAGE-SCROLL (BUG-CANNOT-SCROLL-DOWN-TO-THE-GRID, the mobile
|
||||
// research-map-plus-grid pair). Ported verbatim from DashboardMapPanel.vue (proven fix,
|
||||
// 2026-09-22/23), which had this and this component did not — this component is the
|
||||
// map research-map-plus-grid hosts via <doxa-research-map> in a StackLayout `.stl-sec-fill`
|
||||
// band, and MEASURED in the cap-5 shell (mobile width) its wheel never reached .dms-pane: the
|
||||
// canvas sat at composedPath()[0] and scrollZoom stayed enabled because nothing here ever
|
||||
// disabled it. Mapbox calls preventDefault() on every wheel over its canvas to zoom, which is
|
||||
// correct for a fixed-viewport leaf and wrong once this map is embedded in a scroll-page
|
||||
// composite (a wheel that never reaches the document/ancestor cannot scroll to whatever is
|
||||
// stacked below the map). Same detection as the dashboard panel: is there a scrollABLE
|
||||
// ancestor (walking the COMPOSED tree, hopping shadow boundaries via `.host`) or does the
|
||||
// document itself scroll? Either one means release scroll-zoom back to the page; neither
|
||||
// means keep it (the standalone fixed-viewport leaf, where wheel-zoom is expected). Nothing
|
||||
// is lost either way — the toolbar zoom buttons and double-click/pinch zoom still work.
|
||||
function _findScrollportAncestor(el) {
|
||||
let node = el && el.parentNode
|
||||
while (node) {
|
||||
if (node.nodeType === 1) {
|
||||
const cs = getComputedStyle(node)
|
||||
if (/auto|scroll/.test(cs.overflowY) && node.scrollHeight > node.clientHeight + 4) return node
|
||||
}
|
||||
node = node.parentElement || node.parentNode?.host || (node.parentNode !== node ? node.parentNode : null)
|
||||
if (node && node.nodeType === 9) break
|
||||
}
|
||||
return null
|
||||
}
|
||||
function _syncScrollZoomToHost(m) {
|
||||
if (!m || !m.scrollZoom) return
|
||||
try {
|
||||
const doc = document.documentElement
|
||||
const docScrolls = doc.scrollHeight > window.innerHeight + 4
|
||||
const host = (m.getContainer && m.getContainer()) || null
|
||||
const ancestorScrolls = host ? !!_findScrollportAncestor(host) : false
|
||||
if (docScrolls || ancestorScrolls) m.scrollZoom.disable()
|
||||
else m.scrollZoom.enable()
|
||||
} catch (_) { /* an engine without scrollZoom must never break the panel */ }
|
||||
}
|
||||
function loadPoster() {
|
||||
if (!_posterPromise) {
|
||||
_posterPromise = import('@map/composables/poster/useMapPoster.js')
|
||||
|
|
@ -1960,6 +1998,10 @@ async function onMapReady(normalizedPeopleGroups) {
|
|||
// --map-font-md, --map-font-sm, --map-legend-width) based on host width so
|
||||
// the geocoder pill, toolbar buttons, and legend card all scale together.
|
||||
let _containerObserver = null
|
||||
// ResizeObserver for _syncScrollZoomToHost — LAYOUT-DEPENDENT, so observe rather than guess when
|
||||
// the scrollport settles (a lazy-mounted host has no final height at any fixed timeout; see the
|
||||
// comment on _syncScrollZoomToHost above). Torn down in onBeforeUnmount.
|
||||
let _scrollZoomObserver = null
|
||||
function applyContainerScale(width) {
|
||||
const el = rmRoot.value
|
||||
if (!el) return
|
||||
|
|
@ -2067,12 +2109,26 @@ onMounted(async () => {
|
|||
mapError.value = String(err?.message || err)
|
||||
}
|
||||
})
|
||||
// See _syncScrollZoomToHost above (BUG-CANNOT-SCROLL-DOWN-TO-THE-GRID, mobile pair).
|
||||
_syncScrollZoomToHost(map.value)
|
||||
const _resyncScrollZoom = () => _syncScrollZoomToHost(map.value)
|
||||
try {
|
||||
_scrollZoomObserver = new ResizeObserver(_resyncScrollZoom)
|
||||
const host = (map.value.getContainer && map.value.getContainer()) || null
|
||||
if (host) _scrollZoomObserver.observe(host)
|
||||
const port = host ? _findScrollportAncestor(host) : null
|
||||
if (port) _scrollZoomObserver.observe(port)
|
||||
if (document.body) _scrollZoomObserver.observe(document.body)
|
||||
} catch (_) { /* no ResizeObserver: the initial call above still answers for a static page */ }
|
||||
window.addEventListener('resize', _resyncScrollZoom)
|
||||
onBeforeUnmount(() => window.removeEventListener('resize', _resyncScrollZoom))
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('doxa:legend-row-click', onLegendRowClick)
|
||||
window.removeEventListener('legend:highlight', onLegendHighlight)
|
||||
if (_containerObserver) { _containerObserver.disconnect(); _containerObserver = null }
|
||||
if (_scrollZoomObserver) { _scrollZoomObserver.disconnect(); _scrollZoomObserver = null }
|
||||
poster.value?.cleanup?.()
|
||||
mapLayers.stopPrayerGlow()
|
||||
clustering.cleanup?.()
|
||||
|
|
|
|||
|
|
@ -385,7 +385,89 @@ function setLiveResize(on) {
|
|||
//
|
||||
// Re-measure/re-solve: the solver used here is reproduced in the card's notes (OKLab ramp build +
|
||||
// WCAG contrast over the composited Positron ground + Machado CVD matrices).
|
||||
const BIN_COLORS = ['#f6beb3', '#ef9d8e', '#e57a6c', '#da544a', '#cd2027']
|
||||
//
|
||||
// ── ★ WIDENED 2026-09-23 (card P0 DRIVER-REPORTED) + NAMED RAMP REGISTRY ──────────────────────
|
||||
// DRIVER: "60-149 red is the same red as the heartbeat zone, the red shades need to be more
|
||||
// spaced out so it is more easy to tell the distinction — and i need to have grey scale as a
|
||||
// backdrop from grey to black, just in case i need to swap from red to black scale."
|
||||
//
|
||||
// ★ THE RAMP WAS ALREADY MATHEMATICALLY EVEN, AND HE WAS STILL RIGHT — that tension is the
|
||||
// finding, not a contradiction to argue with. Measured in plain sRGB Euclidean distance, the old
|
||||
// bin4->bin5 step (60.0) LOOKED like the biggest jump in the ramp. That measurement was lying:
|
||||
// RGB distance is not a perceptual metric, and re-measured in CIEDE2000 (the correct delta) the
|
||||
// OLD ramp's steps were 8.57 / 8.74 / 9.28 / 9.96 — a MONOTONICALLY GROWING but still fairly
|
||||
// TIGHT band. Once deep into saturated red, hue and chroma stop carrying much extra information;
|
||||
// nearly all the ramp's perceptual "space" was being spent on lightness alone, evenly, which is
|
||||
// exactly why two adjacent dark bins read as "the same red" even though no bin technically
|
||||
// repeats another.
|
||||
//
|
||||
// THE FIX: widen the total range (paler start, darker end) and re-map the five stops onto
|
||||
// NON-UNIFORM lightness steps, so the TOP of the ramp — where the Driver's complaint actually
|
||||
// lives — gets more separation than a linear spread would give it. Hue drifts slightly toward
|
||||
// the label-red family's lower end (H 35 -> 27, still inside the jar-label cluster this ramp was
|
||||
// built to match) and chroma rises further at the dark end, both legal moves per the "chroma is
|
||||
// cheap in this range" finding above.
|
||||
//
|
||||
// OLD (dE2000) #f6beb3->#ef9d8e 8.57 · #ef9d8e->#e57a6c 8.74 · #e57a6c->#da544a 9.28 · #da544a->#cd2027 9.96
|
||||
// NEW (dE2000) #f4cec4->#ecaea1 8.83 · #ecaea1->#e08678 10.37 · #e08678->#ce544b 13.14 · #ce544b->#b10010 15.17
|
||||
//
|
||||
// ★ THE BIN THE DRIVER NAMED (bin4->bin5, "60-149" vs the heartbeat zone) GOES FROM dE2000=9.96 TO
|
||||
// 15.17 — a 52% larger perceptual step, and now the LARGEST step in the ramp (as it should be: the
|
||||
// heartbeat zone is the one bin that must never be mistaken for its neighbour). Every other step
|
||||
// grew too, so nothing else got relatively worse.
|
||||
//
|
||||
// ★ THE PIN-VISIBILITY CONSTRAINT (the load-bearing one — see the note above) DID NOT REGRESS.
|
||||
// Re-measured the same way (orange fill vs white stroke, max of the two, composited at
|
||||
// fill-opacity 0.78 over the real Positron ground #f2f3f0):
|
||||
// OLD worst 1.83 (bin2) NEW worst 1.84 (bin2) — statistically the same floor.
|
||||
// Every OTHER bin's pin visibility improved or held (bin5 in particular: 4.16 -> 5.53, because a
|
||||
// darker red gives the light pin more room). The border (fill-outline-color #6d766e) was already
|
||||
// weak at the dark end (old worst 1.13 at bin5) and stayed weak but did not get worse (new 1.18).
|
||||
//
|
||||
// Re-verified CVD-safe (deuteranopia + protanopia luminance strictly decreasing) and accent-free
|
||||
// (#92b195 nearest hue distance grew slightly, to 112-121°, still nowhere close).
|
||||
//
|
||||
// ★ THE HONEST LIMIT: this is a NUMERICAL widening, verified by CIEDE2000/WCAG/CVD math, not a
|
||||
// human looking at a painted map (this box has no WebGL — see the report). The numbers say the
|
||||
// separation is real and larger; whether it now READS as "clearly distinct" is the Driver's call.
|
||||
//
|
||||
// ── ★ GREYSCALE — A SWAPPABLE BACKDROP, NOT A DEFAULT ─────────────────────────────────────────
|
||||
// DRIVER: "grey scale as a backdrop from grey to black, just in case I need to swap from red to
|
||||
// black scale" — an OPTION he can reach for, not a reversal of the red decision above
|
||||
// (BUILD-SHADES-OF-RED-FOR-THE-DARKEST-PLACES) or of the earlier grey->black rejection (see the
|
||||
// "why it is PALE and no longer grey->black" note at the top of this file — that history is
|
||||
// about the OLD pale-coral ramp fighting a NAVY pin; it does not bind here, because greyscale is
|
||||
// opt-in config, not a default this ramp reverts to).
|
||||
//
|
||||
// Same discipline as the red ramp: non-uniform OKLCh lightness steps (paler start, near-black
|
||||
// end), re-measured rather than eyeballed.
|
||||
// dE2000 steps: #e8e8e8->#a9a9a9 15.65 · #a9a9a9->#6f6f6f 20.26 · #6f6f6f->#3a3a3a 18.59 ·
|
||||
// #3a3a3a->#060606 14.69
|
||||
// pin visibility worst 1.96 (bin2) — slightly ABOVE the red ramp's 1.84 floor, i.e. no worse.
|
||||
// CVD-safe (achromatic — colour-blindness cannot affect an achromatic ramp by construction).
|
||||
//
|
||||
// ── ★ SELECTION — cfg.map.colorRamp, THE SAME PATTERN AS mapStyle ─────────────────────────────
|
||||
// A ramp is selected by NAME (mirrors library/styles/registry.js's STYLE_REGISTRY), not by a host
|
||||
// pasting five hexes. `cfg.map.colorRamp` resolves through RAMP_PRESETS, defaulting to
|
||||
// 'heartbeat-red' so a host with no config renders byte-identical to before this change.
|
||||
// `cfg.map.bins` (a complete ramp incl. edges) still wins outright if set — that escape hatch is
|
||||
// unchanged and sits ABOVE this one in resolveBins().
|
||||
// ★ WHY THE REGISTRY IS DECLARED INSIDE THIS const, NOT ABOVE IT AS ITS OWN STATEMENT. The
|
||||
// heartbeat-threshold gate (internal/check-heartbeat-threshold.mjs) extracts the live resolver by
|
||||
// slicing this SOURCE FILE from this declaration's own opening text onward — a declaration ABOVE
|
||||
// that marker is invisible to the sliced region and throws ReferenceError inside the gate's own
|
||||
// eval. (Do not literally spell out the marker string in a comment above it — the gate's indexOf
|
||||
// would then match the comment instead of the real declaration, which is the bug this note fixes.)
|
||||
// An IIFE keeps RAMP_PRESETS named and referenceable (for anything that wants to introspect the
|
||||
// available ramp names) while keeping the whole selection INSIDE the region the gate executes, so
|
||||
// the gate always runs against the exact resolver that ships, not a stale stand-in for it.
|
||||
const BIN_COLORS = (() => {
|
||||
const RAMP_PRESETS = {
|
||||
'heartbeat-red': ['#f4cec4', '#ecaea1', '#e08678', '#ce544b', '#b10010'],
|
||||
greyscale: ['#e8e8e8', '#a9a9a9', '#6f6f6f', '#3a3a3a', '#060606'],
|
||||
}
|
||||
return RAMP_PRESETS[cfg.map?.colorRamp] || RAMP_PRESETS['heartbeat-red']
|
||||
})()
|
||||
|
||||
// ── HEARTBEAT ZONES: the thresholds, resolved from config ────────────────────────────────────
|
||||
// A HEARTBEAT ZONE is a country in the TOP bin — "the most people groups in them". The edge is
|
||||
|
|
|
|||
Loading…
Reference in a new issue