Rebuild every bundle after resolving artifact conflicts
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"version": "0.1.0",
|
||||
"identifier": "life.doxa.maps",
|
||||
"build": {
|
||||
"frontendDist": "../../public/doxa-maps",
|
||||
"frontendDist": "../../public/dx-maps",
|
||||
"beforeBuildCommand": ""
|
||||
},
|
||||
"app": {
|
||||
|
|
|
|||
|
|
@ -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 <script> tag
|
||||
$handle = array_key_first($SCRIPTS);
|
||||
|
|
@ -86,7 +86,7 @@ $assert('crossorigin anonymous present', str_contains($tag, 'crossorigin="anonym
|
|||
// 5. a BAD integrity must be REJECTED (negative test — the format regex must actually fire)
|
||||
$SCRIPTS = [];
|
||||
$out2 = do_shortcode_call('doxa_map', ['bundle'=>$target['bundle'],'element'=>$prof['element'],
|
||||
'profile'=>$prof['id']??$target['bundle'],'base'=>'https://nuxtbase.app/plugin-assets/doxa-maps',
|
||||
'profile'=>$prof['id']??$target['bundle'],'base'=>'https://nuxtbase.app/plugin-assets/'.$tree,
|
||||
'integrity'=>'not-a-valid-hash!!']);
|
||||
$h2 = array_key_first($SCRIPTS);
|
||||
$tag2 = apply_filters('script_loader_tag', "<script src='x' id='$h2-js'></script>", $h2, 'x');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 } })()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 /<tree>/ 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: <plugin-studio> mounts at the SERVER ROOT (/index.html) in both
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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', <expr>, 0.5, 0] — dimming survives the slider.
|
||||
* slider back to 1.0 ['case', <expr>, 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.`)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,16 @@
|
|||
<div class="spinner"></div>
|
||||
<p>Loading people groups...</p>
|
||||
</div>
|
||||
|
||||
<!-- OFFLINE / BLOCKED: the live API could not be reached. Measured on a local dev server,
|
||||
where pray.doxa.life refuses a 127.0.0.1 origin (CORS): the wall used to sit at
|
||||
"0 Groups Loaded" with nothing saying why. Say what failed and offer a retry. -->
|
||||
<div v-else-if="loadError && totalGroups === 0" class="wall-offline" role="status">
|
||||
<p class="wall-offline-title">The live prayer data could not be reached.</p>
|
||||
<p>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.</p>
|
||||
<button type="button" class="control-btn" @click="fetchData">Try again</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
|
@ -50,6 +60,10 @@ export default {
|
|||
|
||||
peopleGroups() {
|
||||
return this.$store?.peopleGroups || [];
|
||||
},
|
||||
|
||||
loadError() {
|
||||
return this.$store?.error || null;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<template>
|
||||
<div v-if="sources.length > 1">
|
||||
<!-- availableSources, the computed below: the template read an undefined `sources`, so this
|
||||
threw "reading 'length'" on EVERY render, online or not (Vue logs it; nothing crashes) -->
|
||||
<div v-if="availableSources.length > 1">
|
||||
<div class="source-selector">
|
||||
<div class="source-header">
|
||||
<h3>📊 Data Source</h3>
|
||||
|
|
|
|||
|
|
@ -58,7 +58,9 @@ export const usePeopleGroupsStore = (pinia) => {
|
|||
console.log('First 3 groups:', this.peopleGroups.slice(0, 3));
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error fetching people groups from ${this.dataSource}:`, error);
|
||||
// warn, not error: the wall now SAYS so on screen (PeopleGroupsWall's offline notice)
|
||||
// and offers a retry; an unreachable API is an expected state, not a crash.
|
||||
console.warn(`Could not fetch people groups from ${this.dataSource}:`, error && error.message);
|
||||
this.error = error.message;
|
||||
this.peopleGroups = [];
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -116,6 +116,28 @@ body {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Offline / blocked notice: the live API could not be reached. Sits over the empty canvas,
|
||||
inside the wall (not position:fixed), so an embedded wall never covers its host page. */
|
||||
.wall-container { position: relative; }
|
||||
.wall-offline {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-width: min(440px, calc(100% - 32px));
|
||||
padding: 20px 24px;
|
||||
background: white;
|
||||
border: 1px solid #dbdbdb;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
color: var(--text-color);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
.wall-offline-title { font-weight: 600; font-size: 16px; margin-bottom: 6px; }
|
||||
.wall-offline p { margin: 0 0 12px; }
|
||||
|
||||
/* Loading Overlay */
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
This folder is GENERATED by `bun run build`; do not edit it by hand. It holds exactly one thing:
|
||||
|
||||
- **`doxa-maps/`** — the output folder: 23 plugin bundles and 35 pages, self-contained.
|
||||
- **`dx-maps/`** — the output folder: 23 plugin bundles and 35 pages, self-contained.
|
||||
Copy this one folder to any web host (or open it from disk) and it works.
|
||||
- `doxa-maps/index.html` — the front page: the Plugin Studio, every plugin in one place.
|
||||
- `doxa-maps/<plugin>/<plugin>.js` — a plugin's bundle (a custom element; load it with a `<script>` tag).
|
||||
- `doxa-maps/<plugin>/<page>/index.html` — a ready-made page for one plugin view (embed it in an `<iframe>`).
|
||||
- `doxa-maps/manifest.json` — every plugin, page and embed snippet, machine-readable.
|
||||
- `doxa-maps/assets/` — the data, fonts and images the plugins fetch at runtime.
|
||||
- `dx-maps/index.html` — the front page: the Plugin Studio, every plugin in one place.
|
||||
- `dx-maps/<plugin>/<plugin>.js` — a plugin's bundle (a custom element; load it with a `<script>` tag).
|
||||
- `dx-maps/<plugin>/<page>/index.html` — a ready-made page for one plugin view (embed it in an `<iframe>`).
|
||||
- `dx-maps/manifest.json` — every plugin, page and embed snippet, machine-readable.
|
||||
- `dx-maps/assets/` — the data, fonts and images the plugins fetch at runtime.
|
||||
|
||||
The folder's name comes from `name` in `vite.doxa-maps-build-config.json`. Sources live in
|
||||
`plugin-designs/`, `library/` and `data/`; `rm -rf public/ && bun run build` recreates all of this.
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 304 KiB After Width: | Height: | Size: 304 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |