VerticalTabsLayout mirrors for right-to-left, and the gallery sets its direction

Arabic did not flip the shell (reported by studio for matrix cell E). The
layout is written in physical terms: rail at left:0, content at left:256px,
translateX(-…) slides. So an Arabic gallery kept its rail on the left with
LTR geometry.

The layout now carries an additive :dir(rtl) mirror block, in BOTH copies
of its CSS (VTL_SHADOW_CSS and the style block). The rail anchors right,
content right:256px, the collapse and drawer slides go positive, the
collapsed paddings, auto-margins and text-align mirror, and the arrow SVGs
flip. translateX has no logical form, so this is a mirror block rather than
logical properties. LTR consumers match none of it.

The gallery sets dir (and lang) on the layout from its own language: rtl
for Arabic, ltr otherwise. So an English gallery on some other RTL page
keeps a Latin rail unmirrored.

Measured on the served built tree:
  1400px ar: rail [944,1200], content [0,944], text right; collapsed =
             a 72px rail on the right
  390px ar:  drawer parked off-canvas right, slides in to [944,1200]
  LTR:       studio, stack-demo and the gallery at 1400px geometry
             identical before and after. The 390px differences seen once
             were transition timing and did not reproduce.

The shared layout is compiled into doxa-maps-spa and plugin-studio, so
both bundles are rebuilt here. stack-demo's bundle came out byte-identical:
it does not compile the layout.
This commit is contained in:
doxa-maps-88655c70 2026-09-25 04:41:01 +00:00
parent e72ac1b4a2
commit 5e7f73d0be
11 changed files with 157 additions and 30 deletions

View file

@ -497,6 +497,36 @@ const VTL_SHADOW_CSS = `
.vtl.rail-collapsed .vtl-tier { padding-left:8px; }
.vtl.rail-collapsed .vtl-tl, .vtl.rail-collapsed .vtl-td, .vtl.rail-collapsed .vtl-cap, .vtl.rail-collapsed .vtl-chev { display:inline; }
}
/* ★ RIGHT-TO-LEFT (ar). THIS BLOCK EXISTS TWICE (VTL_SHADOW_CSS and the scoped style block); keep them
byte-identical. Everything above is written in PHYSICAL terms (left:0 rail, left:256px content,
translateX(-…) slides), so an Arabic shell kept the rail on the left with LTR geometry (reported by
studio for cell E, 2026-09-25). These are ADDITIVE overrides scoped by :dir(rtl), so an LTR consumer
matches none of them and renders exactly as before. :dir() follows the dir attribute through the
shadow boundary, so either a consumer setting dir="rtl" on the layout or an RTL host page flips it.
translateX has no logical form, which is why this is a mirror block rather than logical properties. */
.vtl:dir(rtl) .vtl-nav { left:auto; right:0; border-right:0; border-left:1px solid var(--vtl-border); }
.vtl:dir(rtl) .vtl-main { left:0; right:256px; }
.vtl:dir(rtl) .vtl-tab, .vtl:dir(rtl) .vtl-tier-btn { text-align:right; }
.vtl:dir(rtl) .vtl-meta, .vtl:dir(rtl) .vtl-td, .vtl:dir(rtl) .vtl-chev { margin-left:0; margin-right:auto; }
.vtl:dir(rtl) .vtl-chev:not(.open) { transform:scaleX(-1); }
.vtl:dir(rtl) .vtl-collapse-ic svg { transform:scaleX(-1); }
.vtl:dir(rtl) .vtl-fab { left:auto; right:calc(12px + env(safe-area-inset-right)); }
.vtl.rail-collapsed:dir(rtl) .vtl-nav { transform:translateX(184px); }
.vtl.rail-collapsed:dir(rtl) .vtl-main { left:0; right:72px; }
.vtl.rail-collapsed:dir(rtl) .vtl-collapse { padding-left:10px; padding-right:184px; }
.vtl.rail-collapsed:dir(rtl) .vtl-tab { padding-left:12px; padding-right:188px; }
.vtl.rail-collapsed:dir(rtl) .vtl-tier { padding-left:8px; padding-right:196px; }
@media (max-width: 760px) {
.vtl:dir(rtl) .vtl-nav { transform:translateX(100%); }
.vtl.nav-open:dir(rtl) .vtl-nav { transform:translateX(0); }
.vtl:dir(rtl) .vtl-main, .vtl.rail-collapsed:dir(rtl) .vtl-main { left:0; right:0; }
.vtl.rail-collapsed:dir(rtl) .vtl-nav { transform:translateX(100%); }
.vtl.rail-collapsed.nav-open:dir(rtl) .vtl-nav { transform:translateX(0); }
.vtl.rail-collapsed:dir(rtl) .vtl-collapse { padding-left:10px; padding-right:10px; }
.vtl.rail-collapsed:dir(rtl) .vtl-tab { padding-left:12px; padding-right:10px; }
.vtl.rail-collapsed:dir(rtl) .vtl-tier { padding-left:8px; padding-right:8px; }
}
`
export default defineComponent({
@ -955,4 +985,34 @@ export default defineComponent({
.vtl.rail-collapsed .vtl-tier { padding-left:8px; }
.vtl.rail-collapsed .vtl-tl, .vtl.rail-collapsed .vtl-td, .vtl.rail-collapsed .vtl-cap, .vtl.rail-collapsed .vtl-chev { display:inline; }
}
/* ★ RIGHT-TO-LEFT (ar). THIS BLOCK EXISTS TWICE (VTL_SHADOW_CSS and the scoped style block); keep them
byte-identical. Everything above is written in PHYSICAL terms (left:0 rail, left:256px content,
translateX(-…) slides), so an Arabic shell kept the rail on the left with LTR geometry (reported by
studio for cell E, 2026-09-25). These are ADDITIVE overrides scoped by :dir(rtl), so an LTR consumer
matches none of them and renders exactly as before. :dir() follows the dir attribute through the
shadow boundary, so either a consumer setting dir="rtl" on the layout or an RTL host page flips it.
translateX has no logical form, which is why this is a mirror block rather than logical properties. */
.vtl:dir(rtl) .vtl-nav { left:auto; right:0; border-right:0; border-left:1px solid var(--vtl-border); }
.vtl:dir(rtl) .vtl-main { left:0; right:256px; }
.vtl:dir(rtl) .vtl-tab, .vtl:dir(rtl) .vtl-tier-btn { text-align:right; }
.vtl:dir(rtl) .vtl-meta, .vtl:dir(rtl) .vtl-td, .vtl:dir(rtl) .vtl-chev { margin-left:0; margin-right:auto; }
.vtl:dir(rtl) .vtl-chev:not(.open) { transform:scaleX(-1); }
.vtl:dir(rtl) .vtl-collapse-ic svg { transform:scaleX(-1); }
.vtl:dir(rtl) .vtl-fab { left:auto; right:calc(12px + env(safe-area-inset-right)); }
.vtl.rail-collapsed:dir(rtl) .vtl-nav { transform:translateX(184px); }
.vtl.rail-collapsed:dir(rtl) .vtl-main { left:0; right:72px; }
.vtl.rail-collapsed:dir(rtl) .vtl-collapse { padding-left:10px; padding-right:184px; }
.vtl.rail-collapsed:dir(rtl) .vtl-tab { padding-left:12px; padding-right:188px; }
.vtl.rail-collapsed:dir(rtl) .vtl-tier { padding-left:8px; padding-right:196px; }
@media (max-width: 760px) {
.vtl:dir(rtl) .vtl-nav { transform:translateX(100%); }
.vtl.nav-open:dir(rtl) .vtl-nav { transform:translateX(0); }
.vtl:dir(rtl) .vtl-main, .vtl.rail-collapsed:dir(rtl) .vtl-main { left:0; right:0; }
.vtl.rail-collapsed:dir(rtl) .vtl-nav { transform:translateX(100%); }
.vtl.rail-collapsed.nav-open:dir(rtl) .vtl-nav { transform:translateX(0); }
.vtl.rail-collapsed:dir(rtl) .vtl-collapse { padding-left:10px; padding-right:10px; }
.vtl.rail-collapsed:dir(rtl) .vtl-tab { padding-left:12px; padding-right:10px; }
.vtl.rail-collapsed:dir(rtl) .vtl-tier { padding-left:8px; padding-right:8px; }
}
</style>

View file

@ -65,7 +65,7 @@ The gallery is tested to leave its host page as it found it: it does not scroll
your URL or `<title>`, or inherit your body typography (a blog's `font: 20px/3` stays outside the
box). It does add `@font-face` rules and the map engine's stylesheet to your `<head>`; fonts have to
be declared at document level to reach a shadow root, and the engine styles only target map classes.
Right-to-left pages keep their direction. `bun run check:gallery-embed` enforces all of this.
Direction follows the gallery's language: in Arabic the whole shell mirrors, with the tab rail on the right. `bun run check:gallery-embed` enforces all of this.
## What the server must send

View file

@ -57,6 +57,8 @@
the direct-element mount and the heavy-tab teardown machinery live in the default slot below,
driven by activeIndex. -->
<VerticalTabsLayout
:dir="uiDir"
:lang="uiLocale"
:title="heading"
:nav-label="t('navLabel')"
:header="showHeader"
@ -937,6 +939,11 @@ export default defineComponent({
},
// The locale the gallery's OWN words render in: the effective one when it ships, else English.
uiLocale() { return LOCALES.includes(this.effectiveLocale) ? this.effectiveLocale : 'en' },
// Direction FOLLOWS THE GALLERY'S LANGUAGE, set explicitly on the layout root (a fallthrough attr
// on VerticalTabsLayout's .vtl): Arabic lays the shell out right-to-left (the layout mirrors its
// rail under :dir(rtl)), every other shipped locale left-to-right — including an English gallery
// placed on some other RTL page, whose Latin rail must not be mirrored.
uiDir() { return this.uiLocale === 'ar' ? 'rtl' : 'ltr' },
// The engine a HOST asked for ('' = none; each map keeps its own ladder).
effectiveProvider() {
const p = String(this.provider || this.cfg.provider || '').trim().toLowerCase()
@ -1415,9 +1422,9 @@ export default defineComponent({
Measured 2026-09-25 on a host with `body{font:20px/3 Georgia; letter-spacing:2px}`: the gallery
root computed font-size 20px, line-height 60px, letter-spacing 2px — every rail row and map legend
re-flowed by the partner's blog styles. These are reset to the values the gallery is designed
against (its own leaf page's defaults). `direction` is deliberately NOT reset, so an RTL host page
still lays the gallery out RTL; `all: initial` would have reset it too. Family and colour are set by
the layout itself (.vtl). */
against (its own leaf page's defaults). `direction` is not reset here: the layout root carries an
explicit dir from the gallery's language (uiDir), which is what actually decides RTL. Family and
colour are set by the layout itself (.vtl). */
:host {
font-size: 16px; line-height: normal; font-weight: 400; font-style: normal; font-variant: normal;
font-stretch: normal; letter-spacing: normal; word-spacing: normal; text-transform: none;

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
<script src="../../doxa-maps-spa.js" integrity="sha384-Y0yYckt4MZMLTeUPpc1M6rFGH90giZOdN8VGaR+kKLX2OzBh8QFRzSphPKHRYr2V" crossorigin="anonymous"></script>
<script src="../../doxa-maps-spa.js" integrity="sha384-Gq3cr144mzszbujdJUx1pLYKNMRM9IHomwvdy0GO5B0CVO1LoJS5hsSKKFlcn0Am" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
<script src="../doxa-maps-spa.js" integrity="sha384-Y0yYckt4MZMLTeUPpc1M6rFGH90giZOdN8VGaR+kKLX2OzBh8QFRzSphPKHRYr2V" crossorigin="anonymous"></script>
<script src="../doxa-maps-spa.js" integrity="sha384-Gq3cr144mzszbujdJUx1pLYKNMRM9IHomwvdy0GO5B0CVO1LoJS5hsSKKFlcn0Am" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -302,7 +302,7 @@
"bundle": "doxa-maps-spa",
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"output": "doxa-maps/doxa-maps-spa/doxa-maps-spa.js",
"integrity": "sha384-Y0yYckt4MZMLTeUPpc1M6rFGH90giZOdN8VGaR+kKLX2OzBh8QFRzSphPKHRYr2V",
"integrity": "sha384-Gq3cr144mzszbujdJUx1pLYKNMRM9IHomwvdy0GO5B0CVO1LoJS5hsSKKFlcn0Am",
"elements": [
"doxa-maps-spa"
],
@ -327,7 +327,7 @@
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"embed": {
"iframe": "<iframe src=\"./doxa-maps-spa/doxa-maps-spa/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-Y0yYckt4MZMLTeUPpc1M6rFGH90giZOdN8VGaR+kKLX2OzBh8QFRzSphPKHRYr2V\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-Gq3cr144mzszbujdJUx1pLYKNMRM9IHomwvdy0GO5B0CVO1LoJS5hsSKKFlcn0Am\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
},
"instances": [
{
@ -799,7 +799,7 @@
"bundle": "plugin-studio",
"scriptUrl": "./plugin-studio/plugin-studio.js",
"output": "doxa-maps/plugin-studio/plugin-studio.js",
"integrity": "sha384-NXCBL46038pXHJb8lEhrC/U2ZIBLvsevalJdp9/OU/8cp3LGx2TinkGoY6j8usV3",
"integrity": "sha384-ueMdhH+oGiRQsFSZcddK14WPENJ18oi4hSEwzK8pZNNvHCRoVyXyJGzgsJr0sMPG",
"elements": [
"plugin-studio"
],
@ -827,7 +827,7 @@
"scriptUrl": "./plugin-studio/plugin-studio.js",
"embed": {
"iframe": "<iframe src=\"./plugin-studio/plugin-studio/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-NXCBL46038pXHJb8lEhrC/U2ZIBLvsevalJdp9/OU/8cp3LGx2TinkGoY6j8usV3\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-ueMdhH+oGiRQsFSZcddK14WPENJ18oi4hSEwzK8pZNNvHCRoVyXyJGzgsJr0sMPG\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
},
"instances": [
{

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
<script src="../plugin-studio.js" integrity="sha384-NXCBL46038pXHJb8lEhrC/U2ZIBLvsevalJdp9/OU/8cp3LGx2TinkGoY6j8usV3" crossorigin="anonymous"></script>
<script src="../plugin-studio.js" integrity="sha384-ueMdhH+oGiRQsFSZcddK14WPENJ18oi4hSEwzK8pZNNvHCRoVyXyJGzgsJr0sMPG" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
<script src="../../plugin-studio.js" integrity="sha384-NXCBL46038pXHJb8lEhrC/U2ZIBLvsevalJdp9/OU/8cp3LGx2TinkGoY6j8usV3" crossorigin="anonymous"></script>
<script src="../../plugin-studio.js" integrity="sha384-ueMdhH+oGiRQsFSZcddK14WPENJ18oi4hSEwzK8pZNNvHCRoVyXyJGzgsJr0sMPG" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: