Merge origin/development (a77a3280f) into studio/bible-push
This commit is contained in:
commit
56d9a111f6
59 changed files with 958 additions and 341 deletions
|
|
@ -106,8 +106,8 @@ Details, per host (Cloudflare Pages, jsDelivr, any web server) and per embed sty
|
|||
Copy the built `dx-maps/` folder into your site's `public/` (renaming it is fine) and your site becomes
|
||||
the host: no second server and no cross-site setup, because the maps and the page share one origin.
|
||||
|
||||
**Live example:** DX-life's drop-in branch copies the folder into its `public/` and fills `/research` with the
|
||||
Research Gallery.
|
||||
**Live example:** DX-life's drop-in branch copies the folder into its `public/` and fills `/research` with
|
||||
Deep Research.
|
||||
|
||||
**The two routes, on your laptop:** DX-life has one branch per route: `research-gallery-drop-in` (this
|
||||
section, no Docker) and `research-gallery-subdomain` (the dx-maps container on its own address, with
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ desktop path has never been run by anyone, and the steps say so where that is th
|
|||
| Piece | State |
|
||||
|---|---|
|
||||
| The web tree (`bun run build` → `public/dx-maps/`) | Proven. Every landing builds it. |
|
||||
| The generic Tauri shell (`deployment/src-tauri/`) | Exists. It opens the Research Gallery, builds only a Linux `.deb`, and has never been packaged on macOS or Windows. |
|
||||
| The generic Tauri shell (`deployment/src-tauri/`) | Exists. It opens Deep Research, builds only a Linux `.deb`, and has never been packaged on macOS or Windows. |
|
||||
| The engine on Linux (`bun run dev`, laptop mic) | Proven. |
|
||||
| The engine taking the **webview's** microphone (`RUDDER_AUDIO=browser`) | Proven on Linux: audio streamed over `/api/rudder/stream` came back as the full test sentence. |
|
||||
| The engine **compiling** for macOS (arm64, x86_64) and Windows (x86_64) | Proven by cross-compiling. It has **never been run** on either. |
|
||||
|
|
@ -124,6 +124,6 @@ macOS and Windows the app must do the same on first run:
|
|||
- `desktop:build` has run on Linux only; the app it produced has not been launched yet. Nothing has run on macOS or Windows.
|
||||
- The macOS/Windows runtime archive layout, and whether the engine's derived path matches it.
|
||||
- The first-run download, including resuming an interrupted 742 MB download.
|
||||
- Mapbox basemaps in the Research Gallery need the token host to allow the Tauri origin
|
||||
- Mapbox basemaps in Deep Research need the token host to allow the Tauri origin
|
||||
(`deployment/src-tauri/README.md`). Rudder itself needs no token.
|
||||
- If the engine crashes, its speech server may keep running (and holding ~1 GB) until the app quits.
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ The built tree has TWO kinds of page, and they need DIFFERENT framing policy:
|
|||
| **Direct embed** (the product) | `<bundle>/<profile>/index.html`, `<bundle>/<profile>/<instance>/index.html` — the bare map pages the manifest's `iframeUrl` points at | **`*`** — framable on any partner site (that's the whole point) |
|
||||
| **Staging** (internal preview) | tree root `index.html`, per-bundle `<bundle>/index.html` | **`'self'`** — preview only |
|
||||
|
||||
The Research Gallery (`doxa-maps-spa/doxa-maps-spa/index.html`) is a direct-embed page like any
|
||||
Deep Research (`doxa-maps-spa/doxa-maps-spa/index.html`) is a direct-embed page like any
|
||||
other leaf. Its full embed contract (script and iframe) is in
|
||||
`plugin-designs/doxa-maps-spa/EMBED.md`.
|
||||
|
||||
|
|
|
|||
|
|
@ -7,14 +7,15 @@ being duplicated.
|
|||
|
||||
## The one rule that keeps everything clean
|
||||
|
||||
> **Parameterized + genuinely reused by more than one map → the shared library**
|
||||
> **Could serve more than one map → build it in the shared library**, parameterized
|
||||
> (`library/`, alias `@map`).
|
||||
> **Not reused → it lives in your app-profile folder** (`plugin-designs/<your-map>/`).
|
||||
> **One-off or experimental → it lives in your app-profile folder** (`plugin-designs/<your-map>/`).
|
||||
|
||||
"**Local by default, promote deliberately.**" When in doubt, keep your change local to your
|
||||
own map; something graduates into the shared library only once a *second* map genuinely
|
||||
needs it. Full reasoning — the promotion / orphan-audit rule — is in
|
||||
[`../documentation/explanation.md`](../explanation.md).
|
||||
**Reusable by default.** If a component could serve more than one map, build it once in the shared
|
||||
library, driven by props, and land it through the build owner, so other maps adopt it instead of
|
||||
copying it. A one-off or experimental piece stays in your map's folder. A new pattern starts as a pilot
|
||||
on one plugin, is approved, and is then rolled out by each owner. The rule in full:
|
||||
[`skills/plugin-designer`](../../skills/plugin-designer/SKILL.md), section 3.
|
||||
|
||||
## Start here
|
||||
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ rsync -a --delete public/dx-maps/ <your-host-checkout>/dx-maps/
|
|||
it can see and whether it finds itself. To check that what you published is *current*, run
|
||||
`bun run check:catalog-currency`.
|
||||
|
||||
**Your plugin surfaces itself.** The Research Gallery (`doxa-maps-spa`) reads the built `manifest.json`
|
||||
**Your plugin surfaces itself.** Deep Research (`doxa-maps-spa`) reads the built `manifest.json`
|
||||
and puts every bundle it doesn't already curate into its folded **Experimental** shelf, with the label and
|
||||
description from the manifest — no code edit. Promoting it to a curated tab is an optional edit to
|
||||
`TAB_DEFS` and `NAV_MODEL` in `plugin-designs/doxa-maps-spa/`. One row per bundle: a bundle's extra
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ and a change here can break other maps.
|
|||
`import … from '@map/components/ResearchMapFilterPanel.vue'`
|
||||
- **ResearchMapSideMenu** — ResearchMapSideMenu.vue — Side drawer specific to the research-map profile.
|
||||
`import … from '@map/components/ResearchMapSideMenu.vue'`
|
||||
- **ShellLanguageMenu** — ShellLanguageMenu — the language menu a header-off shell puts in VerticalTabsLayout's #phone-end
|
||||
`import … from '@map/components/ShellLanguageMenu.vue'`
|
||||
- **SideMenuDrawer** — SideMenuDrawer.vue — Slide-in side panel for the map application.
|
||||
`import … from '@map/components/SideMenuDrawer.vue'`
|
||||
- **ViewSelect** — ViewSelect.vue — the "SELECT VIEW" switcher: a fieldset + legend wrapping a row of
|
||||
|
|
@ -264,6 +266,8 @@ and a change here can break other maps.
|
|||
`import … from '@map/utils/html.js'`
|
||||
- **languageLabel** — languageLabel.js — turn the API's comma-inverted language label into the natural reading.
|
||||
`import … from '@map/utils/languageLabel.js'`
|
||||
- **languageRequest** — languageRequest.js — how a shell asks its HOST PAGE before it changes language.
|
||||
`import … from '@map/utils/languageRequest.js'`
|
||||
- **loadPinnedUmd** — loadPinnedUmd — load a PINNED, integrity-checked UMD library and capture it WITHOUT touching the host
|
||||
`import … from '@map/utils/loadPinnedUmd.js'`
|
||||
- **peopleGroupPoints** — peopleGroupPoints.js — SHARED (@map/utils/peopleGroupPoints.js).
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ A URL that returns a public token as JSON (`{token|tk|accessToken}`) or as a bar
|
|||
found; a token baked at build time wins.
|
||||
- **The studio:** the reader's URL (localStorage). The studio fetches it itself, and the fetched
|
||||
token then travels as `tk` / `window.MAP_TOKEN`.
|
||||
- **Plugins:** none reads `profile-config.tokenUrl` today. The studio and the Research Gallery
|
||||
- **Plugins:** none reads `profile-config.tokenUrl` today. The studio and Deep Research
|
||||
forward it, but no element consumes it (see the open points below).
|
||||
|
||||
## 5. People-group list: `'doxa'` | `'unreached'` | `'frontier'` | `'frontier-500k'` | `'unengaged'`
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ Smallest first. Which bundle is the current example of each: **[reference-capabi
|
|||
| 2 | **preset-element** (prop-table) | `parameterized` | one profile + a table of named presets; each row is a ready-made map with its own embed page | the bundle ships `<name>.instances.json` — `{ "instances": [{ "id", "title", "props" }] }`; one page per row at `<bundle>/<profile>/<id>/index.html` |
|
||||
| 3 | **multi-view-element** (monolithic) | `monolithic` | one profile holding several screens (tabs or steps), one shown at a time | declared: `<profile>.meta.json` → `{ "type": "monolithic" }` |
|
||||
| 4 | **composite-element** (nested elements) | `nested-elements-mfe` | a profile that places other plugins together and ships the set as one new element | declared: `<profile>.meta.json` → `{ "type": "nested-elements-mfe", "composes": [ … ] }` |
|
||||
| 5 | **app-shell** (nested SPAs) | `nested-mfe-spa` | the outer frame — rail, header, navigation — that mounts any of the others; e.g. the Research Gallery (`doxa-maps-spa`) and the Plugin Studio | declared the same way; a compiled Vue custom element like every other rung |
|
||||
| 5 | **app-shell** (nested SPAs) | `nested-mfe-spa` | the outer frame — rail, header, navigation — that mounts any of the others; e.g. Deep Research (`doxa-maps-spa`) and the Plugin Studio | declared the same way; a compiled Vue custom element like every other rung |
|
||||
|
||||
**Tiers 3–5 are always declared, never guessed** (either spelling; see `TYPE_ALIASES` in
|
||||
`internal/plugin-gallery/discover.mjs`). A folder's apparent simplicity does not decide its tier: an
|
||||
|
|
|
|||
|
|
@ -112,6 +112,45 @@ ok(!noCountry.length && resolveTerritory({ shapeName: 'Mahārāshtra' }).country
|
|||
ok(!disagree.length, `Copy's regroup warning disagrees with apply-grouping: ${disagree.join('; ')}`,
|
||||
`the regroup warning matches apply-grouping's own verdict in all ${Object.keys(REGROUP_CASES).length} cases (India, China, countries, and the three it refuses)`)
|
||||
|
||||
// ── A4. A STALLED people-group load can never hang Copy ─────────────────────────────────────────────────
|
||||
// Copy races the people-group detection against COPY_WAIT_MS through settleWithin(); past it, it copies the territory
|
||||
// lines alone (they need no network) and says why. The self-test: a promise that NEVER settles must still resolve to
|
||||
// the marker in time; a value passes through; a failure still rejects (its own message is kept).
|
||||
{
|
||||
const MARK = {}, t0 = Date.now()
|
||||
// The gate's own backstop: if settleWithin waited for ever, this reads 'GATE-TIMEOUT' and FAILS by name.
|
||||
const stalled = await Promise.race([X.settleWithin(new Promise(() => {}), 60, MARK), new Promise((r) => setTimeout(() => r('GATE-TIMEOUT'), 2000))])
|
||||
const quick = await X.settleWithin(Promise.resolve('v'), 1000, MARK)
|
||||
const failed = await X.settleWithin(Promise.reject(new Error('down')), 1000, MARK).then(() => 'resolved', (e) => e.message)
|
||||
ok(stalled === MARK && Date.now() - t0 < 1000 && quick === 'v' && failed === 'down',
|
||||
`settleWithin: a stalled source gave ${stalled === MARK ? 'the marker' : JSON.stringify(stalled)}, a value gave ${quick}, a failure gave ${failed}`,
|
||||
'a NEVER-settling people-group load still lets Copy settle in time (and a value / a failure pass through)')
|
||||
}
|
||||
|
||||
// ── A5. A FAILED load never shows the reader a raw browser message ─────────────────────────────────────
|
||||
// The second Copy after the 30 s abort flashed "signal is aborted without reason". The self-test aborts a REAL fetch
|
||||
// (the signal is aborted before the request starts, so nothing is sent) and makes a real one fail (a refused
|
||||
// localhost port, bounded at 2 s); both must read as the keyed stall text. A keyed error keeps its own text. The NEGATIVE CONTROL is
|
||||
// the old path, errorText without asReaderError, which must still show the raw message, so this test can see the defect.
|
||||
{
|
||||
const { keyedError, errorText } = await import(pathToFileURL(P('library/i18n/keyedError.js')).href)
|
||||
const en = JSON.parse(readFileSync(P('library/i18n/locales/en/upgMap.json'), 'utf8'))
|
||||
const tr = (k, p = {}) => { const v = k.startsWith('upgMap.') ? en[k.slice(7)] : undefined; return v == null ? k : v.replace(/\{(\w+)\}/g, (m, x) => (p[x] != null ? String(p[x]) : m)) }
|
||||
const stillLoading = () => keyedError('upgMap.peopleStillLoading', 'the people groups are still loading, try again in a moment')
|
||||
const aborted = await fetch('http://127.0.0.1:9/', { signal: AbortSignal.abort() }).then(() => null, (e) => e)
|
||||
// Bounded: a host running the discard service ACCEPTS port 9 and would hang; a timeout is a failure too.
|
||||
const refused = await fetch('http://127.0.0.1:9/', { signal: AbortSignal.timeout(2000) }).then(() => null, (e) => e)
|
||||
const keyed = keyedError('upgMap.selectionAdm1NoData', 'no people-group data for {names}', { names: 'Yunnan' })
|
||||
const shown = (e) => errorText(X.asReaderError(e, stillLoading), tr)
|
||||
const want = en.peopleStillLoading
|
||||
ok(aborted && aborted.name === 'AbortError' && /abort/i.test(errorText(aborted, tr)),
|
||||
`NEGATIVE CONTROL: an aborted fetch gave ${aborted && aborted.name} "${aborted && errorText(aborted, tr)}" on the old path, so the test cannot see the defect`,
|
||||
'negative control: on the old path an aborted fetch shows the raw "' + (aborted ? errorText(aborted, tr) : '') + '"')
|
||||
ok(shown(aborted) === want && refused && shown(refused) === want && shown(keyed) === tr('upgMap.selectionAdm1NoData', { names: 'Yunnan' }),
|
||||
`a failed load reads: aborted "${shown(aborted)}", refused "${refused && shown(refused)}", keyed "${shown(keyed)}"`,
|
||||
'an aborted or refused people-group fetch reads as the stall text; a keyed error keeps its own')
|
||||
}
|
||||
|
||||
// ── A6. A STATE-ONLY selection titles its Prayer list / Card deck ───────────────────────────────────────
|
||||
// Development threw "Reduce of empty array" for a state-only selection: the title named zones and countries only, so
|
||||
// a selection of Gujarāt alone had NO names. On the REAL served table: Gujarāt alone is titled "Gujarāt"; a state +
|
||||
|
|
@ -180,8 +219,13 @@ const wired = {
|
|||
'the Copy button': /@click="copySelection">\{\{ t\('upgMap\.copySelection'\) \}\}/.test(vue),
|
||||
'the Export CSV button': /@click="exportSelectionCsv">/.test(vue) && vue.includes("t('upgMap.exportCsv')"),
|
||||
'the prefetch on batch mode': /if \(next\) selectionRecordsLoad\(\)/.test(vue),
|
||||
'one detection for every action': (vue.match(/await selectionPeople\(\)/g) || []).length >= 2 && /selectionPeople\(\)\.then/.test(vue),
|
||||
'one detection for every action': (vue.match(/await selectionPeople\(\)/g) || []).length >= 2 && /settleWithin\(selectionPeople\(\)/.test(vue),
|
||||
// India's states resolve through the served table (library/data/indiaStates.js), fetched only once a state is selected
|
||||
'Copy waits at most COPY_WAIT_MS for the people groups': /settleWithin\(selectionPeople\(\), COPY_WAIT_MS, STALLED\)/.test(vue) && /if \(p === STALLED\) \{ note = t\('upgMap\.peopleStillLoading'\); return selectionClipboard\(sel, \{ color \}\); \}/.test(vue),
|
||||
'the records fetch times out and leaves the cache': /setTimeout\(\(\) => ctrl\.abort\(\), RECORDS_TIMEOUT_MS\)/.test(vue) && /signal: ctrl \? ctrl\.signal : undefined/.test(vue),
|
||||
'every failed load reads as the stall, never a raw message': /const readerError = \(e\) => asReaderError\(e, stillLoading\)/.test(vue)
|
||||
&& /\}\)\(\)\.catch\(\(e\) => \{ throw readerError\(e\); \}\);/.test(vue)
|
||||
&& /note = errorText\(readerError\(e\), t\)/.test(vue) && /csvFailed', \{ reason: errorText\(readerError\(e\), t\)/.test(vue),
|
||||
// In batch mode a tap on a pin opens NO detail sheet (it covered the whole HUD on a phone); the tapestry
|
||||
// listener under it toggles the territory. The real-tap proof is a browser check; this pins the guard in the source.
|
||||
'a pin tap in batch mode opens no sheet': /map\.on\('click', 'upg-pins-hit', \(e\) => \{[\s\S]{0,700}?if \(batchMode\.value\) return\s*\n\s*const f = e && e\.features/.test(vue),
|
||||
|
|
@ -205,7 +249,7 @@ ok(!missingWires.length, `the HUD is missing: ${missingWires.join(', ')}`, 'the
|
|||
// ── D. the strings, ×11 ──────────────────────────────────────────────────────────────────────────
|
||||
const LANGS = ['en', 'es', 'fr', 'pt', 'de', 'it', 'ro', 'ru', 'ar', 'hi', 'zh']
|
||||
const KEYS = {
|
||||
upgMap: ['copySelection', 'exportCsv', 'copyNotRegroupable', 'selectionColourGroup', 'selectionColour', 'customColour', 'copiedSelection', 'copiedTerritoriesOnly', 'csvSaved', 'csvFailed'] }
|
||||
upgMap: ['copySelection', 'exportCsv', 'copyNotRegroupable', 'peopleStillLoading', 'selectionColourGroup', 'selectionColour', 'customColour', 'copiedSelection', 'copiedTerritoriesOnly', 'csvSaved', 'csvFailed'] }
|
||||
const cat = (l, f) => JSON.parse(readFileSync(P('library/i18n/locales', l, `${f}.json`), 'utf8'))
|
||||
const bad = []
|
||||
for (const [f, keys] of Object.entries(KEYS)) {
|
||||
|
|
|
|||
|
|
@ -113,10 +113,11 @@ const PROBE = () => {
|
|||
if (!rail) return { noRail: true }
|
||||
|
||||
// Any control that can bring the rail on screen. Both are legitimate: the header toggle when a
|
||||
// header exists, the floating opener when it does not.
|
||||
// header exists, the phone bar's ☰ (.vtl-phone-menu, which replaced the floating corner circle) when
|
||||
// it does not.
|
||||
const openers = all
|
||||
.filter(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-fab/.test(cls(n)))
|
||||
.map(n => ({ kind: /vtl-fab/.test(cls(n)) ? 'fab' : 'header-menu', on: onScreen(n), name: name(n) }))
|
||||
.filter(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-phone-menu/.test(cls(n)))
|
||||
.map(n => ({ kind: /vtl-phone-menu/.test(cls(n)) ? 'phone-bar' : 'header-menu', on: onScreen(n), name: name(n) }))
|
||||
|
||||
return {
|
||||
railOnScreen: onScreen(rail),
|
||||
|
|
@ -173,7 +174,7 @@ for (const t of targets) {
|
|||
for (const c of (n.children || [])) w(c)
|
||||
})(document.body)
|
||||
const cls = (n) => (n.className || '').toString()
|
||||
const btn = all.find(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-fab/.test(cls(n)) &&
|
||||
const btn = all.find(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-phone-menu/.test(cls(n)) &&
|
||||
n.getBoundingClientRect().width > 0)
|
||||
if (!btn) return { clicked: false }
|
||||
btn.click()
|
||||
|
|
|
|||
173
internal/check-shell-header.mjs
Normal file
173
internal/check-shell-header.mjs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-shell-header — a header-off shell has ONE way to its tabs and ONE language menu, in the right
|
||||
* place for the width, and a language pick ASKS the host page first. Headless (Vite SSR, no browser).
|
||||
*
|
||||
* THE DESIGN (_qa-cycle/studio-organize/MOBILE-SHELL-HEADER-DESIGN.md): on a phone, a header-off shell
|
||||
* (the Research Gallery, Posterverse) gets a 44px bar with ☰ at the start and a #phone-end slot at the
|
||||
* end; on a desktop, a #rail-end slot at the bottom of the rail. The shell fills both with
|
||||
* library/components/ShellLanguageMenu.vue, whose pick dispatches ONE cancelable
|
||||
* `doxa-language-request` on the host element: cancelled → the host changes the language, the shell
|
||||
* does not; not cancelled → the shell switches, and writes ?lang= only on a page the tree generated.
|
||||
*
|
||||
* ASSERTS, on the REAL components:
|
||||
* 1. VerticalTabsLayout renders the phone bar ONLY when narrow AND header-off, #rail-end ONLY when
|
||||
* wide AND header-off (all four combinations), and never the old floating corner circle.
|
||||
* 2. one pick dispatches exactly ONE doxa-language-request, detail.language = the code, and
|
||||
* cancelable, composed and bubbles all true.
|
||||
* 3. a host that cancels → the menu emits no 'switch' and shows the current language again.
|
||||
* 4. nobody cancels → the menu emits 'switch' once, and ?lang= is written only when
|
||||
* window.MAP_OWNS_PAGE === true.
|
||||
* 5. parity: every SWITCHER_LANGUAGES code has a library/i18n/locales/<code>/ catalogue, and the
|
||||
* menu always lists the current language (fixture: de, which the switcher hides).
|
||||
* --self-test plants each defect in a temporary copy and must see every one refused.
|
||||
* EXIT 0 pass · 1 fail
|
||||
*/
|
||||
import { readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs'
|
||||
import { join, resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const VTL = 'library/layouts/VerticalTabsLayout.vue'
|
||||
const MENU = 'library/components/ShellLanguageMenu.vue'
|
||||
const REQ = 'library/utils/languageRequest.js'
|
||||
const LANGS = 'library/layouts/app-wrapper-material/languages.js'
|
||||
|
||||
const { createServer } = await import('vite')
|
||||
const { default: vue } = await import('@vitejs/plugin-vue')
|
||||
const server = await createServer({ root: ROOT, configFile: false, logLevel: 'error', appType: 'custom',
|
||||
server: { middlewareMode: true, hmr: false, ws: false, watch: null }, plugins: [vue()],
|
||||
resolve: { alias: { '@map': join(ROOT, 'library') } } })
|
||||
const { createSSRApp, h, reactive } = await import('vue')
|
||||
const { renderToString } = await import('vue/server-renderer')
|
||||
|
||||
// A stand-in window for the ?lang= arm: records replaceState calls, owns the page or not.
|
||||
function fakeWindow(owns) {
|
||||
const calls = []
|
||||
return { calls, MAP_OWNS_PAGE: owns, location: { href: 'https://example.test/leaf/index.html?tab=x' },
|
||||
history: { state: null, replaceState: (_s, _t, url) => calls.push(url) } }
|
||||
}
|
||||
// A stand-in DOM for the pick: the select sits in a shadow root whose host is an EventTarget.
|
||||
function fakeSelect(value, host) { return { value, getRootNode: () => ({ host }) } }
|
||||
|
||||
async function audit({ vtl = VTL, menu = MENU, langs = LANGS } = {}) {
|
||||
const bad = []
|
||||
const fail = (what, detail) => bad.push(detail ? `${what} — ${detail}` : what)
|
||||
|
||||
// 1. where the bar and the rail-end render. The layout's chrome strings come through tStatic, whose
|
||||
// locale detection reads <html lang>; SSR has no document, so the render gets a one-field stand-in.
|
||||
if (typeof globalThis.document === 'undefined') globalThis.document = { documentElement: { lang: 'en' } }
|
||||
const Layout = (await server.ssrLoadModule('/' + vtl)).default
|
||||
for (const narrow of [true, false]) for (const header of [true, false]) {
|
||||
// A copy of the real layout whose data says `narrow` (in a browser mounted() reads it from matchMedia,
|
||||
// which SSR never runs). A copy, not `extends`: the render function is not inherited through extends.
|
||||
const Comp = { ...Layout, data() { return { ...Layout.data.call(this, this), narrow } } }
|
||||
const app = createSSRApp({ render: () => h(Comp, { header, items: [{ key: 'a', label: 'A' }], active: 'a' }, {
|
||||
'phone-end': () => h('i', { class: 'probe-phone-end' }),
|
||||
'rail-end': () => h('i', { class: 'probe-rail-end' }) }) })
|
||||
const html = (await renderToString(app)).replace(/<!--[\s\S]*?-->/g, '') // template comments are not markup
|
||||
const tag = `narrow=${narrow} header=${header}`
|
||||
const wantBar = narrow && !header, wantRail = !narrow && !header
|
||||
if (/class="vtl-pbar"/.test(html) !== wantBar) fail(`the phone bar ${wantBar ? 'is missing' : 'renders'} at ${tag}`)
|
||||
if (/probe-phone-end/.test(html) !== wantBar) fail(`#phone-end ${wantBar ? 'is not filled' : 'renders'} at ${tag}`)
|
||||
if (/class="vtl-rail-end"/.test(html) !== wantRail || /probe-rail-end/.test(html) !== wantRail) fail(`#rail-end ${wantRail ? 'is missing' : 'renders'} at ${tag}`)
|
||||
if (wantBar && !/class="vtl-phone-menu"[^>]*aria-expanded=/.test(html)) fail(`the bar's ☰ has no aria-expanded at ${tag}`)
|
||||
if (/vtl-fab|--map-fab-/.test(html)) fail(`the old corner circle (or its --map-fab-* footprint) renders at ${tag}`)
|
||||
if (wantBar && !/has-phone-bar/.test(html)) fail(`the phone bar is not a grid row (.has-phone-bar missing) at ${tag}`)
|
||||
}
|
||||
|
||||
// 2-4. the pick, through the menu's REAL handler (its <script setup> bindings; no DOM needed)
|
||||
const Menu = (await server.ssrLoadModule('/' + menu)).default
|
||||
// The menu's setup, run INSIDE an SSR render so its compiled wrapper finds the SSR context; in dev mode a
|
||||
// <script setup> returns its bindings, so the gate calls the component's real onPick and options.
|
||||
const mount = async (current) => {
|
||||
const emitted = []; let b = null
|
||||
const Probe = { setup() { b = Menu.setup(reactive({ current, collapsed: false }), { emit: (e, ...a) => emitted.push([e, ...a]), expose: () => {}, attrs: {}, slots: {} }); return () => null } }
|
||||
await renderToString(createSSRApp(Probe))
|
||||
if (!b || typeof b.onPick !== 'function') throw new Error('ShellLanguageMenu setup did not return its bindings (onPick)')
|
||||
return { b, emitted }
|
||||
}
|
||||
const run = async ({ cancel, owns }) => {
|
||||
const host = new EventTarget(); const seen = []
|
||||
host.addEventListener('doxa-language-request', (e) => { seen.push(e); if (cancel) e.preventDefault() })
|
||||
host.addEventListener('language-change', (e) => seen.push(e))
|
||||
const win = fakeWindow(owns); const had = globalThis.window; globalThis.window = win
|
||||
const { b, emitted } = await mount('en'); const sel = fakeSelect('ar', host)
|
||||
try { b.onPick({ target: sel }) } finally { if (had === undefined) delete globalThis.window; else globalThis.window = had }
|
||||
return { seen, emitted, sel, win }
|
||||
}
|
||||
const up = await run({ cancel: false, owns: true })
|
||||
const reqs = up.seen.filter((e) => e.type === 'doxa-language-request')
|
||||
if (up.seen.length !== 1 || reqs.length !== 1) fail('one pick must dispatch exactly ONE doxa-language-request', `saw ${up.seen.map((e) => e.type).join(', ') || 'none'}`)
|
||||
const e = reqs[0]
|
||||
if (e && !(e.detail && e.detail.language === 'ar')) fail('the event must carry detail.language', JSON.stringify(e.detail))
|
||||
if (e && !(e.cancelable && e.composed && e.bubbles)) fail('the event must be cancelable, composed and bubbling', `cancelable=${e.cancelable} composed=${e.composed} bubbles=${e.bubbles}`)
|
||||
if (up.emitted.filter(([n]) => n === 'switch').length !== 1 || (up.emitted[0] || [])[1] !== 'ar') fail('an uncancelled pick must emit switch("ar") once', JSON.stringify(up.emitted))
|
||||
if (!(up.win.calls.length === 1 && /[?&]lang=ar\b/.test(up.win.calls[0]) && /[?&]tab=x\b/.test(up.win.calls[0]))) fail('an uncancelled pick on a tree-owned page must write ?lang= (keeping the query)', JSON.stringify(up.win.calls))
|
||||
const notOwned = await run({ cancel: false, owns: undefined })
|
||||
if (notOwned.win.calls.length) fail('?lang= was written on a page the tree does not own (MAP_OWNS_PAGE unset)', JSON.stringify(notOwned.win.calls))
|
||||
const cancelled = await run({ cancel: true, owns: true })
|
||||
if (cancelled.emitted.some(([n]) => n === 'switch')) fail('a cancelled request must NOT switch the shell', JSON.stringify(cancelled.emitted))
|
||||
if (cancelled.win.calls.length) fail('a cancelled request must not write ?lang=', JSON.stringify(cancelled.win.calls))
|
||||
if (cancelled.sel.value !== 'en') fail('after a cancel the menu must show the current language again', cancelled.sel.value)
|
||||
|
||||
// 5. parity + the current language
|
||||
const L = await server.ssrLoadModule('/' + langs)
|
||||
const missing = L.SWITCHER_LANGUAGES.map((l) => l.code).filter((c) => !existsSync(join(ROOT, 'library/i18n/locales', c)))
|
||||
if (missing.length) fail('a switcher language has no catalogue', missing.join(', '))
|
||||
const de = (await mount('de')).b.options
|
||||
const codes = (de && de.value ? de.value : []).map((l) => l.code)
|
||||
if (!codes.includes('de')) fail('the menu must list the current language even when the switcher hides it (de)', codes.join(' '))
|
||||
const en = (await mount('en')).b.options
|
||||
if ((en && en.value ? en.value : []).map((l) => l.code).join() !== L.SWITCHER_LANGUAGES.map((l) => l.code).join()) fail('with a listed language current, the menu must be exactly the switcher list')
|
||||
return bad
|
||||
}
|
||||
|
||||
// ── --self-test: each defect planted in a temporary copy must be refused ─────────────────────────────
|
||||
function plant(rel, suffix, edit, redirect = []) {
|
||||
const src = readFileSync(join(ROOT, rel), 'utf8')
|
||||
let out = edit(src)
|
||||
if (out === src) throw new Error(`poison "${suffix}" did not change ${rel}`)
|
||||
for (const [from, to] of redirect) out = out.split(from).join(to)
|
||||
const dot = rel.lastIndexOf('.'); const tmp = rel.slice(0, rel.lastIndexOf('/') + 1) + `.tmp-shellhdr-${suffix}` + rel.slice(dot)
|
||||
writeFileSync(join(ROOT, tmp), out); PLANTED.push(tmp); return tmp
|
||||
}
|
||||
const PLANTED = [] // every temporary copy, removed in finally whatever happens
|
||||
const POISONS = [
|
||||
{ name: 'the bar also shows with a header', files: () => ({ vtl: plant(VTL, 'bar-header', (s) => s.replace('phoneBarShown() { return this.narrow && !this.header }', 'phoneBarShown() { return this.narrow }')) }) },
|
||||
{ name: 'the rail-end also shows on a phone', files: () => ({ vtl: plant(VTL, 'rail-phone', (s) => s.replace('railEndShown() { return !this.narrow && !this.header }', 'railEndShown() { return !this.header }')) }) },
|
||||
{ name: 'the corner circle is back', files: () => ({ vtl: plant(VTL, 'fab', (s) => s.replace('<div v-if="phoneBarShown" class="vtl-pbar">', '<button v-if="phoneBarShown" class="vtl-fab">☰</button><div v-if="phoneBarShown" class="vtl-pbar">')) }) },
|
||||
{ name: 'the event is not cancelable', files: () => { const r = plant(REQ, 'nocancel', (s) => s.replace('bubbles: true, composed: true, cancelable: true', 'bubbles: true, composed: true')); return { menu: plant(MENU, 'm-nocancel', (s) => s + ' ', [['../utils/languageRequest.js', '../utils/' + r.split('/').pop()]]) } } },
|
||||
{ name: 'the event does not leave the shadow root', files: () => { const r = plant(REQ, 'nocomposed', (s) => s.replace('bubbles: true, composed: true, cancelable: true', 'bubbles: true, cancelable: true')); return { menu: plant(MENU, 'm-nocomposed', (s) => s + ' ', [['../utils/languageRequest.js', '../utils/' + r.split('/').pop()]]) } } },
|
||||
{ name: 'the detail is Vue-shaped (an array)', files: () => { const r = plant(REQ, 'arr', (s) => s.replace('detail: { language: code }', 'detail: [{ language: code }]')); return { menu: plant(MENU, 'm-arr', (s) => s + ' ', [['../utils/languageRequest.js', '../utils/' + r.split('/').pop()]]) } } },
|
||||
{ name: 'a second event is fired', files: () => ({ menu: plant(MENU, 'twice', (s) => s.replace(" if (requestLanguage(sel, code)) {", " sel.getRootNode().host.dispatchEvent(new CustomEvent('language-change', { detail: { language: code } }))\n if (requestLanguage(sel, code)) {")) }) },
|
||||
{ name: 'a cancelled pick still switches', files: () => ({ menu: plant(MENU, 'ignorecancel', (s) => s.replace(' if (requestLanguage(sel, code)) {', ' if (requestLanguage(sel, code) || true) {')) }) },
|
||||
{ name: '?lang= is written on any page', files: () => { const r = plant(REQ, 'anypage', (s) => s.replace('win.MAP_OWNS_PAGE !== true || ', '')); return { menu: plant(MENU, 'm-anypage', (s) => s + ' ', [['../utils/languageRequest.js', '../utils/' + r.split('/').pop()]]) } } },
|
||||
{ name: 'the hidden current language is dropped', files: () => ({ menu: plant(MENU, 'nocurrent', (s) => s.replace(' if (cur) list.push(cur)', ' if (cur && !cur.hideFromSwitcher) list.push(cur)')) }) },
|
||||
{ name: 'a switcher language has no catalogue', files: () => ({ langs: plant(LANGS, 'xx', (s) => s.replace("{ code: 'ru', name: 'Russian',", "{ code: 'xx', name: 'Nowhere', nativeName: 'Nowhere', flag: '' },\n { code: 'ru', name: 'Russian',")) }) },
|
||||
]
|
||||
|
||||
let exit = 0
|
||||
try {
|
||||
if (process.argv.includes('--self-test')) {
|
||||
let caught = 0
|
||||
for (const p of POISONS) {
|
||||
try {
|
||||
const bad = await audit(p.files())
|
||||
if (bad.length) { caught++; console.log(` ${p.name}: caught (${bad[0].slice(0, 90)})`) }
|
||||
else { console.log(` ${p.name}: NOT CAUGHT`); exit = 1 }
|
||||
} finally {
|
||||
while (PLANTED.length) rmSync(join(ROOT, PLANTED.pop()), { force: true })
|
||||
}
|
||||
}
|
||||
console.log(exit ? `SHELL-HEADER SELF-TEST: FAIL — ${POISONS.length - caught} of ${POISONS.length} defects passed.` : `SHELL-HEADER SELF-TEST: PASS — all ${POISONS.length} planted defects refused.`)
|
||||
} else {
|
||||
const bad = await audit()
|
||||
if (bad.length) { exit = 1; console.log(`SHELL-HEADER: FAIL — ${bad.length} problem(s)`); for (const b of bad) console.log(' ✗ ' + b) }
|
||||
else console.log('SHELL-HEADER: PASS — the phone bar only on a narrow header-off layout, #rail-end only on a wide one, no corner circle; one cancelable composed doxa-language-request per pick; cancelled → no switch; ?lang= only on tree-owned pages; the switcher has catalogues and the current language is always listed.')
|
||||
}
|
||||
} finally {
|
||||
while (PLANTED.length) rmSync(join(ROOT, PLANTED.pop()), { force: true })
|
||||
await server.close()
|
||||
}
|
||||
process.exit(exit)
|
||||
93
internal/check-upg-count.mjs
Normal file
93
internal/check-upg-count.mjs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
#!/usr/bin/env node
|
||||
// check-upg-count — UPG-100 never writes its list's TOTAL into a sentence. The wall's intro printed "2,106" in all
|
||||
// eleven languages after the list had grown to 2,107, because the number lived inside the translated text. Now every
|
||||
// sentence takes it as {n}, from the funnel's last step (plugin-designs/my-upg-100-list/data/upg-total.js), formatted
|
||||
// for the reader's locale. Headless. Asserts:
|
||||
// A. the total is known (vision-funnel.json's last step) and upgTotal() reads it;
|
||||
// B. no upgMap value in any of the 11 locales contains the total (or the stale 2,106) in ANY digit system: Latin
|
||||
// with any separator, Arabic-Indic, Devanagari. A built-in NEGATIVE CONTROL plants "2,106" and must be caught;
|
||||
// C. upgMap.wallIntroA carries {n} in all 11 locales;
|
||||
// D. the consumers pass the live total: poster-wall.vue reads upgMap.wallIntroA with n (not the frozen
|
||||
// messages.upg100WallIntroA), and upg-100-list.vue passes no number literal to fetchingUupgs;
|
||||
// E. ONE SENTENCE, ONE DIGIT SYSTEM: the wall intro, rendered through each locale's real catalogue, writes its
|
||||
// numbers in one digit system (ar: Arabic-Indic, so ٢٬١٠٧ beside ٢٧ and ٩:١٤). A NEGATIVE CONTROL renders ar the
|
||||
// old way (plain Intl 'ar', which CLDR formats Western) and must be flagged as mixed.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const P = (...a) => join(REPO, ...a)
|
||||
const fails = [], notes = []
|
||||
const ok = (cond, msg, note) => { if (!cond) fails.push(msg); else if (note) notes.push(` ok ${note}`) }
|
||||
const LANGS = ['en', 'es', 'fr', 'pt', 'de', 'it', 'ro', 'ru', 'ar', 'hi', 'zh']
|
||||
|
||||
let upgTotal, formatCount, countInSentence, funnel
|
||||
try {
|
||||
;({ upgTotal, formatCount, countInSentence } = await import(pathToFileURL(P('plugin-designs/my-upg-100-list/data/upg-total.js')).href))
|
||||
funnel = JSON.parse(readFileSync(P('data/people-groups/vision-funnel.json'), 'utf8'))
|
||||
} catch (e) {
|
||||
console.error(`UPG-COUNT: NOT MEASURED (exit 75) — an input did not load: ${e.message}`)
|
||||
process.exit(75)
|
||||
}
|
||||
|
||||
// ── A ──
|
||||
const total = upgTotal(funnel)
|
||||
ok(total > 1000, `the list's total reads as ${total} from vision-funnel.json`, `the list's total is ${total}, from the funnel`)
|
||||
|
||||
// ── B: a digit-system-blind scan ──
|
||||
const NATIVE = { '٠': 0, '١': 1, '٢': 2, '٣': 3, '٤': 4, '٥': 5, '٦': 6, '٧': 7, '٨': 8, '٩': 9, '०': 0, '१': 1, '२': 2, '३': 3, '४': 4, '५': 5, '६': 6, '७': 7, '८': 8, '९': 9 }
|
||||
const numbersIn = (s) => String(s).replace(/[٠-٩०-९]/g, (d) => NATIVE[d])
|
||||
.replace(/(\d)[,.'\u0020\u00A0\u202F\u2009\u066C](?=\d{3}\b)/g, '$1') // drop thousands separators (fr U+202F, ru U+00A0, ar U+066C)
|
||||
.match(/\d+/g) || []
|
||||
const FORBIDDEN = new Set([String(total), '2106'])
|
||||
const hasTotal = (s) => numbersIn(s).some((n) => FORBIDDEN.has(n))
|
||||
ok(hasTotal('We did the same with the 2,106 unreached') && hasTotal('فعلنا المثل مع ٢١٠٦ شعوب') && hasTotal(`mit den ${String(total).replace(/(\d)(\d{3})$/, '$1.$2')} Gruppen`)
|
||||
&& hasTotal(`les ${new Intl.NumberFormat('fr').format(total)} groupes`) && hasTotal(`из ${new Intl.NumberFormat('ru').format(total)} групп`),
|
||||
'NEGATIVE CONTROL: the scanner missed a planted total, so it cannot guard anything', 'negative control: a planted 2,106 / ٢١٠٦ / 2.107 / fr 2 107 / ru 2 107 is caught')
|
||||
const leaks = []
|
||||
for (const l of LANGS) {
|
||||
const cat = JSON.parse(readFileSync(P('library/i18n/locales', l, 'upgMap.json'), 'utf8'))
|
||||
for (const [k, v] of Object.entries(cat)) if (typeof v === 'string' && hasTotal(v)) leaks.push(`${l} upgMap.${k}`)
|
||||
}
|
||||
ok(!leaks.length, `the total is written into a sentence: ${leaks.slice(0, 6).join(', ')}`, `no upgMap sentence in the 11 locales carries the total (${total}) or the stale 2,106`)
|
||||
|
||||
// ── C ──
|
||||
const noN = LANGS.filter((l) => !String(JSON.parse(readFileSync(P('library/i18n/locales', l, 'upgMap.json'), 'utf8')).wallIntroA || '').includes('{n}'))
|
||||
ok(!noN.length, `upgMap.wallIntroA has no {n} in: ${noN.join(', ')}`, 'the wall intro takes the total as {n} in all 11 locales')
|
||||
|
||||
// ── D ──
|
||||
const wall = readFileSync(P('plugin-designs/my-upg-100-list/poster-wall.vue'), 'utf8')
|
||||
const map = readFileSync(P('plugin-designs/my-upg-100-list/upg-100-list.vue'), 'utf8')
|
||||
ok(/countInSentence\(t, 'upgMap\.wallIntroA', upgTotal\(/.test(wall) && !/t\('messages\.upg100WallIntroA'\)/.test(wall),
|
||||
'poster-wall.vue does not render the intro from upgMap.wallIntroA with the live total', 'the wall intro renders the live total')
|
||||
ok(!/fetchingUupgs', \{ n: \d/.test(map) && /countInSentence\(t, 'messages\.fetchingUupgs', upgTotal\(/.test(map),
|
||||
'upg-100-list.vue passes a number literal to fetchingUupgs', 'the fetching status shows the live total')
|
||||
|
||||
// ── E: one sentence, one digit system ──
|
||||
const SYSTEMS = [['latn', /[0-9]/], ['arab', /[\u0660-\u0669]/], ['arabext', /[\u06F0-\u06F9]/], ['deva', /[\u0966-\u096F]/], ['beng', /[\u09E6-\u09EF]/]]
|
||||
const systemsIn = (s) => SYSTEMS.filter(([, re]) => re.test(String(s))).map(([nu]) => nu)
|
||||
const trFor = (l) => {
|
||||
const c = JSON.parse(readFileSync(P('library/i18n/locales', l, 'upgMap.json'), 'utf8'))
|
||||
return (k, p = {}) => String(c[k.replace(/^upgMap\./, '')] ?? k).replace(/\{(\w+)\}/g, (m, x) => (p[x] != null ? String(p[x]) : m))
|
||||
}
|
||||
const arOld = trFor('ar')('upgMap.wallIntroA', { n: formatCount(total, 'ar') })
|
||||
ok(systemsIn(arOld).length > 1, `NEGATIVE CONTROL: the old ar render reads as ONE digit system (${systemsIn(arOld).join('+')}), so this check cannot see the defect`,
|
||||
`negative control: the old ar render (plain Intl 'ar' = "${formatCount(total, 'ar')}") is flagged as mixed (${systemsIn(arOld).join('+')})`)
|
||||
const mixed = []
|
||||
for (const l of LANGS) {
|
||||
const s = countInSentence(trFor(l), 'upgMap.wallIntroA', total, l)
|
||||
if (systemsIn(s).length > 1) mixed.push(`${l} ${systemsIn(s).join('+')}`)
|
||||
if (!numbersIn(s).includes(String(total))) mixed.push(`${l} does not show ${total}`)
|
||||
}
|
||||
const arNew = countInSentence(trFor('ar'), 'upgMap.wallIntroA', total, 'ar')
|
||||
ok(!mixed.length, `the wall intro: ${mixed.join('; ')}`,
|
||||
`the wall intro shows ${total} in ONE digit system in all 11 locales (ar: "${(arNew.match(/[\u0660-\u0669٬]{3,}/) || ['?'])[0]}")`)
|
||||
|
||||
for (const n of notes) console.log(n)
|
||||
if (fails.length) {
|
||||
console.error('UPG-COUNT: FAIL')
|
||||
for (const f of fails) console.error(` ✗ ${f}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`UPG-COUNT: PASS — the list's total (${total}) lives in one place and every sentence takes it as {n}.`)
|
||||
91
library/components/ShellLanguageMenu.vue
Normal file
91
library/components/ShellLanguageMenu.vue
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<!--
|
||||
ShellLanguageMenu — the language menu a header-off shell puts in VerticalTabsLayout's #phone-end
|
||||
(the phone bar) and #rail-end (the bottom of the desktop rail) slots. ONE component for both shells
|
||||
and both places, so the list, the host contract and the look cannot drift apart.
|
||||
|
||||
THE LIST: SWITCHER_LANGUAGES (app-wrapper-material/languages.js) PLUS the current language when it is
|
||||
one of the hidden ones, so a reader on /de/… always sees Deutsch selected. hideFromSwitcher is the
|
||||
product's choice and is not changed here.
|
||||
|
||||
THE CONTRACT (library/utils/languageRequest.js): a pick first asks the host page with a cancelable
|
||||
doxa-language-request. Cancelled → nothing happens here; the host sets the shell's `language`, and
|
||||
`current` follows. Not cancelled → emits 'switch' (the shell runs its own setLocale + saveLocale)
|
||||
and writes ?lang= only on a page this tree generated.
|
||||
|
||||
A NATIVE <select>, laid transparent over its own face: the OS picker on a phone, the keyboard and
|
||||
screen-reader behaviour of a real select everywhere, and the face (🌐 + the language's own name, or
|
||||
its code in a collapsed rail) is only paint. The menu opens where the OS puts it (upward at the
|
||||
bottom of the rail), so it needs no placement prop.
|
||||
-->
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { SWITCHER_LANGUAGES, getLanguage } from '../layouts/app-wrapper-material/languages.js'
|
||||
import { icon } from '../layouts/app-wrapper-material/icons.js'
|
||||
import { requestLanguage, writeOwnedLangParam } from '../utils/languageRequest.js'
|
||||
import { tStatic } from '../i18n/index.js'
|
||||
|
||||
const props = defineProps({
|
||||
/** The shell's effective language code ('en', 'ar', …). */
|
||||
current: { type: String, default: 'en' },
|
||||
/** The desktop rail is collapsed to its icon strip: show the code, not the name. */
|
||||
collapsed: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['switch'])
|
||||
|
||||
const options = computed(() => {
|
||||
const list = SWITCHER_LANGUAGES.slice()
|
||||
if (props.current && !list.some((l) => l.code === props.current)) {
|
||||
const cur = getLanguage(props.current)
|
||||
if (cur) list.push(cur)
|
||||
}
|
||||
return list
|
||||
})
|
||||
const currentLang = computed(() => getLanguage(props.current) || { code: props.current, nativeName: props.current })
|
||||
// The menu's name, in the language the reader is reading (the catalogue's controls.language).
|
||||
const ariaLabel = computed(() => tStatic('controls.language', props.current) || 'Language')
|
||||
const globe = icon('globe')
|
||||
|
||||
function onPick(e) {
|
||||
const sel = e.target
|
||||
const code = sel.value
|
||||
if (!code || code === props.current) return
|
||||
if (requestLanguage(sel, code)) {
|
||||
emit('switch', code)
|
||||
writeOwnedLangParam(code)
|
||||
} else {
|
||||
// The host took the request; show the current language until the host changes it.
|
||||
sel.value = props.current
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="slm" :class="{ collapsed }">
|
||||
<span class="slm-face" aria-hidden="true">
|
||||
<span class="slm-ic" v-html="globe"></span>
|
||||
<span v-if="collapsed" class="slm-code">{{ String(current).toUpperCase() }}</span>
|
||||
<span v-else class="slm-name" :lang="currentLang.code" :dir="currentLang.dir || null">{{ currentLang.nativeName }}</span>
|
||||
<span v-if="!collapsed" class="slm-caret">▾</span>
|
||||
</span>
|
||||
<select class="slm-select" :value="current" :aria-label="ariaLabel" :title="ariaLabel" @change="onPick">
|
||||
<option v-for="l in options" :key="l.code" :value="l.code" :lang="l.code" :dir="l.dir || null">{{ l.nativeName }}</option>
|
||||
</select>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 44px tall: the phone bar's end slot and the rail's bottom row are both tap targets. */
|
||||
.slm { position: relative; display: inline-flex; align-items: center; min-height: 44px; max-width: 100%; box-sizing: border-box; border-radius: 8px; color: #3b463d; }
|
||||
.slm-face { display: inline-flex; align-items: center; gap: 6px; padding-inline: 10px; min-width: 0; font-size: 13px; font-weight: 500; line-height: 1.2; }
|
||||
.slm-ic { flex: 0 0 auto; display: inline-flex; color: var(--vtl-muted, #6d766e); }
|
||||
.slm-ic :deep(svg) { width: 18px; height: 18px; }
|
||||
.slm-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
||||
.slm-code { font-weight: 600; font-size: 12px; letter-spacing: .03em; }
|
||||
.slm-caret { flex: 0 0 auto; color: var(--vtl-muted, #6d766e); font-size: 11px; }
|
||||
.slm.collapsed .slm-face { flex-direction: column; gap: 2px; padding-inline: 4px; }
|
||||
/* The real control: transparent, covering the face, so every tap and key lands on the select. */
|
||||
.slm-select { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; margin: 0; border: 0; cursor: pointer; font-size: 16px; }
|
||||
.slm:hover { background: var(--vtl-accented, #d1d4cd); }
|
||||
.slm:focus-within { outline: 2px solid var(--vtl-accent, #92b195); outline-offset: -2px; }
|
||||
@media (prefers-reduced-motion: reduce) { .slm { transition: none; } }
|
||||
</style>
|
||||
|
|
@ -7,9 +7,9 @@ A change here does not affect one map. It ripples to **all** of them. So:
|
|||
- **Prototyping or experimenting? Don't edit these.** Put your custom, one-off, not-yet-proven component
|
||||
inside your own map's folder (`plugin-designs/<your-map>/`), where it can be as messy as you need and
|
||||
breaks nothing else.
|
||||
- **Only proven, parameterized, reusable components graduate into this library.** If a component has hard-coded
|
||||
values, assumptions about one specific map, or hasn't been used by more than one map yet — it is **not**
|
||||
ready to live here.
|
||||
- **Reusable by default.** If a component could serve more than one map, build it here once, driven by props,
|
||||
and land it through the build owner, so other maps adopt it instead of copying it. If it has hard-coded
|
||||
values or assumptions about one specific map, it is **not** ready to live here: parameterize it first.
|
||||
- **A reusable component takes props, not assumptions.** Before you move something in, make it parameterized:
|
||||
the map, the legend, the controls, the search bar all take configuration — yours should too.
|
||||
- **If you must change a shared component, check every map that uses it.** Run `bun run dev` and click through
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ without importing each other. This file is the contract they share. It is **docu
|
|||
exists today**, verified against `development`, not a proposal.
|
||||
|
||||
The components still live in `plugin-designs/research-dashboard/src/components/dashboards/`. They
|
||||
are deliberately **not** in `library/` yet: a library component with a single consumer is a
|
||||
relocation, not a promotion, and the repo has already had to move such modules back. They graduate
|
||||
when a second dashboard consumes them.
|
||||
are not in `library/` yet. Under the repo's "reusable by default" rule
|
||||
([`skills/plugin-designer`](../../skills/plugin-designer/SKILL.md), section 3), a component another
|
||||
dashboard could use belongs in `library/`, driven by props; moving these is a library change for the
|
||||
build owner.
|
||||
|
||||
## The shape
|
||||
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@
|
|||
"exportCsv": "تصدير CSV",
|
||||
"copyNotRegroupable": "لا يمكن إعادة تجميعها معًا: انسخ ولايات أو مقاطعات بلد واحد، أو البلدان، كلًّا على حدة",
|
||||
"displayName": "ملصقات قائمة UPG 100",
|
||||
"wallHeading": "ملصقات قائمة UPG 100"
|
||||
"wallHeading": "ملصقات قائمة UPG 100",
|
||||
"wallIntroA": "كيف تُطعم جمعًا من خمسة آلاف؟ أجلسهم يسوع جماعاتٍ من خمسين ومن مئة، فصار المستحيل قابلًا للمشاركة (لوقا ٩:١٤). فعلنا المثل مع {n} شعوب غير مبلَّغة: ٢٧ قائمة من نحو خمسين إلى مئة. تُطبع كل قائمة حزمةَ صلاة وملصقًا، ليحمل ٢٧ تلميذًا أو كنيسة أو قائدًا محليًا كلٌّ واحدة — مصلّين لأجلهم ",
|
||||
"peopleStillLoading": "لا تزال الشعوب قيد التحميل، حاول مرة أخرى بعد لحظة"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@
|
|||
"exportCsv": "CSV exportieren",
|
||||
"copyNotRegroupable": "Nicht gemeinsam umgruppierbar: Bundesstaaten oder Provinzen eines Landes oder die Länder getrennt kopieren",
|
||||
"displayName": "Poster der UPG 100 Liste",
|
||||
"wallHeading": "Poster der UPG 100 Liste"
|
||||
"wallHeading": "Poster der UPG 100 Liste",
|
||||
"wallIntroA": "Wie würden Sie eine Menge von fünftausend speisen? Jesus ließ sie in Gruppen zu fünfzig und hundert lagern, und das Unmögliche wurde teilbar (Lukas 9,14). Dasselbe haben wir mit den {n} unerreichten Volksgruppen getan: 27 Listen von etwa fünfzig bis hundert. Jede Liste druckt als Gebetspaket und als Poster, damit 27 Jünger, Gemeinden oder einheimische Leiter je eine tragen können — und für sie beten ",
|
||||
"peopleStillLoading": "die Volksgruppen werden noch geladen; versuchen Sie es gleich noch einmal"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@
|
|||
"exportCsv": "Export CSV",
|
||||
"copyNotRegroupable": "Can't be regrouped together: copy one country's states or provinces, or the countries, separately",
|
||||
"displayName": "UPG 100 List Posters",
|
||||
"wallHeading": "UPG 100 List Posters"
|
||||
"wallHeading": "UPG 100 List Posters",
|
||||
"wallIntroA": "How would you feed a crowd of five thousand? Jesus sat them down in groups of fifty and a hundred, and the impossible became shareable (Luke 9:14). We did the same with the {n} unreached people groups: 27 lists of about fifty to a hundred. Each list prints as a prayer packet and a poster, so 27 disciples, churches or local leaders can each carry one — praying for them ",
|
||||
"peopleStillLoading": "the people groups are still loading, try again in a moment"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@
|
|||
"exportCsv": "Exportar CSV",
|
||||
"copyNotRegroupable": "No se pueden reagrupar juntos: copia por separado los estados o provincias de un solo país, o los países",
|
||||
"displayName": "Carteles de la lista UPG 100",
|
||||
"wallHeading": "Carteles de la lista UPG 100"
|
||||
"wallHeading": "Carteles de la lista UPG 100",
|
||||
"wallIntroA": "¿Cómo alimentarías a una multitud de cinco mil? Jesús los sentó en grupos de cincuenta y de cien, y lo imposible se volvió compartible (Lucas 9:14). Hicimos lo mismo con los {n} pueblos no alcanzados: 27 listas de unos cincuenta a cien. Cada lista se imprime como paquete de oración y cartel, para que 27 discípulos, iglesias o líderes locales lleven una cada uno — orando por ellos ",
|
||||
"peopleStillLoading": "los pueblos aún se están cargando; inténtalo de nuevo en un momento"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@
|
|||
"exportCsv": "Exporter en CSV",
|
||||
"copyNotRegroupable": "Impossible de les regrouper ensemble : copiez séparément les États ou provinces d’un seul pays, ou les pays",
|
||||
"displayName": "Affiches de la liste UPG 100",
|
||||
"wallHeading": "Affiches de la liste UPG 100"
|
||||
"wallHeading": "Affiches de la liste UPG 100",
|
||||
"wallIntroA": "Comment nourririez-vous une foule de cinq mille personnes ? Jésus les a fait asseoir par groupes de cinquante et de cent, et l’impossible est devenu partageable (Luc 9.14). Nous avons fait de même avec les {n} peuples non atteints : 27 listes d’environ cinquante à cent. Chaque liste s’imprime en dossier de prière et en affiche, pour que 27 disciples, Églises ou responsables locaux en portent chacun une — en priant pour eux ",
|
||||
"peopleStillLoading": "les peuples sont encore en cours de chargement ; réessayez dans un instant"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@
|
|||
"exportCsv": "CSV निर्यात करें",
|
||||
"copyNotRegroupable": "इन्हें एक साथ पुनर्समूहित नहीं किया जा सकता: एक देश के राज्य या प्रांत, या देश, अलग-अलग कॉपी करें",
|
||||
"displayName": "UPG 100 सूची पोस्टर",
|
||||
"wallHeading": "UPG 100 सूची पोस्टर"
|
||||
"wallHeading": "UPG 100 सूची पोस्टर",
|
||||
"wallIntroA": "पाँच हज़ार की भीड़ को आप कैसे खिलाते? यीशु ने उन्हें पचास-पचास और सौ-सौ के समूहों में बिठाया, और असंभव बाँटने योग्य हो गया (लूका 9:14). हमने {n} अनपहुँचे जनसमूहों के साथ वही किया: लगभग पचास से सौ तक की 27 सूचियाँ. हर सूची प्रार्थना पैकेट और पोस्टर के रूप में छपती है, ताकि 27 शिष्य, कलीसियाएँ या स्थानीय अगुवे एक-एक ले सकें — उनके लिए प्रार्थना करते हुए ",
|
||||
"peopleStillLoading": "जनसमूह अभी लोड हो रहे हैं, थोड़ी देर में फिर कोशिश करें"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@
|
|||
"exportCsv": "Esporta CSV",
|
||||
"copyNotRegroupable": "Non si possono raggruppare insieme: copia separatamente gli stati o le province di un solo paese, o i paesi",
|
||||
"displayName": "Poster della lista UPG 100",
|
||||
"wallHeading": "Poster della lista UPG 100"
|
||||
"wallHeading": "Poster della lista UPG 100",
|
||||
"wallIntroA": "Come sfameresti una folla di cinquemila? Gesù li fece sedere a gruppi di cinquanta e di cento, e l’impossibile divenne condivisibile (Luca 9:14). Abbiamo fatto lo stesso con i {n} popoli non raggiunti: 27 liste di circa cinquanta-cento. Ogni lista si stampa come pacchetto di preghiera e manifesto, così 27 discepoli, chiese o responsabili locali ne portano una ciascuno — pregando per loro ",
|
||||
"peopleStillLoading": "i popoli si stanno ancora caricando; riprova tra un momento"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@
|
|||
"exportCsv": "Exportar CSV",
|
||||
"copyNotRegroupable": "Não podem ser reagrupados juntos: copie separadamente os estados ou províncias de um só país, ou os países",
|
||||
"displayName": "Cartazes da lista UPG 100",
|
||||
"wallHeading": "Cartazes da lista UPG 100"
|
||||
"wallHeading": "Cartazes da lista UPG 100",
|
||||
"wallIntroA": "Como você alimentaria uma multidão de cinco mil? Jesus os sentou em grupos de cinquenta e de cem, e o impossível tornou-se compartilhável (Lucas 9.14). Fizemos o mesmo com os {n} povos não alcançados: 27 listas de cerca de cinquenta a cem. Cada lista imprime como pacote de oração e cartaz, para que 27 discípulos, igrejas ou líderes locais levem uma cada um — orando por eles ",
|
||||
"peopleStillLoading": "os povos ainda estão carregando; tente de novo em um instante"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@
|
|||
"exportCsv": "Exportă CSV",
|
||||
"copyNotRegroupable": "Nu pot fi regrupate împreună: copiază separat statele sau provinciile unei singure țări, sau țările",
|
||||
"displayName": "Afișe din lista UPG 100",
|
||||
"wallHeading": "Afișe din lista UPG 100"
|
||||
"wallHeading": "Afișe din lista UPG 100",
|
||||
"wallIntroA": "Cum ai hrăni o mulțime de cinci mii? Isus i-a așezat în grupuri de cincizeci și de o sută, iar imposibilul a devenit împărțibil (Luca 9:14). Am făcut la fel cu cele {n} popoare neatinse: 27 de liste de aproximativ cincizeci până la o sută. Fiecare listă se tipărește ca pachet de rugăciune și afiș, ca 27 de ucenici, biserici sau lideri locali să poarte fiecare câte una — rugându-se pentru ei ",
|
||||
"peopleStillLoading": "popoarele încă se încarcă; încearcă din nou peste o clipă"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@
|
|||
"exportCsv": "Экспорт в CSV",
|
||||
"copyNotRegroupable": "Нельзя перегруппировать вместе: копируйте отдельно штаты или провинции одной страны либо страны",
|
||||
"displayName": "Плакаты списка UPG 100",
|
||||
"wallHeading": "Плакаты списка UPG 100"
|
||||
"wallHeading": "Плакаты списка UPG 100",
|
||||
"wallIntroA": "Как накормить толпу в пять тысяч человек? Иисус рассадил их группами по пятьдесят и по сто, и невозможное стало посильным (Лк. 9:14). Мы поступили так же с {n} неохваченными народами: 27 списков примерно по пятьдесят-сто. Каждый список печатается как молитвенный пакет и плакат, чтобы 27 учеников, церквей или местных служителей взяли по одному — молясь о них ",
|
||||
"peopleStillLoading": "народы ещё загружаются; попробуйте ещё раз через мгновение"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@
|
|||
"exportCsv": "导出 CSV",
|
||||
"copyNotRegroupable": "无法一起重新分组:请分别复制同一国家的邦或省,或各个国家",
|
||||
"displayName": "UPG 100 名单海报",
|
||||
"wallHeading": "UPG 100 名单海报"
|
||||
"wallHeading": "UPG 100 名单海报",
|
||||
"wallIntroA": "你会怎样喂饱五千人的群众?耶稣让他们一排五十、一排一百地坐下,不可能的事就变得可以分担了(路加福音 9:14)。我们对 {n} 个未得之民做了同样的事:27 份名单,每份约五十到一百。每份名单都能印成祷告手册和海报,让 27 位门徒、教会或当地领袖各领一份 — 为他们祷告 ",
|
||||
"peopleStillLoading": "族群仍在加载中,请稍后再试"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,20 +91,11 @@
|
|||
after a resize back to desktop — the previous broken-restore bug.
|
||||
-->
|
||||
<template>
|
||||
<!-- ★ THE FAB'S FOOTPRINT IS PUBLISHED, NOT GUESSED. .vtl-fab is a 44px circle pinned to the
|
||||
bottom-left, and it lives in the LAYOUT — outside every plugin's shadow root — so it paints
|
||||
over plugin content whatever z-index the plugin uses internally (the mobile legend sheet is
|
||||
z-index:1000 and still loses, because they are different stacking contexts). A scrolled-to-
|
||||
the-end legend therefore ends UNDER the circle.
|
||||
A plugin cannot know whether the circle is there, so the layout says so: --map-fab-clearance
|
||||
is set ONLY while the button is actually rendered, and custom properties inherit through the
|
||||
flattened tree, so it reaches into each shadow root. No circle, no reserved space.
|
||||
--map-fab-beside is the same fact for the OTHER axis: where a sibling control must start to
|
||||
sit NEXT TO the circle rather than on top of it (12px inset + 44px button + 8px gap). A
|
||||
consumer that hardcoded 64px would silently overlap the day the circle resizes; reading it
|
||||
means the circle's geometry is stated once, here, by the component that owns it. -->
|
||||
<div class="vtl" :class="{ 'nav-open': navOpen, 'rail-collapsed': railCollapsed, 'no-header': !header }"
|
||||
:style="fabShown ? { '--map-fab-clearance': 'calc(56px + env(safe-area-inset-bottom))', '--map-fab-beside': 'calc(56px + env(safe-area-inset-left))' } : null">
|
||||
<!-- ★ NO CORNER CIRCLE ANY MORE (the phone bar below replaced it). The layout used to publish
|
||||
--map-fab-clearance / --map-fab-beside while a floating ☰ sat over the pane's bottom-start
|
||||
corner; with the ☰ in a bar ABOVE the pane nothing covers plugin content, so neither is set and
|
||||
every reader of them (BackToTopButton, PinHint, LegendMobile, the grid) takes its own fallback. -->
|
||||
<div class="vtl" :class="{ 'nav-open': navOpen, 'rail-collapsed': railCollapsed, 'no-header': !header, 'has-phone-bar': phoneBarShown }">
|
||||
<header class="vtl-top" v-if="header">
|
||||
<button class="vtl-menu" type="button" :aria-expanded="String(navOpen)" @click="navOpen = !navOpen"
|
||||
:aria-label="label('toggleList')">☰</button>
|
||||
|
|
@ -113,25 +104,25 @@
|
|||
<span class="vtl-meta"><slot name="meta" /></span>
|
||||
</header>
|
||||
|
||||
<!-- ★ HEADERLESS MOBILE ESCAPE HATCH — the ONLY way to reach the rail when there is no header.
|
||||
FOUND 2026-09-22 by resizing the cap-5 SPA to a phone width. That SPA is header-OFF by
|
||||
default (the no-stacked-headers rule), so the toggle above — which lives inside
|
||||
`v-if="header"` — does not exist. On mobile the rail is an off-canvas drawer at
|
||||
translateX(-100%) with `navOpen` starting false, and the only other toggle (.vtl-collapse)
|
||||
sits INSIDE the drawer, measured off-screen at x:-244. Net effect: every tab except the one
|
||||
that happened to open was UNREACHABLE. Measured, not inferred.
|
||||
<!-- ★ THE PHONE BAR — the way to the rail AND to the language when there is no header.
|
||||
Header-off shells (the Research Gallery, Posterverse) have no .vtl-top, so on a phone this slim
|
||||
bar is their only chrome: ☰ at the inline START (right in ar, the edge the drawer opens from)
|
||||
and a #phone-end slot at the inline END, which the shell fills with its own language menu
|
||||
(library/components/ShellLanguageMenu.vue). It is a real grid row (.has-phone-bar), so the
|
||||
drawer and the pane both start below it and it never covers plugin content.
|
||||
44px: the ☰ and the menu ARE the bar's tap targets; a 40px bar would push their 44px hit
|
||||
areas into the pane. Rendered ONLY when narrow AND header-off (phoneBarShown), so desktop and
|
||||
every header-on consumer (plugin-studio) render exactly as before. It replaced the floating
|
||||
corner circle that did this job before (found 2026-09-22: header-off on a phone, every tab
|
||||
but the first was unreachable), and check:nav-reachable still presses it. -->
|
||||
<div v-if="phoneBarShown" class="vtl-pbar">
|
||||
<button class="vtl-phone-menu" type="button" :aria-expanded="String(navOpen)"
|
||||
@click="navOpen = !navOpen" :aria-label="label('toggleList')">☰</button>
|
||||
<span class="vtl-pbar-end"><slot name="phone-end" /></span>
|
||||
</div>
|
||||
|
||||
Rendered ONLY when narrow AND headerless, so no existing consumer can see a change: every
|
||||
other case is desktop (rail always visible) or header-on (the toggle above already works).
|
||||
It hides itself while the drawer is open — tapping a tab closes the drawer anyway, and a
|
||||
floating button over an open drawer would just cover a row. -->
|
||||
<button v-if="fabShown"
|
||||
class="vtl-fab" type="button" :aria-expanded="String(navOpen)"
|
||||
@click="navOpen = true" :aria-label="label('openViews')">☰</button>
|
||||
|
||||
<!-- Beside the circle, driving the shell's own scroller. Reads --map-fab-beside, which this
|
||||
layout publishes only while the circle is up, so with no circle it falls back to the
|
||||
plain inset instead of leaving a gap where a button used to be. -->
|
||||
<!-- Driving the shell's own scroller. It reads --map-fab-beside, which nothing sets now that the
|
||||
corner circle is gone, so it sits at its plain inset. -->
|
||||
<BackToTopButton v-if="narrow" :target="activeHostEl"
|
||||
:labels="{ backToTop: T('actions.backToTop', 'Back to top') }" />
|
||||
|
||||
|
|
@ -142,7 +133,7 @@
|
|||
an off-canvas drawer parked at translateX(-100%) and slid in by the `nav-open` class. Because it
|
||||
is never display:none, a mobile-closed drawer can no longer strand the rail after a resize back
|
||||
to desktop (the old cross-breakpoint bug). `aria-hidden` mirrors real visibility for a11y. -->
|
||||
<nav class="vtl-nav" :aria-label="navLabel" :aria-hidden="String(!railShown)"
|
||||
<nav class="vtl-nav" :class="{ 'has-rail-end': railEndShown && !!$slots['rail-end'] }" :aria-label="navLabel" :aria-hidden="String(!railShown)"
|
||||
:role="$slots.rail ? null : 'tablist'" :aria-orientation="$slots.rail ? null : 'vertical'">
|
||||
<!-- THE HOST'S WAY HOME (optional). A host page that embeds this layout full-page (DX Life's
|
||||
/deep-research) has no header of its own there, so it hands in its homepage URL and label
|
||||
|
|
@ -236,6 +227,16 @@
|
|||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- #rail-end: pinned to the BOTTOM of the desktop rail, where apps put account details. A
|
||||
header-off shell puts its language menu here (the same ShellLanguageMenu as the phone bar),
|
||||
so a desktop reader can change language with no header above the pane. Rendered only when
|
||||
WIDE AND header-off: a header-on consumer keeps its #toolbar menu (never two), and on a
|
||||
phone the bar's #phone-end carries it. `collapsed` tells the filler the rail is the 72px
|
||||
icon strip, so it shows the language code instead of its name. -->
|
||||
<div v-if="railEndShown && $slots['rail-end']" class="vtl-rail-end">
|
||||
<slot name="rail-end" :collapsed="railCollapsed" />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="vtl-main">
|
||||
|
|
@ -327,6 +328,8 @@ const VTL_SHADOW_CSS = `
|
|||
content to size it and collapses to 0 (the 1fr row sits empty) → the same 0-height black screen.
|
||||
With no header there is only one region, so declare a single 1fr row and the body fills. */
|
||||
.vtl.no-header { grid-template-rows:1fr; }
|
||||
/* ...except on a phone, where the header-off layout has its slim bar (.vtl-pbar) as a real first row. */
|
||||
.vtl.no-header.has-phone-bar { grid-template-rows:auto 1fr; }
|
||||
/* MATERIAL APP BAR = 64px desktop / 56px mobile (2026-09-21), was 51px from 11px padding.
|
||||
Stated as an explicit min-height rather than left to padding arithmetic, so the number a
|
||||
reader sees is the number that renders. min-height keeps a wrapping title from clipping.
|
||||
|
|
@ -337,17 +340,17 @@ const VTL_SHADOW_CSS = `
|
|||
THIS RULE EXISTS TWICE (VTL_SHADOW_CSS and the scoped style block); keep them identical. */
|
||||
.vtl-top { flex:0 0 auto; display:flex; align-items:center; gap:10px; box-sizing:border-box; min-height:64px; padding:8px 16px; border-bottom:1px solid var(--vtl-border); background:#ffffff; }
|
||||
.vtl-menu { display:none; font:16px/1 system-ui; color:#3b463d; background:#d1d4cd; border:1px solid #d1d4cd; border-radius:8px; padding:5px 10px; cursor:pointer; }
|
||||
/* Headerless-mobile drawer opener. Only ever rendered when narrow AND header-off AND the drawer is
|
||||
shut, so it cannot appear in any layout that already has a working toggle. z-index sits ABOVE the
|
||||
content but BELOW the drawer (.vtl-nav is z-index:20) so the drawer covers it when it slides in.
|
||||
44px is the minimum comfortable touch target; the safe-area insets keep it clear of an iOS home
|
||||
indicator and a notch in landscape. */
|
||||
/* THE CORNER CIRCLE NEVER MOVES: 40px, 8px from the bottom-start corner of the layout (plus the safe area). It does not
|
||||
ride above a legend sheet or follow a scroll; it sits over the sheet's corner, and the collapsed sheet pads its
|
||||
start edge by --map-fab-beside so its caret and title clear it. A fixed spot cannot fly or re-animate. */
|
||||
.vtl-fab { position:absolute; z-index:15; left:calc(8px + env(safe-area-inset-left)); bottom:calc(8px + env(safe-area-inset-bottom)); width:40px; height:40px; display:flex; align-items:center; justify-content:center; font:18px/1 system-ui; color:#3b463d; background:#ffffff; border:1px solid var(--vtl-border); border-radius:50%; box-shadow:0 2px 10px rgba(0,0,0,.18); cursor:pointer; }
|
||||
.vtl-fab:hover { background:#d1d4cd; }
|
||||
.vtl-fab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:2px; }
|
||||
/* THE PHONE BAR (phoneBarShown = narrow AND header-off; it replaced the floating corner circle).
|
||||
A real grid row above .vtl-body (.has-phone-bar below), so the drawer and the pane both start under
|
||||
it and it never covers plugin content. 44px + the top safe area; the rule line is an INSET SHADOW,
|
||||
not a border, so the 44px is the whole bar and the ☰ is a full 44×44 target. Flex follows the
|
||||
writing direction, so in ar the ☰ sits at the right (where the drawer opens) and the #phone-end
|
||||
menu at the left, with no :dir override. THIS RULE EXISTS TWICE; keep the copies identical. */
|
||||
.vtl-pbar { display:flex; align-items:center; gap:8px; box-sizing:border-box; height:calc(44px + env(safe-area-inset-top)); padding:env(safe-area-inset-top) calc(4px + env(safe-area-inset-right)) 0 calc(4px + env(safe-area-inset-left)); background:#ffffff; box-shadow:inset 0 -1px 0 var(--vtl-border); }
|
||||
.vtl-phone-menu { flex:0 0 auto; width:44px; height:44px; display:inline-flex; align-items:center; justify-content:center; padding:0; margin:0; font:20px/1 system-ui; color:#3b463d; background:transparent; border:0; border-radius:8px; cursor:pointer; }
|
||||
.vtl-phone-menu:hover { background:var(--vtl-accented); }
|
||||
.vtl-phone-menu:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
||||
.vtl-pbar-end { margin-inline-start:auto; display:inline-flex; align-items:center; min-width:0; }
|
||||
.vtl-menu:hover { background:#d1d4cd; }
|
||||
.vtl-brand { display:inline-flex; align-items:center; }
|
||||
.vtl-brand:empty { display:none; }
|
||||
|
|
@ -381,6 +384,13 @@ const VTL_SHADOW_CSS = `
|
|||
.vtl-home-ic { flex:0 0 auto; display:inline-flex; }
|
||||
.vtl-home-ic svg { width:16px; height:16px; }
|
||||
.vtl-home-tx { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
/* THE RAIL'S BOTTOM SLOT (#rail-end: railEndShown = wide AND header-off, and a filler present). The nav
|
||||
becomes a column ONLY then, so every other rail lays out exactly as before. margin-top:auto parks the
|
||||
slot at the bottom while the tabs are short; sticky keeps it in view once they scroll. The nav's own
|
||||
bottom padding moves into the slot, so the sticky row sits flush with the rail's bottom edge. */
|
||||
.vtl-nav.has-rail-end { display:flex; flex-direction:column; padding-bottom:0; }
|
||||
.vtl-nav.has-rail-end > * { flex-shrink:0; }
|
||||
.vtl-rail-end { margin-top:auto; position:sticky; bottom:0; padding:8px 0 12px; background:#ffffff; border-top:1px solid var(--vtl-border); }
|
||||
.vtl-grp { margin-bottom:2px; }
|
||||
.vtl-grp + .vtl-grp { margin-top:8px; padding-top:8px; border-top:1px solid var(--vtl-border); }
|
||||
.vtl-tier { width:100%; padding:8px 8px 6px; display:flex; align-items:center; gap:8px; box-sizing:border-box; }
|
||||
|
|
@ -502,6 +512,7 @@ const VTL_SHADOW_CSS = `
|
|||
nav translate -184px the 256px rail slides left, leaving 256-184 = 72px visible
|
||||
main left 72px the content pane starts exactly where the rail ends
|
||||
collapse pad 184px pushes the toggle's icon into the 72px strip that stays on screen
|
||||
(the #rail-end slot takes the same pad, so its menu sits in the strip too)
|
||||
tab pad 188px the same for each row's icon (4px more, matching the old 196/200 pair)
|
||||
Change one without the others and the icons drift out of the visible strip or the content
|
||||
overlaps the rail. Old values were -196 / 60 / 196 / 200.
|
||||
|
|
@ -510,6 +521,7 @@ const VTL_SHADOW_CSS = `
|
|||
.vtl.rail-collapsed .vtl-main { left:72px; }
|
||||
.vtl.rail-collapsed .vtl-collapse, .vtl.rail-collapsed .vtl-home { padding-left:184px; justify-content:flex-start; }
|
||||
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(0deg); }
|
||||
.vtl.rail-collapsed .vtl-rail-end { padding-left:184px; }
|
||||
.vtl.rail-collapsed .vtl-collapse-tx, .vtl.rail-collapsed .vtl-home-tx { display:none; }
|
||||
.vtl.rail-collapsed .vtl-tab { padding-left:188px; gap:0; }
|
||||
.vtl.rail-collapsed .vtl-tab-text { display:none; }
|
||||
|
|
@ -568,12 +580,12 @@ const VTL_SHADOW_CSS = `
|
|||
.vtl:dir(rtl) .vtl-meta, .vtl:dir(rtl) .vtl-td, .vtl:dir(rtl) .vtl-chev { margin-left:0; margin-right:auto; }
|
||||
.vtl:dir(rtl) .vtl-chev:not(.open) { transform:scaleX(-1); }
|
||||
.vtl:dir(rtl) .vtl-collapse-ic svg { transform:scaleX(-1); }
|
||||
.vtl:dir(rtl) .vtl-fab { left:auto; right:calc(8px + env(safe-area-inset-right)); }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-nav { transform:translateX(184px); }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-main { left:0; right:72px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-collapse, .vtl.rail-collapsed:dir(rtl) .vtl-home { padding-left:10px; padding-right:184px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tab { padding-left:12px; padding-right:188px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tier { padding-left:8px; padding-right:196px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-rail-end { padding-left:0; padding-right:184px; }
|
||||
@media (max-width: 760px) {
|
||||
.vtl:dir(rtl) .vtl-nav { transform:translateX(100%); }
|
||||
.vtl.nav-open:dir(rtl) .vtl-nav { transform:translateX(0); }
|
||||
|
|
@ -587,7 +599,7 @@ const VTL_SHADOW_CSS = `
|
|||
/* ★ REDUCED MOTION, OWNED BY THIS COMPONENT. A page-level rule cannot reach into a shadow root,
|
||||
and no generated leaf ships one anyway (measured: 0 across the whole tree). Additive only. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vtl-nav, .vtl-collapse-ic, .vtl-tab, .vtl-tier, .vtl-fab, .vtl-scrim { transition: none !important; animation: none !important; }
|
||||
.vtl-nav, .vtl-collapse-ic, .vtl-tab, .vtl-tier, .vtl-pbar, .vtl-phone-menu, .vtl-rail-end, .vtl-scrim { transition: none !important; animation: none !important; }
|
||||
}
|
||||
`
|
||||
|
||||
|
|
@ -648,14 +660,13 @@ export default defineComponent({
|
|||
},
|
||||
computed: {
|
||||
/**
|
||||
* ★ ONE SOURCE OF TRUTH FOR THE FLOATING CIRCLE, because two copies of this condition is the
|
||||
* bug waiting to happen. The button's v-if and the --map-fab-clearance that reserves room for
|
||||
* it MUST agree: if they drift, you get either a legend whose last row sits under the circle
|
||||
* (the reported case) or a strip of dead whitespace with no circle to justify it. Neither
|
||||
* has a symptom a gate can see, so the fix is to make disagreement impossible rather than to
|
||||
* test for it.
|
||||
* ★ ONE SOURCE OF TRUTH FOR EACH SLOT'S PLACE. The phone bar and the rail's bottom slot are the
|
||||
* same control in two places, and exactly one of them may show: the bar on a phone, the rail-end
|
||||
* on a desktop, and neither when a header is on (its #toolbar carries the menu). The template,
|
||||
* the grid row (.has-phone-bar) and check:shell-header all read these two, never a copy.
|
||||
*/
|
||||
fabShown() { return this.narrow && !this.header && !this.navOpen },
|
||||
phoneBarShown() { return this.narrow && !this.header },
|
||||
railEndShown() { return !this.narrow && !this.header },
|
||||
/**
|
||||
* The element the back-to-top arrow scrolls. .vtl-host is the shell's scroller
|
||||
* (overflow-y:auto) and there is one PER TAB, so this follows `active` rather than binding a
|
||||
|
|
@ -918,6 +929,8 @@ export default defineComponent({
|
|||
content to size it and collapses to 0 (the 1fr row sits empty) → the same 0-height black screen.
|
||||
With no header there is only one region, so declare a single 1fr row and the body fills. */
|
||||
.vtl.no-header { grid-template-rows:1fr; }
|
||||
/* ...except on a phone, where the header-off layout has its slim bar (.vtl-pbar) as a real first row. */
|
||||
.vtl.no-header.has-phone-bar { grid-template-rows:auto 1fr; }
|
||||
/* MATERIAL APP BAR = 64px desktop / 56px mobile (2026-09-21), was 51px from 11px padding.
|
||||
Stated as an explicit min-height rather than left to padding arithmetic, so the number a
|
||||
reader sees is the number that renders. min-height keeps a wrapping title from clipping.
|
||||
|
|
@ -928,17 +941,17 @@ export default defineComponent({
|
|||
THIS RULE EXISTS TWICE (VTL_SHADOW_CSS and the scoped style block); keep them identical. */
|
||||
.vtl-top { flex:0 0 auto; display:flex; align-items:center; gap:10px; box-sizing:border-box; min-height:64px; padding:8px 16px; border-bottom:1px solid var(--vtl-border); background:#ffffff; }
|
||||
.vtl-menu { display:none; font:16px/1 system-ui; color:#3b463d; background:#d1d4cd; border:1px solid #d1d4cd; border-radius:8px; padding:5px 10px; cursor:pointer; }
|
||||
/* Headerless-mobile drawer opener. Only ever rendered when narrow AND header-off AND the drawer is
|
||||
shut, so it cannot appear in any layout that already has a working toggle. z-index sits ABOVE the
|
||||
content but BELOW the drawer (.vtl-nav is z-index:20) so the drawer covers it when it slides in.
|
||||
44px is the minimum comfortable touch target; the safe-area insets keep it clear of an iOS home
|
||||
indicator and a notch in landscape. */
|
||||
/* THE CORNER CIRCLE NEVER MOVES: 40px, 8px from the bottom-start corner of the layout (plus the safe area). It does not
|
||||
ride above a legend sheet or follow a scroll; it sits over the sheet's corner, and the collapsed sheet pads its
|
||||
start edge by --map-fab-beside so its caret and title clear it. A fixed spot cannot fly or re-animate. */
|
||||
.vtl-fab { position:absolute; z-index:15; left:calc(8px + env(safe-area-inset-left)); bottom:calc(8px + env(safe-area-inset-bottom)); width:40px; height:40px; display:flex; align-items:center; justify-content:center; font:18px/1 system-ui; color:#3b463d; background:#ffffff; border:1px solid var(--vtl-border); border-radius:50%; box-shadow:0 2px 10px rgba(0,0,0,.18); cursor:pointer; }
|
||||
.vtl-fab:hover { background:#d1d4cd; }
|
||||
.vtl-fab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:2px; }
|
||||
/* THE PHONE BAR (phoneBarShown = narrow AND header-off; it replaced the floating corner circle).
|
||||
A real grid row above .vtl-body (.has-phone-bar below), so the drawer and the pane both start under
|
||||
it and it never covers plugin content. 44px + the top safe area; the rule line is an INSET SHADOW,
|
||||
not a border, so the 44px is the whole bar and the ☰ is a full 44×44 target. Flex follows the
|
||||
writing direction, so in ar the ☰ sits at the right (where the drawer opens) and the #phone-end
|
||||
menu at the left, with no :dir override. THIS RULE EXISTS TWICE; keep the copies identical. */
|
||||
.vtl-pbar { display:flex; align-items:center; gap:8px; box-sizing:border-box; height:calc(44px + env(safe-area-inset-top)); padding:env(safe-area-inset-top) calc(4px + env(safe-area-inset-right)) 0 calc(4px + env(safe-area-inset-left)); background:#ffffff; box-shadow:inset 0 -1px 0 var(--vtl-border); }
|
||||
.vtl-phone-menu { flex:0 0 auto; width:44px; height:44px; display:inline-flex; align-items:center; justify-content:center; padding:0; margin:0; font:20px/1 system-ui; color:#3b463d; background:transparent; border:0; border-radius:8px; cursor:pointer; }
|
||||
.vtl-phone-menu:hover { background:var(--vtl-accented); }
|
||||
.vtl-phone-menu:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
||||
.vtl-pbar-end { margin-inline-start:auto; display:inline-flex; align-items:center; min-width:0; }
|
||||
.vtl-menu:hover { background:#d1d4cd; }
|
||||
.vtl-brand { display:inline-flex; align-items:center; }
|
||||
.vtl-brand:empty { display:none; }
|
||||
|
|
@ -972,6 +985,13 @@ export default defineComponent({
|
|||
.vtl-home-ic { flex:0 0 auto; display:inline-flex; }
|
||||
.vtl-home-ic svg { width:16px; height:16px; }
|
||||
.vtl-home-tx { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
/* THE RAIL'S BOTTOM SLOT (#rail-end: railEndShown = wide AND header-off, and a filler present). The nav
|
||||
becomes a column ONLY then, so every other rail lays out exactly as before. margin-top:auto parks the
|
||||
slot at the bottom while the tabs are short; sticky keeps it in view once they scroll. The nav's own
|
||||
bottom padding moves into the slot, so the sticky row sits flush with the rail's bottom edge. */
|
||||
.vtl-nav.has-rail-end { display:flex; flex-direction:column; padding-bottom:0; }
|
||||
.vtl-nav.has-rail-end > * { flex-shrink:0; }
|
||||
.vtl-rail-end { margin-top:auto; position:sticky; bottom:0; padding:8px 0 12px; background:#ffffff; border-top:1px solid var(--vtl-border); }
|
||||
.vtl-grp { margin-bottom:2px; }
|
||||
.vtl-grp + .vtl-grp { margin-top:8px; padding-top:8px; border-top:1px solid var(--vtl-border); }
|
||||
.vtl-tier { width:100%; padding:8px 8px 6px; display:flex; align-items:center; gap:8px; box-sizing:border-box; }
|
||||
|
|
@ -1093,6 +1113,7 @@ export default defineComponent({
|
|||
nav translate -184px the 256px rail slides left, leaving 256-184 = 72px visible
|
||||
main left 72px the content pane starts exactly where the rail ends
|
||||
collapse pad 184px pushes the toggle's icon into the 72px strip that stays on screen
|
||||
(the #rail-end slot takes the same pad, so its menu sits in the strip too)
|
||||
tab pad 188px the same for each row's icon (4px more, matching the old 196/200 pair)
|
||||
Change one without the others and the icons drift out of the visible strip or the content
|
||||
overlaps the rail. Old values were -196 / 60 / 196 / 200.
|
||||
|
|
@ -1101,6 +1122,7 @@ export default defineComponent({
|
|||
.vtl.rail-collapsed .vtl-main { left:72px; }
|
||||
.vtl.rail-collapsed .vtl-collapse, .vtl.rail-collapsed .vtl-home { padding-left:184px; justify-content:flex-start; }
|
||||
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(0deg); }
|
||||
.vtl.rail-collapsed .vtl-rail-end { padding-left:184px; }
|
||||
.vtl.rail-collapsed .vtl-collapse-tx, .vtl.rail-collapsed .vtl-home-tx { display:none; }
|
||||
.vtl.rail-collapsed .vtl-tab { padding-left:188px; gap:0; }
|
||||
.vtl.rail-collapsed .vtl-tab-text { display:none; }
|
||||
|
|
@ -1159,12 +1181,12 @@ export default defineComponent({
|
|||
.vtl:dir(rtl) .vtl-meta, .vtl:dir(rtl) .vtl-td, .vtl:dir(rtl) .vtl-chev { margin-left:0; margin-right:auto; }
|
||||
.vtl:dir(rtl) .vtl-chev:not(.open) { transform:scaleX(-1); }
|
||||
.vtl:dir(rtl) .vtl-collapse-ic svg { transform:scaleX(-1); }
|
||||
.vtl:dir(rtl) .vtl-fab { left:auto; right:calc(8px + env(safe-area-inset-right)); }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-nav { transform:translateX(184px); }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-main { left:0; right:72px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-collapse, .vtl.rail-collapsed:dir(rtl) .vtl-home { padding-left:10px; padding-right:184px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tab { padding-left:12px; padding-right:188px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tier { padding-left:8px; padding-right:196px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-rail-end { padding-left:0; padding-right:184px; }
|
||||
@media (max-width: 760px) {
|
||||
.vtl:dir(rtl) .vtl-nav { transform:translateX(100%); }
|
||||
.vtl.nav-open:dir(rtl) .vtl-nav { transform:translateX(0); }
|
||||
|
|
@ -1178,6 +1200,6 @@ export default defineComponent({
|
|||
/* ★ REDUCED MOTION, OWNED BY THIS COMPONENT. A page-level rule cannot reach into a shadow root,
|
||||
and no generated leaf ships one anyway (measured: 0 across the whole tree). Additive only. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vtl-nav, .vtl-collapse-ic, .vtl-tab, .vtl-tier, .vtl-fab, .vtl-scrim { transition: none !important; animation: none !important; }
|
||||
.vtl-nav, .vtl-collapse-ic, .vtl-tab, .vtl-tier, .vtl-pbar, .vtl-phone-menu, .vtl-rail-end, .vtl-scrim { transition: none !important; animation: none !important; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
58
library/utils/languageRequest.js
Normal file
58
library/utils/languageRequest.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* languageRequest.js — how a shell asks its HOST PAGE before it changes language.
|
||||
*
|
||||
* A shell (the Research Gallery, Posterverse) shows its own language menu, but the page around it may
|
||||
* own the language: DX Life keeps it in the URL path (/ar/deep-research), and a shell that switched on
|
||||
* its own would fight the page. So a pick is a REQUEST, sent as one native event on the shell's host
|
||||
* element:
|
||||
*
|
||||
* doxa-language-request detail: { language: 'ar' } bubbles, composed, cancelable
|
||||
*
|
||||
* - A host that owns the language calls preventDefault() and changes it itself (it then sets the
|
||||
* shell's `language` attribute). dispatchEvent returns false, and the shell does NOT switch.
|
||||
* - Nobody cancels (a standalone leaf, or a partner page that set `language="es"` once and never
|
||||
* listens): dispatchEvent returns true, and the shell switches itself. That is why the rule keys on
|
||||
* the cancel and not on whether `language` was passed: a static attribute would otherwise leave a
|
||||
* dropdown that does nothing.
|
||||
*
|
||||
* ★ A NATIVE CustomEvent, NOT Vue's emit. Vue's custom-element emit wraps detail in an ARRAY
|
||||
* (detail[0]) and is never cancelable, so the host could neither read detail.language nor say no.
|
||||
*
|
||||
* The host is found the way library/utils/hostElement.js finds it (one hop out of the shadow root);
|
||||
* that helper takes a Vue instance, this takes the DOM node the pick happened on.
|
||||
*/
|
||||
|
||||
export const LANGUAGE_REQUEST_EVENT = 'doxa-language-request'
|
||||
|
||||
/** The custom element a node is rendered inside, or the node itself outside any shadow root. */
|
||||
export function hostOf(el) {
|
||||
try { const rn = el && el.getRootNode && el.getRootNode(); if (rn && rn.host) return rn.host } catch (_) {}
|
||||
return el || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the host page for `code`. Returns true when NOBODY cancelled (the shell should switch itself),
|
||||
* false when a host took the request (the shell waits for the host to set `language`).
|
||||
*/
|
||||
export function requestLanguage(fromEl, code) {
|
||||
const host = hostOf(fromEl)
|
||||
if (!host || typeof host.dispatchEvent !== 'function') return true
|
||||
return host.dispatchEvent(new CustomEvent(LANGUAGE_REQUEST_EVENT, {
|
||||
detail: { language: code }, bubbles: true, composed: true, cancelable: true,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an uncancelled pick in ?lang=, ONLY on a page this tree generated (window.MAP_OWNS_PAGE ===
|
||||
* true, stamped by internal/generate-tree.mjs and honoured by library/i18n). A host's URL is the host's:
|
||||
* a shell embedded anywhere else never writes it. Returns whether it wrote.
|
||||
*/
|
||||
export function writeOwnedLangParam(code, win = (typeof window !== 'undefined' ? window : null)) {
|
||||
try {
|
||||
if (!win || win.MAP_OWNS_PAGE !== true || !win.history || !win.location) return false
|
||||
const url = new URL(win.location.href)
|
||||
url.searchParams.set('lang', code)
|
||||
win.history.replaceState(win.history.state, '', url.toString())
|
||||
return true
|
||||
} catch (_) { return false }
|
||||
}
|
||||
13
package.json
13
package.json
|
|
@ -228,6 +228,7 @@
|
|||
"check:packet-a1-structure": "node internal/check-packet-a1-structure.mjs",
|
||||
"check:india-states": "node internal/check-india-states.mjs",
|
||||
"check:batch-selection-export": "node internal/check-batch-selection-export.mjs",
|
||||
"check:upg-count": "node internal/check-upg-count.mjs",
|
||||
"check:zone-title-tier-a": "node internal/check-zone-title-tier-a.mjs",
|
||||
"check:evangelical-share": "node internal/check-evangelical-share.mjs",
|
||||
"check:map-labels": "node internal/check-map-labels.mjs",
|
||||
|
|
@ -348,8 +349,8 @@
|
|||
"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:capability-derived 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:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:script-flags 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:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight check:no-fleet-names:self-test check:pin-hint-floor:self-test check:poster-sizes:self-test",
|
||||
"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 check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-design-catalogue check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived 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:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:script-flags 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:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight check:no-fleet-names:self-test check:pin-hint-floor:self-test check:poster-sizes:self-test check:shell-header:self-test",
|
||||
"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 check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-design-catalogue check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header",
|
||||
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
|
||||
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
|
||||
"library-index": "node internal/generate-library-index.mjs",
|
||||
|
|
@ -411,7 +412,9 @@
|
|||
"pin:poster-render": "node library/scripts/posters/pin-poster-render.mjs",
|
||||
"check:board-map-options": "node internal/check-board-map-options.mjs",
|
||||
"check:panel-sheets": "node internal/check-panel-sheets.mjs",
|
||||
"check:poster-wall-host-seams": "node internal/check-poster-wall-host-seams.mjs"
|
||||
"check:poster-wall-host-seams": "node internal/check-poster-wall-host-seams.mjs",
|
||||
"check:shell-header:self-test": "node internal/check-shell-header.mjs --self-test",
|
||||
"check:shell-header": "node internal/check-shell-header.mjs"
|
||||
},
|
||||
"//scripts": {
|
||||
"_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.",
|
||||
|
|
@ -566,6 +569,7 @@
|
|||
"check:packet-a1-structure": "the prayer packet is built to its specified structure, read page by page from each sheet's CONTENT: full detail = map, triangle, life calendar, % evangelical, profiles, bounded set, centered set, wheel, points; lightweight = map, triangle, profiles, wheel, points. Page 1 places a map-only vertical sheet on the packet's own margin and says it is one of the UPG 100 prayer lists only when a count is supplied; page 2 is one inverted triangle whose packets tier and \"- N\" steps appear only where true; the wheel carries the Metacamp Module 1 PDF's colours and opens at nine o'clock; all 28 legend zones resolve, Deaf included.",
|
||||
"check:india-states": "India's states-and-union-territories table (data/people-groups/india-states.json) stays true to the stamping it is derived from: all 36 ISO 3166-2:IN divisions (28 state + 8 union_territory, Nagaland + Mizoram listed even when empty), every India people group of admin-membership.json in exactly ONE division, every shapeName a feature of the served geometry, NO DRIFT (re-derived from today's inputs it is byte-identical and meta's input sha384s are the files on disk), and pin == table == served copy under a size ceiling. Mutation-tested. Headless; in gates:pre. By the UPG-100 owner.",
|
||||
"check:batch-selection-export": "UPG-100's batch selection EXPORTS its selection (restored 2026-09-30): Copy's first line parses in data/apply-grouping.mjs (names, UUPG total, colour) with the people-group CSV after it; the CSV header, quoting and formula-neutralising hold under a real CSV parse; the HUD wires the colour swatches, hex input, Copy, Export CSV and the records prefetch; the 9 new strings are in all 11 locales, translated. Mutation-tested (CSV first, no formula guard, Copy unwired: each FAILS). Headless; in gates:pre. By the UPG-100 owner.",
|
||||
"check:upg-count": "UPG-100 never writes its list's TOTAL into a sentence (the wall intro printed a stale 2,106 in eleven languages): the total comes from the funnel's last step (data/upg-total.js), no upgMap value in any locale carries it or 2,106 in any digit system (Latin, Arabic-Indic, Devanagari; a built-in negative control plants it), upgMap.wallIntroA takes {n} ×11, and the wall intro + the fetching status pass the live total. Mutation-tested. Headless; in gates:pre. By the UPG-100 owner.",
|
||||
"check:tree-relative": "the committed tree names no host of its own. With embedBaseUrl empty in the build config, the manifest must say embedBase \".\" and every scriptUrl, iframeUrl and embed snippet src must be relative. check:drop cannot see this: it looks for root-relative \"/...\" references and skips https:// ones, so a tree built with EMBED_BASE_URL (a deploy build) would pass it and pin every snippet to one host, the way the retired CDN route broke. Data addresses (runtime.apiBaseUrl) are not checked. SKIPs when the config sets embedBaseUrl. Static; takes an optional manifest path for poison tests.",
|
||||
"check:universal-props": "every plugin reads the props the Plugin Studio header sets for all of them through the library: map engine, locale, Mapbox token and token URL, and the people-group list. It reads each bundle's source AND its shipped bundle and fails on a bypass (a mapbox:// style literal, a private token global, a list fetched around the library); a by-design exception is ALLOWed by name with its reason. --report also prints the matrix. Headless.",
|
||||
"check:zone-title-tier-a": "generated zone titles ship for ONE-country zones only: the 3 such zones produce a localized, non-code title in en/es/ar that differs across languages, AND every other zone declines so its hand-written label survives unchanged (a multi-country zone has a name that is not a list of its members). Two-sided, and NOT MEASURED when the country-name supplier cannot resolve a known code.",
|
||||
|
|
@ -673,7 +677,8 @@
|
|||
"check:popup-pan-controls": "the popup auto-pan reserves the map's bottom control band, so a popup that clears the CLIP is also clear of the things that take the click. Measured at 390 on a 366x732 map: the pan fitted the popup inside the container, cut 0, and a real click on Full Profile still did nothing because the top element at the button's centre was div.maplibregl-ctrl-attrib-inner (and span.pin-hint-pill on the sibling pin) - a cut-height check cannot see this, because the cut really was zero. Attribution is a licence term of the basemap, so the popup may not be raised over it; the usable frame is shorter than the container and the fit test says so. Runs the SHIPPED panPlanFor, read out of the SFC with new Function (it is not exported), never a re-typed copy, and asserts where the popup ENDS UP rather than re-deriving the expected number from the same formula. Sweeps band heights 0-120px and every width the component renders at. Source-only, headless.",
|
||||
"check:control-inset": "bottomControlInset MEASURES the map's real bottom control band, so the pan reserves the band the controls actually occupy. check:popup-pan-controls proves the ARITHMETIC; this proves the band is real. If the inset silently returned 0 -- a selector typo, an engine renaming its control classes, a control moving outside the container -- panPlanFor would get bottomInset 0, behave exactly as it did before the fix, and check:popup-pan-controls would still pass every case, because its arithmetic is still correct: the bug back with both gates green. 8 cases against a real DOM, running the SHIPPED bottomControlInset read out of the SFC (it is not exported), never a copy: the band equals what the DOM independently says (not merely non-zero); no controls reserves 0; a zero-sized control reserves nothing; the taller of two corners wins; a wrapped attribution reserves more than one line; a control outside the container is ignored; a null container returns 0; and the MAPBOX corners count too, since the component takes a provider prop and every other case renders MapLibre names. Needs a browser: every assertion rests on getBoundingClientRect. 75 = no slot, never a pass.",
|
||||
"check:panel-sheets": "the shared panel geometry (library/components/poster/panelSheets.js) for posters printed on several sheets: 60x96 = 3 × 60×36 stacked and 66x66 = 2 × 36×66 side by side, 6 in joins, and a single sheet; the sheets tile the finished poster, glue slices tile and bleed slices repeat exactly the join, a bleed join prints a guide not a solid band, a screen preview always shows the red band, and the Bless 5x8ft takes its bands and marks from the module. Two-sided (4 planted defects). Headless.",
|
||||
"check:poster-wall-host-seams": "PosterWall's two host seams, and that with neither set every wall behaves as before. headerAction: default null is the Show-on-map TOGGLE (aria-pressed, one window event per click, is-last, no emit); { labelKey, ariaKey } is a plain button that emits header-action with the item, no window event, no aria-pressed, and reads the label alone for an item with no count. previewSrc: default null is the stored previews/<id>.webp and no IntersectionObserver; set, a card renders only once in view of the wall's own scroller, one render at a time, cached per page, and null / a throw / a timeout ends on the preview-unavailable placeholder. Image errors: a default wall retries the tree's own copy before the placeholder; a generated preview goes straight to it. Section heads: a section with people reads \"<n> people groups · <items>\" exactly as before; one with none reads its items alone. sectionItemCountKey lets a wall count one section in its own words ({ key, n }); unnamed sections keep the default. Headless: PosterWall compiled as a client component on a recording renderer; real onClick and a recording IntersectionObserver. 17 mutants each FAIL."
|
||||
"check:poster-wall-host-seams": "PosterWall's two host seams, and that with neither set every wall behaves as before. headerAction: default null is the Show-on-map TOGGLE (aria-pressed, one window event per click, is-last, no emit); { labelKey, ariaKey } is a plain button that emits header-action with the item, no window event, no aria-pressed, and reads the label alone for an item with no count. previewSrc: default null is the stored previews/<id>.webp and no IntersectionObserver; set, a card renders only once in view of the wall's own scroller, one render at a time, cached per page, and null / a throw / a timeout ends on the preview-unavailable placeholder. Image errors: a default wall retries the tree's own copy before the placeholder; a generated preview goes straight to it. Section heads: a section with people reads \"<n> people groups · <items>\" exactly as before; one with none reads its items alone. sectionItemCountKey lets a wall count one section in its own words ({ key, n }); unnamed sections keep the default. Headless: PosterWall compiled as a client component on a recording renderer; real onClick and a recording IntersectionObserver. 17 mutants each FAIL.",
|
||||
"check:shell-header": "a header-off shell has ONE way to its tabs and ONE language menu, in the right place for the width: VerticalTabsLayout renders the phone bar (☰ + #phone-end) only when narrow AND header-off, #rail-end only when wide AND header-off, and no corner circle; ShellLanguageMenu's pick dispatches exactly one cancelable, composed, bubbling doxa-language-request (detail.language), does not switch when a host cancels, and writes ?lang= only on a tree-owned page; every switcher language has a catalogue and the current language is always listed. Headless (Vite SSR). Run the `check:shell-header:self-test` script for the NEGATIVE test (11 planted defects)"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/android": "^7.6.9",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Embedding the Research Gallery
|
||||
# Embedding Deep Research
|
||||
|
||||

|
||||

|
||||
|
||||
`<doxa-maps-spa>` (labeled **Research Gallery**) is the capability-5 app shell: one element whose
|
||||
`<doxa-maps-spa>` (labeled **Deep Research**) is the capability-5 app shell: one element whose
|
||||
tab rail nests every production map. It is built to drop onto somebody else's page. Below,
|
||||
`TREE` means the URL of the built `doxa-maps/` folder wherever it is served, for example
|
||||
`https://YOUR-HOST/doxa-maps`.
|
||||
|
|
|
|||
|
|
@ -850,6 +850,11 @@ function handleAggregateResult(evt) {
|
|||
}
|
||||
}
|
||||
|
||||
// The pins' resting opacity: the value the library creates the layer with (useMapLayers'
|
||||
// language-family-pins). Every path that clears a filter restores this, so a cleared map looks
|
||||
// exactly like a fresh one.
|
||||
const PIN_REST_OPACITY = 1
|
||||
|
||||
// Un-apply the aggregate filter so the map returns to normal colors.
|
||||
let _aggregateFilterActive = false
|
||||
function clearAggregateFilter() {
|
||||
|
|
@ -857,7 +862,7 @@ function clearAggregateFilter() {
|
|||
const m = map.value
|
||||
if (!m?.getLayer('language-family-pins')) return
|
||||
m.setPaintProperty('language-family-pins', 'circle-color', getBaseColorExpr())
|
||||
m.setPaintProperty('language-family-pins', 'circle-opacity', 0.9)
|
||||
m.setPaintProperty('language-family-pins', 'circle-opacity', PIN_REST_OPACITY)
|
||||
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', 1)
|
||||
_aggregateFilterActive = false
|
||||
}
|
||||
|
|
@ -1215,7 +1220,7 @@ function applyLegendFilter(filterType, filterKey) {
|
|||
if (!filterKey) {
|
||||
_currentFilter = { type: null, key: null }
|
||||
m.setPaintProperty('language-family-pins', 'circle-color', getBaseColorExpr())
|
||||
m.setPaintProperty('language-family-pins', 'circle-opacity', 0.9)
|
||||
m.setPaintProperty('language-family-pins', 'circle-opacity', PIN_REST_OPACITY)
|
||||
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', 1)
|
||||
_syncHitboxFilter(m, null)
|
||||
mapLayers.syncGlowFilter(null)
|
||||
|
|
@ -1227,7 +1232,7 @@ function applyLegendFilter(filterType, filterKey) {
|
|||
if (!matchExpr) {
|
||||
_currentFilter = { type: null, key: null }
|
||||
m.setPaintProperty('language-family-pins', 'circle-color', getBaseColorExpr())
|
||||
m.setPaintProperty('language-family-pins', 'circle-opacity', 0.9)
|
||||
m.setPaintProperty('language-family-pins', 'circle-opacity', PIN_REST_OPACITY)
|
||||
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', 1)
|
||||
_syncHitboxFilter(m, null)
|
||||
mapLayers.syncGlowFilter(null)
|
||||
|
|
|
|||
|
|
@ -74,6 +74,28 @@ export function selectionClipboard(selection, opts = {}, csv = '') {
|
|||
return csv ? head + '\n\n' + csv : head;
|
||||
}
|
||||
|
||||
/**
|
||||
* `promise` if it settles within `ms`, else `marker`. A rejection still rejects (so a load failure keeps its own
|
||||
* message). Copy uses it so a stalled people-group load can never hold the territory lines hostage: those need
|
||||
* no network, and apply-grouping needs nothing else.
|
||||
*/
|
||||
export function settleWithin(promise, ms, marker) {
|
||||
let timer = 0;
|
||||
const late = new Promise((resolve) => { timer = setTimeout(() => resolve(marker), ms); });
|
||||
return Promise.race([promise, late]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
/**
|
||||
* The error a READER sees for a failed load. One that already carries a catalogue key (keyedError: an HTTP status,
|
||||
* no service configured) passes through, because its text is already true and translated. Anything else (the
|
||||
* request aborted by our own timeout, the network gone, a body that is not JSON) arrives as the browser's raw English
|
||||
* ("signal is aborted without reason"), so it becomes `fallback(e)`, the keyed "still loading, try again" text. The
|
||||
* failed load leaves the cache, so trying again really does fetch again.
|
||||
*/
|
||||
export function asReaderError(e, fallback) {
|
||||
return e && e.i18nKey ? e : fallback(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* The names a selection's Prayer list and Card deck are titled by: its zones (already localized by the caller), its
|
||||
* countries, and the India states and union territories that BROUGHT people (`from`: slug → the territory names
|
||||
|
|
@ -97,4 +119,4 @@ export function selectionCsvFileName(label) {
|
|||
return `upg-100-${base}.csv`;
|
||||
}
|
||||
|
||||
export default { SELECTION_CSV_COLUMNS, csvCell, payloadName, regroupKind, regroupable, selectionCsv, selectionClipboard, selectionTitleNames, selectionCsvFileName };
|
||||
export default { SELECTION_CSV_COLUMNS, csvCell, payloadName, regroupKind, regroupable, selectionCsv, selectionClipboard, settleWithin, asReaderError, selectionTitleNames, selectionCsvFileName };
|
||||
|
|
|
|||
36
plugin-designs/my-upg-100-list/data/upg-total.js
vendored
Normal file
36
plugin-designs/my-upg-100-list/data/upg-total.js
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// upg-total.js — how many people groups the UPG-100 list covers, from ONE place: the funnel's last step
|
||||
// (data/people-groups/vision-funnel.json, the same figure the packet's footnote uses). Never write the number into
|
||||
// a string: the list grows (2,106 was printed in eleven languages after it had become 2,107), so every sentence
|
||||
// takes it as {n}, formatted for the reader's locale. check:upg-count keeps it that way.
|
||||
// PURE (no JSON import, so node gates can import it): the .vue callers import the funnel and pass it in.
|
||||
|
||||
/** The list's total: the funnel's last step (the whole DOXA set). 0 when the funnel is missing. */
|
||||
export function upgTotal(funnel) {
|
||||
const steps = (funnel && Array.isArray(funnel.steps)) ? funnel.steps : []
|
||||
return Number(steps.length ? steps[steps.length - 1].groups : 0) || 0
|
||||
}
|
||||
|
||||
/** A count as the reader writes numbers (2,107 · 2.107 · 2 107), in `numberingSystem` when one is given (٢٬١٠٧). */
|
||||
export function formatCount(n, locale, numberingSystem) {
|
||||
try { return new Intl.NumberFormat(locale || undefined, numberingSystem ? { numberingSystem } : undefined).format(n) } catch (_) { return String(n) }
|
||||
}
|
||||
|
||||
// The non-Western digit system a text already writes its numbers in, or null (Western digits, or none).
|
||||
const DIGIT_SYSTEMS = [['arab', /[\u0660-\u0669]/], ['arabext', /[\u06F0-\u06F9]/], ['deva', /[\u0966-\u096F]/], ['beng', /[\u09E6-\u09EF]/]]
|
||||
export function digitSystemOf(text) {
|
||||
for (const [nu, re] of DIGIT_SYSTEMS) if (re.test(String(text || ''))) return nu
|
||||
return null
|
||||
}
|
||||
|
||||
// ★ ONE SENTENCE, ONE DIGIT SYSTEM. {n} is written in the digits the translated sentence already uses for its own
|
||||
// numbers. CLDR's default for a bare `ar` is Western (Intl.NumberFormat('ar') gives "2,107"), but the ar wall intro
|
||||
// writes "٢٧" and "٩:١٤" in Arabic-Indic, so a plain locale format would put "2,107" between them. The sentence is
|
||||
// rendered once with an empty slot to read its digits, then with the count. Locales whose sentence uses Western
|
||||
// digits (or none) format exactly as before.
|
||||
// `tr` is the caller's t(); passing it in keeps this module pure for the node gate.
|
||||
export function countInSentence(tr, key, n, locale, params = {}) {
|
||||
const bare = tr(key, { ...params, n: '' })
|
||||
return tr(key, { ...params, n: formatCount(n, locale, digitSystemOf(bare)) })
|
||||
}
|
||||
|
||||
export default { upgTotal, formatCount, digitSystemOf, countInSentence }
|
||||
|
|
@ -46,6 +46,9 @@ import PosterWall from '@map/components/poster/PosterWall.vue'
|
|||
import { resolveAssetsBase } from '@map/utils/embedBase.js'
|
||||
// The reader's language, for the DATA fetch below. Not for interface text — that goes through t().
|
||||
import { getRuntimeLocale, detectLocale } from '@map/i18n/index.js'
|
||||
// The list's total for the intro, from the funnel (never a number written into a string; see data/upg-total.js).
|
||||
import visionFunnel from '../../data/people-groups/vision-funnel.json'
|
||||
import { upgTotal, countInSentence } from './data/upg-total.js'
|
||||
// Named imports: only the list arrays ship in the bundle; the file's bookkeeping fields stay out.
|
||||
import { zonesForList } from './data/zones-for-list.js'
|
||||
// The SAME normalize+evaluate pipeline the sibling map profile uses to turn the live
|
||||
|
|
@ -244,7 +247,8 @@ const intro = computed(() => {
|
|||
// The three plain-text keys come from the shared catalogue (rule 5B). Until they land, the
|
||||
// single legacy key is still the source of truth, so this renders identically before AND after: no
|
||||
// flag day, and this consumer does not have to land in step with the catalogue.
|
||||
const a = t('messages.upg100WallIntroA')
|
||||
// The total is {n}, formatted for the reader (it printed a stale 2,106 in all eleven languages).
|
||||
const a = countInSentence(t, 'upgMap.wallIntroA', upgTotal(visionFunnel), getRuntimeLocale() || detectLocale())
|
||||
const b = t('messages.upg100WallIntroEmph')
|
||||
const c = t('messages.upg100WallIntroB')
|
||||
const missing = (v, key) => !v || v === key
|
||||
|
|
|
|||
|
|
@ -128,7 +128,9 @@ import { colorForZone } from './data/zone-colors.js'
|
|||
// clicked tapestry feature carries shapeName (ADM1) OR iso3 (ADM0) and this maps
|
||||
// either to { key, name, count } from admin-counts/membership (NEVER edits them).
|
||||
import { resolveTerritory, buildCountryNameMap, formatClipboard, ADM1_COUNTS, ISO3_NAME } from './data/territory-counts.js'
|
||||
import { selectionCsv, selectionClipboard, selectionCsvFileName, regroupable, selectionTitleNames } from './data/selection-export.js'
|
||||
import { selectionCsv, selectionClipboard, selectionCsvFileName, regroupable, settleWithin, asReaderError, selectionTitleNames } from './data/selection-export.js'
|
||||
import visionFunnel from '../../data/people-groups/vision-funnel.json'
|
||||
import { upgTotal, countInSentence } from './data/upg-total.js'
|
||||
// 09b-search-bar §1(a-c): unified search index (PG + zone + territory) + the
|
||||
// tokenized contains matcher. Pure data + pure fns; this profile builds the rows
|
||||
// from the live records (PG), the legend leafZones (zone), and admin-counts +
|
||||
|
|
@ -1824,16 +1826,26 @@ function selectedAdm1Names() {
|
|||
// Copy tap builds its text from cache and writes it inside the tap's user activation (a clipboard write
|
||||
// after a network wait is refused, on phones above all). A failed load is dropped so the next try refetches.
|
||||
let _selRecords = null; // { lang, promise }
|
||||
const RECORDS_TIMEOUT_MS = 30000; // the people-group list: abort a request that has not answered by then
|
||||
const COPY_WAIT_MS = 8000; // Copy waits this long for the people groups, then copies the territories alone
|
||||
// Any load failure without a catalogue key (the timeout's abort, a dropped network) reads as the stall does.
|
||||
const stillLoading = () => keyedError('upgMap.peopleStillLoading', 'the people groups are still loading, try again in a moment');
|
||||
const readerError = (e) => asReaderError(e, stillLoading);
|
||||
function selectionRecordsLoad() {
|
||||
const lang = packetLang();
|
||||
if (_selRecords && _selRecords.lang === lang) return _selRecords.promise;
|
||||
if (!API_BASE) return Promise.reject(keyedError('wall.hud.noApi', 'no people-groups service is configured'));
|
||||
const fields = PRINT_FIELDS; // the print standard's one field list (lists/peopleGroupRecords.js)
|
||||
// A STALLED request must not hold the cache forever: it aborts after RECORDS_TIMEOUT_MS, rejects, and the catch
|
||||
// below drops it, so the next action fetches again.
|
||||
const ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||
const timer = ctrl ? setTimeout(() => ctrl.abort(), RECORDS_TIMEOUT_MS) : 0;
|
||||
const promise = (async () => {
|
||||
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=${lang}`, { cache: 'no-store' });
|
||||
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=${lang}`, { cache: 'no-store', signal: ctrl ? ctrl.signal : undefined })
|
||||
.finally(() => clearTimeout(timer));
|
||||
if (!resp.ok) throw keyedError('wall.hud.fetchFailed', 'could not load the people groups ({status})', { status: resp.status });
|
||||
return normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig));
|
||||
})();
|
||||
})().catch((e) => { throw readerError(e); });
|
||||
_selRecords = { lang, promise };
|
||||
promise.catch(() => { if (_selRecords && _selRecords.promise === promise) _selRecords = null; });
|
||||
return promise;
|
||||
|
|
@ -1919,10 +1931,12 @@ function copySelection() {
|
|||
if (!sel.length) return;
|
||||
const color = selectedColor.value;
|
||||
let count = null, note = '';
|
||||
const text = selectionPeople().then((p) => {
|
||||
const STALLED = {};
|
||||
const text = settleWithin(selectionPeople(), COPY_WAIT_MS, STALLED).then((p) => {
|
||||
if (p === STALLED) { note = t('upgMap.peopleStillLoading'); return selectionClipboard(sel, { color }); }
|
||||
count = p.records.length; note = selectionNote(p.missing);
|
||||
return selectionClipboard(sel, { color }, selectionCsv(p.records, p.from, { color }));
|
||||
}, (e) => { note = errorText(e, t); return selectionClipboard(sel, { color }); });
|
||||
}, (e) => { note = errorText(readerError(e), t); return selectionClipboard(sel, { color }); });
|
||||
text.then((v) => { try { window.__UPG_LAST_CLIPBOARD__ = v } catch (_) {} });
|
||||
// A selection apply-grouping cannot regroup in one go (a mix of kinds, or a legend zone) still copies; the
|
||||
// flash says so, so a partner does not hand an agent a line it will refuse.
|
||||
|
|
@ -1980,7 +1994,7 @@ async function exportSelectionCsv() {
|
|||
const note = selectionNote(p.missing);
|
||||
flashRef(copyFlash, t('upgMap.csvSaved', { count: p.records.length }) + (note ? ' · ' + note.slice(0, 90) : ''), note ? 6000 : 3000);
|
||||
} catch (e) {
|
||||
flashRef(copyFlash, t('upgMap.csvFailed', { reason: errorText(e, t).slice(0, 90) }), 5000);
|
||||
flashRef(copyFlash, t('upgMap.csvFailed', { reason: errorText(readerError(e), t).slice(0, 90) }), 5000);
|
||||
} finally { batchBusy.value = false; busyAction.value = ''; }
|
||||
}
|
||||
|
||||
|
|
@ -2746,7 +2760,7 @@ onMounted(async () => {
|
|||
// The pins need none of it: they are built from `records` (lat/lng) and slugToZone, and the
|
||||
// selector evaluate() at the pin build maps slugs to zone keys without touching any geometry.
|
||||
// 68 KB, arriving at ~2.9 s cold.
|
||||
status.value = t('messages.fetchingUupgs', { n: 2106 })
|
||||
status.value = countInSentence(t, 'messages.fetchingUupgs', upgTotal(visionFunnel), packetLang()) // the funnel's total, never a literal
|
||||
const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await recordsPromise), profileConfig))
|
||||
// 09b shift-select §1(b): build iso3→display-name from the LIVE records so an
|
||||
// ADM0 country click resolves to the exact label the legend shows (the ADM0
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -67,7 +67,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
|
||||
<script src="../../doxa-maps-spa.js" integrity="sha384-I6e3h2TFC+urJVeJBUpvnQmmG1oT8SE0qtUNZKGnzhUDCx64/S8NlFhhIjl6oO+t" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-maps-spa.js" integrity="sha384-eMJnHI1Ed2HY5E2Zx0NpfPeu0upkZUhFg8sffdCZbsz9UP+rnWwQEtSTolFHcQxu" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
|
||||
<script src="../doxa-maps-spa.js" integrity="sha384-I6e3h2TFC+urJVeJBUpvnQmmG1oT8SE0qtUNZKGnzhUDCx64/S8NlFhhIjl6oO+t" crossorigin="anonymous"></script>
|
||||
<script src="../doxa-maps-spa.js" integrity="sha384-eMJnHI1Ed2HY5E2Zx0NpfPeu0upkZUhFg8sffdCZbsz9UP+rnWwQEtSTolFHcQxu" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -361,7 +361,7 @@
|
|||
"bundle": "doxa-maps-spa",
|
||||
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
|
||||
"output": "dx-maps/doxa-maps-spa/doxa-maps-spa.js",
|
||||
"integrity": "sha384-I6e3h2TFC+urJVeJBUpvnQmmG1oT8SE0qtUNZKGnzhUDCx64/S8NlFhhIjl6oO+t",
|
||||
"integrity": "sha384-eMJnHI1Ed2HY5E2Zx0NpfPeu0upkZUhFg8sffdCZbsz9UP+rnWwQEtSTolFHcQxu",
|
||||
"elements": [
|
||||
"doxa-maps-spa"
|
||||
],
|
||||
|
|
@ -391,7 +391,7 @@
|
|||
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./doxa-maps-spa/doxa-maps-spa/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-I6e3h2TFC+urJVeJBUpvnQmmG1oT8SE0qtUNZKGnzhUDCx64/S8NlFhhIjl6oO+t\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-eMJnHI1Ed2HY5E2Zx0NpfPeu0upkZUhFg8sffdCZbsz9UP+rnWwQEtSTolFHcQxu\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -464,7 +464,7 @@
|
|||
"bundle": "doxa-simple-map",
|
||||
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
|
||||
"output": "dx-maps/doxa-simple-map/doxa-simple-map.js",
|
||||
"integrity": "sha384-XzBgYOIOG6CmLTnBtTgvUPBsIgTUrStxnPQnmMxMjpRiLKVzUxMSxlj0t/kNOtIf",
|
||||
"integrity": "sha384-seSUOqnWtxcfoZheLbeQK11GQe/yEPesNoQOonRanttibWkpddniUwjZfGQGB/s8",
|
||||
"elements": [
|
||||
"doxa-map",
|
||||
"doxa-simple-map"
|
||||
|
|
@ -484,8 +484,8 @@
|
|||
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./doxa-simple-map/doxa-simple-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-XzBgYOIOG6CmLTnBtTgvUPBsIgTUrStxnPQnmMxMjpRiLKVzUxMSxlj0t/kNOtIf\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-XzBgYOIOG6CmLTnBtTgvUPBsIgTUrStxnPQnmMxMjpRiLKVzUxMSxlj0t/kNOtIf\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>"
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-seSUOqnWtxcfoZheLbeQK11GQe/yEPesNoQOonRanttibWkpddniUwjZfGQGB/s8\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-seSUOqnWtxcfoZheLbeQK11GQe/yEPesNoQOonRanttibWkpddniUwjZfGQGB/s8\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -882,7 +882,7 @@
|
|||
"bundle": "my-upg-100-list",
|
||||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"output": "dx-maps/my-upg-100-list/my-upg-100-list.js",
|
||||
"integrity": "sha384-SVpZeuorgVIBQxaKjxw3jsEHEZXuF3jxvjoGduL/LNX43z4PsjRCy2rRIbFmE85f",
|
||||
"integrity": "sha384-w4MZ+fMNdxwE/8sP1J21vjE4GbhaBRqzZWXPRb7zzQcK2YCstJnFY3HbvfqErQNz",
|
||||
"elements": [
|
||||
"my-upg-100-list-map"
|
||||
],
|
||||
|
|
@ -905,7 +905,7 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-SVpZeuorgVIBQxaKjxw3jsEHEZXuF3jxvjoGduL/LNX43z4PsjRCy2rRIbFmE85f\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-w4MZ+fMNdxwE/8sP1J21vjE4GbhaBRqzZWXPRb7zzQcK2YCstJnFY3HbvfqErQNz\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -921,8 +921,8 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-SVpZeuorgVIBQxaKjxw3jsEHEZXuF3jxvjoGduL/LNX43z4PsjRCy2rRIbFmE85f\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-SVpZeuorgVIBQxaKjxw3jsEHEZXuF3jxvjoGduL/LNX43z4PsjRCy2rRIbFmE85f\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-w4MZ+fMNdxwE/8sP1J21vjE4GbhaBRqzZWXPRb7zzQcK2YCstJnFY3HbvfqErQNz\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-w4MZ+fMNdxwE/8sP1J21vjE4GbhaBRqzZWXPRb7zzQcK2YCstJnFY3HbvfqErQNz\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -938,8 +938,8 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-SVpZeuorgVIBQxaKjxw3jsEHEZXuF3jxvjoGduL/LNX43z4PsjRCy2rRIbFmE85f\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-SVpZeuorgVIBQxaKjxw3jsEHEZXuF3jxvjoGduL/LNX43z4PsjRCy2rRIbFmE85f\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-w4MZ+fMNdxwE/8sP1J21vjE4GbhaBRqzZWXPRb7zzQcK2YCstJnFY3HbvfqErQNz\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-w4MZ+fMNdxwE/8sP1J21vjE4GbhaBRqzZWXPRb7zzQcK2YCstJnFY3HbvfqErQNz\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -1012,7 +1012,7 @@
|
|||
"bundle": "plugin-studio",
|
||||
"scriptUrl": "./plugin-studio/plugin-studio.js",
|
||||
"output": "dx-maps/plugin-studio/plugin-studio.js",
|
||||
"integrity": "sha384-1Xj2sf1jO2XAjNCS5kSFvmfLVNpZdE+KzXb1x0BL7bg5jNEfpX0xh5bp+0P2ypjI",
|
||||
"integrity": "sha384-Ho56LhDEIeUJwhGvHC9k3g/CTVhnVfz/o4r7QiYj+lHnjwOKZVsHxhGNnqJ16KEn",
|
||||
"elements": [
|
||||
"plugin-studio"
|
||||
],
|
||||
|
|
@ -1043,7 +1043,7 @@
|
|||
"scriptUrl": "./plugin-studio/plugin-studio.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./plugin-studio/plugin-studio/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-1Xj2sf1jO2XAjNCS5kSFvmfLVNpZdE+KzXb1x0BL7bg5jNEfpX0xh5bp+0P2ypjI\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-Ho56LhDEIeUJwhGvHC9k3g/CTVhnVfz/o4r7QiYj+lHnjwOKZVsHxhGNnqJ16KEn\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -1134,7 +1134,7 @@
|
|||
"bundle": "posterverse",
|
||||
"scriptUrl": "./posterverse/posterverse.js",
|
||||
"output": "dx-maps/posterverse/posterverse.js",
|
||||
"integrity": "sha384-kAWi98zRQBcoK3x24F/INIfOqevCKR4xsEjrEqFHXGeOEJKAH3AeCTQhwNo23cvT",
|
||||
"integrity": "sha384-4JtSJ3JzXdoM1lWlRDeXMk8o5rwS76yF8Q6vf4OYtAhDziW4ZBKBfqvU09h6BLDR",
|
||||
"elements": [
|
||||
"posterverse-app"
|
||||
],
|
||||
|
|
@ -1159,7 +1159,7 @@
|
|||
"scriptUrl": "./posterverse/posterverse.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./posterverse/posterverse/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./posterverse/posterverse.js\" integrity=\"sha384-kAWi98zRQBcoK3x24F/INIfOqevCKR4xsEjrEqFHXGeOEJKAH3AeCTQhwNo23cvT\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<posterverse-app profile-config='{\"profile\":\"posterverse\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></posterverse-app>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./posterverse/posterverse.js\" integrity=\"sha384-4JtSJ3JzXdoM1lWlRDeXMk8o5rwS76yF8Q6vf4OYtAhDziW4ZBKBfqvU09h6BLDR\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<posterverse-app profile-config='{\"profile\":\"posterverse\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></posterverse-app>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -76,7 +76,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-XzBgYOIOG6CmLTnBtTgvUPBsIgTUrStxnPQnmMxMjpRiLKVzUxMSxlj0t/kNOtIf" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-seSUOqnWtxcfoZheLbeQK11GQe/yEPesNoQOonRanttibWkpddniUwjZfGQGB/s8" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-XzBgYOIOG6CmLTnBtTgvUPBsIgTUrStxnPQnmMxMjpRiLKVzUxMSxlj0t/kNOtIf" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-seSUOqnWtxcfoZheLbeQK11GQe/yEPesNoQOonRanttibWkpddniUwjZfGQGB/s8" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
|
||||
<script src="../doxa-simple-map.js" integrity="sha384-XzBgYOIOG6CmLTnBtTgvUPBsIgTUrStxnPQnmMxMjpRiLKVzUxMSxlj0t/kNOtIf" crossorigin="anonymous"></script>
|
||||
<script src="../doxa-simple-map.js" integrity="sha384-seSUOqnWtxcfoZheLbeQK11GQe/yEPesNoQOonRanttibWkpddniUwjZfGQGB/s8" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-XzBgYOIOG6CmLTnBtTgvUPBsIgTUrStxnPQnmMxMjpRiLKVzUxMSxlj0t/kNOtIf" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-seSUOqnWtxcfoZheLbeQK11GQe/yEPesNoQOonRanttibWkpddniUwjZfGQGB/s8" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -24,6 +24,6 @@
|
|||
</head>
|
||||
<body>
|
||||
<plugin-studio tree-base="."></plugin-studio>
|
||||
<script src="./plugin-studio/plugin-studio.js" integrity="sha384-1Xj2sf1jO2XAjNCS5kSFvmfLVNpZdE+KzXb1x0BL7bg5jNEfpX0xh5bp+0P2ypjI" crossorigin="anonymous"></script>
|
||||
<script src="./plugin-studio/plugin-studio.js" integrity="sha384-Ho56LhDEIeUJwhGvHC9k3g/CTVhnVfz/o4r7QiYj+lHnjwOKZVsHxhGNnqJ16KEn" crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -361,7 +361,7 @@
|
|||
"bundle": "doxa-maps-spa",
|
||||
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
|
||||
"output": "dx-maps/doxa-maps-spa/doxa-maps-spa.js",
|
||||
"integrity": "sha384-I6e3h2TFC+urJVeJBUpvnQmmG1oT8SE0qtUNZKGnzhUDCx64/S8NlFhhIjl6oO+t",
|
||||
"integrity": "sha384-eMJnHI1Ed2HY5E2Zx0NpfPeu0upkZUhFg8sffdCZbsz9UP+rnWwQEtSTolFHcQxu",
|
||||
"elements": [
|
||||
"doxa-maps-spa"
|
||||
],
|
||||
|
|
@ -391,7 +391,7 @@
|
|||
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./doxa-maps-spa/doxa-maps-spa/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-I6e3h2TFC+urJVeJBUpvnQmmG1oT8SE0qtUNZKGnzhUDCx64/S8NlFhhIjl6oO+t\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-eMJnHI1Ed2HY5E2Zx0NpfPeu0upkZUhFg8sffdCZbsz9UP+rnWwQEtSTolFHcQxu\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -464,7 +464,7 @@
|
|||
"bundle": "doxa-simple-map",
|
||||
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
|
||||
"output": "dx-maps/doxa-simple-map/doxa-simple-map.js",
|
||||
"integrity": "sha384-XzBgYOIOG6CmLTnBtTgvUPBsIgTUrStxnPQnmMxMjpRiLKVzUxMSxlj0t/kNOtIf",
|
||||
"integrity": "sha384-seSUOqnWtxcfoZheLbeQK11GQe/yEPesNoQOonRanttibWkpddniUwjZfGQGB/s8",
|
||||
"elements": [
|
||||
"doxa-map",
|
||||
"doxa-simple-map"
|
||||
|
|
@ -484,8 +484,8 @@
|
|||
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./doxa-simple-map/doxa-simple-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-XzBgYOIOG6CmLTnBtTgvUPBsIgTUrStxnPQnmMxMjpRiLKVzUxMSxlj0t/kNOtIf\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-XzBgYOIOG6CmLTnBtTgvUPBsIgTUrStxnPQnmMxMjpRiLKVzUxMSxlj0t/kNOtIf\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>"
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-seSUOqnWtxcfoZheLbeQK11GQe/yEPesNoQOonRanttibWkpddniUwjZfGQGB/s8\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-seSUOqnWtxcfoZheLbeQK11GQe/yEPesNoQOonRanttibWkpddniUwjZfGQGB/s8\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -882,7 +882,7 @@
|
|||
"bundle": "my-upg-100-list",
|
||||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"output": "dx-maps/my-upg-100-list/my-upg-100-list.js",
|
||||
"integrity": "sha384-SVpZeuorgVIBQxaKjxw3jsEHEZXuF3jxvjoGduL/LNX43z4PsjRCy2rRIbFmE85f",
|
||||
"integrity": "sha384-w4MZ+fMNdxwE/8sP1J21vjE4GbhaBRqzZWXPRb7zzQcK2YCstJnFY3HbvfqErQNz",
|
||||
"elements": [
|
||||
"my-upg-100-list-map"
|
||||
],
|
||||
|
|
@ -905,7 +905,7 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-SVpZeuorgVIBQxaKjxw3jsEHEZXuF3jxvjoGduL/LNX43z4PsjRCy2rRIbFmE85f\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-w4MZ+fMNdxwE/8sP1J21vjE4GbhaBRqzZWXPRb7zzQcK2YCstJnFY3HbvfqErQNz\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -921,8 +921,8 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-SVpZeuorgVIBQxaKjxw3jsEHEZXuF3jxvjoGduL/LNX43z4PsjRCy2rRIbFmE85f\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-SVpZeuorgVIBQxaKjxw3jsEHEZXuF3jxvjoGduL/LNX43z4PsjRCy2rRIbFmE85f\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-w4MZ+fMNdxwE/8sP1J21vjE4GbhaBRqzZWXPRb7zzQcK2YCstJnFY3HbvfqErQNz\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-w4MZ+fMNdxwE/8sP1J21vjE4GbhaBRqzZWXPRb7zzQcK2YCstJnFY3HbvfqErQNz\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -938,8 +938,8 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-SVpZeuorgVIBQxaKjxw3jsEHEZXuF3jxvjoGduL/LNX43z4PsjRCy2rRIbFmE85f\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-SVpZeuorgVIBQxaKjxw3jsEHEZXuF3jxvjoGduL/LNX43z4PsjRCy2rRIbFmE85f\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-w4MZ+fMNdxwE/8sP1J21vjE4GbhaBRqzZWXPRb7zzQcK2YCstJnFY3HbvfqErQNz\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-w4MZ+fMNdxwE/8sP1J21vjE4GbhaBRqzZWXPRb7zzQcK2YCstJnFY3HbvfqErQNz\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -1012,7 +1012,7 @@
|
|||
"bundle": "plugin-studio",
|
||||
"scriptUrl": "./plugin-studio/plugin-studio.js",
|
||||
"output": "dx-maps/plugin-studio/plugin-studio.js",
|
||||
"integrity": "sha384-1Xj2sf1jO2XAjNCS5kSFvmfLVNpZdE+KzXb1x0BL7bg5jNEfpX0xh5bp+0P2ypjI",
|
||||
"integrity": "sha384-Ho56LhDEIeUJwhGvHC9k3g/CTVhnVfz/o4r7QiYj+lHnjwOKZVsHxhGNnqJ16KEn",
|
||||
"elements": [
|
||||
"plugin-studio"
|
||||
],
|
||||
|
|
@ -1043,7 +1043,7 @@
|
|||
"scriptUrl": "./plugin-studio/plugin-studio.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./plugin-studio/plugin-studio/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-1Xj2sf1jO2XAjNCS5kSFvmfLVNpZdE+KzXb1x0BL7bg5jNEfpX0xh5bp+0P2ypjI\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-Ho56LhDEIeUJwhGvHC9k3g/CTVhnVfz/o4r7QiYj+lHnjwOKZVsHxhGNnqJ16KEn\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -1134,7 +1134,7 @@
|
|||
"bundle": "posterverse",
|
||||
"scriptUrl": "./posterverse/posterverse.js",
|
||||
"output": "dx-maps/posterverse/posterverse.js",
|
||||
"integrity": "sha384-kAWi98zRQBcoK3x24F/INIfOqevCKR4xsEjrEqFHXGeOEJKAH3AeCTQhwNo23cvT",
|
||||
"integrity": "sha384-4JtSJ3JzXdoM1lWlRDeXMk8o5rwS76yF8Q6vf4OYtAhDziW4ZBKBfqvU09h6BLDR",
|
||||
"elements": [
|
||||
"posterverse-app"
|
||||
],
|
||||
|
|
@ -1159,7 +1159,7 @@
|
|||
"scriptUrl": "./posterverse/posterverse.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./posterverse/posterverse/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./posterverse/posterverse.js\" integrity=\"sha384-kAWi98zRQBcoK3x24F/INIfOqevCKR4xsEjrEqFHXGeOEJKAH3AeCTQhwNo23cvT\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<posterverse-app profile-config='{\"profile\":\"posterverse\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></posterverse-app>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./posterverse/posterverse.js\" integrity=\"sha384-4JtSJ3JzXdoM1lWlRDeXMk8o5rwS76yF8Q6vf4OYtAhDziW4ZBKBfqvU09h6BLDR\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<posterverse-app profile-config='{\"profile\":\"posterverse\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></posterverse-app>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -67,7 +67,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-SVpZeuorgVIBQxaKjxw3jsEHEZXuF3jxvjoGduL/LNX43z4PsjRCy2rRIbFmE85f" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-w4MZ+fMNdxwE/8sP1J21vjE4GbhaBRqzZWXPRb7zzQcK2YCstJnFY3HbvfqErQNz" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-SVpZeuorgVIBQxaKjxw3jsEHEZXuF3jxvjoGduL/LNX43z4PsjRCy2rRIbFmE85f" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-w4MZ+fMNdxwE/8sP1J21vjE4GbhaBRqzZWXPRb7zzQcK2YCstJnFY3HbvfqErQNz" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-SVpZeuorgVIBQxaKjxw3jsEHEZXuF3jxvjoGduL/LNX43z4PsjRCy2rRIbFmE85f" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-w4MZ+fMNdxwE/8sP1J21vjE4GbhaBRqzZWXPRb7zzQcK2YCstJnFY3HbvfqErQNz" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -67,7 +67,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
|
||||
<script src="../plugin-studio.js" integrity="sha384-1Xj2sf1jO2XAjNCS5kSFvmfLVNpZdE+KzXb1x0BL7bg5jNEfpX0xh5bp+0P2ypjI" crossorigin="anonymous"></script>
|
||||
<script src="../plugin-studio.js" integrity="sha384-Ho56LhDEIeUJwhGvHC9k3g/CTVhnVfz/o4r7QiYj+lHnjwOKZVsHxhGNnqJ16KEn" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
|
||||
<script src="../../plugin-studio.js" integrity="sha384-1Xj2sf1jO2XAjNCS5kSFvmfLVNpZdE+KzXb1x0BL7bg5jNEfpX0xh5bp+0P2ypjI" crossorigin="anonymous"></script>
|
||||
<script src="../../plugin-studio.js" integrity="sha384-Ho56LhDEIeUJwhGvHC9k3g/CTVhnVfz/o4r7QiYj+lHnjwOKZVsHxhGNnqJ16KEn" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -67,7 +67,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <posterverse-app> custom element. -->
|
||||
<script src="../../posterverse.js" integrity="sha384-kAWi98zRQBcoK3x24F/INIfOqevCKR4xsEjrEqFHXGeOEJKAH3AeCTQhwNo23cvT" crossorigin="anonymous"></script>
|
||||
<script src="../../posterverse.js" integrity="sha384-4JtSJ3JzXdoM1lWlRDeXMk8o5rwS76yF8Q6vf4OYtAhDziW4ZBKBfqvU09h6BLDR" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <posterverse-app> custom element. -->
|
||||
<script src="../posterverse.js" integrity="sha384-kAWi98zRQBcoK3x24F/INIfOqevCKR4xsEjrEqFHXGeOEJKAH3AeCTQhwNo23cvT" crossorigin="anonymous"></script>
|
||||
<script src="../posterverse.js" integrity="sha384-4JtSJ3JzXdoM1lWlRDeXMk8o5rwS76yF8Q6vf4OYtAhDziW4ZBKBfqvU09h6BLDR" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ levels 1 and 2 are detected on their own.
|
|||
| 2 | preset element (`parameterized`) | one screen, several named presets from `<screen>.instances.json` | `plugin-designs/doxa-simple-map/` |
|
||||
| 3 | multi-view element (`monolithic`) | many views fused inside one element | `plugin-designs/doxa-research-map/` |
|
||||
| 4 | composite element (`nested-elements-mfe`) | nests other elements, each still embeddable alone, wired by props and events | `plugin-designs/dashboard-plus-grid/` (the Big Picture tab) |
|
||||
| 5 | app shell (`nested-mfe-spa`) | nests whole apps | `plugin-designs/doxa-maps-spa/` (the Research Gallery) |
|
||||
| 5 | app shell (`nested-mfe-spa`) | nests whole apps | `plugin-designs/doxa-maps-spa/` (Deep Research) |
|
||||
|
||||
**Your declaration is checked, not merely trusted.** `internal/check-capability-derived.mjs`
|
||||
derives the level from what your plugin actually `composes` and fails when an explicit `"type"`
|
||||
|
|
@ -221,7 +221,7 @@ line number, because lines move.
|
|||
| Interop: one element reacts to another's events | Simple Research: `plugin-designs/research-map-plus-grid/research-map-plus-grid.vue` | the map listening for the grid's `groups-filtered` event |
|
||||
| Composite element (capability 4) | Big Picture: `plugin-designs/dashboard-plus-grid/dashboard-plus-grid.meta.json` | `"composes"`: two elements, each still embeddable alone |
|
||||
| One bundle, several embeddable screens | UPG-100: `plugin-designs/my-upg-100-list/` | `upg-100-mfe`, `upg-100-list` and `poster-wall` from one bundle |
|
||||
| App shell (capability 5) | Research Gallery: `plugin-designs/doxa-maps-spa/doxa-maps-spa.vue` | every plugin as a tab, filled from the manifest |
|
||||
| App shell (capability 5) | Deep Research: `plugin-designs/doxa-maps-spa/doxa-maps-spa.vue` | every plugin as a tab, filled from the manifest |
|
||||
|
||||
`emissivePaint` and `labelFont` (section 5) have no production-plugin exemplar, because the
|
||||
production plugins get them through the library's own layers. The starter uses both directly:
|
||||
|
|
@ -258,7 +258,7 @@ tag from any host), the **mobile app** (the same folder wrapped by Capacitor, `b
|
|||
the **desktop app** (the same folder in Tauri, `bun run tauri:build`, a proof of concept).
|
||||
|
||||
**Your plugin runs everywhere unless you say otherwise.** A `<profile>.meta.json` `"targets"` list
|
||||
narrows that, and it is read, not decorative: the Research Gallery skips a bundle that does not
|
||||
narrows that, and it is read, not decorative: Deep Research skips a bundle that does not
|
||||
list `web` (`plugin-designs/doxa-maps-spa/doxa-maps-spa.vue:605`). Declare it only for a plugin
|
||||
that genuinely cannot work in a plain web embed — Rudder needs a local speech engine. Details:
|
||||
`references/targets.md`.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ A plugin that needs one names it in its `<profile>.meta.json` `"backend"` key, a
|
|||
`plugin-designs/rudder-voice/rudder-voice.meta.json` does with `"backend": "backend/rudder-engine"`.
|
||||
|
||||
A plugin that needs a local backend almost certainly also needs `"targets"` without `web`, which
|
||||
keeps it out of the Research Gallery — see `targets.md`. Rudder declares both.
|
||||
keeps it out of Deep Research — see `targets.md`. Rudder declares both.
|
||||
|
||||
## The database rule: schema is a commit, never a click
|
||||
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ a widget target without a corner (or a corner without the target), and a `backen
|
|||
|
||||
- `internal/profile-meta.mjs:28` reads the field (string entries only) and `:36` carries it into
|
||||
the manifest, so every consumer of the manifest sees it.
|
||||
- **The Research Gallery skips a bundle whose every profile is a widget or lacks the `web`
|
||||
- **Deep Research skips a bundle whose every profile is a widget or lacks the `web`
|
||||
target** — `plugin-designs/doxa-maps-spa/doxa-maps-spa.vue:605`:
|
||||
|
||||
```js
|
||||
|
|
|
|||
Loading…
Reference in a new issue