The touch-target fix is what pushed the toggle into the zoom buttons

The Driver, test round 2: some of the dashboard map's controls overlap each other.

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.
This commit is contained in:
Doxa Maps 2026-09-28 19:34:02 +00:00
parent 6e31def193
commit de183c3e94
3 changed files with 154 additions and 6 deletions

View file

@ -0,0 +1,134 @@
#!/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.
*
* Driver, 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.`)

View file

@ -207,6 +207,7 @@
"check:pin-selection": "node internal/check-pin-selection.mjs",
"check:poster-action-print": "node internal/check-poster-action-print.mjs",
"check:poster-layout": "node internal/check-poster-layout.mjs",
"check:map-control-overlap": "node internal/check-map-control-overlap.mjs",
"check:poster-card-fits": "node internal/check-poster-card-fits.mjs",
"check:poster-degrades": "node internal/check-poster-degrades-honestly.mjs",
"check:poster-print-fallback": "node internal/check-poster-print-fallback.mjs",
@ -290,7 +291,7 @@
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:frozen-labels",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:script-registry check:poster-layout",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:script-registry check:poster-layout check:map-control-overlap",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
@ -370,6 +371,7 @@
"check:dep-names": "every declared dep is a REAL package name (a rename once rewrote @capacitor/* and broke bun install in a clone)",
"check:script-registry": "one bundle script executes ONCE PER PAGE. Every plugin is a self-contained IIFE, so library/utils/embedScript.js is compiled into each one and a module-scoped cache was one cache per BUNDLE: two shells on a page each loaded the same child, which re-executed a whole bundle (a second Vue, i18n and pinia) before its guarded customElements.define silently declined. The check imports the module twice under different URLs — two module instances, one globalThis, the same shape as two IIFEs — and asserts the second caller receives the FIRST ONE'S PROMISE, because counting script tags alone is satisfied by the DOM-adoption path and would not catch a regression. Headless; in gates:pre.",
"check:poster-layout": "the EXPORTED poster is a layout, not a map with things painted on it (Driver 2026-09-28: the legend beside the map, never over it, with a proper header and footer). usePosterLayout always computed the right geometry; the renderer never used it, spawning the hidden map at FULL PAGE size and compositing the title, legend and footer on top. One arm checks the geometry on four real sheets, the other checks the renderer CONSUMES it — the link that was missing, and the reason a computeLayout-only test would have passed throughout the bug's life. Headless; in gates:pre.",
"check:map-control-overlap": "two controls pinned to the same corner of the research-dashboard map must not land on each other (Driver 2026-09-28: \"some map controls overlap each other\"). The zoom toolbar was pinned at a literal top:34px, tuned when the projection toggle measured 23px tall; a later WCAG 2.5.8 fix gave the toggle min-height:24px, which in a shadow root with no global box-sizing rendered 34px and ran 8px into a toolbar drawn over it at z-index 10. The check COMPUTES the toggle's rendered height from its own declarations, honouring box-sizing, and requires the toolbar to clear it and to DERIVE its offset rather than restate a number. Headless; in gates:pre.",
"check:describe-group": "the prayer packet's generated profile sentence (library/packets/describeGroup.js): each GSEC level states its own fact, and an index-ordered language name (\"Arabic, Moroccan\") is un-inverted by library/utils/languageLabel.js without shredding a comma inside parentheses, the 13 names the old split printed reversed. A catalogue arm renders every GSEC level from the REAL packet-strings.json in en and ar and fails on a blank sentence. Headless; in gates:pre.",
"check:descriptions": "every shipped profile must say what it is (reads the BUILT manifest)",
"check:done-evidence": "a card may not reach status:done with NO bluf AND NO fields — the Doxa team inherits this board and an evidence-less 'done' cannot be reconciled against the code. 10 pre-existing are baselined by id; the list should only ever SHRINK.",

View file

@ -1599,7 +1599,7 @@ useShadowStyles(`
shows on engines without fog support (MapLibre), where black is the honest fallback. */
.rd-mapwrap[data-projection="globe"]{ background:#000; }
.rd-mapwrap .rd-mapc{ position:absolute; inset:0; }
.rd-proj{ position:absolute; right:8px; top:8px; z-index:3; font-size:11px; font-weight:700; cursor:pointer; user-select:none;
.rd-proj{ position:absolute; right:8px; top:var(--rd-proj-top,8px); z-index:3; font-size:11px; font-weight:700; cursor:pointer; user-select:none;
background:#1a1a2ecc; color:#fff; border:1px solid #ffffff33; border-radius:6px; padding:4px 9px; backdrop-filter:blur(4px); }
.rd-proj:hover{ background:#1a1a2e; }
/* ★ TOUCH TARGET: 24px, STATED, AT EVERY WIDTH. Measured on a phone (390x844) this rendered
@ -1615,7 +1615,19 @@ useShadowStyles(`
/* ★ AND min-WIDTH now that the word is gone. The 24 used to come for free from the text
("🌐 Globe" measured 74px wide); a glyph-only pill is about 29px with its padding, which passes,
but only by accident of the font. Stating both axes keeps 2.5.8 AA true if the glyph changes. */
.rd-proj{ min-height:24px; min-width:24px; display:inline-flex; align-items:center; justify-content:center; }
/* ★ border-box, AND THE TOOLBAR BELOW IS DERIVED FROM THIS HEIGHT RATHER THAN RE-TUNED TO MATCH IT.
The 24px touch target above was declared as a CONTENT height, and nothing in this shadow root
sets box-sizing globally — it appears exactly once, on .rd-legend-map. So the rendered box was
24 + 4+4 padding + 1+1 border = 34px at top:8, ending at y=42, while .rd-zoomtools starts at
top:34. The two controls overlapped by 8px, and the toolbar's z-index:10 against this element's
z-index:3 drew the zoom button OVER the projection toggle. The measurement in the comment above
dates it exactly: at 74x23 this box ended at y=31 and cleared the toolbar, so it was the
touch-target fix itself that created the collision.
border-box makes 24 mean twenty-four, and --rd-proj-h hands that number to the toolbar so the
two cannot drift apart again. An agreement between two rules should be stated once, not tuned
in two places that no one thinks to check together. */
.rd-proj{ min-height:var(--rd-proj-h,24px); min-width:24px; box-sizing:border-box;
display:inline-flex; align-items:center; justify-content:center; }
/* Zoom cluster. MapToolbar defaults to top:6px on the inline-end edge, which is exactly where
the Globe/Flat pill already sits, so drop this instance below it. 34px clears the pill
(top:8px + roughly 22px tall) with a small breathing gap.
@ -1624,7 +1636,7 @@ useShadowStyles(`
same shadow root — so a tie would be resolved by injection ORDER, which is not something
this panel controls. Doubling the class (.rd-zoomtools.rd-zoomtools) raises specificity so
these win deterministically, without !important and without editing the shared toolbar. */
.rd-zoomtools.rd-zoomtools{ top:34px; gap:4px; }
.rd-zoomtools.rd-zoomtools{ top:calc(var(--rd-proj-top,8px) + var(--rd-proj-h,24px) + var(--rd-ctl-gap,6px)); gap:4px; }
/* Economical use of space: 26px instead of the toolbar's standard 32px. The dashboard map is
one cell among many, so the controls stay present but visually quiet. Also match the light
dashboard surface/border tokens so the cluster reads as part of this panel. */
@ -1635,7 +1647,7 @@ useShadowStyles(`
.rd-zoomtools.rd-zoomtools .mcb:hover:not(:disabled){ background:var(--dx-surface,#f3f3f1); box-shadow:0 1px 4px rgba(0,0,0,0.14); }
/* MapToolbar's own mobile breakpoint re-asserts top/gap and a 36px button, so restate here. */
@media(max-width:767px){
.rd-zoomtools.rd-zoomtools{ top:34px; gap:4px; }
.rd-zoomtools.rd-zoomtools{ top:calc(var(--rd-proj-top,8px) + var(--rd-proj-h,24px) + var(--rd-ctl-gap,6px)); gap:4px; }
.rd-zoomtools.rd-zoomtools .mcb{ width:26px; height:26px; }
}
/* LEGEND vs ATTRIBUTION — a LICENCE constraint, not a taste call.
@ -1650,7 +1662,7 @@ useShadowStyles(`
Bottom-LEFT was not available when this was written (a caption held that corner; it has since
been removed, so the corner is free if a future control needs it).
Stays on the right so the legend sits beside the choropleth it explains. */
.rd-mapwrap{ --rd-attrib-clear:26px; }
.rd-mapwrap{ --rd-attrib-clear:26px; --rd-proj-h:24px; --rd-proj-top:8px; --rd-ctl-gap:6px; }
/* ★ LOGICAL, NOT PHYSICAL (rega's Arabic survey). A physical right:8px pins this to the right
edge in every locale, so in Arabic the legend stayed where an English reader expects it while
everything around it mirrored. inset-inline-end puts it at the END of the reading direction —