Merge development (a77a3280f: the shell header, the docs) into the whole-India landing

# Conflicts:
#	package.json
This commit is contained in:
Doxa Maps 2026-10-01 10:02:25 +00:00
commit 448ef0ac70
32 changed files with 541 additions and 166 deletions

View file

@ -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

View file

@ -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.

View file

@ -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`.

View file

@ -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

View file

@ -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

View file

@ -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).

View file

@ -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'`

View file

@ -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

View file

@ -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()

View 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)

View 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>

View file

@ -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

View file

@ -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

View file

@ -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>

View 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 }
}

View file

@ -349,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:side-lanes check:side-lanes: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",
"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:side-lanes check:side-lanes: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",
@ -413,6 +413,8 @@
"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:shell-header:self-test": "node internal/check-shell-header.mjs --self-test",
"check:shell-header": "node internal/check-shell-header.mjs",
"check:side-lanes": "node plugin-designs/country-posters/scripts/check-side-lanes.mjs",
"check:side-lanes:self-test": "node plugin-designs/country-posters/scripts/check-side-lanes.mjs --self-test",
"check:side-lane-photo-pixels": "node plugin-designs/country-posters/scripts/check-side-lane-photo-pixels.mjs",
@ -680,6 +682,7 @@
"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: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)",
"check:side-lanes": "the large side-lane sheets (60x96, 66x66, 36x60) through the real poster-render.js, on a SEEDED worst case (855 groups, stacked duplicates, tight clusters, real and placeholder photos): every name drawn exactly once, leader crossings 0 (re-measured from the SVG, and equal to the renderer's own count), no overflow rows, photos only where a real one exists and the format allows them.",
"check:side-lanes:self-test": "a poster-render.js copy WITHOUT the lane clamp must make check:side-lanes' crossing arm FAIL on the same seeded data (it does: 36 crossings), so the arm is shown able to fail.",
"check:side-lane-photo-pixels": "BROWSER gate (browser seat; elsewhere 75 NOT MEASURED): the 60x96 side-lane sheet rasterised by poster-raster.js, measured in pixels at every photo slot. A data: photo draws; every photo on its own host stays flat, never the broken-image glyph an external <image> rasterises as.",

View file

@ -1,8 +1,8 @@
# Embedding the Research Gallery
# Embedding Deep Research
![The Research Gallery embedded by script on a partner page: the tab rail on the left, Big Picture open](../../documentation/readme-images/deploy3-js-embed-dxlife.png)
![Deep Research embedded by script on a partner page: the tab rail on the left, Big Picture open](../../documentation/readme-images/deploy3-js-embed-dxlife.png)
`<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`.

File diff suppressed because one or more lines are too long

View file

@ -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:

View file

@ -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:

View file

@ -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": [
{
@ -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": [
{

View file

@ -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>

View file

@ -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": [
{
@ -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

View file

@ -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:

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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`.

View file

@ -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

View file

@ -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