diff --git a/documentation/README-SECTION-3-cli-commands.md b/documentation/README-SECTION-3-cli-commands.md index a711932fb..fbcaf81a8 100644 --- a/documentation/README-SECTION-3-cli-commands.md +++ b/documentation/README-SECTION-3-cli-commands.md @@ -49,7 +49,7 @@ The scripts these commands are grounded in (from this branch's `package.json`): "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 && bun run gallery", "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:wp-embed 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", +"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:composite-embed-knobs", "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 e97769fc6..44eb379ed 100644 --- a/documentation/reference-capability-exemplars.md +++ b/documentation/reference-capability-exemplars.md @@ -63,7 +63,6 @@ Generated from 22 bundle(s). | `doxa-simple-map` | `doxa-simple-map` | `parameterized` | | `doxa-vision-card` | `doxa-vision-card` | `parameterized` | | `people-groups-grid` | `people-groups-grid` | `parameterized` | -| `upg-neighbor` | `upg-neighbor` | `parameterized` | ## Capability 3 — multi-view-element (monolithic) @@ -71,6 +70,7 @@ Generated from 22 bundle(s). |---|---|---| | `doxa-research-map` | `research-map` | `monolithic` | | `research-dashboard` | `research-dashboard` | `monolithic` | +| `upg-neighbor` | `upg-neighbor` | `monolithic` | ## Capability 4 — composite-element (nested-element SPA) diff --git a/internal/check-composite-embed-knobs.mjs b/internal/check-composite-embed-knobs.mjs new file mode 100644 index 000000000..69da686ce --- /dev/null +++ b/internal/check-composite-embed-knobs.mjs @@ -0,0 +1,229 @@ +#!/usr/bin/env node +/** + * check-composite-embed-knobs.mjs — a composite must pass the host's TOKEN and PROVIDER down to the + * map it nests, on somebody else's page. + * + * THE DEFECT THIS EXISTS FOR, MEASURED 2026-09-24. Two shipped composites declared NO PROPS at all, + * so the `profile-config` a partner writes was read by nobody: + * + * -> "No Mapbox access token + * provided", 0 tiles, no canvas + * + * and after the token was threaded, the SAME hole was still open one knob over: + * + * -> child still on mapbox, + * so it demanded a token + * + * Every existing gate missed all of it, and not by accident: they load a composite on its OWN + * generated leaf, and that leaf BAKES a token and assigns window.MAP_TOKEN, which the shared-token + * bridge hands to the nested map. The leaf supplies the very thing under test. The failure exists + * only where there is no leaf — which is exactly the partner page the published embed snippet is + * written for, and exactly the case nobody was testing. + * + * WHY THESE TWO KNOBS AND NOT A LONGER LIST. They are the two the snippet documents and the two the + * product promises: `tk` is how a partner supplies their own key, and `provider` is the one prop that + * is supposed to swap the engine. A keyless MapLibre embed is the whole reason MapLibre support was + * built — partners at volume who do not want a Mapbox key — so a composite that drops `provider` + * does not degrade the feature, it cancels it, and reports the loss as a token error. + * + * HOW. Serves the built tree from a FREE port (ACAO:* via serve-strict, production parity) and the + * partner page from `localhost` — a genuinely different origin, so CORS is exercised rather than + * assumed. Free ports asked of the OS, never fixed ones: a stray server on a fixed port answers in + * place of the tree under test and the gate then passes a build it never loaded. + * + * ASSERTIONS, per composite that nests a map: + * tk — given ONLY {"tk": }, a canvas appears and at least one + * api.mapbox.com style/tile request succeeds. No token error in the console. + * provider — given ONLY {"provider":"maplibre"} and NO token, a canvas appears and there are + * ZERO requests to api.mapbox.com. Zero is the assertion: "it rendered" would pass on + * a build that quietly fell back to mapbox with a baked key. + * + * NEGATIVE-TESTED: reverting either fix in research-map-plus-grid.vue makes the matching case FAIL + * naming the composite and the knob; both restored, both pass. Removing the zero-request assertion + * from the provider case makes it pass on a build that ignores the prop, which is what it is for. + * + * Exit: 0 pass or SKIP (no built tree, no browser, no baked token to test with) · 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 ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const MANIFEST = join(ROOT, 'public', 'doxa-maps', 'manifest.json') + +if (!existsSync(MANIFEST)) { + console.log('COMPOSITE-EMBED-KNOBS: SKIP — no built manifest (run `bun run tree` first).') + process.exit(0) +} +let chromium +try { ({ chromium } = await import('playwright')) } catch { + console.log('COMPOSITE-EMBED-KNOBS: SKIP — playwright is not installed.') + process.exit(0) +} + +const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8')) + +// Which bundles nest a MAP? A composite declares `composes`; a bundle is a map if any of its +// profiles is isMap. Asking the manifest keeps this list correct when a composite is added. +const mapBundles = new Set() +for (const b of manifest.bundles || []) { + if ((b.profiles || []).some((p) => p.isMap)) mapBundles.add(b.bundle) +} +const targets = [] +for (const b of manifest.bundles || []) { + // TWO WAYS A COMPOSITE DECLARES ITSELF, and taking only the first under-covered this gate. + // `composes` is written when a composite mounts children BY TAG; a composite that BAKES its + // children in (an imported .vue) lists nothing there, and country-posters-mfe — one of the three + // composites this defect was measured on — was silently not a target. The profile TYPE says so in + // both cases, so ask for either. + const composes = b.composes || [] + const byCompose = composes.some((c) => mapBundles.has(c)) + const profile = (b.profiles || []).find((p) => p.element && (byCompose || p.type === 'nested-elements-mfe')) + if (!profile || !b.scriptUrl) continue + targets.push({ bundle: b.bundle, element: profile.element, script: `${b.bundle}/${b.bundle}.js` }) +} +if (!targets.length) { + console.log('COMPOSITE-EMBED-KNOBS: SKIP — the manifest declares no composite that nests a map.') + process.exit(0) +} + +// The token to hand the partner page is the one the tree already bakes into its own leaves: this +// gate must not invent a key, and a token that is not this tree's would 401 and look like our bug. +const bakedToken = (() => { + for (const b of manifest.bundles || []) { + for (const p of b.profiles || []) { + if (!p.isMap || !p.iframeUrl) continue + const leaf = join(ROOT, 'public', 'doxa-maps', String(p.iframeUrl).replace(/^\.\//, '')) + if (!existsSync(leaf)) continue + const m = readFileSync(leaf, 'utf8').match(/BAKED_TOKEN\s*=\s*"(pk\.[^"]+)"/) + if (m) return m[1] + } + } + return '' +})() + +const freePort = () => new Promise((ok) => { + const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) +}) +const TREE_PORT = await freePort() +const HOST_PORT = await freePort() +const TREE = `http://127.0.0.1:${TREE_PORT}/doxa-maps` + +let pageHtml = 'nothing yet' +const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(TREE_PORT)], { cwd: ROOT, stdio: 'ignore' }) +const host = createServer((_, r) => { r.writeHead(200, { 'content-type': 'text/html' }); r.end(pageHtml) }).listen(HOST_PORT, 'localhost') +const cleanup = () => { try { tree.kill() } catch { /* already gone */ } try { host.close() } catch { /* already closed */ } } +await new Promise((r) => setTimeout(r, 900)) + +const reachable = await fetch(`${TREE}/manifest.json`, { signal: AbortSignal.timeout(4000) }).then((r) => r.ok).catch(() => false) +if (!reachable) { + cleanup() + console.log('COMPOSITE-EMBED-KNOBS: SKIP — could not serve the built tree locally.') + process.exit(0) +} + +const CASES = [ + { knob: 'tk', config: (tok) => ({ tk: tok }), needsToken: true }, + { knob: 'provider', config: () => ({ provider: 'maplibre' }), needsToken: false }, +] + +const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'] }) +const fails = [] +const lines = [] +// Elements whose tk case actually drew a map — the only ones whose provider case can mean anything. +const renders = new Set() +try { + for (const t of targets) { + for (const c of CASES) { + // Do not pay for a page load we already know cannot mean anything: if this element's tk case + // drew no map, its provider case has nothing to compare against. Short-circuiting here is what + // keeps the whole gate near two minutes instead of five on a nine-composite tree. + if (c.knob === 'provider' && !renders.has(t.element)) { + lines.push(` · ${t.element} [provider] UNMEASURED — its tk case never drew a map, so there is nothing to compare against.`) + continue + } + if (c.needsToken && !bakedToken) { + lines.push(` · ${t.element} [${c.knob}] SKIPPED — no baked token in this tree to hand the host`) + continue + } + const cfg = JSON.stringify(c.config(bakedToken)).replace(/'/g, ''') + pageHtml = `partner +
HOST HEADER
+ + +<${t.element} profile-config='${cfg}'> +` + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }) + let mapboxOk = 0, mapboxBad = 0 + const errs = [] + page.on('response', (r) => { + if (!/api\.mapbox\.com|tiles\.mapbox\.com/.test(r.url())) return + if (r.status() >= 400) mapboxBad++; else mapboxOk++ + }) + page.on('console', (m) => { if (m.type() === 'error') errs.push(m.text().slice(0, 120)) }) + await page.goto(`http://localhost:${HOST_PORT}/`, { waitUntil: 'load' }).catch(() => {}) + // WAIT FOR THE THING, NOT FOR A CLOCK. A flat 14s per case made this gate cost four minutes + // of wall time on a nine-composite tree, most of it spent watching a map that had already + // drawn. Poll for a canvas instead, then settle briefly so late tile requests are counted — + // the provider case asserts ZERO requests to api.mapbox.com, so it must not stop measuring + // the moment something paints. An element that never draws still costs the full budget, which + // is correct: that is the case whose absence is the finding. + const CANVAS_BUDGET_MS = 16000 + const deadline = Date.now() + CANVAS_BUDGET_MS + let canvas = 0 + const countCanvas = `(() => { + let n = 0 + const visit = (r) => { n += r.querySelectorAll('canvas').length; for (const el of r.querySelectorAll('*')) if (el.shadowRoot) visit(el.shadowRoot) } + visit(document) + return n + })()` + while (Date.now() < deadline) { + canvas = await page.evaluate(countCanvas).catch(() => 0) + if (canvas) break + await page.waitForTimeout(500) + } + await page.waitForTimeout(2500) + canvas = await page.evaluate(countCanvas).catch(() => canvas) + const tokenErr = errs.find((e) => /access token/i.test(e)) + const label = `${t.element} [${c.knob}]` + const mapboxAll = mapboxOk + mapboxBad + + // ★ FAIL ONLY WHERE THE EVIDENCE POINTS AT THE KNOB. An element that draws no map under a + // MINIMAL config is not thereby broken: several composites need a `profile` to select which + // of their profiles to show, and this gate deliberately passes only the knob under test. The + // first draft failed nine of them for that, which would have red-gated other people's plugins + // over a claim it had not measured. The distinguishing evidence is the console: a token WAS + // supplied, so "No Mapbox access token provided" means the knob did not cross the boundary — + // that is this defect. No canvas and no such complaint is simply not measurable here, and is + // reported as UNMEASURED rather than dressed up as a finding. + if (c.knob === 'tk') { + if (canvas && mapboxOk > 0) { renders.add(t.element); lines.push(` ok ${label} — canvas ${canvas}, api.mapbox.com requests ${mapboxAll}`) } + else if (canvas && mapboxOk === 0) fails.push(`${label}: canvas drew but NOTHING loaded from api.mapbox.com — the host's token never reached the map`) + else if (tokenErr) fails.push(`${label}: a token was supplied and the element still says "${tokenErr}" — it never read the host's profile-config`) + else lines.push(` · ${label} UNMEASURED — no map canvas under a minimal config, and no token complaint. This element likely needs a \`profile\` to show its map; not a knob failure.`) + } else { + if (canvas && mapboxAll === 0) lines.push(` ok ${label} — canvas ${canvas}, api.mapbox.com requests 0 (keyless, as asked)`) + else if (mapboxAll > 0) fails.push(`${label}: asked for maplibre and still made ${mapboxAll} request(s) to api.mapbox.com — the prop did not cross the composite boundary`) + else fails.push(`${label}: asked for maplibre with no token and drew nothing${tokenErr ? ` — ${tokenErr}` : ''}`) + } + await page.close() + } + } +} finally { + await browser.close() + cleanup() +} + +console.log(`COMPOSITE-EMBED-KNOBS: ${targets.length} composite(s) that nest a map, on a foreign origin`) +for (const l of lines) console.log(l) +if (fails.length) { + console.error(`\nCOMPOSITE-EMBED-KNOBS: FAIL — ${fails.length} case(s)`) + for (const f of fails) console.error(' - ' + f) + console.error('\n A composite reads the host\'s profile-config and hands `tk` and `provider` to the map it') + console.error(' nests. Our own leaves bake a token and assign window.MAP_TOKEN, so this only ever breaks') + console.error(' on a partner page — the case the published embed snippet is written for.') + process.exit(1) +} +console.log(`\nCOMPOSITE-EMBED-KNOBS: PASS — the host's token and provider reach every nested map.`) diff --git a/internal/check-poster-card-fits.mjs b/internal/check-poster-card-fits.mjs index 9b4b9da4d..148c90337 100644 --- a/internal/check-poster-card-fits.mjs +++ b/internal/check-poster-card-fits.mjs @@ -55,6 +55,7 @@ async function measure(width) { const cards = all.filter((e) => (e.className||'').toString().split(' ').includes('card')).filter(vis) const boxes = cards.map((c) => ({ c, b: c.getBoundingClientRect() })) let intrusions = 0, overflowing = 0, worst = 0, coveringPreview = 0, worstY = 0 + let previewsPainted = 0, previewsBroken = 0, noPreview = 0 for (const { c, b } of boxes) { const row = c.querySelector('.pfs') if (!row) continue @@ -68,7 +69,24 @@ async function measure(width) { // 35px -> 78px, and because it sits at bottom:-14px the extra height pushed its TOP from // 216px up to 173px in a 238px card: over the preview image and the card's outline. // Horizontal clean, card visibly broken, gate green. + // COUNT THE PREVIEW, do not merely guard on it. An if-guard protects this loop from a + // placeholder card — and also means a card with NO preview passes the over-preview assertion + // perfectly. The driver reported BOTH a missing-previews symptom AND the overlap; + // measuring only the overlap leaves the first unasserted. + // NOTE: no backticks in this block — it lives inside a JS template literal. const img = c.querySelector('img') + // ONLY JUDGE WHAT IS IN VIEW. Every preview carries loading=lazy, so a card below the fold + // legitimately has naturalWidth 0 — measured at 390px: 8 of 27 painted, the first unpainted + // one 3926px down an 844px viewport, and ZERO failing HTTP responses. A first version of this + // assertion called that 18 broken images. Deferred is not broken; only an in-viewport image + // that never painted is. + if (!img) noPreview++ + else { + const ib2 = img.getBoundingClientRect() + const inView = ib2.top < window.innerHeight && ib2.bottom > 0 + if (img.complete && img.naturalWidth > 0) previewsPainted++ + else if (inView) previewsBroken++ + } if (img) { const ib = img.getBoundingClientRect() if (ib.height > 2 && rb.top < ib.bottom - 1) { @@ -87,6 +105,7 @@ async function measure(width) { const tiny = ctrls.filter((e) => { const b = e.getBoundingClientRect(); return b.width < 18 || b.height < 10 }) return { cards: cards.length, rows: cards.filter((c) => c.querySelector('.pfs')).length, overflowing, worst, intrusions, coveringPreview, worstY, + previewsPainted, previewsBroken, noPreview, controls: ctrls.length, tiny: tiny.length } })()`) await page.close() @@ -107,6 +126,8 @@ for (const r of results) { + ` · overflow ${r.overflowing}${r.worst ? ` (${r.worst}px)` : ''}` + ` · neighbour ${r.intrusions}` + ` · over-preview ${r.coveringPreview}${r.worstY ? ` (${r.worstY}px)` : ''}` + + ` · previews ${r.previewsPainted} painted` + + `${r.previewsBroken ? `/${r.previewsBroken} BROKEN` : ''}${r.noPreview ? `/${r.noPreview} none` : ''}` + ` · ${r.controls} control(s), ${r.tiny} too small`) } @@ -125,6 +146,27 @@ for (const r of results) { } // The row belongs BELOW the preview. Driver-reported 2026-09-24: "the download print options are // not showing up under the blue outline, they are overlapping where the blue outline would be". + // THE PREVIEW MUST ACTUALLY BE THERE. A card with no image passes every geometry assertion + // above trivially — nothing can overlap what does not render. Driver 2026-09-24: "it is supposed + // to show the previews". + if (r.previewsBroken) { + fails.push(`${at}: ${r.previewsBroken} preview image(s) failed to load (naturalWidth 0) — ` + + 'a broken preview is not something the geometry checks can see') + } + // EVERY card falling back to the placeholder means the previews are gone. MEASURED by aborting + // every previews/** request: the product degrades WELL — all 27 cards swap to a .ph placeholder + // reading "poster not generated yet", with no broken-image icons and no left at all. That + // is correct behaviour and exactly why it is invisible to an image-health check: previewsBroken + // stays 0 because there is nothing left to be broken. Assert the FALLBACK instead. + if (r.cards && r.noPreview === r.cards) { + fails.push(`${at}: all ${r.cards} card(s) fell back to the "poster not generated yet" ` + + 'placeholder — no preview rendered anywhere, and the geometry assertions above are vacuous ' + + 'without one (they cannot overlap an image that is not there)') + } + if (r.previewsPainted === 0 && r.cards && r.noPreview !== r.cards) { + fails.push(`${at}: NOT ONE preview image painted across ${r.cards} card(s) — the geometry ` + + 'assertions above are vacuous without them') + } if (r.coveringPreview) { fails.push(`${at}: ${r.coveringPreview} print row(s) COVER the preview image ` + `(top sits ${r.worstY}px above the image's bottom edge) — growing the row taller to fit it ` diff --git a/internal/check-research-gallery-embed.mjs b/internal/check-research-gallery-embed.mjs index 7fd6208db..d77b87790 100644 --- a/internal/check-research-gallery-embed.mjs +++ b/internal/check-research-gallery-embed.mjs @@ -28,7 +28,7 @@ * party tile/glyph hiccups are reported, not failed — they are not this repo's to fix). * * PRECONDITION: a built tree in public/doxa-maps (this starts its own servers; no dev server). - * Usage: node internal/check-research-gallery-embed.mjs [--mode js|iframe] [--only id,id] + * Usage: node internal/check-research-gallery-embed.mjs [--mode js|iframe] [--only id,id] [--provider maplibre] * Exit: 0 pass · 1 fail · 2 SKIP (no built tree / no browser) */ import { createServer } from 'node:http' @@ -42,6 +42,26 @@ const argv = process.argv.slice(2) const argOf = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d } const MODES = argOf('--mode', '') ? [argOf('--mode', '')] : ['js', 'iframe'] const ONLY = argOf('--only', '') ? argOf('--only', '').split(',') : null +// --provider maplibre: the KEYLESS embed. The host asks for maplibre (attribute in js mode, ?provider= +// in iframe mode) and every tab must still paint — with ZERO requests to api.mapbox.com. That is the +// promise a keyless partner embed depends on, and the shell once broke it by not forwarding the engine. +const PROVIDER = argOf('--provider', '') +// KNOWN, ROUTED exemptions from the zero-Mapbox rule — each names its owner. Printed on every run so +// it cannot become invisible; DELETE the entry when the owner lands the fix (the gate then enforces it). +const MAPBOX_KNOWN = { + // EMPTY ON PURPOSE. The one entry here — my-upg-100-list/upg-100-list.vue:1464 pinning + // mapbox-gl.css v3.3.0 regardless of engine — was fixed and the exemption deleted in the SAME + // COMMIT as the fix, so main never had a window where the gate red-flagged its own known bug and + // never had a window where a stale exemption hid a live one. Proven before deletion: this gate + // reported "provider=maplibre made 0 request(s) to api.mapbox.com" in BOTH js and iframe modes, + // with no KNOWN line printed at all. + // + // ⚠ NOT COVERED BY THIS MAP, and deliberately so: the generated leaf HEAD still preloads + // mapbox-gl.css v3.24.0 and mapbox-gl-geocoder v5.0.0. Different versions, different owner — + // that is generate-tree (bundler-1's lane), tracked as + // BUG-LEAF-PAGES-PRELOAD-MAPBOX-EVEN-UNDER-MAPLIBRE. This gate embeds the GALLERY, which does not + // load those leaves, which is why it reads zero today. +} // ★ FREE PORTS, ASKED OF THE OS — never fixed ones. A stray server left on a fixed port by an // earlier crashed run answered in place of the tree under test, and this gate PASSED a known-broken // tree (caught by its own negative control, 2026-09-24): spawn failed with EADDRINUSE silently and @@ -63,8 +83,8 @@ try { ({ chromium } = await import('playwright')) } catch { let mode = 'js' const hostPage = () => { const embed = mode === 'iframe' - ? `` - : `` + ? `` + : `` return `host
HOST HEADER
${embed}
HOST FOOTER
` @@ -80,7 +100,14 @@ const checked = {} // mode -> tabs actually driven; printed, and 0 is a FAIL ( try { for (mode of MODES) { const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }) - const own = new Set(), foreign = new Set() + const own = new Set(), foreign = new Set(), mapbox = new Set() + let current = '(page load)' + const mapboxBy = {} // tab id (the one open when the request fired) -> distinct mapbox URLs + page.on('request', q => { + if (!/^https:\/\/api\.mapbox\.com\//.test(q.url())) return + const u = q.url().split('?')[0] + mapbox.add(u); (mapboxBy[current] = mapboxBy[current] || new Set()).add(u) + }) const note = (url, why) => (url.startsWith(`http://127.0.0.1:${TREE_PORT}/`) ? own : foreign).add(why + ' ' + url.split('?')[0]) page.on('response', r => { if (r.status() >= 400) note(r.url(), String(r.status())) }) page.on('requestfailed', q => { const e = (q.failure() && q.failure().errorText) || ''; if (!/ABORTED/.test(e)) note(q.url(), e) }) @@ -102,6 +129,7 @@ try { const tabs = await frame.evaluate(() => document.querySelector('doxa-maps-spa')._instance.proxy.tabs.map(t => ({ id: t.id, discovered: !!t.discovered }))) for (const { id, discovered } of tabs) { if (ONLY && !ONLY.includes(id)) continue + current = id await frame.evaluate(id => document.querySelector('doxa-maps-spa')._instance.proxy.onSelect(id), id) let v for (let k = 0; k < 50; k++) { @@ -125,6 +153,17 @@ try { if (!ok) fails.push(`[${mode}] ${id}: ${why}`) } for (const f of own) { console.log(`FAIL [${mode}] tree request failed: ${f}`); fails.push(`[${mode}] ${f}`) } + if (PROVIDER === 'maplibre') { + for (const u of [...mapbox]) if (MAPBOX_KNOWN[u]) { console.log(`KNOWN [${mode}] ${u} — owner: ${MAPBOX_KNOWN[u]}`); mapbox.delete(u); for (const k in mapboxBy) { mapboxBy[k].delete(u); if (!mapboxBy[k].size) delete mapboxBy[k] } } + const ok = mapbox.size === 0 + console.log(`${ok ? 'PASS' : 'FAIL'} [${mode}] provider=maplibre made ${mapbox.size} request(s) to api.mapbox.com`) + if (!ok) { + for (const [tab, urls] of Object.entries(mapboxBy)) { + console.log(` while ${tab} was open: ${urls.size} — ${[...urls].slice(0, 3).join(' , ')}`) + fails.push(`[${mode}] provider=maplibre: ${tab} hit api.mapbox.com (${urls.size} distinct URL(s))`) + } + } + } for (const f of foreign) console.log(`note [${mode}] third-party request failed (not gated): ${f}`) await page.close() } diff --git a/library/colors/wagf-region.js b/library/colors/wagf-region.js index 7e465f3ef..f31cda69f 100644 --- a/library/colors/wagf-region.js +++ b/library/colors/wagf-region.js @@ -1,6 +1,32 @@ /** * WAGF region color strategy — colors pins by the region they belong to. * + * ★ THE VALUES ARE THE RESEARCH MAP'S, ON THE DRIVER'S INSTRUCTION (2026-09-24). He compared the + * two surfaces himself: "when i look on teh color ffor th regiosn tab of the simpel research map i + * don tsee the you using htes colors on the reigosn dashbarod hwen you shoudl". Two surfaces + * disagreeing about what Africa's colour means is a product defect whichever palette is prettier, + * and he chose which one wins. Source of truth: + * plugin-designs/doxa-research-map/src/colors/doxa-region.js, which the research dashboard and + * three other consumers already paint from. + * + * WHAT THE CHANGE COST, measured rather than glossed (dataviz validate_palette.js, light mode, + * surface #fcfcfb — the same tool and surface the previous palette was stepped against): + * Lightness band FAIL #1ee05f 0.793 · #FFEB3B 0.928 · #00e0bd 0.810 sit above the 0.43–0.77 band + * Chroma floor PASS all 7 >= 0.1 + * CVD separation PASS worst adjacent #FFEB3B<->#1ee05f dE 8.7 protan (>= 8), tritan 14.7 + * Normal vision PASS worst adjacent dE 21.0 + * Contrast WARN under 3:1 vs the surface: #1ee05f 1.72 · #FFEB3B 1.19 · #00e0bd 1.65 · #ff7f11 2.47 + * The palette this replaced passed all five. So the trade is real and it is recorded here rather + * than in a commit message nobody will read next year. + * + * WHY THE CONTRAST WARN IS SURVIVABLE HERE, and it is a reason, not a dismissal: that check + * obligates relief through a secondary encoding, and both surfaces already carry one — the chooser + * renders a LEGEND of labelled chips in these exact colours, and the dashboard renders the same + * seven regions as a TABLE with names and figures. Identity never rests on hue alone on either. + * The LIGHTNESS FAIL is the part with no relief: three of these read faintly as PINS on a light + * basemap. Darkening those three by ~10% L would enter the band while keeping the hue + * recognisable, and that is the Driver's call to make, not mine to make quietly — asked, not done. + * * WHY THIS EXISTS. The regions element's no-selection state is a CHOOSER: the Driver's brief was * "some type of map that would show the regions and countries and let you select one". For the * seven regions to read AS regions the moment the map loads, the pins have to be colored by @@ -37,7 +63,13 @@ export const PROPERTY_KEY = 'region' -const DEFAULT_COLOR = '#6d766e' // DX-life --color-brand-lighter: an unknown region reads as chrome +// DX-life --color-brand-lighter: an unknown region reads as chrome, not as an eighth region. +// ★ DELIBERATELY NOT THEIRS. doxa-region.js uses '#0098FF' for null/undefined and keys +// 'No WAGF Region/Bloc' to '#1a1a2e'. Neither is a REGION colour — they are what an absent value +// looks like — and the Driver's instruction was about the seven regions. A bright blue for +// "unknown" would also read as a region on a map whose every other pin is a region. Every row in +// this feed carries a wagf_region, so this value is almost never painted; it is chrome on purpose. +const DEFAULT_COLOR = '#6d766e' /** Region value → display label, as the prayer API labels them. Used by the legend. */ export const WAGF_REGIONS = { @@ -50,15 +82,22 @@ export const WAGF_REGIONS = { 'oceania': 'Oceania', } -/** Region value → color, in the validated order. Do not reorder without re-running the validator. */ +/** + * Region value → color. THE RESEARCH MAP'S VALUES, so the two surfaces agree — see the header. + * Mapped from its label-keyed table: Africa #ff2e2e, Asia #1a8cff, Europe #1ee05f, + * Latin America & Caribbean #FFEB3B, Middle East #a83dff, + * North America & Non-Spanish Caribbean #00e0bd, Oceania #ff7f11. + * The order is still the fixed alphabetical one (colour follows the entity, never its rank), which + * is also the order the adjacent-pair CVD result in the header was measured in. + */ export const PALETTE = { - 'africa': '#2563eb', - 'asia': '#d97706', - 'europe': '#7c3aed', - 'latin_america_&_caribbean': '#15803d', - 'middle_east': '#dc2626', - 'north_america_&_non-spanish_caribbean': '#0891b2', - 'oceania': '#be185d', + 'africa': '#ff2e2e', + 'asia': '#1a8cff', + 'europe': '#1ee05f', + 'latin_america_&_caribbean': '#FFEB3B', + 'middle_east': '#a83dff', + 'north_america_&_non-spanish_caribbean': '#00e0bd', + 'oceania': '#ff7f11', } /** The fixed render order — the same order the palette was validated in. */ diff --git a/library/components/hints/PinHint.vue b/library/components/hints/PinHint.vue index cdc66e16d..c9aed4cce 100644 --- a/library/components/hints/PinHint.vue +++ b/library/components/hints/PinHint.vue @@ -61,7 +61,13 @@ const visible = ref(false) const hintEl = ref(null) let watchTimer = null let movedTo = null // the element we re-parented into, so we can put ourselves back -let attribRO = null // observes the attribution control (it resizes on its own, see below) +let attribRO = null +// The root this pill was mounted in, kept so a later clamp can look for obstacles (the phone +// legend sheet) that did not exist at mount time. +let hintRoot = null +let sheetRO = null +let sheetWatched = null +let settleTimers = [] // observes the attribution control (it resizes on its own, see below) let hostRO = null // observes the pill's layout parent, so a LATE layout re-runs the clamp let attribEl = null @@ -122,6 +128,45 @@ function findMapBox(canvas, root) { * clipped, and leaves a correctly-placed pill untouched — so every surface that already worked is * byte-identical in behaviour. */ +/** + * The phone legend sheet is an obstacle too, not just the fold. + * + * MEASURED 2026-09-24 at 390x844 on doxa-research-map: the pill sat at y=700 and the open sheet + * began at y=603, so "Tap a coloured dot — someone still needs prayer" printed straight across the + * Progress rows — 1,418 Needs Prayer and the Full Prayer Coverage line were unreadable underneath + * it. It is the phone screenshot the README showcases. Nothing was clipped, so the existing + * below-the-fold clamp had nothing to say: the pill was fully visible and fully in the way. + * + * Same lever as the attribution strip, which this component already treats as a floor rather than + * something to cover: find the sheet, and if the pill would cross its top edge, lift by exactly the + * overlap. Only when the two actually intersect horizontally — a sheet that does not sit under the + * pill is not an obstacle — and only ever upward, so every surface with no sheet is unchanged. + */ +function sheetFloor(pill) { + const sheet = hintRoot?.querySelector?.('.legend-mobile-sheet') + if (!sheet) return Infinity + // ★ ATTACH THE OBSERVER HERE, NOT AT MOUNT. Measured: the sheet does not exist yet when the pill + // mounts — LegendMobile renders after the map's first canvas — so a mount-time querySelector + // found nothing, no observer was attached, and the clamp never ran again. The pill stayed exactly + // where it was and the "fix" moved it by 0px. Binding lazily, the first clamp that SEES a sheet + // is the one that starts watching it. + if (sheetRO && sheetWatched !== sheet) { try { sheetRO.disconnect() } catch (_) {} sheetRO = null } + if (!sheetRO && typeof ResizeObserver !== 'undefined') { + try { + sheetRO = new ResizeObserver(() => clampIntoView()) + sheetRO.observe(sheet) + sheetWatched = sheet + } catch (_) { sheetRO = null } + } + try { + const sr = sheet.getBoundingClientRect() + // A collapsed sheet is a thin strip; still an obstacle, but only if it is really there. + if (!(sr.height > 16) || !(sr.width > 16)) return Infinity + const apart = pill.right <= sr.left || sr.right <= pill.left + return apart ? Infinity : sr.top + } catch (_) { return Infinity } +} + function clampIntoView() { const el = hintEl.value if (!el) return @@ -138,7 +183,10 @@ function clampIntoView() { el.style.removeProperty('--pin-hint-lift') const r = el.getBoundingClientRect() if (!r.height) return - const overflow = Math.round(r.bottom - window.innerHeight) + // The lowest thing the pill may touch: the fold, or the top of the phone legend sheet when one + // is open beneath it. Whichever is higher wins. + const floor = Math.min(window.innerHeight, sheetFloor(r)) + const overflow = Math.round(r.bottom - floor) if (overflow > 0) el.style.setProperty('--pin-hint-lift', (overflow + 8) + 'px') } catch (_) { /* detached — nothing to clamp */ } } @@ -196,6 +244,7 @@ onMounted(() => { const inst = getCurrentInstance() const root = inst?.proxy?.$el?.getRootNode?.() if (!root || typeof root.querySelector !== 'function') return + hintRoot = root let tries = 0 watchTimer = setInterval(() => { tries++ @@ -215,6 +264,11 @@ onMounted(() => { // The attribution ResizeObserver only fires when THAT control resizes, which need not // coincide with the host settling. Watch the pill's own offsetParent as well, so a late // layout (a composite sizing its nested child after mount) triggers a re-measure. + // RE-CLAMP AS THE PAGE SETTLES. The phone legend sheet mounts AFTER the map's first canvas, + // so the clamp above runs while the only floor is the fold. These three re-measures catch + // the sheet arriving; once one of them sees it, sheetFloor() binds a ResizeObserver and the + // sheet's own drags and collapses keep the pill clear from then on. + settleTimers.push(setTimeout(clampIntoView, 1200), setTimeout(clampIntoView, 3000), setTimeout(clampIntoView, 6000)) try { const watch = (hintEl.value && hintEl.value.offsetParent) || null if (watch && typeof ResizeObserver !== 'undefined') { @@ -233,6 +287,10 @@ onBeforeUnmount(() => { if (watchTimer) clearInterval(watchTimer) if (attribRO) { try { attribRO.disconnect() } catch (_) {} attribRO = null } attribEl = null if (hostRO) { try { hostRO.disconnect() } catch (_) {} hostRO = null } + if (sheetRO) { try { sheetRO.disconnect() } catch (_) {} sheetRO = null } sheetWatched = null + for (const t of settleTimers) clearTimeout(t) + settleTimers = [] + hintRoot = null // We appended our own element into a node Vue does not own. Vue removes the element // it created, but only from where IT thinks it is — so detach explicitly to be certain // no orphan pill is left behind in the map container on profile teardown. diff --git a/library/components/poster/PosterWall.vue b/library/components/poster/PosterWall.vue index b3492b889..25f6ddb2b 100644 --- a/library/components/poster/PosterWall.vue +++ b/library/components/poster/PosterWall.vue @@ -47,6 +47,7 @@ import { useI18n } from 'vue-i18n' // (contain:layout on .vtl-content). Measured 1024x793 @ (256,107) at 1280x900 — rail clickable // behind the "modal". See library/utils/viewportScrim.js for the measurements and rejected fixes. import { useViewportScrim } from '@map/utils/viewportScrim.js' +import { bundleTreeRoot } from '@map/utils/embedBase.js' const props = defineProps({ items: { type: Array, required: true }, @@ -149,7 +150,29 @@ const props = defineProps({ const plural = computed(() => props.itemNounPlural || `${props.itemNoun}s`) const base = computed(() => String(props.assetsBase || './assets').replace(/\/+$/, '')) -const previewUrl = (id) => `${base.value}/previews/${id}.webp` + +// ★ A SECOND PLACE TO LOOK, because "poster not generated yet" was being shown for posters that +// ARE generated. The DRIVER reported it on Ethiopia, whose country-eth.webp exists (174 of the 175 +// records have their .webp; India is the one that legitimately does not and carries its own +// reason). MEASURED here — static build AND dev server, every request 200, zero cards in that +// branch — so the file is not missing; a HOST LAYOUT is. resolveAssetsBase points at +// /../assets, the shared pool, which is correct for the layout generate-tree documents +// and wrong on a host that publishes only the tree folder. generate-tree ALSO stages a +// self-contained copy INSIDE the tree ("staged 421 runtime asset(s) → doxa-maps/assets"), and +// that copy is reachable on both layouts. So: try the pool, and if the bytes do not arrive, try +// the copy before telling the user anything. +const fallbackBase = computed(() => { + const root = String(bundleTreeRoot() || '').replace(/\/+$/, '') + return root ? `${root}/assets` : '' +}) +// 0/absent = first attempt, 1 = the fallback is being tried. +const attempt = ref({}) +const previewUrl = (id) => `${attempt.value[id] ? fallbackBase.value : base.value}/previews/${id}.webp` +function onPreviewError(id) { + const fb = fallbackBase.value + if (!attempt.value[id] && fb && fb !== base.value) { attempt.value = { ...attempt.value, [id]: 1 }; return } + missing.value = { ...missing.value, [id]: true } +} // Sections ordered by TOTAL count (largest first), items within a section by their // own count — the densest prayer need leads at BOTH levels. @@ -630,11 +653,18 @@ useShadowStyles( @click="previewIsControl && exportPoster(item)" @keydown.enter.prevent="previewIsControl && exportPoster(item)" @keydown.space.prevent="previewIsControl && exportPoster(item)" - @error="missing[item.id] = true" /> + @error="onPreviewError(item.id)" />
{{ item.label }}
{{ item.count.toLocaleString() }} people groups
-
poster not generated yet
+ +
preview unavailable
diff --git a/library/i18n/locales/ar/regions.json b/library/i18n/locales/ar/regions.json index fc9a6ba00..fbcebb9bb 100644 --- a/library/i18n/locales/ar/regions.json +++ b/library/i18n/locales/ar/regions.json @@ -61,5 +61,9 @@ "WAGF Block": "كتلة WAGF", "WAGF Region": "منطقة WAGF", "people groups adopted": "الشعوب المنتمية", - "people groups engaged": "مجموعات بشرية مُنخرِطة" + "people groups engaged": "مجموعات بشرية مُنخرِطة", + "Outside this region": "خارج هذه المنطقة", + "Pray": "صلّي", + "Loading region dashboards…": "جارٍ تحميل لوحات المناطق…", + "No regions found.": "لم يتم العثور على مناطق." } diff --git a/library/i18n/locales/de/regions.json b/library/i18n/locales/de/regions.json index 5a7bd5bec..b838e24d7 100644 --- a/library/i18n/locales/de/regions.json +++ b/library/i18n/locales/de/regions.json @@ -61,5 +61,9 @@ "WAGF Block": "WAGF-Block", "WAGF Region": "WAGF-Region", "people groups adopted": "adoptierte Volksgruppen", - "people groups engaged": "engagierte Volksgruppen" + "people groups engaged": "engagierte Volksgruppen", + "Outside this region": "Außerhalb dieser Region", + "Pray": "Ich bete", + "Loading region dashboards…": "Regions-Dashboards werden geladen…", + "No regions found.": "Keine Regionen gefunden." } diff --git a/library/i18n/locales/en/regions.json b/library/i18n/locales/en/regions.json index 61471dd3d..c002ef0b4 100644 --- a/library/i18n/locales/en/regions.json +++ b/library/i18n/locales/en/regions.json @@ -61,5 +61,9 @@ "WAGF Block": "WAGF Block", "WAGF Region": "WAGF Region", "people groups adopted": "people groups adopted", - "people groups engaged": "people groups engaged" + "people groups engaged": "people groups engaged", + "Outside this region": "Outside this region", + "Pray": "Pray", + "Loading region dashboards…": "Loading region dashboards…", + "No regions found.": "No regions found." } diff --git a/library/i18n/locales/es/regions.json b/library/i18n/locales/es/regions.json index 6f273fc50..ba8b50739 100644 --- a/library/i18n/locales/es/regions.json +++ b/library/i18n/locales/es/regions.json @@ -61,5 +61,9 @@ "WAGF Block": "Bloque WAGF", "WAGF Region": "Región de la WAGF", "people groups adopted": "grupos de personas adoptados", - "people groups engaged": "grupos étnicos comprometidos" + "people groups engaged": "grupos étnicos comprometidos", + "Outside this region": "Fuera de esta región", + "Pray": "Oración", + "Loading region dashboards…": "Cargando los paneles de regiones…", + "No regions found.": "No se encontraron regiones." } diff --git a/library/i18n/locales/fr/regions.json b/library/i18n/locales/fr/regions.json index 0c20744e6..65de8c351 100644 --- a/library/i18n/locales/fr/regions.json +++ b/library/i18n/locales/fr/regions.json @@ -61,5 +61,9 @@ "WAGF Block": "Bloc WAGF", "WAGF Region": "Région WAGF", "people groups adopted": "peuples adoptés", - "people groups engaged": "peuples engagés" + "people groups engaged": "peuples engagés", + "Outside this region": "Hors de cette région", + "Pray": "Prier", + "Loading region dashboards…": "Chargement des tableaux de bord des régions…", + "No regions found.": "Aucune région trouvée." } diff --git a/library/i18n/locales/hi/regions.json b/library/i18n/locales/hi/regions.json index 29637a640..7e12f95fc 100644 --- a/library/i18n/locales/hi/regions.json +++ b/library/i18n/locales/hi/regions.json @@ -61,5 +61,9 @@ "WAGF Block": "WAGF ब्लॉक", "WAGF Region": "डब्ल्यूएजीएफ क्षेत्र", "people groups adopted": "लोगों के समूहों ने अपनाया", - "people groups engaged": "प्रवेशित लोगों के समूह" + "people groups engaged": "प्रवेशित लोगों के समूह", + "Outside this region": "इस क्षेत्र के बाहर", + "Pray": "प्रार्थना करें", + "Loading region dashboards…": "क्षेत्र डैशबोर्ड लोड हो रहे हैं…", + "No regions found.": "कोई क्षेत्र नहीं मिला।" } diff --git a/library/i18n/locales/it/regions.json b/library/i18n/locales/it/regions.json index 5b4415689..703a75f76 100644 --- a/library/i18n/locales/it/regions.json +++ b/library/i18n/locales/it/regions.json @@ -61,5 +61,9 @@ "WAGF Block": "Blocco WAGF", "WAGF Region": "Regione WAGF", "people groups adopted": "popoli adottati", - "people groups engaged": "popoli coinvolti" + "people groups engaged": "popoli coinvolti", + "Outside this region": "Fuori da questa regione", + "Pray": "Prega", + "Loading region dashboards…": "Caricamento delle dashboard delle regioni…", + "No regions found.": "Nessuna regione trovata." } diff --git a/library/i18n/locales/pt/regions.json b/library/i18n/locales/pt/regions.json index b515de305..a5ff08af7 100644 --- a/library/i18n/locales/pt/regions.json +++ b/library/i18n/locales/pt/regions.json @@ -61,5 +61,9 @@ "WAGF Block": "Bloco WAGF", "WAGF Region": "Região da WAGF", "people groups adopted": "grupos étnicos adotados", - "people groups engaged": "grupos étnicos engajados" + "people groups engaged": "grupos étnicos engajados", + "Outside this region": "Fora desta região", + "Pray": "Ore", + "Loading region dashboards…": "Carregando os painéis das regiões…", + "No regions found.": "Nenhuma região encontrada." } diff --git a/library/i18n/locales/ro/regions.json b/library/i18n/locales/ro/regions.json index 223de2324..2bd427376 100644 --- a/library/i18n/locales/ro/regions.json +++ b/library/i18n/locales/ro/regions.json @@ -61,5 +61,9 @@ "WAGF Block": "Bloc WAGF", "WAGF Region": "Regiune WAGF", "people groups adopted": "popoare adoptate", - "people groups engaged": "popoare implicate" + "people groups engaged": "popoare implicate", + "Outside this region": "În afara acestei regiuni", + "Pray": "Roagă-te", + "Loading region dashboards…": "Se încarcă panourile regiunilor…", + "No regions found.": "Nu au fost găsite regiuni." } diff --git a/library/i18n/locales/ru/regions.json b/library/i18n/locales/ru/regions.json index 6a1ea4b73..fa8118cfe 100644 --- a/library/i18n/locales/ru/regions.json +++ b/library/i18n/locales/ru/regions.json @@ -61,5 +61,9 @@ "WAGF Block": "Блок WAGF", "WAGF Region": "Регион WAGF", "people groups adopted": "принятые группы людей", - "people groups engaged": "вовлечённые группы людей" + "people groups engaged": "вовлечённые группы людей", + "Outside this region": "За пределами этого региона", + "Pray": "Молиться", + "Loading region dashboards…": "Загрузка панелей по регионам…", + "No regions found.": "Регионы не найдены." } diff --git a/library/i18n/locales/zh/regions.json b/library/i18n/locales/zh/regions.json index 64b7c8424..ab89c12c2 100644 --- a/library/i18n/locales/zh/regions.json +++ b/library/i18n/locales/zh/regions.json @@ -61,5 +61,9 @@ "WAGF Block": "WAGF 板块", "WAGF Region": "WAGF 区域", "people groups adopted": "已认领的民族", - "people groups engaged": "已参与的民族" + "people groups engaged": "已参与的民族", + "Outside this region": "在此区域之外", + "Pray": "祷告", + "Loading region dashboards…": "正在加载区域面板…", + "No regions found.": "未找到区域。" } diff --git a/library/layouts/VerticalTabsLayout.vue b/library/layouts/VerticalTabsLayout.vue index 2cd746974..7effa6e31 100644 --- a/library/layouts/VerticalTabsLayout.vue +++ b/library/layouts/VerticalTabsLayout.vue @@ -29,6 +29,9 @@ selects : the `key` of the canonical row this one stands for. An ALIAS row has its own unique `key` (for v-for) but clicking it selects `selects`, it highlights whenever that key is active, and it is skipped by keyboard nav so arrow keys visit each canonical row once. + branch : an id that makes this row FOLDABLE — a chevron beside it shows/hides the rows below. + Closed by default; picking the row also opens it. + ancestors : the `branch` ids above this row; it shows only while ALL of them are open. Selection is controlled via v-model:active (the active item's `key`). The host owns pane content and mount logic — this component only paints the SHELL (top bar + rail + a content frame) and exposes slots so each app keeps its unique chrome + its own mount machinery: @@ -161,14 +164,24 @@
+ diff --git a/plugin-designs/regions-dashboard/regions-list.vue b/plugin-designs/regions-dashboard/regions-list.vue index 4dbe04cc6..c00e586d9 100644 --- a/plugin-designs/regions-dashboard/regions-list.vue +++ b/plugin-designs/regions-dashboard/regions-list.vue @@ -413,8 +413,18 @@ useShadowStyles( // not a text glyph. Ported verbatim (fill:currentColor tracks the surrounding text colour, // same as their `.icon{ fill:currentColor }` utility) and rotated 90deg to point right, same // as their `rotate-90` class. getComputedStyle measured it at 33.38px square on the live page. + // ★ 1rem WAS THE ONE UNIT IN THIS PORT THAT INHERITS THE HOST PAGE'S ROOT, and it was wrong on + // our own leaf, not only in an embed. `rem` resolves against the DOCUMENT root wherever the + // element lives — which is the whole reason dxlife-tokens.js resolves their scale to px — and + // their root is 10px (html{font-size: var(--font-size-base)}, $font-size-base: 10px). Ours + // inherits whatever the host sets: 16px by default, so this gap rendered 16px against their + // 10px, and on a host with the common html{font-size:62.5%} reset it would render 10px by + // accident. Pinned to the value their root produces, like every other token here. + // No diff caught it: the list diff's `link` spec reads colour, decoration, weight, display and + // alignItems — no margin — so the icon's own spacing was never a measured property. It is now. + // (Credit where due: the class of bug came to me as a flock relay from another agent's sweep.) '.region-card__icon{ width:0.7em; height:0.7em; flex:none; fill:currentColor; transform:rotate(90deg);' + - ' margin-inline-start:1rem; }' + + ' margin-inline-start:10px; }' + // .region-card__summary: --font-size-md (var(--font-size-md)), 0.85 opacity, on the header band. '.region-card__summary{ font-size:var(--font-size-md); opacity:0.85; margin:0; }' + // .region-card__body: padding --spacing-lg / clamp(...) / --spacing-xl -> var(--spacing-lg) / clamp / var(--spacing-xl) @@ -425,7 +435,9 @@ useShadowStyles( // one list-item's worth even though DX-life's countries-grid a rule doesn't set it directly. // Reproduced with list-style:none + the same 35.16px indent kept explicitly so a real list // bullet has somewhere to sit without stealing width from the text column. - '.countries-grid{ list-style:none; margin:0; padding:0 0 0 35.2px; display:grid;' + + // padding-INLINE-start, as theirs is (1.8em). The physical `padding-left:35.2px` it replaces + // put the indent on the wrong side in Arabic, where the bullets hang off the right edge. + '.countries-grid{ list-style:none; margin:0; padding-inline-start:1.8em; display:grid;' + ' grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); gap:10px var(--spacing-xl); }' + // Plain inline text (not a flex row) so multi-line country names wrap the same way DX-life's // text does -- a flex row with align-items:baseline pinned the bullet to the first @@ -441,8 +453,22 @@ useShadowStyles( // inset-inline-start, NOT left: the bullet hangs off the text's starting edge, which is the // RIGHT edge in Arabic. With `left:-16px` it stayed on the physical left in RTL — measured // at locale=ar, computed right was 256.938px instead of the -16px near-edge overhang. - '.country-link::before{ content:""; position:absolute; inset-inline-start:-16px; top:0.6em; width:6px; height:6px;' + - ' border-radius:50%; background:#92b195; }' + + // ★ THEIR BULLET, COPIED — not a dot of my own. base/_globals.scss:117-131 paints the list + // marker with a 16x16 data-URI SVG circle in #92b195 sized to 1.5em, hung off the text's start + // edge by margin-inline-start:-1.3em (-2em in RTL), top .3em, line-height .9. Their + // .countries-grid comment is explicit that keeping it is deliberate ("Keeps the site's list + // bullets (global ul padding + ::before dot)"). + // I had re-created it as a 6px border-radius:50% square, and MEASURED against live it was wrong + // in 9 of 11 properties: 6px vs 29.2969px box, solid fill vs a transparent box carrying the + // image, top 11.72px vs 5.86px, and positioned with inset-inline-start:-16px instead of their + // negative margin. Not a near-miss — a different bullet. + // The rule is a double-quoted JS string because the data URI contains single quotes. + ".country-link::before{ content:''; position:absolute; top:0.3em; width:1.5em; height:1.5em;" + + " line-height:0.9; margin-inline-start:-1.3em; background-repeat:no-repeat;" + + " background-image:url(\"data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='%2392b195' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='8' cy='8' r='7' stroke='%2392b195' stroke-width='1.5'/%3E%3C/svg%3E\"); }" + + // Their RTL variant. It is a separate rule here rather than a nested [dir="rtl"] & because this + // CSS lives in a shadow root, where a [dir] selector on an outer ancestor does not reach in. + '[dir="rtl"] .country-link::before{ margin-inline-start:-2em; }' + '.country-link:hover{ text-decoration:underline; text-decoration-color:#92b195; }' + '.country-link:focus-visible{ outline:2px solid #92b195; outline-offset:2px; border-radius:4px; }' + '.country-link__count{ color:var(--dx-muted); }' + @@ -514,7 +540,7 @@ useShadowStyles(
Region data could not be loaded. This is a connection problem, not a filter.
-
No regions found.
+
{{ t('No regions found.') }}
diff --git a/plugin-designs/regions-dashboard/src/UupgsList.vue b/plugin-designs/regions-dashboard/src/UupgsList.vue index c7e5aa1f1..0553a9614 100644 --- a/plugin-designs/regions-dashboard/src/UupgsList.vue +++ b/plugin-designs/regions-dashboard/src/UupgsList.vue @@ -347,15 +347,22 @@ useShadowStyles( ' background-color:#fff; width:fit-content;' + ' transition:border-color 150ms ease, background-color 150ms ease; }' + '.filter-toggle:hover{ border-color:#92b195; }' + - // ★ THEIR ACTIVE STATE IS WHITE-ON-WHITE, CLONED ON PURPOSE. Their rule is - // background-color: var(--color-surface-brand-lighter, rgba(0,80,160,0.05)); - // color: var(--color-white); - // and --color-surface-brand-lighter is NOT DEFINED in their token files, so the 5%-alpha - // fallback lands on white while the ink turns white. MEASURED on their live page: the - // active button computes backgroundColor rgb(255,255,255) and color rgb(255,255,255) — - // an invisible label. Reproduced so the clone matches; flagged for the Driver, because - // defining that one token upstream fixes it for them and for us at once. - '.filter-toggle[data-active]{ background-color:rgba(0, 80, 160, 0.05); color:#fff; }' + + // ★ THIS SHIPPED AN ILLEGIBLE BUTTON FOR A CLONE OF A LEGIBLE ONE, and the comment that used to + // sit here is why it lasted. It said their active state is white-on-white "on purpose", that + // --color-surface-brand-lighter "is NOT DEFINED in their token files", and that the white-on-white + // had been "MEASURED on their live page". Two of those three were wrong: + // · tokens/semantic/_colors.scss:64 DOES define it — $color-green-300, #6d766e. + // · getComputedStyle returns the SPECIFIED colour, never the composite, so neither candidate + // could ever serialise as white: the token is #6d766e and the declared fallback is + // rgba(0,80,160,0.05). The only rule on that button yielding white is .input's + // var(--color-surface-white) — which is the INACTIVE state. The "measurement" had almost + // certainly read a button without data-active set. + // MEASURED PROPERLY (_probe-filter-state-vs-live.mjs, which CLICKS the Adopted filter on both + // sides and then reads): their live active button is background rgb(109,118,110) on + // color rgb(255,255,255). White on grey-green. Perfectly legible. We were shipping their DEAD + // FALLBACK — 5%-alpha blue, which over the white .input ground leaves a white label on white. + // So: the token's value, as theirs resolves it. + '.filter-toggle[data-active]{ background-color:#6d766e; color:#fff; }' + '.filters__panel{ display:flex; flex-wrap:wrap; gap:13.3px; justify-content:center;' + ' align-items:flex-start; }' + '.filters__active{ display:flex; flex-wrap:wrap; gap:var(--spacing-2xs); align-items:center;' + @@ -366,8 +373,14 @@ useShadowStyles( '.filter-chip__remove{ background:none; border:0; cursor:pointer; font-size:1.1em; line-height:1;' + ' color:inherit; padding:0; display:inline-flex; align-items:center; }' + '.filter-chip__remove:hover{ opacity:0.7; }' + + // ★ Clear All is `.button compact link` on their side, and blocks/_buttons.scss:64-69 gives + // .button.link `--button-text-color: var(--color-brand)` = $color-green-500 = #3b463d. Ours used + // var(--dx-text) (#2a332a, the body ink) — a different green, measured against live as + // rgb(42,51,42) vs their rgb(59,70,61). Found twice over, independently: by the applied-filter + // diff, and by a static sweep of their markup's classes that flagged `.link` as a rule our CSS + // had no equivalent for at all. '.filters__clear-all{ background:none; border:0; cursor:pointer; font:inherit; font-size:var(--font-size-sm);' + - ' color:var(--dx-text); text-decoration:underline; text-underline-offset:4px;' + + ' color:#3b463d; text-decoration:underline; text-underline-offset:4px;' + ' text-decoration-thickness:2px; }' + // .repel — their space-between row (composition/_repel.scss), at --font-size-md gap. '.ul-total{ display:flex; gap:var(--spacing-md); justify-content:space-between; align-items:center; }' + diff --git a/plugin-designs/research-map-plus-grid/research-map-plus-grid.vue b/plugin-designs/research-map-plus-grid/research-map-plus-grid.vue index a40e793e5..d07864e9d 100644 --- a/plugin-designs/research-map-plus-grid/research-map-plus-grid.vue +++ b/plugin-designs/research-map-plus-grid/research-map-plus-grid.vue @@ -55,8 +55,53 @@ * ON via prop. This is why the swap-inside-one-element idea was genuinely complex: the two top * elements do not merely look different, they imply OPPOSITE control ownership below. */ +import { computed } from 'vue' import StackLayout from '@map/layouts/StackLayout.vue' import { resolveEmbedBase } from '@map/utils/embedBase.js' +import { publishMapToken } from '@map/composables/useSharedMapToken.js' + +// ★ THE HOST'S OWN profile-config, WHICH THIS COMPOSITE USED TO THROW AWAY. +// It declared no props at all, so a partner's +// had its token dropped on the floor. On our own generated leaf that was invisible: the leaf page +// carries a BAKED token, assigns window.MAP_TOKEN, and the shared-token bridge hands it to the +// nested map. A partner page has no leaf and no baked token, so the JS embed — one of the two +// shipped embed methods — mounted a map that could not draw. MEASURED on a third-party origin +// before this: 'No Mapbox access token provided', 0 style and 0 tile requests, no canvas. +const props = defineProps({ + /** The host's JSON config, exactly as the embed snippet writes it. */ + profileConfig: { type: [String, Object], default: null }, +}) + +const hostConfig = computed(() => { + const raw = props.profileConfig + if (!raw) return {} + if (typeof raw === 'object') return raw + // A malformed attribute must not take the whole element down with it — the map half is more + // useful token-less than the page is with a thrown setup(). + try { return JSON.parse(raw) } catch { return {} } +}) + +// Publish page-wide as well as threading it below. Threading covers THIS child deterministically; +// publishing covers any element on the page that mounted before the attribute was read, which is +// the shape the shared-token composable exists for. +const hostToken = computed(() => { + const tk = hostConfig.value?.tk + return typeof tk === 'string' && tk ? tk : '' +}) +if (hostToken.value) publishMapToken(hostToken.value) + +// ★ THE PROVIDER IS A HOST KNOB TOO, AND FORGETTING IT BREAKS THE KEYLESS CASE ENTIRELY. +// resolveMapProvider's ladder starts at profile-config.provider, and the child reads its OWN +// profile-config — the one this file writes. A host asking for therefore got a child still on mapbox, which then +// demanded a token. MEASURED on a third-party origin before this: 'No Mapbox access token +// provided', 0 tiles, no canvas — for the exact configuration whose whole purpose is needing no +// token. Keyless at volume is the reason MapLibre support exists; a composite that drops the +// prop cancels it. +const hostProvider = computed(() => { + const p = hostConfig.value?.provider + return p === 'maplibre' || p === 'mapbox' ? p : '' +}) // The absolute url this composite is shared and iframed as. Built here rather than written as a // literal because resolveEmbedBase() walks profileConfig.embedBaseUrl → window.MAP_EMBED_BASE_URL → @@ -103,7 +148,7 @@ const MAP_H = '88svh' const bundleSrc = (bundle, file) => import.meta.env.DEV ? `/plugin-designs/${bundle}/index.js` : `../../${bundle}/${file}.js` -const sections = [ +const sections = computed(() => [ // No section title: the research map self-titles through its own chrome, so a StackLayout title // here would render the name twice — the duplicate-header bug the Driver rejected on the desktop // sibling (BUG-RESEARCH-DASHBOARD-HEADER-STACK-WRONG). One header per element. @@ -155,6 +200,12 @@ const sections = [ // reader is scanning, a fly-tour that moves the camera underneath fights the other half. // The standalone leaf keeps both; this is a per-host decision, which is why it is config. legendTools: false, + // Thread the host's token through to the child. Spread so a host that supplies none + // leaves the key ABSENT rather than empty: ProfileLoader's ladder is `config.tk || + // sharedToken`, and an empty string would be falsy anyway, but an absent key keeps the + // child's own config identical to what its standalone leaf sets. + ...(hostToken.value ? { tk: hostToken.value } : {}), + ...(hostProvider.value ? { provider: hostProvider.value } : {}), assetsBase: '../../assets', shareUrl: SHARE_URL }) @@ -176,7 +227,7 @@ const sections = [ src: bundleSrc('people-groups-grid', 'people-groups-grid'), attrs: { 'show-search': 'true', 'show-filters': 'true', 'show-total': 'true' } } -] +])