doxa-maps/internal/check-square-map-controls.mjs
Doxa Maps 65faa2d971 Keep the square panel together on a phone
A media query adds no specificity. The mobile block's
`.map-toolbar { gap: 6px }` is (0,1,0) exactly like
`.map-toolbar--square { gap: 0 }`, and it comes later in the sheet, so it
won: on every phone a square toolbar fell back to separate buttons inside
one ringed panel. The shape broke precisely where the Driver is most likely
to look at it.

The --no-geocoder pair is (0,2,0) and outranked the variant whatever the
order, so it needs the two-class answer.

This is the same lesson as the goal cards' gap-vs-margin: a ported mechanism
is only ported if it still WINS, and specificity is the part that decides
whether it does. Found by following the phone thread rather than by looking
at the variant again.

check:square-map-controls now asserts the override is inside the mobile
block, that the two-class form answers --no-geocoder, and -- as a control --
that the base rule really does come first, which is why the override is
needed at all. Removing it turns the gate red.

The CSS comment explaining it cost one more mistake: I wrote the selectors
in backticks, inside the template literal that holds the stylesheet. A
backtick ends the literal, which is the trap check:sfc-parse names in its own
failure text, and it caught me with it.
2026-09-27 06:04:08 +00:00

172 lines
12 KiB
JavaScript

#!/usr/bin/env node
// check-square-map-controls.mjs — `bun run check:square-map-controls`. Source-only, headless, no network.
//
// The square map-control variant: is it the ENGINE'S card, and does nothing else move?
//
// The Driver asked for square controls as an OPTION beside the round ones. The key fact that shaped
// the implementation is that the square look is not a design of DX-life's at all: their CountryMap.vue
// adds mapboxgl.NavigationControl + FullscreenControl and overrides NOTHING, so what he admired is the
// ENGINE'S DEFAULT grouped card. That makes the target published and exact rather than a matter of
// taste -- so this check reads the engine's own stylesheet out of node_modules and compares our ported
// values against it. A port checked against its SOURCE cannot drift with my memory of it.
//
// AND IT ASSERTS THE BLAST RADIUS, which is the half a visual review cannot see: round is the default
// and no consumer opts in, so all nine maps that use MapToolbar render exactly as before.
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
// Each copy of this repo grades ITS OWN tree (check:no-abs-paths, 2026-09-24).
const REPO = process.env.DOXA_REPO || resolve(dirname(fileURLToPath(import.meta.url)), '..')
const rd = (p) => readFileSync(join(REPO, p), 'utf8')
let fails = 0
const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ }
const TOOLBAR_SRC = rd('library/components/map-controls/MapToolbar.vue')
const BUTTON = rd('library/components/map-controls/MapControlButton.vue')
// ★ CSS ASSERTIONS READ THE STYLESHEET, NOT THE FILE. The first run of this check went red on
// "we do not adopt 29px" -- because the prop's own doc comment QUOTES the engine's rule, 29px and all.
// A comment is not a declaration, and a checker that cannot tell them apart will keep finding the
// documentation it is standing on. So the CSS tests run against the text actually passed to
// useShadowStyles; the prop and template tests still read the whole file, which is where they live.
function shadowCss(src) {
const at = src.indexOf('useShadowStyles(')
const open = src.indexOf('`', at)
const close = src.indexOf('`', open + 1)
return open < 0 || close < 0 ? '' : src.slice(open + 1, close)
}
const TOOLBAR = shadowCss(TOOLBAR_SRC)
if (!TOOLBAR.trim()) { console.log(' FAIL - could not extract MapToolbar\'s stylesheet; nothing below was measured'); process.exit(1) }
// ── 1. the ENGINE is the oracle, read from node_modules ─────────────────────────────────────────
const ENGINE = 'node_modules/maplibre-gl/dist/maplibre-gl.css'
if (existsSync(join(REPO, ENGINE))) {
const css = rd(ENGINE).replace(/\/\*[\s\S]*?\*\//g, ' ')
const rule = (sel) => {
const m = css.match(new RegExp(sel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*\\{([^}]*)\\}'))
return m ? m[1].replace(/\s+/g, ' ').trim() : null
}
const group = rule('.maplibregl-ctrl-group')
const filled = rule('.maplibregl-ctrl-group:not(:empty)')
const divider = rule('.maplibregl-ctrl-group button+button')
console.log(`\n the engine's own rules, read from ${ENGINE}:`)
console.log(` group { ${group} }`)
console.log(` filled { ${filled} }`)
console.log(` divider { ${divider} }`)
const radius = (group || '').match(/border-radius:\s*([^;]+)/)
ok(!!radius && radius[1].trim() === '4px', `the engine's group radius is 4px, and ours is too`)
ok(/border-radius:\s*4px;/.test(TOOLBAR), 'our .map-toolbar--square carries border-radius:4px')
const ring = (filled || '').match(/box-shadow:\s*([^;]+)/)
ok(!!ring, 'the engine states a ring on a non-empty group')
ok(/box-shadow:\s*0 0 0 2px rgba\(0, 0, 0, 0\.1\)/.test(TOOLBAR),
`our ring matches the engine's 0 0 0 2px rgba(0,0,0,.1) (engine: ${ring && ring[1]})`)
ok(/border-top:\s*1px solid #ddd/.test(TOOLBAR),
`our divider matches the engine's 1px solid #ddd (engine: ${divider})`)
// ★ the ONE value deliberately NOT ported
const btn = rule('.maplibregl-ctrl-group button')
const eng = (btn || '').match(/height:\s*(\d+)px/)
ok(!!eng && eng[1] === '29', `the engine sizes its buttons ${eng && eng[1]}px`)
ok(!new RegExp('height:\\s*29px').test(TOOLBAR) && !new RegExp('width:\\s*29px').test(TOOLBAR),
'and we DO NOT adopt 29px -- the variant takes the SHAPE and leaves the SIZE, so the touch target is not cut')
ok(/--map-btn-size/.test(TOOLBAR), 'our size still comes from --map-btn-size')
} else {
console.log(`\n NOT MEASURED against the engine: ${ENGINE} is absent, so the ported values were not compared to their source.`)
fails++ // a missing oracle is not a pass
}
// ── 2. the grouped card is a CONTAINER, not a radius swap ───────────────────────────────────────
ok(/\.map-toolbar--square\s*\{[^}]*gap:\s*0/.test(TOOLBAR), 'the square toolbar sets gap:0 -- one panel, not a spaced column')
ok(/\.map-toolbar--square\s*\{[^}]*background:\s*#fff/.test(TOOLBAR), 'the PANEL owns the background')
ok(/\.map-toolbar--square \.mcb[^{]*\{[^}]*background:\s*transparent/.test(TOOLBAR), 'and the buttons own none of it')
ok(/\.map-toolbar--square \.mcb[^{]*\{[^}]*box-shadow:\s*none/.test(TOOLBAR), 'the buttons drop their own shadow, so there is one ring not N')
ok(/\.map-toolbar--square > \* \+ \*\s*\{[^}]*border-top/.test(TOOLBAR),
'the divider is on the toolbar CHILDREN, so it still lands where two different control types meet')
ok(/\.map-toolbar--square \.hbg-ctrl\s*\{[^}]*margin:\s*0/.test(TOOLBAR),
"the hamburger wrapper's 10px margin is zeroed -- it would otherwise tear the panel apart")
// ★ no overflow:hidden, and the reason is a focus ring
ok(!/\.map-toolbar--square\s*\{[^}]*overflow:\s*hidden/.test(TOOLBAR),
'the square panel does NOT use overflow:hidden -- it would clip a focus ring on any inner button')
ok(/:first-child[^{]*\{[^}]*border-radius:\s*4px 4px 0 0/.test(TOOLBAR) &&
/:last-child[^{]*\{[^}]*border-radius:\s*0 0 4px 4px/.test(TOOLBAR),
'outer corners come from first/last child rounding instead, which is the engine\'s own mechanism')
ok(/:only-child[^{]*\{[^}]*border-radius:\s*4px/.test(TOOLBAR), 'and a lone control is rounded on all four corners')
// ── 2b. THE PANEL SURVIVES THE PHONE, which it did not at first ────────────────────────
// A media query adds NO specificity, so the mobile `.map-toolbar { gap: 6px }` is (0,1,0) exactly like
// `.map-toolbar--square { gap: 0 }` and, coming later, WON -- a square toolbar fell back to six
// separate buttons inside one ringed panel on every phone. The --no-geocoder pair is (0,2,0) and
// outranked the variant whatever the order, so it needs the two-class answer.
{
const mq = TOOLBAR.indexOf('@media (max-width: 767px)')
ok(mq > -1, 'the mobile media block is present')
const mobile = mq > -1 ? TOOLBAR.slice(mq) : ''
ok(/\.map-toolbar--square[^{]*\{[^}]*gap:\s*0/.test(mobile),
'the square variant re-states gap:0 INSIDE the mobile block, so the phone rule cannot outrank it')
ok(/\.map-toolbar--square\.map-toolbar--no-geocoder[^{]*\{[^}]*gap:\s*0/.test(mobile),
'and answers the (0,2,0) --no-geocoder pair with the two-class form')
const sqGap = TOOLBAR.indexOf('.map-toolbar--square {')
ok(sqGap > -1 && sqGap < mq, 'CONTROL: the base square rule really does come BEFORE the media block, which is why the override is needed at all')
}
// ── 3. forced colours, which the round buttons never had ────────────────────────────────────────
ok(/@media \(forced-colors: active\)/.test(TOOLBAR), 'the square variant ships a forced-colors block')
ok(/ButtonText/.test(TOOLBAR), 'using ButtonText, so the ring and dividers survive a replaced palette')
// ── 4. the standalone per-button override ───────────────────────────────────────────────────────
ok(/shape:\s*\{ type: String,\s*default: 'round' \}/.test(BUTTON), "MapControlButton's shape defaults to 'round'")
ok(/\.mcb--square\{ border-radius:4px; \}/.test(BUTTON), 'a standalone square takes the engine\'s 4px corners')
ok(/'mcb--square': shape === 'square'/.test(BUTTON), 'and the class is bound to the prop')
// ── 5. BLAST RADIUS: round is the default and nobody opted in ────────────────────────────────────
ok(/controls:\s*\{ type: String, default: 'round' \}/.test(TOOLBAR_SRC), "MapToolbar's controls prop defaults to 'round'")
const CONSUMERS = [
['doxa-simple-map', 'plugin-designs/doxa-simple-map/doxa-simple-map.vue', 8],
['resources-map', 'plugin-designs/gospel-resources/resources-map.vue', 5],
['upg-100-list', 'plugin-designs/my-upg-100-list/upg-100-list.vue', 6],
['countries-map', 'plugin-designs/country-posters/countries-map.vue', 5],
['DashboardMapPanel', 'plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue', 2],
['demo-map', 'plugin-designs/demo-map/demo-map.vue', 5],
['connectivity-map', 'plugin-designs/cities-internet/connectivity-map.vue', 5],
['research-map', 'plugin-designs/doxa-research-map/research-map.vue', 7],
['all-people-groups', 'plugin-designs/all-people-groups/all-people-groups.vue', 1],
]
let optedIn = 0
for (const [name, path] of CONSUMERS) {
const src = rd(path)
const m = src.match(/<MapToolbar[^>]*controls=/)
if (m) { optedIn++; console.log(` ${name} opts in`) }
}
ok(optedIn === 0, `none of the ${CONSUMERS.length} MapToolbar consumers passes controls=, so every one renders exactly as before (${optedIn} opted in)`)
// ── 6. THE GEOMETRY, for rega to confirm with eyes ──────────────────────────────────────────────
// A square toolbar is SHORTER, because the gaps become dividers. That is the one measurable knock-on:
// MapControlMenu's matchToolbar limits a menu to the toolbar column's height.
const BTN = 32, GAP_DESKTOP = 8, GAP_MOBILE = 6
console.log('\n toolbar column height, round vs square (button 32px; ring is box-shadow, so outside layout):')
for (const [name, , n] of CONSUMERS) {
const round = n * BTN + (n - 1) * GAP_DESKTOP
const square = n * BTN + (n - 1) * 1 // 1px dividers replace the 8px gaps
console.log(` ${String(n).padStart(2)} control(s) ${name.padEnd(18)} round ${String(round).padStart(3)}px square ${String(square).padStart(3)}px shorter by ${round - square}px`)
}
const five = { round: 5 * BTN + 4 * GAP_DESKTOP, square: 5 * BTN + 4 }
ok(five.square < five.round,
`a 5-control toolbar goes ${five.round}px -> ${five.square}px, so a matchToolbar menu gets ${five.round - five.square}px shorter -- rega should confirm the menu still fits its content`)
console.log(` at mobile gap ${GAP_MOBILE}px a 5-control toolbar is ${5 * BTN + 4 * GAP_MOBILE}px round vs ${five.square}px square`)
// ── 7. NEG CONTROLS ─────────────────────────────────────────────────────────────────────────────
ok(!/\.map-toolbar\s*\{[^}]*border-radius/.test(TOOLBAR),
'NEG CONTROL: the DEFAULT .map-toolbar has no border-radius, so the square rules are genuinely scoped to the variant')
ok(/border-radius:\s*50%/.test(BUTTON),
'NEG CONTROL: the round button is still a 50% circle -- the default was not quietly changed')
ok(!/controls === 'round'/.test(TOOLBAR_SRC),
'NEG CONTROL: nothing branches on round, so round is the ABSENCE of the square class rather than a second code path')
console.log(fails ? `\nSQUARE-CONTROLS: ${fails} FAIL(s)` : '\nSQUARE-CONTROLS: the variant matches the engine\'s own rules, keeps our touch target, and changes no consumer')
process.exit(fails ? 1 : 0)