Merge development (a77a3280f: the shell header, the docs) into the whole-India landing
# Conflicts: # package.json
This commit is contained in:
commit
448ef0ac70
32 changed files with 541 additions and 166 deletions
|
|
@ -106,8 +106,8 @@ Details, per host (Cloudflare Pages, jsDelivr, any web server) and per embed sty
|
|||
Copy the built `dx-maps/` folder into your site's `public/` (renaming it is fine) and your site becomes
|
||||
the host: no second server and no cross-site setup, because the maps and the page share one origin.
|
||||
|
||||
**Live example:** DX-life's drop-in branch copies the folder into its `public/` and fills `/research` with the
|
||||
Research Gallery.
|
||||
**Live example:** DX-life's drop-in branch copies the folder into its `public/` and fills `/research` with
|
||||
Deep Research.
|
||||
|
||||
**The two routes, on your laptop:** DX-life has one branch per route: `research-gallery-drop-in` (this
|
||||
section, no Docker) and `research-gallery-subdomain` (the dx-maps container on its own address, with
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ desktop path has never been run by anyone, and the steps say so where that is th
|
|||
| Piece | State |
|
||||
|---|---|
|
||||
| The web tree (`bun run build` → `public/dx-maps/`) | Proven. Every landing builds it. |
|
||||
| The generic Tauri shell (`deployment/src-tauri/`) | Exists. It opens the Research Gallery, builds only a Linux `.deb`, and has never been packaged on macOS or Windows. |
|
||||
| The generic Tauri shell (`deployment/src-tauri/`) | Exists. It opens Deep Research, builds only a Linux `.deb`, and has never been packaged on macOS or Windows. |
|
||||
| The engine on Linux (`bun run dev`, laptop mic) | Proven. |
|
||||
| The engine taking the **webview's** microphone (`RUDDER_AUDIO=browser`) | Proven on Linux: audio streamed over `/api/rudder/stream` came back as the full test sentence. |
|
||||
| The engine **compiling** for macOS (arm64, x86_64) and Windows (x86_64) | Proven by cross-compiling. It has **never been run** on either. |
|
||||
|
|
@ -124,6 +124,6 @@ macOS and Windows the app must do the same on first run:
|
|||
- `desktop:build` has run on Linux only; the app it produced has not been launched yet. Nothing has run on macOS or Windows.
|
||||
- The macOS/Windows runtime archive layout, and whether the engine's derived path matches it.
|
||||
- The first-run download, including resuming an interrupted 742 MB download.
|
||||
- Mapbox basemaps in the Research Gallery need the token host to allow the Tauri origin
|
||||
- Mapbox basemaps in Deep Research need the token host to allow the Tauri origin
|
||||
(`deployment/src-tauri/README.md`). Rudder itself needs no token.
|
||||
- If the engine crashes, its speech server may keep running (and holding ~1 GB) until the app quits.
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ The built tree has TWO kinds of page, and they need DIFFERENT framing policy:
|
|||
| **Direct embed** (the product) | `<bundle>/<profile>/index.html`, `<bundle>/<profile>/<instance>/index.html` — the bare map pages the manifest's `iframeUrl` points at | **`*`** — framable on any partner site (that's the whole point) |
|
||||
| **Staging** (internal preview) | tree root `index.html`, per-bundle `<bundle>/index.html` | **`'self'`** — preview only |
|
||||
|
||||
The Research Gallery (`doxa-maps-spa/doxa-maps-spa/index.html`) is a direct-embed page like any
|
||||
Deep Research (`doxa-maps-spa/doxa-maps-spa/index.html`) is a direct-embed page like any
|
||||
other leaf. Its full embed contract (script and iframe) is in
|
||||
`plugin-designs/doxa-maps-spa/EMBED.md`.
|
||||
|
||||
|
|
|
|||
|
|
@ -7,14 +7,15 @@ being duplicated.
|
|||
|
||||
## The one rule that keeps everything clean
|
||||
|
||||
> **Parameterized + genuinely reused by more than one map → the shared library**
|
||||
> **Could serve more than one map → build it in the shared library**, parameterized
|
||||
> (`library/`, alias `@map`).
|
||||
> **Not reused → it lives in your app-profile folder** (`plugin-designs/<your-map>/`).
|
||||
> **One-off or experimental → it lives in your app-profile folder** (`plugin-designs/<your-map>/`).
|
||||
|
||||
"**Local by default, promote deliberately.**" When in doubt, keep your change local to your
|
||||
own map; something graduates into the shared library only once a *second* map genuinely
|
||||
needs it. Full reasoning — the promotion / orphan-audit rule — is in
|
||||
[`../documentation/explanation.md`](../explanation.md).
|
||||
**Reusable by default.** If a component could serve more than one map, build it once in the shared
|
||||
library, driven by props, and land it through the build owner, so other maps adopt it instead of
|
||||
copying it. A one-off or experimental piece stays in your map's folder. A new pattern starts as a pilot
|
||||
on one plugin, is approved, and is then rolled out by each owner. The rule in full:
|
||||
[`skills/plugin-designer`](../../skills/plugin-designer/SKILL.md), section 3.
|
||||
|
||||
## Start here
|
||||
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ rsync -a --delete public/dx-maps/ <your-host-checkout>/dx-maps/
|
|||
it can see and whether it finds itself. To check that what you published is *current*, run
|
||||
`bun run check:catalog-currency`.
|
||||
|
||||
**Your plugin surfaces itself.** The Research Gallery (`doxa-maps-spa`) reads the built `manifest.json`
|
||||
**Your plugin surfaces itself.** Deep Research (`doxa-maps-spa`) reads the built `manifest.json`
|
||||
and puts every bundle it doesn't already curate into its folded **Experimental** shelf, with the label and
|
||||
description from the manifest — no code edit. Promoting it to a curated tab is an optional edit to
|
||||
`TAB_DEFS` and `NAV_MODEL` in `plugin-designs/doxa-maps-spa/`. One row per bundle: a bundle's extra
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ and a change here can break other maps.
|
|||
`import … from '@map/components/ResearchMapFilterPanel.vue'`
|
||||
- **ResearchMapSideMenu** — ResearchMapSideMenu.vue — Side drawer specific to the research-map profile.
|
||||
`import … from '@map/components/ResearchMapSideMenu.vue'`
|
||||
- **ShellLanguageMenu** — ShellLanguageMenu — the language menu a header-off shell puts in VerticalTabsLayout's #phone-end
|
||||
`import … from '@map/components/ShellLanguageMenu.vue'`
|
||||
- **SideMenuDrawer** — SideMenuDrawer.vue — Slide-in side panel for the map application.
|
||||
`import … from '@map/components/SideMenuDrawer.vue'`
|
||||
- **ViewSelect** — ViewSelect.vue — the "SELECT VIEW" switcher: a fieldset + legend wrapping a row of
|
||||
|
|
@ -264,6 +266,8 @@ and a change here can break other maps.
|
|||
`import … from '@map/utils/html.js'`
|
||||
- **languageLabel** — languageLabel.js — turn the API's comma-inverted language label into the natural reading.
|
||||
`import … from '@map/utils/languageLabel.js'`
|
||||
- **languageRequest** — languageRequest.js — how a shell asks its HOST PAGE before it changes language.
|
||||
`import … from '@map/utils/languageRequest.js'`
|
||||
- **loadPinnedUmd** — loadPinnedUmd — load a PINNED, integrity-checked UMD library and capture it WITHOUT touching the host
|
||||
`import … from '@map/utils/loadPinnedUmd.js'`
|
||||
- **peopleGroupPoints** — peopleGroupPoints.js — SHARED (@map/utils/peopleGroupPoints.js).
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ A URL that returns a public token as JSON (`{token|tk|accessToken}`) or as a bar
|
|||
found; a token baked at build time wins.
|
||||
- **The studio:** the reader's URL (localStorage). The studio fetches it itself, and the fetched
|
||||
token then travels as `tk` / `window.MAP_TOKEN`.
|
||||
- **Plugins:** none reads `profile-config.tokenUrl` today. The studio and the Research Gallery
|
||||
- **Plugins:** none reads `profile-config.tokenUrl` today. The studio and Deep Research
|
||||
forward it, but no element consumes it (see the open points below).
|
||||
|
||||
## 5. People-group list: `'doxa'` | `'unreached'` | `'frontier'` | `'frontier-500k'` | `'unengaged'`
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ Smallest first. Which bundle is the current example of each: **[reference-capabi
|
|||
| 2 | **preset-element** (prop-table) | `parameterized` | one profile + a table of named presets; each row is a ready-made map with its own embed page | the bundle ships `<name>.instances.json` — `{ "instances": [{ "id", "title", "props" }] }`; one page per row at `<bundle>/<profile>/<id>/index.html` |
|
||||
| 3 | **multi-view-element** (monolithic) | `monolithic` | one profile holding several screens (tabs or steps), one shown at a time | declared: `<profile>.meta.json` → `{ "type": "monolithic" }` |
|
||||
| 4 | **composite-element** (nested elements) | `nested-elements-mfe` | a profile that places other plugins together and ships the set as one new element | declared: `<profile>.meta.json` → `{ "type": "nested-elements-mfe", "composes": [ … ] }` |
|
||||
| 5 | **app-shell** (nested SPAs) | `nested-mfe-spa` | the outer frame — rail, header, navigation — that mounts any of the others; e.g. the Research Gallery (`doxa-maps-spa`) and the Plugin Studio | declared the same way; a compiled Vue custom element like every other rung |
|
||||
| 5 | **app-shell** (nested SPAs) | `nested-mfe-spa` | the outer frame — rail, header, navigation — that mounts any of the others; e.g. Deep Research (`doxa-maps-spa`) and the Plugin Studio | declared the same way; a compiled Vue custom element like every other rung |
|
||||
|
||||
**Tiers 3–5 are always declared, never guessed** (either spelling; see `TYPE_ALIASES` in
|
||||
`internal/plugin-gallery/discover.mjs`). A folder's apparent simplicity does not decide its tier: an
|
||||
|
|
|
|||
|
|
@ -113,10 +113,11 @@ const PROBE = () => {
|
|||
if (!rail) return { noRail: true }
|
||||
|
||||
// Any control that can bring the rail on screen. Both are legitimate: the header toggle when a
|
||||
// header exists, the floating opener when it does not.
|
||||
// header exists, the phone bar's ☰ (.vtl-phone-menu, which replaced the floating corner circle) when
|
||||
// it does not.
|
||||
const openers = all
|
||||
.filter(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-fab/.test(cls(n)))
|
||||
.map(n => ({ kind: /vtl-fab/.test(cls(n)) ? 'fab' : 'header-menu', on: onScreen(n), name: name(n) }))
|
||||
.filter(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-phone-menu/.test(cls(n)))
|
||||
.map(n => ({ kind: /vtl-phone-menu/.test(cls(n)) ? 'phone-bar' : 'header-menu', on: onScreen(n), name: name(n) }))
|
||||
|
||||
return {
|
||||
railOnScreen: onScreen(rail),
|
||||
|
|
@ -173,7 +174,7 @@ for (const t of targets) {
|
|||
for (const c of (n.children || [])) w(c)
|
||||
})(document.body)
|
||||
const cls = (n) => (n.className || '').toString()
|
||||
const btn = all.find(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-fab/.test(cls(n)) &&
|
||||
const btn = all.find(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-phone-menu/.test(cls(n)) &&
|
||||
n.getBoundingClientRect().width > 0)
|
||||
if (!btn) return { clicked: false }
|
||||
btn.click()
|
||||
|
|
|
|||
173
internal/check-shell-header.mjs
Normal file
173
internal/check-shell-header.mjs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-shell-header — a header-off shell has ONE way to its tabs and ONE language menu, in the right
|
||||
* place for the width, and a language pick ASKS the host page first. Headless (Vite SSR, no browser).
|
||||
*
|
||||
* THE DESIGN (_qa-cycle/studio-organize/MOBILE-SHELL-HEADER-DESIGN.md): on a phone, a header-off shell
|
||||
* (the Research Gallery, Posterverse) gets a 44px bar with ☰ at the start and a #phone-end slot at the
|
||||
* end; on a desktop, a #rail-end slot at the bottom of the rail. The shell fills both with
|
||||
* library/components/ShellLanguageMenu.vue, whose pick dispatches ONE cancelable
|
||||
* `doxa-language-request` on the host element: cancelled → the host changes the language, the shell
|
||||
* does not; not cancelled → the shell switches, and writes ?lang= only on a page the tree generated.
|
||||
*
|
||||
* ASSERTS, on the REAL components:
|
||||
* 1. VerticalTabsLayout renders the phone bar ONLY when narrow AND header-off, #rail-end ONLY when
|
||||
* wide AND header-off (all four combinations), and never the old floating corner circle.
|
||||
* 2. one pick dispatches exactly ONE doxa-language-request, detail.language = the code, and
|
||||
* cancelable, composed and bubbles all true.
|
||||
* 3. a host that cancels → the menu emits no 'switch' and shows the current language again.
|
||||
* 4. nobody cancels → the menu emits 'switch' once, and ?lang= is written only when
|
||||
* window.MAP_OWNS_PAGE === true.
|
||||
* 5. parity: every SWITCHER_LANGUAGES code has a library/i18n/locales/<code>/ catalogue, and the
|
||||
* menu always lists the current language (fixture: de, which the switcher hides).
|
||||
* --self-test plants each defect in a temporary copy and must see every one refused.
|
||||
* EXIT 0 pass · 1 fail
|
||||
*/
|
||||
import { readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs'
|
||||
import { join, resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const VTL = 'library/layouts/VerticalTabsLayout.vue'
|
||||
const MENU = 'library/components/ShellLanguageMenu.vue'
|
||||
const REQ = 'library/utils/languageRequest.js'
|
||||
const LANGS = 'library/layouts/app-wrapper-material/languages.js'
|
||||
|
||||
const { createServer } = await import('vite')
|
||||
const { default: vue } = await import('@vitejs/plugin-vue')
|
||||
const server = await createServer({ root: ROOT, configFile: false, logLevel: 'error', appType: 'custom',
|
||||
server: { middlewareMode: true, hmr: false, ws: false, watch: null }, plugins: [vue()],
|
||||
resolve: { alias: { '@map': join(ROOT, 'library') } } })
|
||||
const { createSSRApp, h, reactive } = await import('vue')
|
||||
const { renderToString } = await import('vue/server-renderer')
|
||||
|
||||
// A stand-in window for the ?lang= arm: records replaceState calls, owns the page or not.
|
||||
function fakeWindow(owns) {
|
||||
const calls = []
|
||||
return { calls, MAP_OWNS_PAGE: owns, location: { href: 'https://example.test/leaf/index.html?tab=x' },
|
||||
history: { state: null, replaceState: (_s, _t, url) => calls.push(url) } }
|
||||
}
|
||||
// A stand-in DOM for the pick: the select sits in a shadow root whose host is an EventTarget.
|
||||
function fakeSelect(value, host) { return { value, getRootNode: () => ({ host }) } }
|
||||
|
||||
async function audit({ vtl = VTL, menu = MENU, langs = LANGS } = {}) {
|
||||
const bad = []
|
||||
const fail = (what, detail) => bad.push(detail ? `${what} — ${detail}` : what)
|
||||
|
||||
// 1. where the bar and the rail-end render. The layout's chrome strings come through tStatic, whose
|
||||
// locale detection reads <html lang>; SSR has no document, so the render gets a one-field stand-in.
|
||||
if (typeof globalThis.document === 'undefined') globalThis.document = { documentElement: { lang: 'en' } }
|
||||
const Layout = (await server.ssrLoadModule('/' + vtl)).default
|
||||
for (const narrow of [true, false]) for (const header of [true, false]) {
|
||||
// A copy of the real layout whose data says `narrow` (in a browser mounted() reads it from matchMedia,
|
||||
// which SSR never runs). A copy, not `extends`: the render function is not inherited through extends.
|
||||
const Comp = { ...Layout, data() { return { ...Layout.data.call(this, this), narrow } } }
|
||||
const app = createSSRApp({ render: () => h(Comp, { header, items: [{ key: 'a', label: 'A' }], active: 'a' }, {
|
||||
'phone-end': () => h('i', { class: 'probe-phone-end' }),
|
||||
'rail-end': () => h('i', { class: 'probe-rail-end' }) }) })
|
||||
const html = (await renderToString(app)).replace(/<!--[\s\S]*?-->/g, '') // template comments are not markup
|
||||
const tag = `narrow=${narrow} header=${header}`
|
||||
const wantBar = narrow && !header, wantRail = !narrow && !header
|
||||
if (/class="vtl-pbar"/.test(html) !== wantBar) fail(`the phone bar ${wantBar ? 'is missing' : 'renders'} at ${tag}`)
|
||||
if (/probe-phone-end/.test(html) !== wantBar) fail(`#phone-end ${wantBar ? 'is not filled' : 'renders'} at ${tag}`)
|
||||
if (/class="vtl-rail-end"/.test(html) !== wantRail || /probe-rail-end/.test(html) !== wantRail) fail(`#rail-end ${wantRail ? 'is missing' : 'renders'} at ${tag}`)
|
||||
if (wantBar && !/class="vtl-phone-menu"[^>]*aria-expanded=/.test(html)) fail(`the bar's ☰ has no aria-expanded at ${tag}`)
|
||||
if (/vtl-fab|--map-fab-/.test(html)) fail(`the old corner circle (or its --map-fab-* footprint) renders at ${tag}`)
|
||||
if (wantBar && !/has-phone-bar/.test(html)) fail(`the phone bar is not a grid row (.has-phone-bar missing) at ${tag}`)
|
||||
}
|
||||
|
||||
// 2-4. the pick, through the menu's REAL handler (its <script setup> bindings; no DOM needed)
|
||||
const Menu = (await server.ssrLoadModule('/' + menu)).default
|
||||
// The menu's setup, run INSIDE an SSR render so its compiled wrapper finds the SSR context; in dev mode a
|
||||
// <script setup> returns its bindings, so the gate calls the component's real onPick and options.
|
||||
const mount = async (current) => {
|
||||
const emitted = []; let b = null
|
||||
const Probe = { setup() { b = Menu.setup(reactive({ current, collapsed: false }), { emit: (e, ...a) => emitted.push([e, ...a]), expose: () => {}, attrs: {}, slots: {} }); return () => null } }
|
||||
await renderToString(createSSRApp(Probe))
|
||||
if (!b || typeof b.onPick !== 'function') throw new Error('ShellLanguageMenu setup did not return its bindings (onPick)')
|
||||
return { b, emitted }
|
||||
}
|
||||
const run = async ({ cancel, owns }) => {
|
||||
const host = new EventTarget(); const seen = []
|
||||
host.addEventListener('doxa-language-request', (e) => { seen.push(e); if (cancel) e.preventDefault() })
|
||||
host.addEventListener('language-change', (e) => seen.push(e))
|
||||
const win = fakeWindow(owns); const had = globalThis.window; globalThis.window = win
|
||||
const { b, emitted } = await mount('en'); const sel = fakeSelect('ar', host)
|
||||
try { b.onPick({ target: sel }) } finally { if (had === undefined) delete globalThis.window; else globalThis.window = had }
|
||||
return { seen, emitted, sel, win }
|
||||
}
|
||||
const up = await run({ cancel: false, owns: true })
|
||||
const reqs = up.seen.filter((e) => e.type === 'doxa-language-request')
|
||||
if (up.seen.length !== 1 || reqs.length !== 1) fail('one pick must dispatch exactly ONE doxa-language-request', `saw ${up.seen.map((e) => e.type).join(', ') || 'none'}`)
|
||||
const e = reqs[0]
|
||||
if (e && !(e.detail && e.detail.language === 'ar')) fail('the event must carry detail.language', JSON.stringify(e.detail))
|
||||
if (e && !(e.cancelable && e.composed && e.bubbles)) fail('the event must be cancelable, composed and bubbling', `cancelable=${e.cancelable} composed=${e.composed} bubbles=${e.bubbles}`)
|
||||
if (up.emitted.filter(([n]) => n === 'switch').length !== 1 || (up.emitted[0] || [])[1] !== 'ar') fail('an uncancelled pick must emit switch("ar") once', JSON.stringify(up.emitted))
|
||||
if (!(up.win.calls.length === 1 && /[?&]lang=ar\b/.test(up.win.calls[0]) && /[?&]tab=x\b/.test(up.win.calls[0]))) fail('an uncancelled pick on a tree-owned page must write ?lang= (keeping the query)', JSON.stringify(up.win.calls))
|
||||
const notOwned = await run({ cancel: false, owns: undefined })
|
||||
if (notOwned.win.calls.length) fail('?lang= was written on a page the tree does not own (MAP_OWNS_PAGE unset)', JSON.stringify(notOwned.win.calls))
|
||||
const cancelled = await run({ cancel: true, owns: true })
|
||||
if (cancelled.emitted.some(([n]) => n === 'switch')) fail('a cancelled request must NOT switch the shell', JSON.stringify(cancelled.emitted))
|
||||
if (cancelled.win.calls.length) fail('a cancelled request must not write ?lang=', JSON.stringify(cancelled.win.calls))
|
||||
if (cancelled.sel.value !== 'en') fail('after a cancel the menu must show the current language again', cancelled.sel.value)
|
||||
|
||||
// 5. parity + the current language
|
||||
const L = await server.ssrLoadModule('/' + langs)
|
||||
const missing = L.SWITCHER_LANGUAGES.map((l) => l.code).filter((c) => !existsSync(join(ROOT, 'library/i18n/locales', c)))
|
||||
if (missing.length) fail('a switcher language has no catalogue', missing.join(', '))
|
||||
const de = (await mount('de')).b.options
|
||||
const codes = (de && de.value ? de.value : []).map((l) => l.code)
|
||||
if (!codes.includes('de')) fail('the menu must list the current language even when the switcher hides it (de)', codes.join(' '))
|
||||
const en = (await mount('en')).b.options
|
||||
if ((en && en.value ? en.value : []).map((l) => l.code).join() !== L.SWITCHER_LANGUAGES.map((l) => l.code).join()) fail('with a listed language current, the menu must be exactly the switcher list')
|
||||
return bad
|
||||
}
|
||||
|
||||
// ── --self-test: each defect planted in a temporary copy must be refused ─────────────────────────────
|
||||
function plant(rel, suffix, edit, redirect = []) {
|
||||
const src = readFileSync(join(ROOT, rel), 'utf8')
|
||||
let out = edit(src)
|
||||
if (out === src) throw new Error(`poison "${suffix}" did not change ${rel}`)
|
||||
for (const [from, to] of redirect) out = out.split(from).join(to)
|
||||
const dot = rel.lastIndexOf('.'); const tmp = rel.slice(0, rel.lastIndexOf('/') + 1) + `.tmp-shellhdr-${suffix}` + rel.slice(dot)
|
||||
writeFileSync(join(ROOT, tmp), out); PLANTED.push(tmp); return tmp
|
||||
}
|
||||
const PLANTED = [] // every temporary copy, removed in finally whatever happens
|
||||
const POISONS = [
|
||||
{ name: 'the bar also shows with a header', files: () => ({ vtl: plant(VTL, 'bar-header', (s) => s.replace('phoneBarShown() { return this.narrow && !this.header }', 'phoneBarShown() { return this.narrow }')) }) },
|
||||
{ name: 'the rail-end also shows on a phone', files: () => ({ vtl: plant(VTL, 'rail-phone', (s) => s.replace('railEndShown() { return !this.narrow && !this.header }', 'railEndShown() { return !this.header }')) }) },
|
||||
{ name: 'the corner circle is back', files: () => ({ vtl: plant(VTL, 'fab', (s) => s.replace('<div v-if="phoneBarShown" class="vtl-pbar">', '<button v-if="phoneBarShown" class="vtl-fab">☰</button><div v-if="phoneBarShown" class="vtl-pbar">')) }) },
|
||||
{ name: 'the event is not cancelable', files: () => { const r = plant(REQ, 'nocancel', (s) => s.replace('bubbles: true, composed: true, cancelable: true', 'bubbles: true, composed: true')); return { menu: plant(MENU, 'm-nocancel', (s) => s + ' ', [['../utils/languageRequest.js', '../utils/' + r.split('/').pop()]]) } } },
|
||||
{ name: 'the event does not leave the shadow root', files: () => { const r = plant(REQ, 'nocomposed', (s) => s.replace('bubbles: true, composed: true, cancelable: true', 'bubbles: true, cancelable: true')); return { menu: plant(MENU, 'm-nocomposed', (s) => s + ' ', [['../utils/languageRequest.js', '../utils/' + r.split('/').pop()]]) } } },
|
||||
{ name: 'the detail is Vue-shaped (an array)', files: () => { const r = plant(REQ, 'arr', (s) => s.replace('detail: { language: code }', 'detail: [{ language: code }]')); return { menu: plant(MENU, 'm-arr', (s) => s + ' ', [['../utils/languageRequest.js', '../utils/' + r.split('/').pop()]]) } } },
|
||||
{ name: 'a second event is fired', files: () => ({ menu: plant(MENU, 'twice', (s) => s.replace(" if (requestLanguage(sel, code)) {", " sel.getRootNode().host.dispatchEvent(new CustomEvent('language-change', { detail: { language: code } }))\n if (requestLanguage(sel, code)) {")) }) },
|
||||
{ name: 'a cancelled pick still switches', files: () => ({ menu: plant(MENU, 'ignorecancel', (s) => s.replace(' if (requestLanguage(sel, code)) {', ' if (requestLanguage(sel, code) || true) {')) }) },
|
||||
{ name: '?lang= is written on any page', files: () => { const r = plant(REQ, 'anypage', (s) => s.replace('win.MAP_OWNS_PAGE !== true || ', '')); return { menu: plant(MENU, 'm-anypage', (s) => s + ' ', [['../utils/languageRequest.js', '../utils/' + r.split('/').pop()]]) } } },
|
||||
{ name: 'the hidden current language is dropped', files: () => ({ menu: plant(MENU, 'nocurrent', (s) => s.replace(' if (cur) list.push(cur)', ' if (cur && !cur.hideFromSwitcher) list.push(cur)')) }) },
|
||||
{ name: 'a switcher language has no catalogue', files: () => ({ langs: plant(LANGS, 'xx', (s) => s.replace("{ code: 'ru', name: 'Russian',", "{ code: 'xx', name: 'Nowhere', nativeName: 'Nowhere', flag: '' },\n { code: 'ru', name: 'Russian',")) }) },
|
||||
]
|
||||
|
||||
let exit = 0
|
||||
try {
|
||||
if (process.argv.includes('--self-test')) {
|
||||
let caught = 0
|
||||
for (const p of POISONS) {
|
||||
try {
|
||||
const bad = await audit(p.files())
|
||||
if (bad.length) { caught++; console.log(` ${p.name}: caught (${bad[0].slice(0, 90)})`) }
|
||||
else { console.log(` ${p.name}: NOT CAUGHT`); exit = 1 }
|
||||
} finally {
|
||||
while (PLANTED.length) rmSync(join(ROOT, PLANTED.pop()), { force: true })
|
||||
}
|
||||
}
|
||||
console.log(exit ? `SHELL-HEADER SELF-TEST: FAIL — ${POISONS.length - caught} of ${POISONS.length} defects passed.` : `SHELL-HEADER SELF-TEST: PASS — all ${POISONS.length} planted defects refused.`)
|
||||
} else {
|
||||
const bad = await audit()
|
||||
if (bad.length) { exit = 1; console.log(`SHELL-HEADER: FAIL — ${bad.length} problem(s)`); for (const b of bad) console.log(' ✗ ' + b) }
|
||||
else console.log('SHELL-HEADER: PASS — the phone bar only on a narrow header-off layout, #rail-end only on a wide one, no corner circle; one cancelable composed doxa-language-request per pick; cancelled → no switch; ?lang= only on tree-owned pages; the switcher has catalogues and the current language is always listed.')
|
||||
}
|
||||
} finally {
|
||||
while (PLANTED.length) rmSync(join(ROOT, PLANTED.pop()), { force: true })
|
||||
await server.close()
|
||||
}
|
||||
process.exit(exit)
|
||||
91
library/components/ShellLanguageMenu.vue
Normal file
91
library/components/ShellLanguageMenu.vue
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<!--
|
||||
ShellLanguageMenu — the language menu a header-off shell puts in VerticalTabsLayout's #phone-end
|
||||
(the phone bar) and #rail-end (the bottom of the desktop rail) slots. ONE component for both shells
|
||||
and both places, so the list, the host contract and the look cannot drift apart.
|
||||
|
||||
THE LIST: SWITCHER_LANGUAGES (app-wrapper-material/languages.js) PLUS the current language when it is
|
||||
one of the hidden ones, so a reader on /de/… always sees Deutsch selected. hideFromSwitcher is the
|
||||
product's choice and is not changed here.
|
||||
|
||||
THE CONTRACT (library/utils/languageRequest.js): a pick first asks the host page with a cancelable
|
||||
doxa-language-request. Cancelled → nothing happens here; the host sets the shell's `language`, and
|
||||
`current` follows. Not cancelled → emits 'switch' (the shell runs its own setLocale + saveLocale)
|
||||
and writes ?lang= only on a page this tree generated.
|
||||
|
||||
A NATIVE <select>, laid transparent over its own face: the OS picker on a phone, the keyboard and
|
||||
screen-reader behaviour of a real select everywhere, and the face (🌐 + the language's own name, or
|
||||
its code in a collapsed rail) is only paint. The menu opens where the OS puts it (upward at the
|
||||
bottom of the rail), so it needs no placement prop.
|
||||
-->
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { SWITCHER_LANGUAGES, getLanguage } from '../layouts/app-wrapper-material/languages.js'
|
||||
import { icon } from '../layouts/app-wrapper-material/icons.js'
|
||||
import { requestLanguage, writeOwnedLangParam } from '../utils/languageRequest.js'
|
||||
import { tStatic } from '../i18n/index.js'
|
||||
|
||||
const props = defineProps({
|
||||
/** The shell's effective language code ('en', 'ar', …). */
|
||||
current: { type: String, default: 'en' },
|
||||
/** The desktop rail is collapsed to its icon strip: show the code, not the name. */
|
||||
collapsed: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['switch'])
|
||||
|
||||
const options = computed(() => {
|
||||
const list = SWITCHER_LANGUAGES.slice()
|
||||
if (props.current && !list.some((l) => l.code === props.current)) {
|
||||
const cur = getLanguage(props.current)
|
||||
if (cur) list.push(cur)
|
||||
}
|
||||
return list
|
||||
})
|
||||
const currentLang = computed(() => getLanguage(props.current) || { code: props.current, nativeName: props.current })
|
||||
// The menu's name, in the language the reader is reading (the catalogue's controls.language).
|
||||
const ariaLabel = computed(() => tStatic('controls.language', props.current) || 'Language')
|
||||
const globe = icon('globe')
|
||||
|
||||
function onPick(e) {
|
||||
const sel = e.target
|
||||
const code = sel.value
|
||||
if (!code || code === props.current) return
|
||||
if (requestLanguage(sel, code)) {
|
||||
emit('switch', code)
|
||||
writeOwnedLangParam(code)
|
||||
} else {
|
||||
// The host took the request; show the current language until the host changes it.
|
||||
sel.value = props.current
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="slm" :class="{ collapsed }">
|
||||
<span class="slm-face" aria-hidden="true">
|
||||
<span class="slm-ic" v-html="globe"></span>
|
||||
<span v-if="collapsed" class="slm-code">{{ String(current).toUpperCase() }}</span>
|
||||
<span v-else class="slm-name" :lang="currentLang.code" :dir="currentLang.dir || null">{{ currentLang.nativeName }}</span>
|
||||
<span v-if="!collapsed" class="slm-caret">▾</span>
|
||||
</span>
|
||||
<select class="slm-select" :value="current" :aria-label="ariaLabel" :title="ariaLabel" @change="onPick">
|
||||
<option v-for="l in options" :key="l.code" :value="l.code" :lang="l.code" :dir="l.dir || null">{{ l.nativeName }}</option>
|
||||
</select>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 44px tall: the phone bar's end slot and the rail's bottom row are both tap targets. */
|
||||
.slm { position: relative; display: inline-flex; align-items: center; min-height: 44px; max-width: 100%; box-sizing: border-box; border-radius: 8px; color: #3b463d; }
|
||||
.slm-face { display: inline-flex; align-items: center; gap: 6px; padding-inline: 10px; min-width: 0; font-size: 13px; font-weight: 500; line-height: 1.2; }
|
||||
.slm-ic { flex: 0 0 auto; display: inline-flex; color: var(--vtl-muted, #6d766e); }
|
||||
.slm-ic :deep(svg) { width: 18px; height: 18px; }
|
||||
.slm-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
||||
.slm-code { font-weight: 600; font-size: 12px; letter-spacing: .03em; }
|
||||
.slm-caret { flex: 0 0 auto; color: var(--vtl-muted, #6d766e); font-size: 11px; }
|
||||
.slm.collapsed .slm-face { flex-direction: column; gap: 2px; padding-inline: 4px; }
|
||||
/* The real control: transparent, covering the face, so every tap and key lands on the select. */
|
||||
.slm-select { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; margin: 0; border: 0; cursor: pointer; font-size: 16px; }
|
||||
.slm:hover { background: var(--vtl-accented, #d1d4cd); }
|
||||
.slm:focus-within { outline: 2px solid var(--vtl-accent, #92b195); outline-offset: -2px; }
|
||||
@media (prefers-reduced-motion: reduce) { .slm { transition: none; } }
|
||||
</style>
|
||||
|
|
@ -7,9 +7,9 @@ A change here does not affect one map. It ripples to **all** of them. So:
|
|||
- **Prototyping or experimenting? Don't edit these.** Put your custom, one-off, not-yet-proven component
|
||||
inside your own map's folder (`plugin-designs/<your-map>/`), where it can be as messy as you need and
|
||||
breaks nothing else.
|
||||
- **Only proven, parameterized, reusable components graduate into this library.** If a component has hard-coded
|
||||
values, assumptions about one specific map, or hasn't been used by more than one map yet — it is **not**
|
||||
ready to live here.
|
||||
- **Reusable by default.** If a component could serve more than one map, build it here once, driven by props,
|
||||
and land it through the build owner, so other maps adopt it instead of copying it. If it has hard-coded
|
||||
values or assumptions about one specific map, it is **not** ready to live here: parameterize it first.
|
||||
- **A reusable component takes props, not assumptions.** Before you move something in, make it parameterized:
|
||||
the map, the legend, the controls, the search bar all take configuration — yours should too.
|
||||
- **If you must change a shared component, check every map that uses it.** Run `bun run dev` and click through
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ without importing each other. This file is the contract they share. It is **docu
|
|||
exists today**, verified against `development`, not a proposal.
|
||||
|
||||
The components still live in `plugin-designs/research-dashboard/src/components/dashboards/`. They
|
||||
are deliberately **not** in `library/` yet: a library component with a single consumer is a
|
||||
relocation, not a promotion, and the repo has already had to move such modules back. They graduate
|
||||
when a second dashboard consumes them.
|
||||
are not in `library/` yet. Under the repo's "reusable by default" rule
|
||||
([`skills/plugin-designer`](../../skills/plugin-designer/SKILL.md), section 3), a component another
|
||||
dashboard could use belongs in `library/`, driven by props; moving these is a library change for the
|
||||
build owner.
|
||||
|
||||
## The shape
|
||||
|
||||
|
|
|
|||
|
|
@ -91,20 +91,11 @@
|
|||
after a resize back to desktop — the previous broken-restore bug.
|
||||
-->
|
||||
<template>
|
||||
<!-- ★ THE FAB'S FOOTPRINT IS PUBLISHED, NOT GUESSED. .vtl-fab is a 44px circle pinned to the
|
||||
bottom-left, and it lives in the LAYOUT — outside every plugin's shadow root — so it paints
|
||||
over plugin content whatever z-index the plugin uses internally (the mobile legend sheet is
|
||||
z-index:1000 and still loses, because they are different stacking contexts). A scrolled-to-
|
||||
the-end legend therefore ends UNDER the circle.
|
||||
A plugin cannot know whether the circle is there, so the layout says so: --map-fab-clearance
|
||||
is set ONLY while the button is actually rendered, and custom properties inherit through the
|
||||
flattened tree, so it reaches into each shadow root. No circle, no reserved space.
|
||||
--map-fab-beside is the same fact for the OTHER axis: where a sibling control must start to
|
||||
sit NEXT TO the circle rather than on top of it (12px inset + 44px button + 8px gap). A
|
||||
consumer that hardcoded 64px would silently overlap the day the circle resizes; reading it
|
||||
means the circle's geometry is stated once, here, by the component that owns it. -->
|
||||
<div class="vtl" :class="{ 'nav-open': navOpen, 'rail-collapsed': railCollapsed, 'no-header': !header }"
|
||||
:style="fabShown ? { '--map-fab-clearance': 'calc(56px + env(safe-area-inset-bottom))', '--map-fab-beside': 'calc(56px + env(safe-area-inset-left))' } : null">
|
||||
<!-- ★ NO CORNER CIRCLE ANY MORE (the phone bar below replaced it). The layout used to publish
|
||||
--map-fab-clearance / --map-fab-beside while a floating ☰ sat over the pane's bottom-start
|
||||
corner; with the ☰ in a bar ABOVE the pane nothing covers plugin content, so neither is set and
|
||||
every reader of them (BackToTopButton, PinHint, LegendMobile, the grid) takes its own fallback. -->
|
||||
<div class="vtl" :class="{ 'nav-open': navOpen, 'rail-collapsed': railCollapsed, 'no-header': !header, 'has-phone-bar': phoneBarShown }">
|
||||
<header class="vtl-top" v-if="header">
|
||||
<button class="vtl-menu" type="button" :aria-expanded="String(navOpen)" @click="navOpen = !navOpen"
|
||||
:aria-label="label('toggleList')">☰</button>
|
||||
|
|
@ -113,25 +104,25 @@
|
|||
<span class="vtl-meta"><slot name="meta" /></span>
|
||||
</header>
|
||||
|
||||
<!-- ★ HEADERLESS MOBILE ESCAPE HATCH — the ONLY way to reach the rail when there is no header.
|
||||
FOUND 2026-09-22 by resizing the cap-5 SPA to a phone width. That SPA is header-OFF by
|
||||
default (the no-stacked-headers rule), so the toggle above — which lives inside
|
||||
`v-if="header"` — does not exist. On mobile the rail is an off-canvas drawer at
|
||||
translateX(-100%) with `navOpen` starting false, and the only other toggle (.vtl-collapse)
|
||||
sits INSIDE the drawer, measured off-screen at x:-244. Net effect: every tab except the one
|
||||
that happened to open was UNREACHABLE. Measured, not inferred.
|
||||
<!-- ★ THE PHONE BAR — the way to the rail AND to the language when there is no header.
|
||||
Header-off shells (the Research Gallery, Posterverse) have no .vtl-top, so on a phone this slim
|
||||
bar is their only chrome: ☰ at the inline START (right in ar, the edge the drawer opens from)
|
||||
and a #phone-end slot at the inline END, which the shell fills with its own language menu
|
||||
(library/components/ShellLanguageMenu.vue). It is a real grid row (.has-phone-bar), so the
|
||||
drawer and the pane both start below it and it never covers plugin content.
|
||||
44px: the ☰ and the menu ARE the bar's tap targets; a 40px bar would push their 44px hit
|
||||
areas into the pane. Rendered ONLY when narrow AND header-off (phoneBarShown), so desktop and
|
||||
every header-on consumer (plugin-studio) render exactly as before. It replaced the floating
|
||||
corner circle that did this job before (found 2026-09-22: header-off on a phone, every tab
|
||||
but the first was unreachable), and check:nav-reachable still presses it. -->
|
||||
<div v-if="phoneBarShown" class="vtl-pbar">
|
||||
<button class="vtl-phone-menu" type="button" :aria-expanded="String(navOpen)"
|
||||
@click="navOpen = !navOpen" :aria-label="label('toggleList')">☰</button>
|
||||
<span class="vtl-pbar-end"><slot name="phone-end" /></span>
|
||||
</div>
|
||||
|
||||
Rendered ONLY when narrow AND headerless, so no existing consumer can see a change: every
|
||||
other case is desktop (rail always visible) or header-on (the toggle above already works).
|
||||
It hides itself while the drawer is open — tapping a tab closes the drawer anyway, and a
|
||||
floating button over an open drawer would just cover a row. -->
|
||||
<button v-if="fabShown"
|
||||
class="vtl-fab" type="button" :aria-expanded="String(navOpen)"
|
||||
@click="navOpen = true" :aria-label="label('openViews')">☰</button>
|
||||
|
||||
<!-- Beside the circle, driving the shell's own scroller. Reads --map-fab-beside, which this
|
||||
layout publishes only while the circle is up, so with no circle it falls back to the
|
||||
plain inset instead of leaving a gap where a button used to be. -->
|
||||
<!-- Driving the shell's own scroller. It reads --map-fab-beside, which nothing sets now that the
|
||||
corner circle is gone, so it sits at its plain inset. -->
|
||||
<BackToTopButton v-if="narrow" :target="activeHostEl"
|
||||
:labels="{ backToTop: T('actions.backToTop', 'Back to top') }" />
|
||||
|
||||
|
|
@ -142,7 +133,7 @@
|
|||
an off-canvas drawer parked at translateX(-100%) and slid in by the `nav-open` class. Because it
|
||||
is never display:none, a mobile-closed drawer can no longer strand the rail after a resize back
|
||||
to desktop (the old cross-breakpoint bug). `aria-hidden` mirrors real visibility for a11y. -->
|
||||
<nav class="vtl-nav" :aria-label="navLabel" :aria-hidden="String(!railShown)"
|
||||
<nav class="vtl-nav" :class="{ 'has-rail-end': railEndShown && !!$slots['rail-end'] }" :aria-label="navLabel" :aria-hidden="String(!railShown)"
|
||||
:role="$slots.rail ? null : 'tablist'" :aria-orientation="$slots.rail ? null : 'vertical'">
|
||||
<!-- THE HOST'S WAY HOME (optional). A host page that embeds this layout full-page (DX Life's
|
||||
/deep-research) has no header of its own there, so it hands in its homepage URL and label
|
||||
|
|
@ -236,6 +227,16 @@
|
|||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- #rail-end: pinned to the BOTTOM of the desktop rail, where apps put account details. A
|
||||
header-off shell puts its language menu here (the same ShellLanguageMenu as the phone bar),
|
||||
so a desktop reader can change language with no header above the pane. Rendered only when
|
||||
WIDE AND header-off: a header-on consumer keeps its #toolbar menu (never two), and on a
|
||||
phone the bar's #phone-end carries it. `collapsed` tells the filler the rail is the 72px
|
||||
icon strip, so it shows the language code instead of its name. -->
|
||||
<div v-if="railEndShown && $slots['rail-end']" class="vtl-rail-end">
|
||||
<slot name="rail-end" :collapsed="railCollapsed" />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="vtl-main">
|
||||
|
|
@ -327,6 +328,8 @@ const VTL_SHADOW_CSS = `
|
|||
content to size it and collapses to 0 (the 1fr row sits empty) → the same 0-height black screen.
|
||||
With no header there is only one region, so declare a single 1fr row and the body fills. */
|
||||
.vtl.no-header { grid-template-rows:1fr; }
|
||||
/* ...except on a phone, where the header-off layout has its slim bar (.vtl-pbar) as a real first row. */
|
||||
.vtl.no-header.has-phone-bar { grid-template-rows:auto 1fr; }
|
||||
/* MATERIAL APP BAR = 64px desktop / 56px mobile (2026-09-21), was 51px from 11px padding.
|
||||
Stated as an explicit min-height rather than left to padding arithmetic, so the number a
|
||||
reader sees is the number that renders. min-height keeps a wrapping title from clipping.
|
||||
|
|
@ -337,17 +340,17 @@ const VTL_SHADOW_CSS = `
|
|||
THIS RULE EXISTS TWICE (VTL_SHADOW_CSS and the scoped style block); keep them identical. */
|
||||
.vtl-top { flex:0 0 auto; display:flex; align-items:center; gap:10px; box-sizing:border-box; min-height:64px; padding:8px 16px; border-bottom:1px solid var(--vtl-border); background:#ffffff; }
|
||||
.vtl-menu { display:none; font:16px/1 system-ui; color:#3b463d; background:#d1d4cd; border:1px solid #d1d4cd; border-radius:8px; padding:5px 10px; cursor:pointer; }
|
||||
/* Headerless-mobile drawer opener. Only ever rendered when narrow AND header-off AND the drawer is
|
||||
shut, so it cannot appear in any layout that already has a working toggle. z-index sits ABOVE the
|
||||
content but BELOW the drawer (.vtl-nav is z-index:20) so the drawer covers it when it slides in.
|
||||
44px is the minimum comfortable touch target; the safe-area insets keep it clear of an iOS home
|
||||
indicator and a notch in landscape. */
|
||||
/* THE CORNER CIRCLE NEVER MOVES: 40px, 8px from the bottom-start corner of the layout (plus the safe area). It does not
|
||||
ride above a legend sheet or follow a scroll; it sits over the sheet's corner, and the collapsed sheet pads its
|
||||
start edge by --map-fab-beside so its caret and title clear it. A fixed spot cannot fly or re-animate. */
|
||||
.vtl-fab { position:absolute; z-index:15; left:calc(8px + env(safe-area-inset-left)); bottom:calc(8px + env(safe-area-inset-bottom)); width:40px; height:40px; display:flex; align-items:center; justify-content:center; font:18px/1 system-ui; color:#3b463d; background:#ffffff; border:1px solid var(--vtl-border); border-radius:50%; box-shadow:0 2px 10px rgba(0,0,0,.18); cursor:pointer; }
|
||||
.vtl-fab:hover { background:#d1d4cd; }
|
||||
.vtl-fab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:2px; }
|
||||
/* THE PHONE BAR (phoneBarShown = narrow AND header-off; it replaced the floating corner circle).
|
||||
A real grid row above .vtl-body (.has-phone-bar below), so the drawer and the pane both start under
|
||||
it and it never covers plugin content. 44px + the top safe area; the rule line is an INSET SHADOW,
|
||||
not a border, so the 44px is the whole bar and the ☰ is a full 44×44 target. Flex follows the
|
||||
writing direction, so in ar the ☰ sits at the right (where the drawer opens) and the #phone-end
|
||||
menu at the left, with no :dir override. THIS RULE EXISTS TWICE; keep the copies identical. */
|
||||
.vtl-pbar { display:flex; align-items:center; gap:8px; box-sizing:border-box; height:calc(44px + env(safe-area-inset-top)); padding:env(safe-area-inset-top) calc(4px + env(safe-area-inset-right)) 0 calc(4px + env(safe-area-inset-left)); background:#ffffff; box-shadow:inset 0 -1px 0 var(--vtl-border); }
|
||||
.vtl-phone-menu { flex:0 0 auto; width:44px; height:44px; display:inline-flex; align-items:center; justify-content:center; padding:0; margin:0; font:20px/1 system-ui; color:#3b463d; background:transparent; border:0; border-radius:8px; cursor:pointer; }
|
||||
.vtl-phone-menu:hover { background:var(--vtl-accented); }
|
||||
.vtl-phone-menu:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
||||
.vtl-pbar-end { margin-inline-start:auto; display:inline-flex; align-items:center; min-width:0; }
|
||||
.vtl-menu:hover { background:#d1d4cd; }
|
||||
.vtl-brand { display:inline-flex; align-items:center; }
|
||||
.vtl-brand:empty { display:none; }
|
||||
|
|
@ -381,6 +384,13 @@ const VTL_SHADOW_CSS = `
|
|||
.vtl-home-ic { flex:0 0 auto; display:inline-flex; }
|
||||
.vtl-home-ic svg { width:16px; height:16px; }
|
||||
.vtl-home-tx { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
/* THE RAIL'S BOTTOM SLOT (#rail-end: railEndShown = wide AND header-off, and a filler present). The nav
|
||||
becomes a column ONLY then, so every other rail lays out exactly as before. margin-top:auto parks the
|
||||
slot at the bottom while the tabs are short; sticky keeps it in view once they scroll. The nav's own
|
||||
bottom padding moves into the slot, so the sticky row sits flush with the rail's bottom edge. */
|
||||
.vtl-nav.has-rail-end { display:flex; flex-direction:column; padding-bottom:0; }
|
||||
.vtl-nav.has-rail-end > * { flex-shrink:0; }
|
||||
.vtl-rail-end { margin-top:auto; position:sticky; bottom:0; padding:8px 0 12px; background:#ffffff; border-top:1px solid var(--vtl-border); }
|
||||
.vtl-grp { margin-bottom:2px; }
|
||||
.vtl-grp + .vtl-grp { margin-top:8px; padding-top:8px; border-top:1px solid var(--vtl-border); }
|
||||
.vtl-tier { width:100%; padding:8px 8px 6px; display:flex; align-items:center; gap:8px; box-sizing:border-box; }
|
||||
|
|
@ -502,6 +512,7 @@ const VTL_SHADOW_CSS = `
|
|||
nav translate -184px the 256px rail slides left, leaving 256-184 = 72px visible
|
||||
main left 72px the content pane starts exactly where the rail ends
|
||||
collapse pad 184px pushes the toggle's icon into the 72px strip that stays on screen
|
||||
(the #rail-end slot takes the same pad, so its menu sits in the strip too)
|
||||
tab pad 188px the same for each row's icon (4px more, matching the old 196/200 pair)
|
||||
Change one without the others and the icons drift out of the visible strip or the content
|
||||
overlaps the rail. Old values were -196 / 60 / 196 / 200.
|
||||
|
|
@ -510,6 +521,7 @@ const VTL_SHADOW_CSS = `
|
|||
.vtl.rail-collapsed .vtl-main { left:72px; }
|
||||
.vtl.rail-collapsed .vtl-collapse, .vtl.rail-collapsed .vtl-home { padding-left:184px; justify-content:flex-start; }
|
||||
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(0deg); }
|
||||
.vtl.rail-collapsed .vtl-rail-end { padding-left:184px; }
|
||||
.vtl.rail-collapsed .vtl-collapse-tx, .vtl.rail-collapsed .vtl-home-tx { display:none; }
|
||||
.vtl.rail-collapsed .vtl-tab { padding-left:188px; gap:0; }
|
||||
.vtl.rail-collapsed .vtl-tab-text { display:none; }
|
||||
|
|
@ -568,12 +580,12 @@ const VTL_SHADOW_CSS = `
|
|||
.vtl:dir(rtl) .vtl-meta, .vtl:dir(rtl) .vtl-td, .vtl:dir(rtl) .vtl-chev { margin-left:0; margin-right:auto; }
|
||||
.vtl:dir(rtl) .vtl-chev:not(.open) { transform:scaleX(-1); }
|
||||
.vtl:dir(rtl) .vtl-collapse-ic svg { transform:scaleX(-1); }
|
||||
.vtl:dir(rtl) .vtl-fab { left:auto; right:calc(8px + env(safe-area-inset-right)); }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-nav { transform:translateX(184px); }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-main { left:0; right:72px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-collapse, .vtl.rail-collapsed:dir(rtl) .vtl-home { padding-left:10px; padding-right:184px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tab { padding-left:12px; padding-right:188px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tier { padding-left:8px; padding-right:196px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-rail-end { padding-left:0; padding-right:184px; }
|
||||
@media (max-width: 760px) {
|
||||
.vtl:dir(rtl) .vtl-nav { transform:translateX(100%); }
|
||||
.vtl.nav-open:dir(rtl) .vtl-nav { transform:translateX(0); }
|
||||
|
|
@ -587,7 +599,7 @@ const VTL_SHADOW_CSS = `
|
|||
/* ★ REDUCED MOTION, OWNED BY THIS COMPONENT. A page-level rule cannot reach into a shadow root,
|
||||
and no generated leaf ships one anyway (measured: 0 across the whole tree). Additive only. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vtl-nav, .vtl-collapse-ic, .vtl-tab, .vtl-tier, .vtl-fab, .vtl-scrim { transition: none !important; animation: none !important; }
|
||||
.vtl-nav, .vtl-collapse-ic, .vtl-tab, .vtl-tier, .vtl-pbar, .vtl-phone-menu, .vtl-rail-end, .vtl-scrim { transition: none !important; animation: none !important; }
|
||||
}
|
||||
`
|
||||
|
||||
|
|
@ -648,14 +660,13 @@ export default defineComponent({
|
|||
},
|
||||
computed: {
|
||||
/**
|
||||
* ★ ONE SOURCE OF TRUTH FOR THE FLOATING CIRCLE, because two copies of this condition is the
|
||||
* bug waiting to happen. The button's v-if and the --map-fab-clearance that reserves room for
|
||||
* it MUST agree: if they drift, you get either a legend whose last row sits under the circle
|
||||
* (the reported case) or a strip of dead whitespace with no circle to justify it. Neither
|
||||
* has a symptom a gate can see, so the fix is to make disagreement impossible rather than to
|
||||
* test for it.
|
||||
* ★ ONE SOURCE OF TRUTH FOR EACH SLOT'S PLACE. The phone bar and the rail's bottom slot are the
|
||||
* same control in two places, and exactly one of them may show: the bar on a phone, the rail-end
|
||||
* on a desktop, and neither when a header is on (its #toolbar carries the menu). The template,
|
||||
* the grid row (.has-phone-bar) and check:shell-header all read these two, never a copy.
|
||||
*/
|
||||
fabShown() { return this.narrow && !this.header && !this.navOpen },
|
||||
phoneBarShown() { return this.narrow && !this.header },
|
||||
railEndShown() { return !this.narrow && !this.header },
|
||||
/**
|
||||
* The element the back-to-top arrow scrolls. .vtl-host is the shell's scroller
|
||||
* (overflow-y:auto) and there is one PER TAB, so this follows `active` rather than binding a
|
||||
|
|
@ -918,6 +929,8 @@ export default defineComponent({
|
|||
content to size it and collapses to 0 (the 1fr row sits empty) → the same 0-height black screen.
|
||||
With no header there is only one region, so declare a single 1fr row and the body fills. */
|
||||
.vtl.no-header { grid-template-rows:1fr; }
|
||||
/* ...except on a phone, where the header-off layout has its slim bar (.vtl-pbar) as a real first row. */
|
||||
.vtl.no-header.has-phone-bar { grid-template-rows:auto 1fr; }
|
||||
/* MATERIAL APP BAR = 64px desktop / 56px mobile (2026-09-21), was 51px from 11px padding.
|
||||
Stated as an explicit min-height rather than left to padding arithmetic, so the number a
|
||||
reader sees is the number that renders. min-height keeps a wrapping title from clipping.
|
||||
|
|
@ -928,17 +941,17 @@ export default defineComponent({
|
|||
THIS RULE EXISTS TWICE (VTL_SHADOW_CSS and the scoped style block); keep them identical. */
|
||||
.vtl-top { flex:0 0 auto; display:flex; align-items:center; gap:10px; box-sizing:border-box; min-height:64px; padding:8px 16px; border-bottom:1px solid var(--vtl-border); background:#ffffff; }
|
||||
.vtl-menu { display:none; font:16px/1 system-ui; color:#3b463d; background:#d1d4cd; border:1px solid #d1d4cd; border-radius:8px; padding:5px 10px; cursor:pointer; }
|
||||
/* Headerless-mobile drawer opener. Only ever rendered when narrow AND header-off AND the drawer is
|
||||
shut, so it cannot appear in any layout that already has a working toggle. z-index sits ABOVE the
|
||||
content but BELOW the drawer (.vtl-nav is z-index:20) so the drawer covers it when it slides in.
|
||||
44px is the minimum comfortable touch target; the safe-area insets keep it clear of an iOS home
|
||||
indicator and a notch in landscape. */
|
||||
/* THE CORNER CIRCLE NEVER MOVES: 40px, 8px from the bottom-start corner of the layout (plus the safe area). It does not
|
||||
ride above a legend sheet or follow a scroll; it sits over the sheet's corner, and the collapsed sheet pads its
|
||||
start edge by --map-fab-beside so its caret and title clear it. A fixed spot cannot fly or re-animate. */
|
||||
.vtl-fab { position:absolute; z-index:15; left:calc(8px + env(safe-area-inset-left)); bottom:calc(8px + env(safe-area-inset-bottom)); width:40px; height:40px; display:flex; align-items:center; justify-content:center; font:18px/1 system-ui; color:#3b463d; background:#ffffff; border:1px solid var(--vtl-border); border-radius:50%; box-shadow:0 2px 10px rgba(0,0,0,.18); cursor:pointer; }
|
||||
.vtl-fab:hover { background:#d1d4cd; }
|
||||
.vtl-fab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:2px; }
|
||||
/* THE PHONE BAR (phoneBarShown = narrow AND header-off; it replaced the floating corner circle).
|
||||
A real grid row above .vtl-body (.has-phone-bar below), so the drawer and the pane both start under
|
||||
it and it never covers plugin content. 44px + the top safe area; the rule line is an INSET SHADOW,
|
||||
not a border, so the 44px is the whole bar and the ☰ is a full 44×44 target. Flex follows the
|
||||
writing direction, so in ar the ☰ sits at the right (where the drawer opens) and the #phone-end
|
||||
menu at the left, with no :dir override. THIS RULE EXISTS TWICE; keep the copies identical. */
|
||||
.vtl-pbar { display:flex; align-items:center; gap:8px; box-sizing:border-box; height:calc(44px + env(safe-area-inset-top)); padding:env(safe-area-inset-top) calc(4px + env(safe-area-inset-right)) 0 calc(4px + env(safe-area-inset-left)); background:#ffffff; box-shadow:inset 0 -1px 0 var(--vtl-border); }
|
||||
.vtl-phone-menu { flex:0 0 auto; width:44px; height:44px; display:inline-flex; align-items:center; justify-content:center; padding:0; margin:0; font:20px/1 system-ui; color:#3b463d; background:transparent; border:0; border-radius:8px; cursor:pointer; }
|
||||
.vtl-phone-menu:hover { background:var(--vtl-accented); }
|
||||
.vtl-phone-menu:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
||||
.vtl-pbar-end { margin-inline-start:auto; display:inline-flex; align-items:center; min-width:0; }
|
||||
.vtl-menu:hover { background:#d1d4cd; }
|
||||
.vtl-brand { display:inline-flex; align-items:center; }
|
||||
.vtl-brand:empty { display:none; }
|
||||
|
|
@ -972,6 +985,13 @@ export default defineComponent({
|
|||
.vtl-home-ic { flex:0 0 auto; display:inline-flex; }
|
||||
.vtl-home-ic svg { width:16px; height:16px; }
|
||||
.vtl-home-tx { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
/* THE RAIL'S BOTTOM SLOT (#rail-end: railEndShown = wide AND header-off, and a filler present). The nav
|
||||
becomes a column ONLY then, so every other rail lays out exactly as before. margin-top:auto parks the
|
||||
slot at the bottom while the tabs are short; sticky keeps it in view once they scroll. The nav's own
|
||||
bottom padding moves into the slot, so the sticky row sits flush with the rail's bottom edge. */
|
||||
.vtl-nav.has-rail-end { display:flex; flex-direction:column; padding-bottom:0; }
|
||||
.vtl-nav.has-rail-end > * { flex-shrink:0; }
|
||||
.vtl-rail-end { margin-top:auto; position:sticky; bottom:0; padding:8px 0 12px; background:#ffffff; border-top:1px solid var(--vtl-border); }
|
||||
.vtl-grp { margin-bottom:2px; }
|
||||
.vtl-grp + .vtl-grp { margin-top:8px; padding-top:8px; border-top:1px solid var(--vtl-border); }
|
||||
.vtl-tier { width:100%; padding:8px 8px 6px; display:flex; align-items:center; gap:8px; box-sizing:border-box; }
|
||||
|
|
@ -1093,6 +1113,7 @@ export default defineComponent({
|
|||
nav translate -184px the 256px rail slides left, leaving 256-184 = 72px visible
|
||||
main left 72px the content pane starts exactly where the rail ends
|
||||
collapse pad 184px pushes the toggle's icon into the 72px strip that stays on screen
|
||||
(the #rail-end slot takes the same pad, so its menu sits in the strip too)
|
||||
tab pad 188px the same for each row's icon (4px more, matching the old 196/200 pair)
|
||||
Change one without the others and the icons drift out of the visible strip or the content
|
||||
overlaps the rail. Old values were -196 / 60 / 196 / 200.
|
||||
|
|
@ -1101,6 +1122,7 @@ export default defineComponent({
|
|||
.vtl.rail-collapsed .vtl-main { left:72px; }
|
||||
.vtl.rail-collapsed .vtl-collapse, .vtl.rail-collapsed .vtl-home { padding-left:184px; justify-content:flex-start; }
|
||||
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(0deg); }
|
||||
.vtl.rail-collapsed .vtl-rail-end { padding-left:184px; }
|
||||
.vtl.rail-collapsed .vtl-collapse-tx, .vtl.rail-collapsed .vtl-home-tx { display:none; }
|
||||
.vtl.rail-collapsed .vtl-tab { padding-left:188px; gap:0; }
|
||||
.vtl.rail-collapsed .vtl-tab-text { display:none; }
|
||||
|
|
@ -1159,12 +1181,12 @@ export default defineComponent({
|
|||
.vtl:dir(rtl) .vtl-meta, .vtl:dir(rtl) .vtl-td, .vtl:dir(rtl) .vtl-chev { margin-left:0; margin-right:auto; }
|
||||
.vtl:dir(rtl) .vtl-chev:not(.open) { transform:scaleX(-1); }
|
||||
.vtl:dir(rtl) .vtl-collapse-ic svg { transform:scaleX(-1); }
|
||||
.vtl:dir(rtl) .vtl-fab { left:auto; right:calc(8px + env(safe-area-inset-right)); }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-nav { transform:translateX(184px); }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-main { left:0; right:72px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-collapse, .vtl.rail-collapsed:dir(rtl) .vtl-home { padding-left:10px; padding-right:184px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tab { padding-left:12px; padding-right:188px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tier { padding-left:8px; padding-right:196px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-rail-end { padding-left:0; padding-right:184px; }
|
||||
@media (max-width: 760px) {
|
||||
.vtl:dir(rtl) .vtl-nav { transform:translateX(100%); }
|
||||
.vtl.nav-open:dir(rtl) .vtl-nav { transform:translateX(0); }
|
||||
|
|
@ -1178,6 +1200,6 @@ export default defineComponent({
|
|||
/* ★ REDUCED MOTION, OWNED BY THIS COMPONENT. A page-level rule cannot reach into a shadow root,
|
||||
and no generated leaf ships one anyway (measured: 0 across the whole tree). Additive only. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vtl-nav, .vtl-collapse-ic, .vtl-tab, .vtl-tier, .vtl-fab, .vtl-scrim { transition: none !important; animation: none !important; }
|
||||
.vtl-nav, .vtl-collapse-ic, .vtl-tab, .vtl-tier, .vtl-pbar, .vtl-phone-menu, .vtl-rail-end, .vtl-scrim { transition: none !important; animation: none !important; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
58
library/utils/languageRequest.js
Normal file
58
library/utils/languageRequest.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* languageRequest.js — how a shell asks its HOST PAGE before it changes language.
|
||||
*
|
||||
* A shell (the Research Gallery, Posterverse) shows its own language menu, but the page around it may
|
||||
* own the language: DX Life keeps it in the URL path (/ar/deep-research), and a shell that switched on
|
||||
* its own would fight the page. So a pick is a REQUEST, sent as one native event on the shell's host
|
||||
* element:
|
||||
*
|
||||
* doxa-language-request detail: { language: 'ar' } bubbles, composed, cancelable
|
||||
*
|
||||
* - A host that owns the language calls preventDefault() and changes it itself (it then sets the
|
||||
* shell's `language` attribute). dispatchEvent returns false, and the shell does NOT switch.
|
||||
* - Nobody cancels (a standalone leaf, or a partner page that set `language="es"` once and never
|
||||
* listens): dispatchEvent returns true, and the shell switches itself. That is why the rule keys on
|
||||
* the cancel and not on whether `language` was passed: a static attribute would otherwise leave a
|
||||
* dropdown that does nothing.
|
||||
*
|
||||
* ★ A NATIVE CustomEvent, NOT Vue's emit. Vue's custom-element emit wraps detail in an ARRAY
|
||||
* (detail[0]) and is never cancelable, so the host could neither read detail.language nor say no.
|
||||
*
|
||||
* The host is found the way library/utils/hostElement.js finds it (one hop out of the shadow root);
|
||||
* that helper takes a Vue instance, this takes the DOM node the pick happened on.
|
||||
*/
|
||||
|
||||
export const LANGUAGE_REQUEST_EVENT = 'doxa-language-request'
|
||||
|
||||
/** The custom element a node is rendered inside, or the node itself outside any shadow root. */
|
||||
export function hostOf(el) {
|
||||
try { const rn = el && el.getRootNode && el.getRootNode(); if (rn && rn.host) return rn.host } catch (_) {}
|
||||
return el || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the host page for `code`. Returns true when NOBODY cancelled (the shell should switch itself),
|
||||
* false when a host took the request (the shell waits for the host to set `language`).
|
||||
*/
|
||||
export function requestLanguage(fromEl, code) {
|
||||
const host = hostOf(fromEl)
|
||||
if (!host || typeof host.dispatchEvent !== 'function') return true
|
||||
return host.dispatchEvent(new CustomEvent(LANGUAGE_REQUEST_EVENT, {
|
||||
detail: { language: code }, bubbles: true, composed: true, cancelable: true,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an uncancelled pick in ?lang=, ONLY on a page this tree generated (window.MAP_OWNS_PAGE ===
|
||||
* true, stamped by internal/generate-tree.mjs and honoured by library/i18n). A host's URL is the host's:
|
||||
* a shell embedded anywhere else never writes it. Returns whether it wrote.
|
||||
*/
|
||||
export function writeOwnedLangParam(code, win = (typeof window !== 'undefined' ? window : null)) {
|
||||
try {
|
||||
if (!win || win.MAP_OWNS_PAGE !== true || !win.history || !win.location) return false
|
||||
const url = new URL(win.location.href)
|
||||
url.searchParams.set('lang', code)
|
||||
win.history.replaceState(win.history.state, '', url.toString())
|
||||
return true
|
||||
} catch (_) { return false }
|
||||
}
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Embedding the Research Gallery
|
||||
# Embedding Deep Research
|
||||
|
||||

|
||||

|
||||
|
||||
`<doxa-maps-spa>` (labeled **Research Gallery**) is the capability-5 app shell: one element whose
|
||||
`<doxa-maps-spa>` (labeled **Deep Research**) is the capability-5 app shell: one element whose
|
||||
tab rail nests every production map. It is built to drop onto somebody else's page. Below,
|
||||
`TREE` means the URL of the built `doxa-maps/` folder wherever it is served, for example
|
||||
`https://YOUR-HOST/doxa-maps`.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -67,7 +67,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
|
||||
<script src="../../doxa-maps-spa.js" integrity="sha384-I6e3h2TFC+urJVeJBUpvnQmmG1oT8SE0qtUNZKGnzhUDCx64/S8NlFhhIjl6oO+t" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-maps-spa.js" integrity="sha384-eMJnHI1Ed2HY5E2Zx0NpfPeu0upkZUhFg8sffdCZbsz9UP+rnWwQEtSTolFHcQxu" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
|
||||
<script src="../doxa-maps-spa.js" integrity="sha384-I6e3h2TFC+urJVeJBUpvnQmmG1oT8SE0qtUNZKGnzhUDCx64/S8NlFhhIjl6oO+t" crossorigin="anonymous"></script>
|
||||
<script src="../doxa-maps-spa.js" integrity="sha384-eMJnHI1Ed2HY5E2Zx0NpfPeu0upkZUhFg8sffdCZbsz9UP+rnWwQEtSTolFHcQxu" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -361,7 +361,7 @@
|
|||
"bundle": "doxa-maps-spa",
|
||||
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
|
||||
"output": "dx-maps/doxa-maps-spa/doxa-maps-spa.js",
|
||||
"integrity": "sha384-I6e3h2TFC+urJVeJBUpvnQmmG1oT8SE0qtUNZKGnzhUDCx64/S8NlFhhIjl6oO+t",
|
||||
"integrity": "sha384-eMJnHI1Ed2HY5E2Zx0NpfPeu0upkZUhFg8sffdCZbsz9UP+rnWwQEtSTolFHcQxu",
|
||||
"elements": [
|
||||
"doxa-maps-spa"
|
||||
],
|
||||
|
|
@ -391,7 +391,7 @@
|
|||
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./doxa-maps-spa/doxa-maps-spa/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-I6e3h2TFC+urJVeJBUpvnQmmG1oT8SE0qtUNZKGnzhUDCx64/S8NlFhhIjl6oO+t\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-eMJnHI1Ed2HY5E2Zx0NpfPeu0upkZUhFg8sffdCZbsz9UP+rnWwQEtSTolFHcQxu\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -24,6 +24,6 @@
|
|||
</head>
|
||||
<body>
|
||||
<plugin-studio tree-base="."></plugin-studio>
|
||||
<script src="./plugin-studio/plugin-studio.js" integrity="sha384-1Xj2sf1jO2XAjNCS5kSFvmfLVNpZdE+KzXb1x0BL7bg5jNEfpX0xh5bp+0P2ypjI" crossorigin="anonymous"></script>
|
||||
<script src="./plugin-studio/plugin-studio.js" integrity="sha384-Ho56LhDEIeUJwhGvHC9k3g/CTVhnVfz/o4r7QiYj+lHnjwOKZVsHxhGNnqJ16KEn" crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -361,7 +361,7 @@
|
|||
"bundle": "doxa-maps-spa",
|
||||
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
|
||||
"output": "dx-maps/doxa-maps-spa/doxa-maps-spa.js",
|
||||
"integrity": "sha384-I6e3h2TFC+urJVeJBUpvnQmmG1oT8SE0qtUNZKGnzhUDCx64/S8NlFhhIjl6oO+t",
|
||||
"integrity": "sha384-eMJnHI1Ed2HY5E2Zx0NpfPeu0upkZUhFg8sffdCZbsz9UP+rnWwQEtSTolFHcQxu",
|
||||
"elements": [
|
||||
"doxa-maps-spa"
|
||||
],
|
||||
|
|
@ -391,7 +391,7 @@
|
|||
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./doxa-maps-spa/doxa-maps-spa/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-I6e3h2TFC+urJVeJBUpvnQmmG1oT8SE0qtUNZKGnzhUDCx64/S8NlFhhIjl6oO+t\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-eMJnHI1Ed2HY5E2Zx0NpfPeu0upkZUhFg8sffdCZbsz9UP+rnWwQEtSTolFHcQxu\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -1012,7 +1012,7 @@
|
|||
"bundle": "plugin-studio",
|
||||
"scriptUrl": "./plugin-studio/plugin-studio.js",
|
||||
"output": "dx-maps/plugin-studio/plugin-studio.js",
|
||||
"integrity": "sha384-1Xj2sf1jO2XAjNCS5kSFvmfLVNpZdE+KzXb1x0BL7bg5jNEfpX0xh5bp+0P2ypjI",
|
||||
"integrity": "sha384-Ho56LhDEIeUJwhGvHC9k3g/CTVhnVfz/o4r7QiYj+lHnjwOKZVsHxhGNnqJ16KEn",
|
||||
"elements": [
|
||||
"plugin-studio"
|
||||
],
|
||||
|
|
@ -1043,7 +1043,7 @@
|
|||
"scriptUrl": "./plugin-studio/plugin-studio.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./plugin-studio/plugin-studio/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-1Xj2sf1jO2XAjNCS5kSFvmfLVNpZdE+KzXb1x0BL7bg5jNEfpX0xh5bp+0P2ypjI\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-Ho56LhDEIeUJwhGvHC9k3g/CTVhnVfz/o4r7QiYj+lHnjwOKZVsHxhGNnqJ16KEn\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -1134,7 +1134,7 @@
|
|||
"bundle": "posterverse",
|
||||
"scriptUrl": "./posterverse/posterverse.js",
|
||||
"output": "dx-maps/posterverse/posterverse.js",
|
||||
"integrity": "sha384-kAWi98zRQBcoK3x24F/INIfOqevCKR4xsEjrEqFHXGeOEJKAH3AeCTQhwNo23cvT",
|
||||
"integrity": "sha384-4JtSJ3JzXdoM1lWlRDeXMk8o5rwS76yF8Q6vf4OYtAhDziW4ZBKBfqvU09h6BLDR",
|
||||
"elements": [
|
||||
"posterverse-app"
|
||||
],
|
||||
|
|
@ -1159,7 +1159,7 @@
|
|||
"scriptUrl": "./posterverse/posterverse.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./posterverse/posterverse/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./posterverse/posterverse.js\" integrity=\"sha384-kAWi98zRQBcoK3x24F/INIfOqevCKR4xsEjrEqFHXGeOEJKAH3AeCTQhwNo23cvT\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<posterverse-app profile-config='{\"profile\":\"posterverse\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></posterverse-app>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./posterverse/posterverse.js\" integrity=\"sha384-4JtSJ3JzXdoM1lWlRDeXMk8o5rwS76yF8Q6vf4OYtAhDziW4ZBKBfqvU09h6BLDR\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<posterverse-app profile-config='{\"profile\":\"posterverse\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></posterverse-app>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -67,7 +67,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
|
||||
<script src="../plugin-studio.js" integrity="sha384-1Xj2sf1jO2XAjNCS5kSFvmfLVNpZdE+KzXb1x0BL7bg5jNEfpX0xh5bp+0P2ypjI" crossorigin="anonymous"></script>
|
||||
<script src="../plugin-studio.js" integrity="sha384-Ho56LhDEIeUJwhGvHC9k3g/CTVhnVfz/o4r7QiYj+lHnjwOKZVsHxhGNnqJ16KEn" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
|
||||
<script src="../../plugin-studio.js" integrity="sha384-1Xj2sf1jO2XAjNCS5kSFvmfLVNpZdE+KzXb1x0BL7bg5jNEfpX0xh5bp+0P2ypjI" crossorigin="anonymous"></script>
|
||||
<script src="../../plugin-studio.js" integrity="sha384-Ho56LhDEIeUJwhGvHC9k3g/CTVhnVfz/o4r7QiYj+lHnjwOKZVsHxhGNnqJ16KEn" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -67,7 +67,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <posterverse-app> custom element. -->
|
||||
<script src="../../posterverse.js" integrity="sha384-kAWi98zRQBcoK3x24F/INIfOqevCKR4xsEjrEqFHXGeOEJKAH3AeCTQhwNo23cvT" crossorigin="anonymous"></script>
|
||||
<script src="../../posterverse.js" integrity="sha384-4JtSJ3JzXdoM1lWlRDeXMk8o5rwS76yF8Q6vf4OYtAhDziW4ZBKBfqvU09h6BLDR" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <posterverse-app> custom element. -->
|
||||
<script src="../posterverse.js" integrity="sha384-kAWi98zRQBcoK3x24F/INIfOqevCKR4xsEjrEqFHXGeOEJKAH3AeCTQhwNo23cvT" crossorigin="anonymous"></script>
|
||||
<script src="../posterverse.js" integrity="sha384-4JtSJ3JzXdoM1lWlRDeXMk8o5rwS76yF8Q6vf4OYtAhDziW4ZBKBfqvU09h6BLDR" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ levels 1 and 2 are detected on their own.
|
|||
| 2 | preset element (`parameterized`) | one screen, several named presets from `<screen>.instances.json` | `plugin-designs/doxa-simple-map/` |
|
||||
| 3 | multi-view element (`monolithic`) | many views fused inside one element | `plugin-designs/doxa-research-map/` |
|
||||
| 4 | composite element (`nested-elements-mfe`) | nests other elements, each still embeddable alone, wired by props and events | `plugin-designs/dashboard-plus-grid/` (the Big Picture tab) |
|
||||
| 5 | app shell (`nested-mfe-spa`) | nests whole apps | `plugin-designs/doxa-maps-spa/` (the Research Gallery) |
|
||||
| 5 | app shell (`nested-mfe-spa`) | nests whole apps | `plugin-designs/doxa-maps-spa/` (Deep Research) |
|
||||
|
||||
**Your declaration is checked, not merely trusted.** `internal/check-capability-derived.mjs`
|
||||
derives the level from what your plugin actually `composes` and fails when an explicit `"type"`
|
||||
|
|
@ -221,7 +221,7 @@ line number, because lines move.
|
|||
| Interop: one element reacts to another's events | Simple Research: `plugin-designs/research-map-plus-grid/research-map-plus-grid.vue` | the map listening for the grid's `groups-filtered` event |
|
||||
| Composite element (capability 4) | Big Picture: `plugin-designs/dashboard-plus-grid/dashboard-plus-grid.meta.json` | `"composes"`: two elements, each still embeddable alone |
|
||||
| One bundle, several embeddable screens | UPG-100: `plugin-designs/my-upg-100-list/` | `upg-100-mfe`, `upg-100-list` and `poster-wall` from one bundle |
|
||||
| App shell (capability 5) | Research Gallery: `plugin-designs/doxa-maps-spa/doxa-maps-spa.vue` | every plugin as a tab, filled from the manifest |
|
||||
| App shell (capability 5) | Deep Research: `plugin-designs/doxa-maps-spa/doxa-maps-spa.vue` | every plugin as a tab, filled from the manifest |
|
||||
|
||||
`emissivePaint` and `labelFont` (section 5) have no production-plugin exemplar, because the
|
||||
production plugins get them through the library's own layers. The starter uses both directly:
|
||||
|
|
@ -258,7 +258,7 @@ tag from any host), the **mobile app** (the same folder wrapped by Capacitor, `b
|
|||
the **desktop app** (the same folder in Tauri, `bun run tauri:build`, a proof of concept).
|
||||
|
||||
**Your plugin runs everywhere unless you say otherwise.** A `<profile>.meta.json` `"targets"` list
|
||||
narrows that, and it is read, not decorative: the Research Gallery skips a bundle that does not
|
||||
narrows that, and it is read, not decorative: Deep Research skips a bundle that does not
|
||||
list `web` (`plugin-designs/doxa-maps-spa/doxa-maps-spa.vue:605`). Declare it only for a plugin
|
||||
that genuinely cannot work in a plain web embed — Rudder needs a local speech engine. Details:
|
||||
`references/targets.md`.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ A plugin that needs one names it in its `<profile>.meta.json` `"backend"` key, a
|
|||
`plugin-designs/rudder-voice/rudder-voice.meta.json` does with `"backend": "backend/rudder-engine"`.
|
||||
|
||||
A plugin that needs a local backend almost certainly also needs `"targets"` without `web`, which
|
||||
keeps it out of the Research Gallery — see `targets.md`. Rudder declares both.
|
||||
keeps it out of Deep Research — see `targets.md`. Rudder declares both.
|
||||
|
||||
## The database rule: schema is a commit, never a click
|
||||
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ a widget target without a corner (or a corner without the target), and a `backen
|
|||
|
||||
- `internal/profile-meta.mjs:28` reads the field (string entries only) and `:36` carries it into
|
||||
the manifest, so every consumer of the manifest sees it.
|
||||
- **The Research Gallery skips a bundle whose every profile is a widget or lacks the `web`
|
||||
- **Deep Research skips a bundle whose every profile is a widget or lacks the `web`
|
||||
target** — `plugin-designs/doxa-maps-spa/doxa-maps-spa.vue:605`:
|
||||
|
||||
```js
|
||||
|
|
|
|||
Loading…
Reference in a new issue