diff --git a/documentation/DEPLOY.md b/documentation/DEPLOY.md index 570bc7060..3e5477766 100644 --- a/documentation/DEPLOY.md +++ b/documentation/DEPLOY.md @@ -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 diff --git a/documentation/HOWTO-build-the-desktop-app.md b/documentation/HOWTO-build-the-desktop-app.md index 610bea9e9..33360ce45 100644 --- a/documentation/HOWTO-build-the-desktop-app.md +++ b/documentation/HOWTO-build-the-desktop-app.md @@ -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. diff --git a/documentation/architecture/embeddability.md b/documentation/architecture/embeddability.md index c0b18499b..fda666d63 100644 --- a/documentation/architecture/embeddability.md +++ b/documentation/architecture/embeddability.md @@ -65,7 +65,7 @@ The built tree has TWO kinds of page, and they need DIFFERENT framing policy: | **Direct embed** (the product) | `//index.html`, `///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 `/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`. diff --git a/documentation/contributing/README.md b/documentation/contributing/README.md index 7d72ff603..a217ad97d 100644 --- a/documentation/contributing/README.md +++ b/documentation/contributing/README.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//`). +> **One-off or experimental → it lives in your app-profile folder** (`plugin-designs//`). -"**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 diff --git a/documentation/develop-a-plugin-and-see-it-live.md b/documentation/develop-a-plugin-and-see-it-live.md index 614925e46..1a073df05 100644 --- a/documentation/develop-a-plugin-and-see-it-live.md +++ b/documentation/develop-a-plugin-and-see-it-live.md @@ -104,7 +104,7 @@ rsync -a --delete public/dx-maps/ /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 diff --git a/documentation/reference-library-index.md b/documentation/reference-library-index.md index c7949d1c1..934addb27 100644 --- a/documentation/reference-library-index.md +++ b/documentation/reference-library-index.md @@ -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). diff --git a/documentation/reference-universal-props.md b/documentation/reference-universal-props.md index 9b756d0e7..b99875510 100644 --- a/documentation/reference-universal-props.md +++ b/documentation/reference-universal-props.md @@ -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'` diff --git a/documentation/reference.md b/documentation/reference.md index 39419d084..7cf033b8e 100644 --- a/documentation/reference.md +++ b/documentation/reference.md @@ -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 `.instances.json` — `{ "instances": [{ "id", "title", "props" }] }`; one page per row at `///index.html` | | 3 | **multi-view-element** (monolithic) | `monolithic` | one profile holding several screens (tabs or steps), one shown at a time | declared: `.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: `.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 diff --git a/internal/check-batch-selection-export.mjs b/internal/check-batch-selection-export.mjs index 0c75b4830..8ffa3d2d8 100644 --- a/internal/check-batch-selection-export.mjs +++ b/internal/check-batch-selection-export.mjs @@ -112,6 +112,45 @@ ok(!noCountry.length && resolveTerritory({ shapeName: 'Mahārāshtra' }).country ok(!disagree.length, `Copy's regroup warning disagrees with apply-grouping: ${disagree.join('; ')}`, `the regroup warning matches apply-grouping's own verdict in all ${Object.keys(REGROUP_CASES).length} cases (India, China, countries, and the three it refuses)`) +// ── A4. A STALLED people-group load can never hang Copy ───────────────────────────────────────────────── +// Copy races the people-group detection against COPY_WAIT_MS through settleWithin(); past it, it copies the territory +// lines alone (they need no network) and says why. The self-test: a promise that NEVER settles must still resolve to +// the marker in time; a value passes through; a failure still rejects (its own message is kept). +{ + const MARK = {}, t0 = Date.now() + // The gate's own backstop: if settleWithin waited for ever, this reads 'GATE-TIMEOUT' and FAILS by name. + const stalled = await Promise.race([X.settleWithin(new Promise(() => {}), 60, MARK), new Promise((r) => setTimeout(() => r('GATE-TIMEOUT'), 2000))]) + const quick = await X.settleWithin(Promise.resolve('v'), 1000, MARK) + const failed = await X.settleWithin(Promise.reject(new Error('down')), 1000, MARK).then(() => 'resolved', (e) => e.message) + ok(stalled === MARK && Date.now() - t0 < 1000 && quick === 'v' && failed === 'down', + `settleWithin: a stalled source gave ${stalled === MARK ? 'the marker' : JSON.stringify(stalled)}, a value gave ${quick}, a failure gave ${failed}`, + 'a NEVER-settling people-group load still lets Copy settle in time (and a value / a failure pass through)') +} + +// ── A5. A FAILED load never shows the reader a raw browser message ───────────────────────────────────── +// The second Copy after the 30 s abort flashed "signal is aborted without reason". The self-test aborts a REAL fetch +// (the signal is aborted before the request starts, so nothing is sent) and makes a real one fail (a refused +// localhost port, bounded at 2 s); both must read as the keyed stall text. A keyed error keeps its own text. The NEGATIVE CONTROL is +// the old path, errorText without asReaderError, which must still show the raw message, so this test can see the defect. +{ + const { keyedError, errorText } = await import(pathToFileURL(P('library/i18n/keyedError.js')).href) + const en = JSON.parse(readFileSync(P('library/i18n/locales/en/upgMap.json'), 'utf8')) + const tr = (k, p = {}) => { const v = k.startsWith('upgMap.') ? en[k.slice(7)] : undefined; return v == null ? k : v.replace(/\{(\w+)\}/g, (m, x) => (p[x] != null ? String(p[x]) : m)) } + const stillLoading = () => keyedError('upgMap.peopleStillLoading', 'the people groups are still loading, try again in a moment') + const aborted = await fetch('http://127.0.0.1:9/', { signal: AbortSignal.abort() }).then(() => null, (e) => e) + // Bounded: a host running the discard service ACCEPTS port 9 and would hang; a timeout is a failure too. + const refused = await fetch('http://127.0.0.1:9/', { signal: AbortSignal.timeout(2000) }).then(() => null, (e) => e) + const keyed = keyedError('upgMap.selectionAdm1NoData', 'no people-group data for {names}', { names: 'Yunnan' }) + const shown = (e) => errorText(X.asReaderError(e, stillLoading), tr) + const want = en.peopleStillLoading + ok(aborted && aborted.name === 'AbortError' && /abort/i.test(errorText(aborted, tr)), + `NEGATIVE CONTROL: an aborted fetch gave ${aborted && aborted.name} "${aborted && errorText(aborted, tr)}" on the old path, so the test cannot see the defect`, + 'negative control: on the old path an aborted fetch shows the raw "' + (aborted ? errorText(aborted, tr) : '') + '"') + ok(shown(aborted) === want && refused && shown(refused) === want && shown(keyed) === tr('upgMap.selectionAdm1NoData', { names: 'Yunnan' }), + `a failed load reads: aborted "${shown(aborted)}", refused "${refused && shown(refused)}", keyed "${shown(keyed)}"`, + 'an aborted or refused people-group fetch reads as the stall text; a keyed error keeps its own') +} + // ── A6. A STATE-ONLY selection titles its Prayer list / Card deck ─────────────────────────────────────── // Development threw "Reduce of empty array" for a state-only selection: the title named zones and countries only, so // a selection of Gujarāt alone had NO names. On the REAL served table: Gujarāt alone is titled "Gujarāt"; a state + @@ -180,8 +219,13 @@ const wired = { 'the Copy button': /@click="copySelection">\{\{ t\('upgMap\.copySelection'\) \}\}/.test(vue), 'the Export CSV button': /@click="exportSelectionCsv">/.test(vue) && vue.includes("t('upgMap.exportCsv')"), 'the prefetch on batch mode': /if \(next\) selectionRecordsLoad\(\)/.test(vue), - 'one detection for every action': (vue.match(/await selectionPeople\(\)/g) || []).length >= 2 && /selectionPeople\(\)\.then/.test(vue), + 'one detection for every action': (vue.match(/await selectionPeople\(\)/g) || []).length >= 2 && /settleWithin\(selectionPeople\(\)/.test(vue), // India's states resolve through the served table (library/data/indiaStates.js), fetched only once a state is selected + 'Copy waits at most COPY_WAIT_MS for the people groups': /settleWithin\(selectionPeople\(\), COPY_WAIT_MS, STALLED\)/.test(vue) && /if \(p === STALLED\) \{ note = t\('upgMap\.peopleStillLoading'\); return selectionClipboard\(sel, \{ color \}\); \}/.test(vue), + 'the records fetch times out and leaves the cache': /setTimeout\(\(\) => ctrl\.abort\(\), RECORDS_TIMEOUT_MS\)/.test(vue) && /signal: ctrl \? ctrl\.signal : undefined/.test(vue), + 'every failed load reads as the stall, never a raw message': /const readerError = \(e\) => asReaderError\(e, stillLoading\)/.test(vue) + && /\}\)\(\)\.catch\(\(e\) => \{ throw readerError\(e\); \}\);/.test(vue) + && /note = errorText\(readerError\(e\), t\)/.test(vue) && /csvFailed', \{ reason: errorText\(readerError\(e\), t\)/.test(vue), // In batch mode a tap on a pin opens NO detail sheet (it covered the whole HUD on a phone); the tapestry // listener under it toggles the territory. The real-tap proof is a browser check; this pins the guard in the source. 'a pin tap in batch mode opens no sheet': /map\.on\('click', 'upg-pins-hit', \(e\) => \{[\s\S]{0,700}?if \(batchMode\.value\) return\s*\n\s*const f = e && e\.features/.test(vue), @@ -205,7 +249,7 @@ ok(!missingWires.length, `the HUD is missing: ${missingWires.join(', ')}`, 'the // ── D. the strings, ×11 ────────────────────────────────────────────────────────────────────────── const LANGS = ['en', 'es', 'fr', 'pt', 'de', 'it', 'ro', 'ru', 'ar', 'hi', 'zh'] const KEYS = { - upgMap: ['copySelection', 'exportCsv', 'copyNotRegroupable', 'selectionColourGroup', 'selectionColour', 'customColour', 'copiedSelection', 'copiedTerritoriesOnly', 'csvSaved', 'csvFailed'] } + upgMap: ['copySelection', 'exportCsv', 'copyNotRegroupable', 'peopleStillLoading', 'selectionColourGroup', 'selectionColour', 'customColour', 'copiedSelection', 'copiedTerritoriesOnly', 'csvSaved', 'csvFailed'] } const cat = (l, f) => JSON.parse(readFileSync(P('library/i18n/locales', l, `${f}.json`), 'utf8')) const bad = [] for (const [f, keys] of Object.entries(KEYS)) { diff --git a/internal/check-nav-reachable-mobile.mjs b/internal/check-nav-reachable-mobile.mjs index f109edf2f..f8a3413ff 100644 --- a/internal/check-nav-reachable-mobile.mjs +++ b/internal/check-nav-reachable-mobile.mjs @@ -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() diff --git a/internal/check-shell-header.mjs b/internal/check-shell-header.mjs new file mode 100644 index 000000000..5f65b388a --- /dev/null +++ b/internal/check-shell-header.mjs @@ -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// 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 ; 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(//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 + + + + diff --git a/library/components/WARNING.md b/library/components/WARNING.md index 05e6c496f..ee282a7ec 100644 --- a/library/components/WARNING.md +++ b/library/components/WARNING.md @@ -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//`), 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 diff --git a/library/dashboards/README.md b/library/dashboards/README.md index 46f4036d8..7b621f69a 100644 --- a/library/dashboards/README.md +++ b/library/dashboards/README.md @@ -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 diff --git a/library/i18n/locales/ar/upgMap.json b/library/i18n/locales/ar/upgMap.json index 64da3d88f..bd9d8e2f5 100644 --- a/library/i18n/locales/ar/upgMap.json +++ b/library/i18n/locales/ar/upgMap.json @@ -17,5 +17,7 @@ "exportCsv": "تصدير CSV", "copyNotRegroupable": "لا يمكن إعادة تجميعها معًا: انسخ ولايات أو مقاطعات بلد واحد، أو البلدان، كلًّا على حدة", "displayName": "ملصقات قائمة UPG 100", - "wallHeading": "ملصقات قائمة UPG 100" + "wallHeading": "ملصقات قائمة UPG 100", + "wallIntroA": "كيف تُطعم جمعًا من خمسة آلاف؟ أجلسهم يسوع جماعاتٍ من خمسين ومن مئة، فصار المستحيل قابلًا للمشاركة (لوقا ٩:١٤). فعلنا المثل مع {n} شعوب غير مبلَّغة: ٢٧ قائمة من نحو خمسين إلى مئة. تُطبع كل قائمة حزمةَ صلاة وملصقًا، ليحمل ٢٧ تلميذًا أو كنيسة أو قائدًا محليًا كلٌّ واحدة — مصلّين لأجلهم ", + "peopleStillLoading": "لا تزال الشعوب قيد التحميل، حاول مرة أخرى بعد لحظة" } diff --git a/library/i18n/locales/de/upgMap.json b/library/i18n/locales/de/upgMap.json index 62d4779be..795efa24d 100644 --- a/library/i18n/locales/de/upgMap.json +++ b/library/i18n/locales/de/upgMap.json @@ -17,5 +17,7 @@ "exportCsv": "CSV exportieren", "copyNotRegroupable": "Nicht gemeinsam umgruppierbar: Bundesstaaten oder Provinzen eines Landes oder die Länder getrennt kopieren", "displayName": "Poster der UPG 100 Liste", - "wallHeading": "Poster der UPG 100 Liste" + "wallHeading": "Poster der UPG 100 Liste", + "wallIntroA": "Wie würden Sie eine Menge von fünftausend speisen? Jesus ließ sie in Gruppen zu fünfzig und hundert lagern, und das Unmögliche wurde teilbar (Lukas 9,14). Dasselbe haben wir mit den {n} unerreichten Volksgruppen getan: 27 Listen von etwa fünfzig bis hundert. Jede Liste druckt als Gebetspaket und als Poster, damit 27 Jünger, Gemeinden oder einheimische Leiter je eine tragen können — und für sie beten ", + "peopleStillLoading": "die Volksgruppen werden noch geladen; versuchen Sie es gleich noch einmal" } diff --git a/library/i18n/locales/en/upgMap.json b/library/i18n/locales/en/upgMap.json index 0dbc862eb..122cc67ae 100644 --- a/library/i18n/locales/en/upgMap.json +++ b/library/i18n/locales/en/upgMap.json @@ -17,5 +17,7 @@ "exportCsv": "Export CSV", "copyNotRegroupable": "Can't be regrouped together: copy one country's states or provinces, or the countries, separately", "displayName": "UPG 100 List Posters", - "wallHeading": "UPG 100 List Posters" + "wallHeading": "UPG 100 List Posters", + "wallIntroA": "How would you feed a crowd of five thousand? Jesus sat them down in groups of fifty and a hundred, and the impossible became shareable (Luke 9:14). We did the same with the {n} unreached people groups: 27 lists of about fifty to a hundred. Each list prints as a prayer packet and a poster, so 27 disciples, churches or local leaders can each carry one — praying for them ", + "peopleStillLoading": "the people groups are still loading, try again in a moment" } diff --git a/library/i18n/locales/es/upgMap.json b/library/i18n/locales/es/upgMap.json index d50196625..396a3580f 100644 --- a/library/i18n/locales/es/upgMap.json +++ b/library/i18n/locales/es/upgMap.json @@ -17,5 +17,7 @@ "exportCsv": "Exportar CSV", "copyNotRegroupable": "No se pueden reagrupar juntos: copia por separado los estados o provincias de un solo país, o los países", "displayName": "Carteles de la lista UPG 100", - "wallHeading": "Carteles de la lista UPG 100" + "wallHeading": "Carteles de la lista UPG 100", + "wallIntroA": "¿Cómo alimentarías a una multitud de cinco mil? Jesús los sentó en grupos de cincuenta y de cien, y lo imposible se volvió compartible (Lucas 9:14). Hicimos lo mismo con los {n} pueblos no alcanzados: 27 listas de unos cincuenta a cien. Cada lista se imprime como paquete de oración y cartel, para que 27 discípulos, iglesias o líderes locales lleven una cada uno — orando por ellos ", + "peopleStillLoading": "los pueblos aún se están cargando; inténtalo de nuevo en un momento" } diff --git a/library/i18n/locales/fr/upgMap.json b/library/i18n/locales/fr/upgMap.json index d963e5aad..ffc650bfa 100644 --- a/library/i18n/locales/fr/upgMap.json +++ b/library/i18n/locales/fr/upgMap.json @@ -17,5 +17,7 @@ "exportCsv": "Exporter en CSV", "copyNotRegroupable": "Impossible de les regrouper ensemble : copiez séparément les États ou provinces d’un seul pays, ou les pays", "displayName": "Affiches de la liste UPG 100", - "wallHeading": "Affiches de la liste UPG 100" + "wallHeading": "Affiches de la liste UPG 100", + "wallIntroA": "Comment nourririez-vous une foule de cinq mille personnes ? Jésus les a fait asseoir par groupes de cinquante et de cent, et l’impossible est devenu partageable (Luc 9.14). Nous avons fait de même avec les {n} peuples non atteints : 27 listes d’environ cinquante à cent. Chaque liste s’imprime en dossier de prière et en affiche, pour que 27 disciples, Églises ou responsables locaux en portent chacun une — en priant pour eux ", + "peopleStillLoading": "les peuples sont encore en cours de chargement ; réessayez dans un instant" } diff --git a/library/i18n/locales/hi/upgMap.json b/library/i18n/locales/hi/upgMap.json index 9c6fd059a..3931d5993 100644 --- a/library/i18n/locales/hi/upgMap.json +++ b/library/i18n/locales/hi/upgMap.json @@ -17,5 +17,7 @@ "exportCsv": "CSV निर्यात करें", "copyNotRegroupable": "इन्हें एक साथ पुनर्समूहित नहीं किया जा सकता: एक देश के राज्य या प्रांत, या देश, अलग-अलग कॉपी करें", "displayName": "UPG 100 सूची पोस्टर", - "wallHeading": "UPG 100 सूची पोस्टर" + "wallHeading": "UPG 100 सूची पोस्टर", + "wallIntroA": "पाँच हज़ार की भीड़ को आप कैसे खिलाते? यीशु ने उन्हें पचास-पचास और सौ-सौ के समूहों में बिठाया, और असंभव बाँटने योग्य हो गया (लूका 9:14). हमने {n} अनपहुँचे जनसमूहों के साथ वही किया: लगभग पचास से सौ तक की 27 सूचियाँ. हर सूची प्रार्थना पैकेट और पोस्टर के रूप में छपती है, ताकि 27 शिष्य, कलीसियाएँ या स्थानीय अगुवे एक-एक ले सकें — उनके लिए प्रार्थना करते हुए ", + "peopleStillLoading": "जनसमूह अभी लोड हो रहे हैं, थोड़ी देर में फिर कोशिश करें" } diff --git a/library/i18n/locales/it/upgMap.json b/library/i18n/locales/it/upgMap.json index 331baec8e..3035b23be 100644 --- a/library/i18n/locales/it/upgMap.json +++ b/library/i18n/locales/it/upgMap.json @@ -17,5 +17,7 @@ "exportCsv": "Esporta CSV", "copyNotRegroupable": "Non si possono raggruppare insieme: copia separatamente gli stati o le province di un solo paese, o i paesi", "displayName": "Poster della lista UPG 100", - "wallHeading": "Poster della lista UPG 100" + "wallHeading": "Poster della lista UPG 100", + "wallIntroA": "Come sfameresti una folla di cinquemila? Gesù li fece sedere a gruppi di cinquanta e di cento, e l’impossibile divenne condivisibile (Luca 9:14). Abbiamo fatto lo stesso con i {n} popoli non raggiunti: 27 liste di circa cinquanta-cento. Ogni lista si stampa come pacchetto di preghiera e manifesto, così 27 discepoli, chiese o responsabili locali ne portano una ciascuno — pregando per loro ", + "peopleStillLoading": "i popoli si stanno ancora caricando; riprova tra un momento" } diff --git a/library/i18n/locales/pt/upgMap.json b/library/i18n/locales/pt/upgMap.json index 4dfa584a9..e7b76a6fc 100644 --- a/library/i18n/locales/pt/upgMap.json +++ b/library/i18n/locales/pt/upgMap.json @@ -17,5 +17,7 @@ "exportCsv": "Exportar CSV", "copyNotRegroupable": "Não podem ser reagrupados juntos: copie separadamente os estados ou províncias de um só país, ou os países", "displayName": "Cartazes da lista UPG 100", - "wallHeading": "Cartazes da lista UPG 100" + "wallHeading": "Cartazes da lista UPG 100", + "wallIntroA": "Como você alimentaria uma multidão de cinco mil? Jesus os sentou em grupos de cinquenta e de cem, e o impossível tornou-se compartilhável (Lucas 9.14). Fizemos o mesmo com os {n} povos não alcançados: 27 listas de cerca de cinquenta a cem. Cada lista imprime como pacote de oração e cartaz, para que 27 discípulos, igrejas ou líderes locais levem uma cada um — orando por eles ", + "peopleStillLoading": "os povos ainda estão carregando; tente de novo em um instante" } diff --git a/library/i18n/locales/ro/upgMap.json b/library/i18n/locales/ro/upgMap.json index 341356892..abc4c3769 100644 --- a/library/i18n/locales/ro/upgMap.json +++ b/library/i18n/locales/ro/upgMap.json @@ -17,5 +17,7 @@ "exportCsv": "Exportă CSV", "copyNotRegroupable": "Nu pot fi regrupate împreună: copiază separat statele sau provinciile unei singure țări, sau țările", "displayName": "Afișe din lista UPG 100", - "wallHeading": "Afișe din lista UPG 100" + "wallHeading": "Afișe din lista UPG 100", + "wallIntroA": "Cum ai hrăni o mulțime de cinci mii? Isus i-a așezat în grupuri de cincizeci și de o sută, iar imposibilul a devenit împărțibil (Luca 9:14). Am făcut la fel cu cele {n} popoare neatinse: 27 de liste de aproximativ cincizeci până la o sută. Fiecare listă se tipărește ca pachet de rugăciune și afiș, ca 27 de ucenici, biserici sau lideri locali să poarte fiecare câte una — rugându-se pentru ei ", + "peopleStillLoading": "popoarele încă se încarcă; încearcă din nou peste o clipă" } diff --git a/library/i18n/locales/ru/upgMap.json b/library/i18n/locales/ru/upgMap.json index a3cc4cf02..204aa90bc 100644 --- a/library/i18n/locales/ru/upgMap.json +++ b/library/i18n/locales/ru/upgMap.json @@ -17,5 +17,7 @@ "exportCsv": "Экспорт в CSV", "copyNotRegroupable": "Нельзя перегруппировать вместе: копируйте отдельно штаты или провинции одной страны либо страны", "displayName": "Плакаты списка UPG 100", - "wallHeading": "Плакаты списка UPG 100" + "wallHeading": "Плакаты списка UPG 100", + "wallIntroA": "Как накормить толпу в пять тысяч человек? Иисус рассадил их группами по пятьдесят и по сто, и невозможное стало посильным (Лк. 9:14). Мы поступили так же с {n} неохваченными народами: 27 списков примерно по пятьдесят-сто. Каждый список печатается как молитвенный пакет и плакат, чтобы 27 учеников, церквей или местных служителей взяли по одному — молясь о них ", + "peopleStillLoading": "народы ещё загружаются; попробуйте ещё раз через мгновение" } diff --git a/library/i18n/locales/zh/upgMap.json b/library/i18n/locales/zh/upgMap.json index f6794e7c4..2be7bdaae 100644 --- a/library/i18n/locales/zh/upgMap.json +++ b/library/i18n/locales/zh/upgMap.json @@ -17,5 +17,7 @@ "exportCsv": "导出 CSV", "copyNotRegroupable": "无法一起重新分组:请分别复制同一国家的邦或省,或各个国家", "displayName": "UPG 100 名单海报", - "wallHeading": "UPG 100 名单海报" + "wallHeading": "UPG 100 名单海报", + "wallIntroA": "你会怎样喂饱五千人的群众?耶稣让他们一排五十、一排一百地坐下,不可能的事就变得可以分担了(路加福音 9:14)。我们对 {n} 个未得之民做了同样的事:27 份名单,每份约五十到一百。每份名单都能印成祷告手册和海报,让 27 位门徒、教会或当地领袖各领一份 — 为他们祷告 ", + "peopleStillLoading": "族群仍在加载中,请稍后再试" } diff --git a/library/layouts/VerticalTabsLayout.vue b/library/layouts/VerticalTabsLayout.vue index 48225bdfe..f1ea8f458 100644 --- a/library/layouts/VerticalTabsLayout.vue +++ b/library/layouts/VerticalTabsLayout.vue @@ -91,20 +91,11 @@ after a resize back to desktop — the previous broken-restore bug. -->