From 2449098a682cda158173d6216879082b0372d072 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sat, 26 Sep 2026 18:01:04 +0000 Subject: [PATCH] The legend mirrors in Arabic: logical insets instead of left/right The shared legend was placed with physical left/right, while the map controls use inset-inline-end. In Arabic the controls mirrored to the left, onto a legend that stayed left: on Simple Research, Prayer Progress and UPG-100 (rega's RTL survey at 52eeb0db; traced by upg100-plugin and repo). SemanticTreeLegend: the panel, its reopen button, the tree-guide hairlines and the export menu use inset-inline-start/end; the closed panel slides off to the right and its collapse arrow flips under :dir(rtl) (additive overrides, as VerticalTabsLayout does). LegendDesktop, LegendMobile, LegendRows, LegendTools and LegendProgressBar: the card, reopen, detail close/collapse buttons, the name gutters that clear them, indents and spacing are logical; LegendTools re-centres under RTL. LTR renders the same. check:legend-logical-insets (with :self-test, both in gates:post) fails on any physical left/right in the legend components unless it is symmetric, an auto reset, or allowed with a reason. --- internal/check-legend-logical-insets.mjs | 93 +++++++++++++++++++ library/components/legends/LegendDesktop.vue | 22 ++--- library/components/legends/LegendMobile.vue | 12 +-- .../components/legends/LegendProgressBar.vue | 2 +- library/components/legends/LegendRows.vue | 2 +- library/components/legends/LegendTools.vue | 3 +- .../components/legends/SemanticTreeLegend.vue | 22 +++-- package.json | 6 +- 8 files changed, 132 insertions(+), 30 deletions(-) create mode 100644 internal/check-legend-logical-insets.mjs diff --git a/internal/check-legend-logical-insets.mjs b/internal/check-legend-logical-insets.mjs new file mode 100644 index 000000000..8f7bc5168 --- /dev/null +++ b/internal/check-legend-logical-insets.mjs @@ -0,0 +1,93 @@ +#!/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)), '..') +const DIR = join(ROOT, 'library/components/legends') + +// file -> substrings of declarations that may stay physical, each with its reason. +const ALLOWED = { + 'LegendMobile.vue': [ + ['.mapboxgl-ctrl-attrib.mapboxgl-compact{margin-right', "the engine's own attribution control, which the engine places"], + ], + '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 = [] + const styles = [...src.matchAll(/]*>([\s\S]*?)<\/style>|`([^`]*\{[^`]*\}[^`]*)`/g)].map((m) => m[1] ?? m[2]) + 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 run(read) { + const all = [] + for (const f of readdirSync(DIR).filter((f) => f.endsWith('.vue')).sort()) all.push(...findings(f, read(f))) + return all +} + +const readReal = (f) => readFileSync(join(DIR, f), 'utf8') + +if (process.argv.includes('--self-test')) { + const poisoned = (f) => { + const s = readReal(f) + return f === 'SemanticTreeLegend.vue' ? s.replace('inset-inline-start: 8px; top: var(--stl-top', 'left: 8px; top: var(--stl-top') : s + } + const hit = run(poisoned) + if (!hit.some((h) => h.includes('.stl-panel') && h.includes('left: 8px'))) { + console.error('LEGEND-LOGICAL-INSETS self-test: FAIL — a poisoned `.stl-panel { left: 8px }` was not caught') + process.exit(1) + } + console.log('LEGEND-LOGICAL-INSETS self-test: PASS — a physical `left: 8px` on .stl-panel is caught') + 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.') diff --git a/library/components/legends/LegendDesktop.vue b/library/components/legends/LegendDesktop.vue index 096e5b2fa..e9c9b4eaf 100644 --- a/library/components/legends/LegendDesktop.vue +++ b/library/components/legends/LegendDesktop.vue @@ -38,7 +38,7 @@ useShadowStyles(` sit at exactly the same position+size — no visible jump when the user clicks a pin and the detail panel swaps in over the language-family legend (qa: 2026-05-02). */ -.legend-container{position:absolute;top:60px;left:8px;width:var(--map-legend-width,380px);max-height:calc(100vh - 80px);background:#fff;border-radius:var(--map-radius,16px);box-shadow:0 2px 8px rgba(0,0,0,0.12),0 1px 3px rgba(0,0,0,0.08);z-index:1000;overflow:hidden;display:flex;flex-direction:column;transition:width 200ms cubic-bezier(0.2,0,0,1),max-height 200ms cubic-bezier(0.2,0,0,1),min-width 200ms cubic-bezier(0.2,0,0,1),border-radius 200ms linear,box-shadow 200ms linear;will-change:width,max-height;} +.legend-container{position:absolute;top:60px;inset-inline-start:8px;width:var(--map-legend-width,380px);max-height:calc(100vh - 80px);background:#fff;border-radius:var(--map-radius,16px);box-shadow:0 2px 8px rgba(0,0,0,0.12),0 1px 3px rgba(0,0,0,0.08);z-index:1000;overflow:hidden;display:flex;flex-direction:column;transition:width 200ms cubic-bezier(0.2,0,0,1),max-height 200ms cubic-bezier(0.2,0,0,1),min-width 200ms cubic-bezier(0.2,0,0,1),border-radius 200ms linear,box-shadow 200ms linear;will-change:width,max-height;} /* ── DESKTOP TYPE SCALE for the LegendRows-based card (Driver, 2026-09-16: larger, more readable desktop legends). The semantic-tree panel's scale lives in SemanticTreeLegend.vue (it renders without this card on some maps). Size only, never layout. ── */ @@ -52,7 +52,7 @@ useShadowStyles(` floating shadow, uppercase monospace font. Hover lifts + glows. Name track auto-sizes to fit title text. */ /* ── Reopen pill (collapsed state) — mirrors STL .stl-reopen ── */ -.legend-reopen{position:absolute;left:8px;top:60px;z-index:1000;display:inline-flex;align-items:center;gap:8px;background:#0d1117;border:1px solid #30363d;border-radius:999px;color:#c9d1d9;cursor:pointer;height:36px;padding:0 14px 0 12px;box-shadow:0 4px 14px rgba(0,0,0,0.25);font:600 11px ui-monospace,monospace;text-transform:uppercase;letter-spacing:0.06em;max-width:320px;transition:color 0.12s,background 0.12s,border-color 0.12s,transform 0.12s,box-shadow 0.12s;} +.legend-reopen{position:absolute;inset-inline-start:8px;top:60px;z-index:1000;display:inline-flex;align-items:center;gap:8px;background:#0d1117;border:1px solid #30363d;border-radius:999px;color:#c9d1d9;cursor:pointer;height:36px;padding:0 14px 0 12px;box-shadow:0 4px 14px rgba(0,0,0,0.25);font:600 11px ui-monospace,monospace;text-transform:uppercase;letter-spacing:0.06em;max-width:320px;transition:color 0.12s,background 0.12s,border-color 0.12s,transform 0.12s,box-shadow 0.12s;} .legend-reopen:hover{color:#fff;background:#1c2333;border-color:#73A17F;box-shadow:0 6px 20px rgba(59,70,61,0.25);transform:translateY(-1px);} .legend-reopen-icon{color:#73A17F;flex-shrink:0;} .legend-reopen-label{display:inline-flex;align-items:center;gap:6px;min-width:0;max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-transform:none;letter-spacing:0;font:600 12px system-ui,sans-serif;} @@ -78,7 +78,7 @@ useShadowStyles(` .legend-container:not(.legend-dark) .legend-collapse-caret:hover{background:rgba(59,70,61,0.12);border-color:#3b463d;color:#3b463d;} /* Detail-mode close button — top-right of the card */ -.detail-close-btn{position:absolute;top:10px;right:10px;background:none;border:none;padding:4px;cursor:pointer;color:#888;display:flex;align-items:center;justify-content:center;z-index:20;transition:color 0.2s ease;} +.detail-close-btn{position:absolute;top:10px;inset-inline-end:10px;background:none;border:none;padding:4px;cursor:pointer;color:#888;display:flex;align-items:center;justify-content:center;z-index:20;transition:color 0.2s ease;} .detail-close-btn:hover{color:#333;} /* Detail-mode collapse caret — top-left of the card, mirrors the inline @@ -86,14 +86,14 @@ useShadowStyles(` above the PeopleGroupDetail sticky header (.detail-header z-index:10) so it is always tappable even when the detail panel scrolls. The button is only rendered in the detail-mode template branch (see