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.
This commit is contained in:
parent
813a52ebbe
commit
0147b6c2c6
3 changed files with 152 additions and 6 deletions
132
internal/check-map-control-overlap.mjs
Normal file
132
internal/check-map-control-overlap.mjs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
#!/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.`)
|
||||
|
|
@ -115,6 +115,7 @@
|
|||
"check:engine-contract:poison": "node internal/check-gospel-engine-contract.mjs --poison",
|
||||
"check:embed-contract:poison": "node internal/check-gospel-embed-contract.mjs --poison",
|
||||
"check:map-attribution": "node internal/check-map-attribution.mjs",
|
||||
"check:map-control-overlap": "node internal/check-map-control-overlap.mjs",
|
||||
"check:studio-rail:self-test": "node internal/check-studio-rail.mjs --self-test",
|
||||
"check:studio-rail": "node internal/check-studio-rail.mjs",
|
||||
"check:translator-contacts": "node internal/check-translator-contacts.mjs",
|
||||
|
|
@ -293,7 +294,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:instance-config-keys check:frozen-labels check:plural-forms",
|
||||
"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:cdn-cache check:plural-forms check:select-zone-race check:script-registry",
|
||||
"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:cdn-cache check:plural-forms check:select-zone-race check:script-registry 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",
|
||||
|
|
@ -431,6 +432,7 @@
|
|||
"check:list-prop-unit": "the people-group LIST prop, measured in Node with no browser: the ladder (profile-config list > ?list= > window.MAP_LIST > doxa) and aliases, the default list untouched and unfetched, a list keeping exactly its membership, an unloadable list failing closed (no rows, never DOXA's whole list), and every imb list having a membership file that matches its registry filter.",
|
||||
"check:manifest-covers": "every DISCOVERED (non-gated) bundle must be in the built manifest",
|
||||
"check:map-attribution": "the map credit line names the basemap of the engine that drew the map: under MapLibre OpenFreeMap + OpenMapTiles + OpenStreetMap contributors (never Mapbox), under Mapbox Mapbox + OpenStreetMap; geoBoundaries on both. Headless.",
|
||||
"check:map-control-overlap": "two controls pinned to the same corner of the research-dashboard map must not land on 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:map-data-painted": "Every DECOUPLED map leaf must have OUR DATA on it, not just a basemap. verify-map-paint asserts a live GL context, attribution and zero console errors — all TRUE of a flawless basemap carrying none of our zones, polygons or pins, so every data-layer regression lands green. Measured 2026-09-26 by starve-probe (abort /assets/{boundaries,tables,people-groups}/ and the people-groups API, leave the basemap alone): 9 of the 13 leaves still report canvas, GL, attribution and 0 errors, so the hole is the MAJORITY of the suite; the other 4 lose their canvas entirely and the existing gate already fails them, so they are deliberately NOT listed — a leaf list is a claim about where a failure would otherwise go unseen. Each leaf is compared AGAINST ITSELF (healthy vs starved) because healthy leaves span 459..2821 colours and any global floor is vacuous or flaky: check:map-paints' 40-colour floor passes a starved upg-100-list at 306. Two measures, pass on EITHER — palette ratio <=0.55 OR >=3% of pixels changed — because each alone false-reds a real leaf (dashboard-plus-grid's 686x340 pane repaints 26.4% of the canvas at palette ratio 0.788; demo-map transforms the palette at ratio 0.196 while repainting 5.0%). A data-free map moves NEITHER. Uses page.screenshot({clip}), not elementHandle.screenshot(), whose stability wait never settles on an animating dashboard canvas (that cost 3 leaves a 20s timeout). Refuses to judge (exit 75) if either run has no canvas, if the starve aborted 0 requests, if the images cannot be compared, or if the gate itself throws. Run check:map-data-painted:poison to prove it can still fail. Browser gate, @browser-slot-max-hold 2700.",
|
||||
"check:map-data-painted:poison": "The mutation arm for check:map-data-painted, and MANDATORY because that gate's assertion is a DISJUNCTION (palette moved OR pixels moved) — a weakened assertion that still passes everything reads as coverage while proving nothing. Starves BOTH runs of our data, so neither measure can move, and requires every leaf to go RED. A green poison run means the bars have drifted into vacuity. Browser gate, @browser-slot-max-hold 2700.",
|
||||
"check:map-embed-cells": "PRODUCTION-MATRIX cells B (iframe) and C (script embed) for the seven Research Gallery maps on a genuinely foreign origin (tree on 127.0.0.1, partner page on localhost). C passes the HOST a distinct token and fails if any Mapbox request uses another. --only bundle/profile,… --cell B|C.",
|
||||
|
|
|
|||
|
|
@ -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; inset-inline-end:8px; top:8px; z-index:3; font-size:11px; font-weight:700; cursor:pointer; user-select:none;
|
||||
.rd-proj{ position:absolute; inset-inline-end: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 —
|
||||
|
|
|
|||
Loading…
Reference in a new issue