diff --git a/capacitor.config.json b/capacitor.config.json index 4c0ffb694..5d4a0c75a 100644 --- a/capacitor.config.json +++ b/capacitor.config.json @@ -15,7 +15,7 @@ "\u26a0\ufe0f SOFT FAILURE: a wrong webDir yields an EMPTY assets/public and a BLANK WHITE SCREEN at launch, not an", "error. After every sync, verify deployment/capacitor/android/app/src/main/assets/public/index.html exists (`bun run cap:verify`)." ], - "webDir": "public/doxa-maps", + "webDir": "public/dx-maps", "server": { "//": [ "Capacitor defaults are deliberately kept: iosScheme 'capacitor', androidScheme 'https', hostname", diff --git a/deployment/src-tauri/tauri.conf.json b/deployment/src-tauri/tauri.conf.json index 80e467f0e..1c66a8daa 100644 --- a/deployment/src-tauri/tauri.conf.json +++ b/deployment/src-tauri/tauri.conf.json @@ -4,7 +4,7 @@ "version": "0.1.0", "identifier": "life.doxa.maps", "build": { - "frontendDist": "../../public/doxa-maps", + "frontendDist": "../../public/dx-maps", "beforeBuildCommand": "" }, "app": { diff --git a/deployment/wordpress/tests/wp-embed-contract-test.php b/deployment/wordpress/tests/wp-embed-contract-test.php index 5214e4ba1..7c018d261 100644 --- a/deployment/wordpress/tests/wp-embed-contract-test.php +++ b/deployment/wordpress/tests/wp-embed-contract-test.php @@ -52,7 +52,7 @@ $out = do_shortcode_call('doxa_map', [ 'bundle' => $target['bundle'], 'element' => $prof['element'], 'profile' => $prof['id'] ?? $prof['name'] ?? $target['bundle'], - 'base' => 'https://nuxtbase.app/plugin-assets/doxa-maps', + 'base' => 'https://nuxtbase.app/plugin-assets/'.$tree, 'integrity' => $integrity, ]); global $SCRIPTS, $INLINE; @@ -76,7 +76,7 @@ $cfg = json_decode($json, true); $assert('profile-config is valid JSON', is_array($cfg), $json ? substr($json,0,60) : 'no match'); // 3. the script src equals the manifest's catalog path for this bundle $src = reset($SCRIPTS)['src'] ?? ''; -$want = "https://nuxtbase.app/plugin-assets/doxa-maps/{$target['bundle']}/{$target['bundle']}.js"; +$want = "https://nuxtbase.app/plugin-assets/{$tree}/{$target['bundle']}/{$target['bundle']}.js"; $assert('script src matches catalog path', $src === $want, $src); // 4. SRI: the filter must add integrity to the emitted ", $h2, 'x'); diff --git a/documentation/README-SECTION-3-cli-commands.md b/documentation/README-SECTION-3-cli-commands.md index 267424343..4289da3a2 100644 --- a/documentation/README-SECTION-3-cli-commands.md +++ b/documentation/README-SECTION-3-cli-commands.md @@ -47,9 +47,9 @@ The scripts these commands are grounded in (from this branch's `package.json`): // claiming to be ground truth, and this one had silently stopped being it. "dev": "bun x vite", "build": "bash internal/build-lock.sh", -"build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post", -"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", -"gates:post": "node internal/run-parallel.mjs --label=post -- 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: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:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed", +"build:unlocked":"bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post", +"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", +"gates:post":"node internal/run-parallel.mjs --label=post -- 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: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", "build:bundles": "node internal/run-parallel.mjs --bundles", // the 22 vite builds, 4 at a time "preview": "bun x vite preview", "serve": "node internal/serve-strict.mjs", // 404s a directory, like production diff --git a/documentation/reference-capability-exemplars.md b/documentation/reference-capability-exemplars.md index 989c0d853..a4f296606 100644 --- a/documentation/reference-capability-exemplars.md +++ b/documentation/reference-capability-exemplars.md @@ -4,7 +4,7 @@ > check:capability-exemplars` fails if this file falls behind the manifest. > > This answers one question authoritatively: **which bundles are the capability-N -> examples right now?** It is derived from `public/doxa-maps/manifest.json`, which is what +> examples right now?** It is derived from `public/dx-maps/manifest.json`, which is what > the build computed from each folder's real shape — so it cannot drift from what ships. > > **Cite this file rather than remembering.** The same citation has been wrong in three diff --git a/internal/check-locale-reachable.mjs b/internal/check-locale-reachable.mjs index f403d2fe7..a56e400ca 100644 --- a/internal/check-locale-reachable.mjs +++ b/internal/check-locale-reachable.mjs @@ -64,8 +64,24 @@ if (!body) { console.error(' stopped testing anything.') process.exit(1) } +// …and every top-level helper of the same file that detectLocale CALLS, transitively. 09d03c60 moved +// the URL half into localeFromUrl(), and a gate that extracted detectLocale alone died with +// "localeFromUrl is not defined" on every case — reporting a working feature as broken. Following the +// calls keeps the gate on the shipped code whatever it is factored into. +const helpers = [] +const seen = new Set(['detectLocale']) +const queue = [body] +while (queue.length) { + const code = queue.shift() + for (const [, name] of code.matchAll(/\b([A-Za-z_$][\w$]*)\s*\(/g)) { + if (seen.has(name)) continue + seen.add(name) + const fn = extractFunction(src, name) + if (fn) { const clean = fn.replace(/\bexport\s+/g, ''); helpers.push(clean); queue.push(clean) } + } +} const make = new Function('SUPPORTED_LOCALES', 'window', 'document', - `${body}\n; return detectLocale;`) + `${helpers.join('\n')}\n${body}\n; return detectLocale;`) const detect = (search, htmlLang) => make(SUPPORTED, { location: { search } }, { documentElement: { lang: htmlLang } })() diff --git a/internal/generate-tree.mjs b/internal/generate-tree.mjs index 576f17c6c..ebde0a745 100644 --- a/internal/generate-tree.mjs +++ b/internal/generate-tree.mjs @@ -1451,6 +1451,10 @@ function main() { // sibling it used to be was also a trap: it made a sibling-relative asset path resolve locally // while it 404'd live (library/utils/embedBase.js records that incident). const pubAssets = mkdtempSync(join(tmpdir(), `${TREE_NAME}-assets-`)) + // …and removed even if this script dies part-way: an 'exit' handler runs on a crash too. A pool left + // behind by a failed build is ~50 MB in /tmp, and on a full disk (2026-09-25) that is what fails the NEXT build. + const dropPool = () => { try { rmSync(pubAssets, { recursive: true, force: true }) } catch { /* already gone */ } } + process.once('exit', dropPool) // EVERY profile's previews, not just the 100-list's. This was hardcoded to // my-upg-100-list, so country-posters shipped with zero thumbnails and its // whole wall fell back to "poster not generated yet" placeholders even though diff --git a/library/layouts/StackLayout.vue b/library/layouts/StackLayout.vue index 6151abef8..fa47bcac5 100644 --- a/library/layouts/StackLayout.vue +++ b/library/layouts/StackLayout.vue @@ -101,11 +101,15 @@ const _stlLoaded = {} // Anything already absolute, protocol-relative or root-relative is passed through untouched; if no // /doxa-maps/ segment is found (a foreign mount) the original string is returned, so this can only // ever fix a path, never break one that was already working. +// eslint-disable-next-line no-undef +const TREE_SEGMENT = (typeof __DOXA_TREE_NAME__ !== 'undefined' ? __DOXA_TREE_NAME__ : 'doxa-maps').replace(/[.*+?^${}()|[\]\\]/g, '\\$&') function resolveBundleSrc(url) { if (!url || /^(https?:)?\/\//.test(url) || url.startsWith('/')) return url const bare = url.replace(/^(\.\.\/)+/, '').replace(/^\.\//, '') - // (1) The document is somewhere inside the tree — anchor on its own /doxa-maps/ segment. - const m = /^(.*\/doxa-maps\/)/.exec(window.location.pathname) + // (1) The document is somewhere inside the tree — anchor on its own // segment. The name is + // baked in at build time (vite define __DOXA_TREE_NAME__, from the build config), never a literal: + // a hardcoded '/doxa-maps/' here broke every composite's nested bundles on a renamed tree. + const m = new RegExp('^(.*/' + TREE_SEGMENT + '/)').exec(window.location.pathname) if (m) return m[1] + bare // (2) THE DOCUMENT IS NOT INSIDE THE TREE AT ALL, so there is no segment to anchor on. // Driver-reported 2026-09-23: mounts at the SERVER ROOT (/index.html) in both diff --git a/package.json b/package.json index 406b1a54e..fcf3eaca5 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,8 @@ "check:poster-wall-localised": "node internal/check-poster-wall-localised.mjs", "check:prayer-walk-planner": "node internal/check-prayer-walk-planner.mjs", "check:print-geometry": "node internal/check-print-geometry.mjs", + "check:pin-dim-slider": "node plugin-designs/doxa-research-map/scripts/check-pin-dim-slider.mjs", + "check:pin-dim-slider": "the side menu's pin-dim slider and the legend's dimming must COMPOSE, not compete. Both want the pin layer's circle-opacity, so there is ONE writer and the value is the PRODUCT: slider x (1 matched, 0 dimmed). Asserts the LIVE paint property, not the store: flat 1 with clustering off, flat 0.5 with clustering on, ['case', expr, 0.5, 0] once something is dimmed, and ['case', expr, 1, 0] when the slider returns to 1. Dims by RELIGION deliberately - a region selection sets a layer FILTER rather than an opacity expression, so testing that path would report a failure about code that never writes opacity.", "check:provider-switch": "node internal/check-provider-switch.mjs", "check:public-hygiene": "node internal/public-hygiene-check.mjs", "check:published": "node internal/check-published-matches-build.mjs", @@ -176,7 +178,7 @@ "dev": "bun x vite", "fix:sri": "node internal/fix-sri.mjs", "gallery": "node internal/generate-gallery.mjs", - "gates:post": "node internal/run-parallel.mjs --label=post -- 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: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:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed", + "gates:post": "node internal/run-parallel.mjs --label=post -- 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: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", "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", "library-index": "node internal/generate-library-index.mjs", "packet-fixture": "node internal/generate-packet-fixture.mjs", diff --git a/plugin-designs/doxa-research-map/research-map.vue b/plugin-designs/doxa-research-map/research-map.vue index 6b12a2dab..2c0e2c1cb 100644 --- a/plugin-designs/doxa-research-map/research-map.vue +++ b/plugin-designs/doxa-research-map/research-map.vue @@ -1113,11 +1113,7 @@ 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) - } + applyPinOpacity(m, null) mapLayers.syncGlowFilter(null) } // Reset region polygon highlight when deselecting on the WAGF Regions tab. @@ -1147,8 +1143,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) + applyPinOpacity(m, null) mapLayers.syncGlowFilter(node.filter) } // Mirror selection into mapStore (loop-safe via legend's _lastInternalId). @@ -1172,19 +1167,8 @@ function onSemanticTreeSelect(node) { // hitbox stays full-size for every pin and the user can tap an // apparent empty area to select a hidden pin. _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 - ]) - } + // Dim via the one writer: matched pins at the slider's scale, others at 0. + applyPinOpacity(m, node.filter) mapLayers.syncGlowFilter(node.filter) } } @@ -1198,8 +1182,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) + applyPinOpacity(m, null) } // Mirror region selection into mapStore (for geocoder / external sync). if (id.startsWith('region:')) mapStore.selectRegion?.(id.slice(7)) @@ -1604,15 +1587,49 @@ function onLegendHighlight(evt) { mapFly.fitBounds?.([[minLng, minLat], [maxLng, maxLat]], 60, { maxZoom }) } +// ─── ONE WRITER FOR PIN OPACITY ────────────────────────────────────────────── +// ★ THE SLIDER AND THE LEGEND DIMMING DO NOT COMPETE, THEY COMPOSE. +// Two features want this one paint property: the side menu's dim slider (fade the individual pins +// while clustering is on, so the clusters read) and legend dimming (hide everything that is not the +// selected row). Written as two owners they would overwrite each other — the slider would erase +// dimming, or dimming would erase the slider, depending on click order. So there is exactly one +// writer and the value is the PRODUCT: opacity = slider × (1 for a matched pin, 0 for a dimmed one). +// The slider scales the whole layer; dimming still dims relative to it; neither silently wins. +// +// The slider was inert before this (mapStore never declared clusterPinOpacity, so dragging it +// changed a number nobody read) — the same defect as the clustering picker beside it, and found +// while fixing that one. +let _pinMatchExpr = null // the current dim expression, or null for "show everything" +function pinBaseAlpha() { + // The slider only applies while clustering is actually drawing — it is hidden in the side menu + // when clustering is off, and applying it then would dim a map with nothing to dim FOR. + const on = mapStore?.showClusters && (mapStore?.clusteringMode || 'off') !== 'off' + const a = Number(mapStore?.clusterPinOpacity) + return on && Number.isFinite(a) ? Math.min(1, Math.max(0, a)) : 1 +} +// matchExpr: an expression to dim by, null to clear dimming, undefined to re-apply what is current +// (which is what the slider's watcher needs — it changes the scale, not the selection). +function applyPinOpacity(m, matchExpr) { + if (matchExpr !== undefined) _pinMatchExpr = matchExpr || null + if (!m || !m.getLayer?.('language-family-pins')) return + const a = pinBaseAlpha() + const value = _pinMatchExpr ? ['case', _pinMatchExpr, a, 0] : a + try { + 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 apply will carry it */ } +} +watch(() => [mapStore?.clusterPinOpacity, mapStore?.clusteringMode, mapStore?.showClusters], + () => applyPinOpacity(map.value)) + // Last-clicked highlight key per kind — clicking the same row again clears. 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) - } + applyPinOpacity(m, null) m.setFilter('language-family-pins', null) _syncHitboxFilter(m, null) mapLayers.syncGlowFilter(null) @@ -1728,8 +1745,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) + applyPinOpacity(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. @@ -1832,17 +1848,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 - ]) - } + applyPinOpacity(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 @@ -2070,6 +2076,14 @@ if (typeof window !== 'undefined') { // many features are actually RENDERED right now. Layers are found by prefix rather than from a // list, so a clustering layer added later shows up here without anyone remembering to add it. // Same shape and purpose as the two handles beside it. + // THE PIN LAYER'S ACTUAL OPACITY EXPRESSION, for the same reason as the handle below: the slider + // and legend dimming COMPOSE into one paint property, and "the store value changed" is not evidence + // that they composed. Returns whatever GL is holding — a number when nothing is dimmed, a ['case',…] + // when something is. + window.__researchMapPinOpacity = () => { + try { return map.value?.getPaintProperty?.('language-family-pins', 'circle-opacity') ?? null } catch (_) { return null } + } + window.__researchMapClusterDraw = () => { const m = map.value const out = {} diff --git a/plugin-designs/doxa-research-map/scripts/check-pin-dim-slider.mjs b/plugin-designs/doxa-research-map/scripts/check-pin-dim-slider.mjs new file mode 100644 index 000000000..42aea1efb --- /dev/null +++ b/plugin-designs/doxa-research-map/scripts/check-pin-dim-slider.mjs @@ -0,0 +1,168 @@ +#!/usr/bin/env node +/** + * check-pin-dim-slider.mjs — the side menu's pin-dim slider and the legend's dimming must COMPOSE, + * not compete, and the slider must actually reach the map. + * + * THE DEFECT. The slider wrote mapStore.clusterPinOpacity, which mapStore never declared, so + * dragging it changed a number nobody read — the same shape of defect as the clustering picker + * beside it, found while fixing that one. And the naive fix would have created a worse one: the pin + * layer's circle-opacity is ALSO owned by legend dimming (a ['case', …, 1, 0] expression), so a flat + * slider value written by a second owner would silently erase dimming, or be erased by it, depending + * on which the reader touched last. + * + * ★ SO THE VALUE IS A PRODUCT, AND THAT IS WHAT THIS ASSERTS: opacity = slider × (1 matched, 0 + * dimmed). The slider scales the whole layer; dimming still dims relative to it; neither wins. + * + * ASSERTIONS, reading the LIVE paint property rather than the store: + * clustering off a plain 1 — the slider is hidden then, and dimming a map with nothing + * to dim FOR would be a regression of its own. + * clustering on, 0.5 a plain 0.5. + * + a dimming selection ['case', , 0.5, 0] — dimming survives the slider. + * slider back to 1.0 ['case', , 1, 0] — the slider survives dimming. + * + * Exit: 0 pass or SKIP (no built tree, no browser, no data) · 1 fail. + */ +import { createServer } from 'node:http' +import { spawn } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const TAG = 'PIN-DIM-SLIDER' +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') +const MANIFEST = join(ROOT, 'public', 'doxa-maps', 'manifest.json') +const skip = (why) => { console.log(`${TAG}: SKIP — ${why}`); process.exit(0) } + +if (!existsSync(MANIFEST)) skip('no built manifest (run `bun run tree` first).') +let chromium +try { ({ chromium } = await import('playwright')) } catch { skip('playwright is not installed.') } + +const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8')) +const bundle = (manifest.bundles || []).find((b) => b.bundle === 'doxa-research-map') +const profile = bundle && (bundle.profiles || []).find((p) => p.iframeUrl && /research-map/.test(p.iframeUrl)) +if (!profile) skip('the manifest declares no research-map leaf to load.') + +const freePort = () => new Promise((ok) => { + const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) +}) +const PORT = await freePort() +const TREE = `http://127.0.0.1:${PORT}/doxa-maps` +const LEAF = `${TREE}/${String(profile.iframeUrl).replace(/^\.\//, '')}` +const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(PORT)], { cwd: ROOT, stdio: 'ignore' }) +const cleanup = () => { try { tree.kill() } catch { /* already gone */ } } +await new Promise((r) => setTimeout(r, 900)) +if (!await fetch(`${TREE}/manifest.json`, { signal: AbortSignal.timeout(4000) }).then((r) => r.ok).catch(() => false)) { + cleanup(); skip('could not serve the built tree locally.') +} +const { requireThisTree } = await import(join(ROOT, 'internal', '_serves-this-tree.mjs')) +await requireThisTree(TREE, ROOT, TAG) + +const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'] }) +const fails = [] +const lines = [] +try { + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }) + await page.goto(LEAF, { waitUntil: 'domcontentloaded', timeout: 60000 }) + + const out = await page.evaluate(async () => { + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + let read = null + for (let i = 0; i < 120 && !read; i++) { + if (typeof window.__researchMapPinOpacity === 'function') read = window.__researchMapPinOpacity + else await sleep(500) + } + if (!read) return { skip: 'the map never exposed its pin-opacity report' } + for (let i = 0; i < 120; i++) { + if ((window.__researchMapVocabulary?.('religionFamily') || []).length) break + await sleep(500) + } + + // Fresh walk each time: the slider is behind v-if="clusterMode !== 'off'", so it does not EXIST + // until a clustering mode is chosen. Caching the visited set across calls (as the clustering gate + // can) would mean searching a DOM snapshot taken before the element was created — which is how + // this gate first reported "the slider is not in the DOM" on a build where it works. + const findAll = (sel) => { + const seen = new Set() + const hits = [] + const walk = (root) => { + if (!root || seen.has(root)) return + seen.add(root) + for (const el of root.querySelectorAll('*')) { + if (el.matches && el.matches(sel)) hits.push(el) + if (el.shadowRoot) walk(el.shadowRoot) + } + } + walk(document) + return hits + } + const buttons = findAll('.rm-cluster-btn') + if (buttons.length !== 4) return { skip: `expected 4 clustering buttons, found ${buttons.length}` } + + const setSlider = (v) => { + const slider = findAll('#rm-pin-op')[0] || findAll('.rm-slider-row input[type=range]')[0] + if (!slider) return false + slider.value = String(v) + slider.dispatchEvent(new Event('input', { bubbles: true })) + return true + } + const host = document.querySelector('doxa-research-map') + // ★ DIM BY RELIGION, NOT BY REGION, AND THAT IS NOT ARBITRARY. MEASURED while writing this: a + // REGION selection does not dim at all — it sets a layer FILTER, so non-matching pins are + // removed rather than faded, and the opacity stays flat. Only the case-expression dim paths + // (religion / prayer / engagement / adoption / language-family) compose with the slider, so + // testing region here would have reported "the slider erased the dimming" about a path that + // never writes opacity. + const dim = () => { + let cfg = {} + try { cfg = JSON.parse(host.getAttribute('profile-config') || '{}') } catch (_) { cfg = {} } + cfg.externalFilters = { region: '', religion: 'Buddhism', language: '' } + host.setAttribute('profile-config', JSON.stringify(cfg)) + } + + const out = {} + buttons[0].click(); await sleep(4000) // Off + out.off = read() + buttons[2].click(); await sleep(8000) // MST — the slider only applies while clustering draws + if (!setSlider(0.5)) return { skip: 'the pin-dim slider never appeared after choosing a clustering mode' } + await sleep(2500) + out.clusteredHalf = read() + dim(); await sleep(9000) + out.dimmedHalf = read() + setSlider(1); await sleep(2500) + out.dimmedFull = read() + return out + }) + + if (out.skip) { await browser.close(); cleanup(); skip(out.skip) } + + const show = (v) => JSON.stringify(v) + lines.push(` · clustering off ${show(out.off)}`) + lines.push(` · clustering on, 0.5 ${show(out.clusteredHalf)}`) + lines.push(` · + dimming, 0.5 ${show(out.dimmedHalf)}`) + lines.push(` · + dimming, 1.0 ${show(out.dimmedFull)}`) + + const isCase = (v) => Array.isArray(v) && v[0] === 'case' && v.length === 4 + if (out.off !== 1) fails.push(`with clustering off the pins should be fully opaque, got ${show(out.off)} — the slider is applying where it is not even shown.`) + if (out.clusteredHalf !== 0.5) fails.push(`the slider at 0.5 with clustering on should give a flat 0.5, got ${show(out.clusteredHalf)} — the slider is not reaching the map.`) + if (!isCase(out.dimmedHalf)) fails.push(`dimming while the slider is at 0.5 should give a ['case', …] expression, got ${show(out.dimmedHalf)} — the slider ERASED the dimming.`) + else { + if (out.dimmedHalf[2] !== 0.5) fails.push(`matched pins should render at the slider's 0.5 while dimming, got ${show(out.dimmedHalf[2])} — the dimming ERASED the slider.`) + if (out.dimmedHalf[3] !== 0) fails.push(`dimmed pins should render at 0, got ${show(out.dimmedHalf[3])}.`) + } + if (!isCase(out.dimmedFull)) fails.push(`returning the slider to 1.0 should keep the dimming expression, got ${show(out.dimmedFull)} — the slider erased it on the way back.`) + else if (out.dimmedFull[2] !== 1) fails.push(`at slider 1.0 matched pins should be fully opaque, got ${show(out.dimmedFull[2])}.`) +} catch (err) { + fails.push(`the measurement itself failed: ${err && err.message}`) +} finally { + await browser.close().catch(() => {}) + cleanup() +} + +for (const l of lines) console.log(l) +if (fails.length) { + console.log(`\n${TAG}: FAIL`) + for (const f of fails) console.log(` ✗ ${f}`) + console.log('\n opacity = slider × (1 matched, 0 dimmed). One writer, one product — see applyPinOpacity.') + process.exit(1) +} +console.log(`${TAG}: PASS — the slider scales the layer, dimming still dims relative to it, and neither erases the other.`) diff --git a/plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue b/plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue index b3dafa17f..4d1b30af1 100644 --- a/plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue +++ b/plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue @@ -1569,14 +1569,17 @@ useShadowStyles(` .rd-proj{ position:absolute; right:8px; 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. Measured on a phone (390x844, pointer:coarse) this rendered + /* ★ TOUCH TARGET: 24px, STATED, AT EVERY WIDTH. Measured on a phone (390x844) this rendered 74x23 — one pixel under WCAG 2.5.8 AA. The fix is to name the 24, not to add padding: the height is padding PLUS a line box the renderer derives from the font, and padding arithmetic - lands at 23.7px, which rounds to the same failure. Mouse sizing is untouched; only a coarse - pointer gets the floor. (Found by regions' phone sweep across every map.) */ - @media (pointer: coarse) { - .rd-proj{ min-height:24px; display:inline-flex; align-items:center; } - } + lands at 23.7px, which rounds to the same failure. + ★ AND IT MUST NOT BE BEHIND pointer:coarse. The first fix scoped this to a coarse pointer, + reasoning that a mouse does not need a touch floor. That was wrong twice over: 2.5.8 is a + TARGET SIZE criterion, not a touch criterion, so it applies to the desktop pointer too — and + the media query meant phones passed at 34px while desktop stayed at 73.5x23, the same one-pixel + failure, now invisible to a phone sweep. Measured again at 1440x900 by upg100 (2a2ea36e). One + rule, every width, no query to be right about. */ + .rd-proj{ min-height:24px; display:inline-flex; align-items: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. diff --git a/plugin-designs/wall-of-people-groups/src/components/PeopleGroupsWall.vue b/plugin-designs/wall-of-people-groups/src/components/PeopleGroupsWall.vue index 742b8b372..873df258b 100644 --- a/plugin-designs/wall-of-people-groups/src/components/PeopleGroupsWall.vue +++ b/plugin-designs/wall-of-people-groups/src/components/PeopleGroupsWall.vue @@ -18,6 +18,16 @@

Loading people groups...

+ + +
+

The live prayer data could not be reached.

+

The wall reads its people groups from pray.doxa.life. You may be offline, or, on a + local development server, the API may not allow this page's address.

+ +
@@ -50,6 +60,10 @@ export default { peopleGroups() { return this.$store?.peopleGroups || []; + }, + + loadError() { + return this.$store?.error || null; } }, mounted() { diff --git a/plugin-designs/wall-of-people-groups/src/components/SourceSelector.vue b/plugin-designs/wall-of-people-groups/src/components/SourceSelector.vue index a84f28af8..13a84ff3e 100644 --- a/plugin-designs/wall-of-people-groups/src/components/SourceSelector.vue +++ b/plugin-designs/wall-of-people-groups/src/components/SourceSelector.vue @@ -1,5 +1,7 @@