Five physical CSS declarations in the chrome every production map renders, so
under Arabic each of them lands on the wrong side for every map at once:
SemanticTreeLegend's active tab chip drew its border on the left, the share
menu's step list padded from the left, its tab spacing and its auto-spacer both
pushed from the right, and the geocoder's search icon sat at left:7px inside an
input that mirrors. All five become logical properties, which in a left-to-right
reading resolve to exactly what they resolved to before: four insertions against
four deletions in the components, one property each, and the English view cannot
move.
The reason they survived is the more useful half. check-legend-logical-insets
existed and passed, but it read only library/components/legends -- the share menu
is one directory away, and a reader in Arabic does not know which folder a control
came from. So the scan now covers map-controls as well.
Widening it was not enough. It still passed, because its CSS extractor matched a
template literal with the pattern `([^`]*\{[^`]*\}[^`]*)`, which describes a
literal containing ONE brace pair. ShareButton keeps its styles in a 600-line
useShadowStyles literal with hundreds, so the scan captured seven fragments
totalling 7,003 characters of a 629-line file and never saw .share-tab at all. It
was reporting PASS on a directory it was barely reading, which is worse than
having no check: a green gate is read as evidence. Template literals are now
taken whole and kept only when they look like CSS -- 2 blocks, 8,462 characters,
and the rule is finally in scope.
The self-test poisons BOTH directories. Poisoning only a legend would have proved
the check works on the half it always covered, and passed unchanged the day the
wider scan silently narrowed again. Its map-controls arm puts back the exact
declaration this commit removes, and its failure message says what a failure
means.
139 lines
7.3 KiB
JavaScript
139 lines
7.3 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-legend-logical-insets.mjs — the legend components position themselves with LOGICAL insets.
|
|
*
|
|
* WHY: the shared legend (.stl-panel, its reopen button, the detail buttons, the tree-guide hairlines) used
|
|
* `left: 8px`. MapToolbar uses `inset-inline-end`, which mirrors under dir="rtl"; a physical `left` does not.
|
|
* So in Arabic the controls moved left onto a legend that stayed left, on three production maps at once
|
|
* (Simple Research, Prayer Progress, UPG-100; rega's survey at 52eeb0db). The fix is inset-inline-start /
|
|
* inset-inline-end; this gate keeps a physical left/right from coming back.
|
|
*
|
|
* WHAT IT READS: every CSS declaration in library/components/legends/*.vue (comments stripped). A
|
|
* declaration `left:` / `right:` / `padding-left|right:` / `margin-left|right:` fails unless it is:
|
|
* - a centring or full-width rule, which is symmetric under RTL (`left:50%`, `left:0` beside `right:0`);
|
|
* - a reset to auto (`left:auto`), which undoes rather than places;
|
|
* - listed in ALLOWED below with the reason it may stay physical.
|
|
* Inline :style bindings computed in JS (the progress bar's popover) are not CSS declarations and are not read.
|
|
*
|
|
* --self-test poisons a copy of one rule and asserts the gate notices: a gate that cannot fail proves nothing.
|
|
*/
|
|
import { readFileSync, readdirSync } from 'node:fs'
|
|
import { join, dirname } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
// ★ THE SHARED CHROME, NOT ONLY THE LEGENDS. This check began as a legends check, and that scope is
|
|
// exactly why three physical declarations survived in ShareButton — the share menu is chrome every
|
|
// production map renders, but it lives one directory away and was never read. A reader in Arabic
|
|
// does not know which folder a control came from. Audited 2026-09-29: ShareButton had padding-left
|
|
// on the step list and margin-right on both the tab spacing and the auto-spacer, so under `ar` the
|
|
// gap fell on the wrong side of every share tab.
|
|
const DIRS = [
|
|
join(ROOT, 'library/components/legends'),
|
|
join(ROOT, 'library/components/map-controls'),
|
|
]
|
|
// Files are addressed as "<dir-name>/<file>" so two directories cannot collide on a base name and so
|
|
// the ALLOWED keys below stay unambiguous.
|
|
const shortOf = (dir, f) => `${dir.split('/').pop()}/${f}`
|
|
|
|
// file -> substrings of declarations that may stay physical, each with its reason.
|
|
const ALLOWED = {
|
|
'legends/LegendMobile.vue': [
|
|
['.mapboxgl-ctrl-attrib.mapboxgl-compact{margin-right', "the engine's own attribution control, which the engine places"],
|
|
],
|
|
'legends/LegendProgressBar.vue': [
|
|
['.lpb-pop{', 'the popover is placed by JS (popLeft) over the bar segment it describes'],
|
|
],
|
|
}
|
|
|
|
const PHYSICAL = /(^|[;{\s])((?:padding|margin)-(?:left|right)|left|right)\s*:\s*([^;}]+)/g
|
|
|
|
function findings(file, src) {
|
|
const out = []
|
|
// ★ THE BACKTICK ARM USED TO MISS MOST OF A BIG SHADOW-CSS LITERAL, AND THAT IS HOW THREE PHYSICAL
|
|
// DECLARATIONS HID IN ShareButton. `([^`]*\{[^`]*\}[^`]*)` matches a literal containing ONE brace
|
|
// pair; a 600-line useShadowStyles(`…`) block has hundreds, so the scan captured 7 fragments
|
|
// totalling 7,003 chars of a 629-line file and never saw .share-tab's margin-right at all. The
|
|
// gate then reported PASS on a directory it was barely reading — the worst kind of green.
|
|
// A <style> block is still matched as before; a template literal is now taken WHOLE, from its
|
|
// opening backtick to its closing one, and only kept when it actually looks like CSS.
|
|
const styles = []
|
|
for (const m of src.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)) styles.push(m[1])
|
|
for (const m of src.matchAll(/`([\s\S]*?)`/g)) {
|
|
const body = m[1]
|
|
if (/\{[\s\S]*:[\s\S]*\}/.test(body)) styles.push(body)
|
|
}
|
|
const allowed = ALLOWED[file] || []
|
|
for (const css of styles) {
|
|
const clean = css.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
for (const rule of clean.split('}')) {
|
|
const [sel = '', body = ''] = rule.split('{')
|
|
const decl = sel.trim() + '{' + body
|
|
for (const m of body.matchAll(PHYSICAL)) {
|
|
const prop = m[2], val = m[3].trim().replace(/!important$/, '').trim()
|
|
if (val === 'auto') continue
|
|
if ((prop === 'left' || prop === 'right') && /^50%$/.test(val)) continue
|
|
if (prop === 'left' && val === '0' && /right\s*:\s*0\b/.test(body)) continue
|
|
if (prop === 'right' && val === '0' && /left\s*:\s*0\b/.test(body)) continue
|
|
if (allowed.some(([s]) => decl.replace(/\s+/g, '').includes(s.replace(/\s+/g, '')))) continue
|
|
out.push(`${file}: ${sel.trim().replace(/\s+/g, ' ').slice(0, 70)} { ${prop}: ${val} }`)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
function eachFile() {
|
|
const out = []
|
|
for (const dir of DIRS) {
|
|
for (const f of readdirSync(dir).filter((x) => x.endsWith('.vue')).sort()) out.push([dir, f])
|
|
}
|
|
return out
|
|
}
|
|
|
|
function run(read) {
|
|
const all = []
|
|
for (const [dir, f] of eachFile()) all.push(...findings(shortOf(dir, f), read(dir, f)))
|
|
return all
|
|
}
|
|
|
|
const readReal = (dir, f) => readFileSync(join(dir, f), 'utf8')
|
|
|
|
if (process.argv.includes('--self-test')) {
|
|
const poisoned = (dir, f) => {
|
|
const s = readReal(dir, f)
|
|
if (f === 'SemanticTreeLegend.vue') return s.replace('inset-inline-start: 8px; top: var(--stl-top', 'left: 8px; top: var(--stl-top')
|
|
// ★ POISON THE NEW DIRECTORY TOO. The legends arm alone would still pass with map-controls
|
|
// unscanned, so a self-test that only poisons a legend proves the check works on the half it
|
|
// always covered. This one puts back the exact declaration the 2026-09-29 audit found live in
|
|
// the share menu — if the widened scan ever silently narrows again, this fails.
|
|
if (f === 'ShareButton.vue') return s.replace('margin-inline-end:20px;', 'margin-right:20px;')
|
|
return s
|
|
}
|
|
const hit = run(poisoned)
|
|
const legendCaught = hit.some((h) => h.includes('.stl-panel') && h.includes('left: 8px'))
|
|
const controlCaught = hit.some((h) => h.includes('ShareButton.vue') && h.includes('margin-right'))
|
|
if (!legendCaught) {
|
|
console.error('LEGEND-LOGICAL-INSETS self-test: FAIL — a poisoned `.stl-panel { left: 8px }` was not caught')
|
|
process.exit(1)
|
|
}
|
|
if (!controlCaught) {
|
|
console.error('LEGEND-LOGICAL-INSETS self-test: FAIL — a poisoned `margin-right:20px` in ShareButton was not caught.\n' +
|
|
' That means map-controls/ is not being scanned, which is the exact gap that let three physical\n' +
|
|
' declarations live in the share menu until 2026-09-29.')
|
|
process.exit(1)
|
|
}
|
|
console.log('LEGEND-LOGICAL-INSETS self-test: PASS — a physical inset is caught in BOTH scanned directories (legends and map-controls)')
|
|
process.exit(0)
|
|
}
|
|
|
|
const bad = run(readReal)
|
|
if (bad.length) {
|
|
console.error(`LEGEND-LOGICAL-INSETS: FAIL — ${bad.length} physical left/right declaration(s) in the legend components:`)
|
|
for (const b of bad) console.error(' ✗ ' + b)
|
|
console.error('\n A physical inset does not mirror under dir="rtl" while the map controls (inset-inline-end) do,')
|
|
console.error(' so in Arabic they overlap. Use inset-inline-start / inset-inline-end (padding-/margin-inline-*),')
|
|
console.error(' or add the rule to ALLOWED in this file with the reason it must stay physical.')
|
|
process.exit(1)
|
|
}
|
|
console.log('LEGEND-LOGICAL-INSETS: PASS — every legend inset is logical, symmetric, or allowed with a reason.')
|