doxa-maps/internal/check-map-control-overlap.mjs
Doxa Maps 0147b6c2c6 The touch-target fix is what pushed the toggle into the zoom buttons
The projection toggle and the zoom toolbar share the top-right corner. The toolbar
was pinned at a literal top:34px, a number that was correct when it was chosen:
the toggle then measured 74x23 and ended at y=31, which this panel's own comment
records as a live measurement. A later accessibility fix gave the toggle
min-height:24px for WCAG 2.5.8. Nothing in this shadow root sets box-sizing
globally -- it appears exactly once, on the legend -- so 24 was a CONTENT height
and the rendered box became 24 plus 8 padding plus 2 border = 34px. Sitting at
top:8 it now ended at y=42 and ran 8px into the toolbar, which has z-index 10
against the toggle's 3 and so drew straight over it.

Nobody did anything careless. A correct fix to one control broke another because
the agreement between them was a magic number living in the other rule, where the
person changing the first had no reason to look.

So the toggle gets box-sizing:border-box, which makes 24 mean twenty-four and
keeps it a legitimate 24px touch target, and the toolbar's offset is now
calc(--rd-proj-top + --rd-proj-h + --rd-ctl-gap). The relationship is stated once,
on the element that owns it, and both the desktop rule and its mobile restatement
read it. Changing the toggle's size now moves the toolbar by construction.

The check computes the toggle's rendered height from its own declarations the way
a browser does, honouring box-sizing, rather than asserting either number --
restating 34 here would have rebuilt the same trap one file further away. Run
against the code as it stood before this commit it reports the overlap and its
exact size. It also requires the toolbar to DERIVE its offset, because a literal
that is correct today is the precise shape of this bug.
2026-09-29 00:06:52 +00:00

132 lines
6.6 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.
*
* 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.`)