doxa-maps/internal/check-map-control-overlap.mjs
Doxa Maps d3cdc2c3bc Two gates the branch backlog turned red
Seven Driver citations came back with the merged branches. Each keeps its
engineering reason and its date, and loses the speaker: the overlap report, the
poster's legend rule, the network-change defect, the chart's provenance.

check:plural-forms crashed rather than failed. It built its locale list by
listing the catalogue directory, so a README.md moved in beside the locale
folders became a 'locale', passed the exists check because a file does exist,
and crashed readdirSync on a non-directory. It now filters to directories. The
gate on development has the same line and only survives because that README is
not there yet -- so this is a fix for development, not a merge artefact.

Both found by running the full 103-gate chain; the shortened chain the merge
produced ran neither.
2026-09-29 14:55:35 +00:00

134 lines
6.7 KiB
JavaScript

#!/usr/bin/env node
/**
* check-map-control-overlap — two controls pinned to the same corner of the dashboard map must not
* land on top of each other.
*
* Reported 2026-09-28 (test round 2, #3): some map controls overlap each other.
*
* WHAT HAPPENED, because it is the interesting part. The projection toggle (.rd-proj) sits at
* top:8px and the zoom toolbar (.rd-zoomtools) was pinned at top:34px — a hand-tuned number chosen
* when the toggle measured 23px tall, which the panel's own comment records as a live measurement
* ("this rendered 74x23"). A later accessibility fix gave the toggle min-height:24px to satisfy WCAG
* 2.5.8. Nothing in that shadow root sets box-sizing globally, so 24 was a CONTENT height: the
* rendered box became 24 + 4+4 padding + 1+1 border = 34px, ending at y=42 and running 8px into a
* toolbar whose z-index:10 drew straight over it. A correct fix to one control broke another,
* because the agreement between them lived as a magic number in a second rule.
*
* So this check does not assert either number. It COMPUTES the toggle's rendered height from its own
* declarations — honouring box-sizing, the way a browser does — and requires the toolbar to start
* below it. Restating "34" here would have re-created the bug in a different file.
*
* Headless, no browser, no dependencies. Exit 0 pass · 1 fail.
*/
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { readFileSync } from 'node:fs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const FILE = 'plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue'
const SRC = readFileSync(resolve(ROOT, FILE), 'utf8')
// Comments record measurements and mention the old numbers on purpose; they must not be readable
// as declarations. (A predicate matching a docstring is a mistake this repo's gates have made.)
const CSS = SRC.replace(/\/\*[\s\S]*?\*\//g, '')
const fails = []
const ok = (what, cond, detail) => { if (!cond) fails.push(what + (detail ? `\n ${detail}` : '')) }
// ── read the declarations we depend on ────────────────────────────────────────
const ruleBody = (selector) => {
// last matching rule wins, the way the cascade would for equal specificity
const re = new RegExp(String.raw`(^|[\s},])${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\s*\{([^}]*)\}`, 'g')
let m, body = null
while ((m = re.exec(CSS))) body = m[2]
return body
}
const allBodies = (selector) => {
const re = new RegExp(String.raw`(^|[\s},])${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\s*\{([^}]*)\}`, 'g')
const out = []; let m
while ((m = re.exec(CSS))) out.push(m[2])
return out
}
const decl = (body, prop) => {
if (!body) return null
const re = new RegExp(String.raw`(?:^|;)\s*${prop}\s*:\s*([^;]+)`, 'i')
const m = body.match(re)
return m ? m[1].trim() : null
}
const px = (v) => { const m = String(v ?? '').match(/(-?[\d.]+)px/); return m ? parseFloat(m[1]) : null }
// custom properties, read from the element that declares them
const wrap = ruleBody('.rd-mapwrap')
const VARS = {}
for (const name of ['--rd-proj-h', '--rd-proj-top', '--rd-ctl-gap']) {
VARS[name] = px(decl(wrap, name.replace('--', '--')))
ok(`${name} is declared on .rd-mapwrap`, VARS[name] != null,
'the toolbar derives its offset from these; an undeclared one silently falls back to the literal default')
}
// resolve a value that may be a var() or a calc() of vars/lengths
const resolveLen = (raw) => {
if (raw == null) return null
let v = String(raw)
v = v.replace(/var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\)/g, (_, name, fb) => {
const got = VARS[name]
return got != null ? `${got}px` : (fb ? fb.trim() : '0px')
})
const calc = v.match(/^calc\((.+)\)$/)
if (calc) {
const parts = calc[1].split('+').map((p) => px(p.trim()))
if (parts.some((p) => p == null)) return null
return parts.reduce((a, b) => a + b, 0)
}
return px(v)
}
// ── .rd-proj: what the browser will actually render ───────────────────────────
const projBodies = allBodies('.rd-proj')
ok('.rd-proj is declared', projBodies.length > 0)
const proj = projBodies.join(';')
const projTop = resolveLen(decl(proj, 'top'))
const projMinH = resolveLen(decl(proj, 'min-height'))
const projPad = decl(proj, 'padding')
const projBorder = decl(proj, 'border')
const projBox = (decl(proj, 'box-sizing') || 'content-box').trim()
ok('.rd-proj declares a top', projTop != null)
ok('.rd-proj declares a min-height (the WCAG 2.5.8 touch target)', projMinH != null)
// padding: "4px 9px" -> vertical is the first value
const padY = projPad ? (px(projPad.split(/\s+/)[0]) ?? 0) * 2 : 0
const borderY = projBorder ? (px(projBorder.split(/\s+/)[0]) ?? 0) * 2 : 0
// content-box adds padding and border OUTSIDE the declared min-height; border-box does not.
const projH = projBox === 'border-box' ? projMinH : (projMinH ?? 0) + padY + borderY
const projBottom = (projTop ?? 0) + projH
// ── .rd-zoomtools: every rule that sets a top, including the mobile restatement ─
const toolTops = allBodies('.rd-zoomtools.rd-zoomtools')
.map((b) => resolveLen(decl(b, 'top')))
.filter((t) => t != null)
ok('.rd-zoomtools pins a top somewhere', toolTops.length > 0)
for (const top of toolTops) {
ok('THE ZOOM TOOLBAR CLEARS THE PROJECTION TOGGLE (they share the top-right corner)',
top >= projBottom,
`.rd-proj renders ${projH}px tall as ${projBox} (min-height ${projMinH} + ${padY} padding + ${borderY} border) ` +
`at top ${projTop}, so it ends at y=${projBottom}; a toolbar top of ${top} overlaps by ${projBottom - top}px`)
}
// The toolbar must DERIVE its offset, not restate a number: that is what let the two drift apart.
const derived = allBodies('.rd-zoomtools.rd-zoomtools')
.map((b) => decl(b, 'top')).filter(Boolean)
ok('the toolbar offset is derived from the toggle, not hard-coded',
derived.length > 0 && derived.every((t) => /var\(\s*--rd-proj-h/.test(t)),
'a literal top here is the exact shape of the original bug: correct today, wrong the next time the toggle changes size')
// And the toggle must stay a legitimate touch target after the box-sizing change.
ok('.rd-proj is still at least 24px tall (WCAG 2.5.8)', projH >= 24,
`border-box means min-height includes padding and border; rendered ${projH}px`)
if (fails.length) {
console.error('MAP-CONTROL-OVERLAP: FAIL')
for (const f of fails) console.error(` ✗ ${f}`)
process.exit(1)
}
console.log(`MAP-CONTROL-OVERLAP: PASS — the projection toggle renders ${projH}px as ${projBox} and ends at y=${projBottom}; every .rd-zoomtools rule starts at or below that, and each derives the offset from --rd-proj-h rather than restating it.`)