From 4d1af7053aa6ff993cc678fd80a09b032773a1c6 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 11:24:02 +0000 Subject: [PATCH] Update from source 73438b43b --- .../country-posters/countries-map.vue | 117 ++++----- .../my-upg-100-list/data/visual-gate.mjs | 61 ++++- .../country-posters/countries-map/index.html | 2 +- .../country-poster-wall/index.html | 2 +- .../country-posters-mfe/index.html | 2 +- .../country-posters/country-posters.js | 238 +++++++++--------- public/dx-maps/doxa-maps-spa/manifest.json | 8 +- public/dx-maps/manifest.json | 8 +- 8 files changed, 241 insertions(+), 197 deletions(-) diff --git a/plugin-designs/country-posters/countries-map.vue b/plugin-designs/country-posters/countries-map.vue index 96108c68..20c4a6b5 100644 --- a/plugin-designs/country-posters/countries-map.vue +++ b/plugin-designs/country-posters/countries-map.vue @@ -659,6 +659,13 @@ async function highlightCountryByIso(iso3) { // Reverse highlightCountryByIso: reset the regions polygon paint to default and // re-hide it when we're not on the Regions tab (a country search may have shown // it cross-tab). Used when the user picks a non-country result or clears search. +// A region paint's idle value, kept DATA-DRIVEN: ['case', …, v, v] draws exactly v, but the property never +// drops back to a plain constant. The load-time pre-warm wraps the layer's paints this way; a clear that +// wrote the plain value would put the property back on its constant variant, and whether an engine +// then re-pays the variant switch on the next highlight is engine-internal. Keeping every write on the +// data-driven form makes it a non-question (upg100 / dx-shepherd CLR). +function regionIdle(v) { return ['case', ['has', 'iso_3166_1_alpha_3'], v, v] } + function clearCountryHighlight() { // Always drop the country boundary outline first — it's independent of // regions-fill, so it must clear even when that layer isn't present (tabs @@ -666,10 +673,10 @@ function clearCountryHighlight() { countryOutline.clearCountry() const m = map.value if (!m || !m.getLayer('regions-fill')) return - m.setPaintProperty('regions-fill', 'fill-opacity', 0.20) + m.setPaintProperty('regions-fill', 'fill-opacity', regionIdle(0.20)) m.setPaintProperty('regions-fill', 'fill-outline-color', null) - m.setPaintProperty('regions-border', 'line-color', 'rgba(60,60,80,0.35)') - m.setPaintProperty('regions-border', 'line-width', 0.6) + m.setPaintProperty('regions-border', 'line-color', regionIdle('rgba(60,60,80,0.35)')) + m.setPaintProperty('regions-border', 'line-width', regionIdle(0.6)) if (activeTab.value?.id !== 'doxa-regions') { m.setLayoutProperty('regions-fill', 'visibility', 'none') m.setLayoutProperty('regions-border', 'visibility', 'none') @@ -1038,18 +1045,14 @@ function onSemanticTreeSelect(node) { if (m && m.getLayer('language-family-pins')) { try { m.setFilter('language-family-pins', null) } catch (_) {} _syncHitboxFilter(m, null) - m.setPaintProperty('language-family-pins', 'circle-opacity', 1) - m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', 1) - if (m.getLayer('language-family-pins-shadow')) { - m.setPaintProperty('language-family-pins-shadow', 'circle-opacity', 1) - } + setPinOpacity(m, null) mapLayers.syncGlowFilter(null) } // Reset region polygon highlight when deselecting on the WAGF Regions tab. if (m && m.getLayer('regions-fill')) { - m.setPaintProperty('regions-fill', 'fill-opacity', 0.7) - m.setPaintProperty('regions-border', 'line-color', 'rgba(60,60,80,0.35)') - m.setPaintProperty('regions-border', 'line-width', 0.6) + m.setPaintProperty('regions-fill', 'fill-opacity', regionIdle(0.7)) + m.setPaintProperty('regions-border', 'line-color', regionIdle('rgba(60,60,80,0.35)')) + m.setPaintProperty('regions-border', 'line-width', regionIdle(0.6)) } if (mapStore.selectedFamily) mapStore.selectFamily(null) if (mapStore.selectedLanguage) mapStore.selectLanguage(null) @@ -1072,8 +1075,7 @@ function onSemanticTreeSelect(node) { if (m && m.getLayer('language-family-pins') && node.filter) { try { m.setFilter('language-family-pins', node.filter) } catch (_) {} _syncHitboxFilter(m, node.filter) - m.setPaintProperty('language-family-pins', 'circle-opacity', 1) - m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', 1) + setPinOpacity(m, null) mapLayers.syncGlowFilter(node.filter) } // Mirror selection into mapStore (loop-safe via legend's _lastInternalId). @@ -1099,17 +1101,7 @@ function onSemanticTreeSelect(node) { _syncHitboxFilter(m, node.filter) // Dim via case expression — matched pins at 1.0, others at 0 (matches // applyDimFilter values). - m.setPaintProperty('language-family-pins', 'circle-opacity', [ - 'case', node.filter, 1, 0 - ]) - m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', [ - 'case', node.filter, 1, 0 - ]) - if (m.getLayer('language-family-pins-shadow')) { - m.setPaintProperty('language-family-pins-shadow', 'circle-opacity', [ - 'case', node.filter, 1, 0 - ]) - } + setPinOpacity(m, node.filter) mapLayers.syncGlowFilter(node.filter) } } @@ -1123,13 +1115,18 @@ function onSemanticTreeSelect(node) { if (m && m.getLayer('language-family-pins') && node.filter) { try { m.setFilter('language-family-pins', node.filter) } catch (_) {} _syncHitboxFilter(m, node.filter) - m.setPaintProperty('language-family-pins', 'circle-opacity', 1) - m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', 1) + setPinOpacity(m, null) } // Mirror region selection into mapStore (for geocoder / external sync). if (id.startsWith('region:')) mapStore.selectRegion?.(id.slice(7)) // Highlight matching polygons using isoCodes (alpha-3) on iso_3166_1_alpha_3. - if (m && m.getLayer('regions-fill')) { + // ★ NOT FOR region: ROWS. A region row also reaches selectRegion above -> the watcher -> + // applyDimFilter, which paints from mapLayers.regionIsoMap: EVERY country in the region. node.isoCodes + // comes from the legend's people-group walk, so it holds only countries WITH PINS; painting it too + // left holes wherever a country has no group, depending on which writer ran last (upg100's drift + // classification; research-map carries the same exemption). block: and country: rows keep painting + // here, because nothing else paints them and the legend's list IS their scope. + if (m && m.getLayer('regions-fill') && !id.startsWith('region:')) { const isoCodes = node.isoCodes || [] if (isoCodes.length) { const isoArr = ['literal', isoCodes] @@ -1143,13 +1140,32 @@ function onSemanticTreeSelect(node) { 'case', ['in', ['get', 'iso_3166_1_alpha_3'], isoArr], 2.5, 0.3 ]) } else { - m.setPaintProperty('regions-fill', 'fill-opacity', 0.7) - m.setPaintProperty('regions-border', 'line-color', 'rgba(60,60,80,0.35)') - m.setPaintProperty('regions-border', 'line-width', 0.6) + m.setPaintProperty('regions-fill', 'fill-opacity', regionIdle(0.7)) + m.setPaintProperty('regions-border', 'line-color', regionIdle('rgba(60,60,80,0.35)')) + m.setPaintProperty('regions-border', 'line-width', regionIdle(0.6)) } } } } +// ── Pin opacity: ONE writer (the research-map pattern, applyPinOpacity) ───────── +// matchExpr dims to it (matched 1, others 0); null clears the dim. Two things it does that the 14 raw +// writes it replaces did not (upg100's drift classification; rega's profile on research-map): +// · READ BEFORE WRITE. One family click reached onSemanticTreeSelect AND the selectedFamily watcher -> +// applyDimFilter, rewriting the same value 4+ times, each a per-feature re-upload. Compared as text +// against the LIVE value (never a remembered one, which would go stale on a style reload). +// · THE UNDIMMED VALUE IS DATA-DRIVEN (['case', ['has','uniqueId'], 1, 1]: every pin at 1, not one pixel +// different). A constant 1 made the FIRST dim a one-off shader-variant compile mid-click; set once at +// load, the compile happens then instead. +function setPinOpacity(m, matchExpr) { + if (!m || !m.getLayer?.('language-family-pins')) return + const value = matchExpr ? ['case', matchExpr, 1, 0] : ['case', ['has', 'uniqueId'], 1, 1] + try { + if (JSON.stringify(m.getPaintProperty('language-family-pins', 'circle-opacity')) === JSON.stringify(value)) return + m.setPaintProperty('language-family-pins', 'circle-opacity', value) + m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', value) + if (m.getLayer('language-family-pins-shadow')) m.setPaintProperty('language-family-pins-shadow', 'circle-opacity', value) + } catch (_) { /* style still loading: the next call carries it */ } +} // Theme bridge: keep the SemanticTreeLegend in sync with the app's dark-mode // state (driven by uiStore.theme via the isDark computed defined later). watch(() => uiStore.theme, (t) => { pplrInstance.theme.value = (t === 'dark' ? 'dark' : 'light') }, { immediate: true }) @@ -1615,21 +1631,17 @@ function onLegendHighlight(evt) { const _lastHL = { family: null, language: null, dialect: null, region: null, prayer: null, engagement: null, adoption: null } function clearAllHighlights(m) { - m.setPaintProperty('language-family-pins', 'circle-opacity', 1) - m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', 1) - if (m.getLayer('language-family-pins-shadow')) { - m.setPaintProperty('language-family-pins-shadow', 'circle-opacity', 1) - } + setPinOpacity(m, null) m.setFilter('language-family-pins', null) _syncHitboxFilter(m, null) mapLayers.syncGlowFilter(null) if (m.getLayer('regions-fill')) { - m.setPaintProperty('regions-fill', 'fill-opacity', 0.7) + m.setPaintProperty('regions-fill', 'fill-opacity', regionIdle(0.7)) m.setPaintProperty('regions-fill', 'fill-outline-color', null) } if (m.getLayer('regions-border')) { - m.setPaintProperty('regions-border', 'line-color', 'rgba(60,60,80,0.35)') - m.setPaintProperty('regions-border', 'line-width', 0.6) + m.setPaintProperty('regions-border', 'line-color', regionIdle('rgba(60,60,80,0.35)')) + m.setPaintProperty('regions-border', 'line-width', regionIdle(0.6)) } _lastHL.family = _lastHL.language = _lastHL.dialect = _lastHL.region = null _lastHL.prayer = _lastHL.engagement = _lastHL.adoption = null @@ -1735,8 +1747,7 @@ function applyDimFilter(detail) { // only pins within that region" UX request (2026-04-26). m.setFilter('language-family-pins', ['==', ['get', 'doxaRegion'], expectedValue]) _syncHitboxFilter(m, ['==', ['get', 'doxaRegion'], expectedValue]) - m.setPaintProperty('language-family-pins', 'circle-opacity', 1) - m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', 1) + setPinOpacity(m, null) if (m.getLayer('regions-fill')) { // Build a match expression on iso_3166_1_alpha_3 (vector tile property) to // highlight only the countries belonging to the selected WAGF region. @@ -1763,9 +1774,9 @@ function applyDimFilter(detail) { 'case', ['in', ['get', 'iso_3166_1_alpha_3'], isoArr], 2.5, 0.3 ]) } else { - m.setPaintProperty('regions-fill', 'fill-opacity', 0.7) - m.setPaintProperty('regions-border', 'line-color', 'rgba(60,60,80,0.35)') - m.setPaintProperty('regions-border', 'line-width', 0.6) + m.setPaintProperty('regions-fill', 'fill-opacity', regionIdle(0.7)) + m.setPaintProperty('regions-border', 'line-color', regionIdle('rgba(60,60,80,0.35)')) + m.setPaintProperty('regions-border', 'line-width', regionIdle(0.6)) } } } else { @@ -1839,17 +1850,7 @@ function applyDimFilter(detail) { matchExpr = ['==', ['get', property], expectedValue] } - m.setPaintProperty('language-family-pins', 'circle-opacity', [ - 'case', matchExpr, 1, 0 - ]) - m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', [ - 'case', matchExpr, 1, 0 - ]) - if (m.getLayer('language-family-pins-shadow')) { - m.setPaintProperty('language-family-pins-shadow', 'circle-opacity', [ - 'case', matchExpr, 1, 0 - ]) - } + setPinOpacity(m, matchExpr) // Restrict the invisible hitbox layer to the matched bucket so dimmed // (opacity-0) pins are no longer hit-testable. Without this the user // can tap on apparent empty space and select a hidden pin, and the @@ -1858,9 +1859,9 @@ function applyDimFilter(detail) { _syncHitboxFilter(m, matchExpr) mapLayers.syncGlowFilter(matchExpr) if (m.getLayer('regions-fill')) { - m.setPaintProperty('regions-fill', 'fill-opacity', 0.20) - m.setPaintProperty('regions-border', 'line-color', 'rgba(60,60,80,0.35)') - m.setPaintProperty('regions-border', 'line-width', 0.6) + m.setPaintProperty('regions-fill', 'fill-opacity', regionIdle(0.20)) + m.setPaintProperty('regions-border', 'line-color', regionIdle('rgba(60,60,80,0.35)')) + m.setPaintProperty('regions-border', 'line-width', regionIdle(0.6)) } } } @@ -2072,6 +2073,8 @@ async function onMapReady(normalizedPeopleGroups) { normalizedPeopleGroups, activeTab.value?.colorStrategy ?? 'languageFamily' ) + // Pre-warm the pins' opacity variant now, not on the first legend click (see setPinOpacity). + setPinOpacity(map.value, null) // Initial-render override: doxa-regions tab → solid grey pins so they // don't blend into the matching-color polygon fill. if (activeTab.value?.id === 'doxa-regions' && map.value?.getLayer('language-family-pins')) { diff --git a/plugin-designs/my-upg-100-list/data/visual-gate.mjs b/plugin-designs/my-upg-100-list/data/visual-gate.mjs index 77fd08a9..722bbaf9 100644 --- a/plugin-designs/my-upg-100-list/data/visual-gate.mjs +++ b/plugin-designs/my-upg-100-list/data/visual-gate.mjs @@ -638,13 +638,38 @@ async function main() { await clearFilter(page); // ── (4) controls present in DOM (zoom, theme, fullscreen) ────────────────── - const zoomIn = await page.locator(`${EL} button.mcb[title="Zoom in"]`).count(); - const zoomOut = await page.locator(`${EL} button.mcb[title="Zoom out"]`).count(); - const fullscreen = await page.locator(`${EL} button.mcb[title="Toggle fullscreen"]`).count(); + // ★ THESE TITLES ARE LOCALIZED (ZoomInButton.vue binds :title="t('buttons.zoomIn')", + // and MapControlButton mirrors title into aria-label). The page is pinned to en-US + // at newPage({ locale }) above (L4/LESSON-03), so matching the English string is not + // failing TODAY — but it is the same coupling that broke the (G-rail) probe when + // Batch Select became a bound title, so the expected strings are resolved from the + // SAME i18n source the component reads instead of being hardcoded here. If this gate + // is ever run in another locale, it keeps working. + // + // These four buttons all share class .mcb with NO stable per-control hook (no + // data-control, and aria-label is localized too), so — unlike .upg-rail-batch — the + // class alone cannot distinguish them and the title is the only identity available. + // The durable fix is a data-* attribute in library/components/map-controls/, which is + // studio's under 5B; sent to them. Until then this is the locale-correct version. + // __dirname is plugin-designs/my-upg-100-list/data → three up is the repo root. + const B = JSON.parse(readFileSync( + join(__dirname, '..', '..', '..', 'library/i18n/locales/en/buttons.json'), 'utf8')); + const titleSel = (s) => `${EL} button.mcb[title="${String(s).replace(/"/g, '\\"')}"]`; + const zoomIn = await page.locator(titleSel(B.zoomIn)).count(); + const zoomOut = await page.locator(titleSel(B.zoomOut)).count(); + const fullscreen = await page.locator(titleSel(B.toggleFullscreen)).count(); + // The theme button is the WEAKEST of the four and cannot use the shim above: its + // title is DYNAMIC ("Switch to dark" / "Switch to light"), so there is no single key + // to resolve — hence the prefix match. A prefix match on English prose degrades to 0 + // under localization, and `> 0` collapses that into the same false as "the button is + // missing". Asserting == 1 (studio's suggestion) does not remove the coupling, but it + // makes the COUNT mean something: 0 and 2 become distinguishable failures instead of + // both reading as "not > 0". The real fix is data-control="theme" (studio, riding the + // next landing that rebuilds consumers anyway). const theme = await page.locator(`${EL} button.mcb[title^="Switch to"]`).count(); const mcbCount = await page.locator(`${EL} button.mcb`).count(); check('(4) map controls present in DOM (zoom + theme + fullscreen)', - zoomIn > 0 && zoomOut > 0 && fullscreen > 0 && theme > 0, + zoomIn > 0 && zoomOut > 0 && fullscreen > 0 && theme === 1, `zoomIn=${zoomIn} zoomOut=${zoomOut} fullscreen=${fullscreen} theme=${theme} (.mcb total=${mcbCount})`); // ── (6) slice 13: pin click → PeopleGroupDetail side panel opens ─────────── @@ -1157,14 +1182,26 @@ async function main() { await page.waitForTimeout(300); // ── (G-rail) the Batch toggle renders INSIDE the MapToolbar control group ── - // Query the rail (.map-toolbar) for the profile batch button (title="Batch - // Select"); assert it is a CHILD of the toolbar (not the .upg-hud), and that - // a real DOM click on it flips __UPG_SELECT__.batchMode. NO-IMAGES: DOM + handle. + // Query the rail (.map-toolbar) for the profile batch button; assert it is a + // CHILD of the toolbar (not the .upg-hud), and that a real DOM click on it + // flips __UPG_SELECT__.batchMode. NO-IMAGES: DOM + handle. + // + // ★ FIND BY CLASS, ASSERT THE TITLE SEPARATELY — the title is LOCALIZED. + // This used to be `.upg-rail-batch[title="Batch Select"]`, which pinned the gate + // to one English string: once the title became `:title="BATCH_SELECT_TITLE"` + // (studio L53) the selector matched in `en` and NOWHERE ELSE, and the gate would + // report "button not in the toolbar" — a product failure — for what is really a + // broken query. `.upg-rail-batch` is already proven unique by railBtns === 1 + // below, so the class alone is the identity. The title is still checked, as the + // property that actually matters for a11y: it must be NON-EMPTY, which holds in + // every locale. That also catches a regression the old equality missed — a + // :title binding resolving to '' or undefined. const railProbe = await page.evaluate((elName) => { const host = document.querySelector(elName); const root = host && host.shadowRoot ? host.shadowRoot : document; const toolbar = root.querySelector('.map-toolbar'); - const btn = toolbar ? toolbar.querySelector('.upg-rail-batch[title="Batch Select"]') : null; + const btn = toolbar ? toolbar.querySelector('.upg-rail-batch') : null; + const title = btn ? (btn.getAttribute('title') || '') : null; const inHud = !!(root.querySelector('.upg-hud .upg-rail-batch')); // must be FALSE // R1.4 (L4): the rail button is the ONLY batch control — the old HUD // .upg-batch-btn must not exist anywhere, and exactly ONE rail button. @@ -1172,7 +1209,7 @@ async function main() { const railBtns = root.querySelectorAll('.upg-rail-batch').length; // must be 1 const before = window.__UPG_SELECT__ ? !!window.__UPG_SELECT__.batchMode : null; if (btn) btn.click(); // engage batch mode via the RAIL button - return { hasToolbar: !!toolbar, inToolbar: !!btn, inHud, hudBtns, railBtns, before }; + return { hasToolbar: !!toolbar, inToolbar: !!btn, title, inHud, hudBtns, railBtns, before }; }, EL); await page.waitForTimeout(250); // R1.4 de-glitch of the GATE itself: aria-pressed is a Vue-rendered binding — @@ -1189,8 +1226,12 @@ async function main() { check('(G-rail) toolA R1.3/R1.4: the SINGLE Batch toggle lives INSIDE the map-controls rail (no HUD button anywhere) AND drives batchMode', railProbe.hasToolbar && railProbe.inToolbar && railProbe.inHud === false && railProbe.hudBtns === 0 && railProbe.railBtns === 1 + // Titled, in WHATEVER locale is under test — asserted as non-empty, never + // against an English literal. A returned value nobody asserts on is decoration. + && typeof railProbe.title === 'string' && railProbe.title.trim() !== '' && railProbe.before === false && railOn === true && railAria === 'true', - `inToolbar=${railProbe.inToolbar} inHud(mustFalse)=${railProbe.inHud} hudBtns(must0)=${railProbe.hudBtns} ` + + `inToolbar=${railProbe.inToolbar} title(mustBeNonEmpty)=${JSON.stringify(railProbe.title)} ` + + `inHud(mustFalse)=${railProbe.inHud} hudBtns(must0)=${railProbe.hudBtns} ` + `railBtns(must1)=${railProbe.railBtns} before=${railProbe.before} afterClick=${railOn} aria-pressed=${railAria}`); // ── (G-2row) batch ON → select 2 legend rows → both selected, union highlight diff --git a/public/dx-maps/country-posters/countries-map/index.html b/public/dx-maps/country-posters/countries-map/index.html index 29b9909e..bbf8313b 100644 --- a/public/dx-maps/country-posters/countries-map/index.html +++ b/public/dx-maps/country-posters/countries-map/index.html @@ -36,7 +36,7 @@ - + - + - + \n\n" + "script": "\n\n\n\n" } }, { @@ -208,7 +208,7 @@ "scriptUrl": "./country-posters/country-posters.js", "embed": { "iframe": "", - "script": "\n\n" + "script": "\n\n" } }, { @@ -224,7 +224,7 @@ "scriptUrl": "./country-posters/country-posters.js", "embed": { "iframe": "", - "script": "\n\n\n\n" + "script": "\n\n\n\n" } } ] diff --git a/public/dx-maps/manifest.json b/public/dx-maps/manifest.json index 17011b98..03934378 100644 --- a/public/dx-maps/manifest.json +++ b/public/dx-maps/manifest.json @@ -169,7 +169,7 @@ "bundle": "country-posters", "scriptUrl": "./country-posters/country-posters.js", "output": "dx-maps/country-posters/country-posters.js", - "integrity": "sha384-U9PCCha2DaAXKeT9e7dlvdtfliRVta5BJXnnjugNgVfplu9BcmO9wySItSAucdT3", + "integrity": "sha384-D63txEhjqtiY0ey50+s/uH67qBC3rdDBkXmuOgwuW6fziyN6hc0F5vhnMV1quqlt", "elements": [ "country-posters-map" ], @@ -192,7 +192,7 @@ "scriptUrl": "./country-posters/country-posters.js", "embed": { "iframe": "", - "script": "\n\n\n\n" + "script": "\n\n\n\n" } }, { @@ -208,7 +208,7 @@ "scriptUrl": "./country-posters/country-posters.js", "embed": { "iframe": "", - "script": "\n\n" + "script": "\n\n" } }, { @@ -224,7 +224,7 @@ "scriptUrl": "./country-posters/country-posters.js", "embed": { "iframe": "", - "script": "\n\n\n\n" + "script": "\n\n\n\n" } } ]