C4 wave 2: five library components share the guard, and keep their own English

The five are NOT one shape, and treating them as one would have changed what
readers see. Measured before touching them:

  ApproxPinNote, FilterDropdown   fall back to a literal map
  SemanticTreeLegend, PosterWall  fall back to humanizeKey() — 'legend.columns.name' renders 'Name'
  PinHint                         falls back to a one-key map
  SemanticTreeLegend, PinHint     also capture i18n.te (used 3x and 1x)
  PosterWall                      also captures i18n.locale

So useOptionalT grew an optional `fallback` hook, and useOptionalI18n was added
for the callers that need te and locale. Every site passes the fallback it
already had, so not one rendered string changes. Routing them through the
catalogue instead would have altered what seven bare-host bundles print, and —
because tStatic honours the runtime locale — would have started translating
strings that are English today. That may be an improvement; it is not this
commit's to make. Changing a default is not a migration.

te keeps its existing contract too: FALSE on a bare host, never "true because the
catalogue has it". PinHint and SemanticTreeLegend both record why — te is how
their callers know to use their own defaults.

THE CHECK NOW LIVES ON MAIN, which is the point of this commit as much as the
migration is. dx-shepherd: the 18-assertion version was in _qa-cycle, so it would
never have run again. internal/check-optional-t-equivalence.mjs is registered as
check:optional-t-equivalence — script line, //scripts note and gates:post token,
all inserted by hand rather than by re-serialising package.json. It carries 46
assertions: the helper's contract across all three host states, a branch-for-
branch equivalence against the old hand-written code, and a PER-SITE REGISTER
that every later C4 wave appends a row to. Negative-tested from outside: strip one
site's own fallback and it fails naming that site.

check:library-index went red on the new composable and is green again after
`bun run library-index`. That is the staleness gate working, not a flake.
This commit is contained in:
Doxa Maps 2026-09-26 18:07:44 +00:00
parent 1a17d3a211
commit 09aba6dc92
8 changed files with 207 additions and 26 deletions

View file

@ -0,0 +1,143 @@
#!/usr/bin/env node
/* check-optional-t-equivalence.mjs — `bun run check:optional-t-equivalence`. Source-only, headless.
EVERY LATER C4 WAVE EXTENDS THIS FILE (dx-shepherd, REGC4G): a wave that migrates a site without
adding its row here is a wave whose behaviour nobody re-checks. Sections 1-6 are the helper's own
contract; section 7 is the per-site register. */
// C4: does useOptionalT() behave like the twenty hand-written guarded translators it replaces?
//
// Same condition the shepherd set for C2 and C3: old pattern vs new, every branch, negative-tested.
// The old shape, verbatim from usePplrT.js before this migration:
// const enLookup = (key) => String(key).replace(/^pplr\./,'').split('.').reduce(...enPplr)
// const fill = (str, params) => params ? str.replace(/\{(\w+)\}/g, ...) : str
// try { const i18n = useI18n(); return (k,p) => { const v = i18n.t(k,p||{}); return v===k ? fill(enLookup(k) ?? k, p) : v } }
// catch (_) { return (k,p) => fill(enLookup(k) ?? String(k), p) }
//
// ★ WHY THIS CANNOT JUST IMPORT THE MODULE. useOptionalT.js imports vue-i18n and @map/i18n/index.js,
// and that index calls `import.meta.glob` — a Vite transform plain Node cannot run. So the source is
// EXTRACTED AND EXECUTED with tStatic and useI18n stubbed, the same approach check-provider-for.mjs
// uses for engineGlobal, and for the same reason: re-typing the function into the gate would test the
// copy rather than the thing that ships.
import { readFileSync } from 'node:fs'
import { codeOnly } from '../plugin-designs/regions-dashboard/scripts/strip-code.mjs'
let fails = 0
const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ }
const src = readFileSync(new URL('../library/composables/useOptionalT.js', import.meta.url), 'utf8')
const body = src
.replace(/^import .*$/gm, '') // drop the two un-runnable imports
.replace(/^export default .*$/gm, '') // and the default re-export (it is not a declaration)
.replace(/^export /gm, '')
const make = (tStatic, useI18n) => new Function('tStatic', 'useI18n',
body + '\nreturn { useOptionalT, useOptionalI18n, fillParams, qualify };')(tStatic, useI18n)
// The English catalogue the old code imported, as a stub tree: messages.en.pplr equivalent.
const EN = { greeting: 'Hello {name}', nested: { deep: 'Deep value' } }
const tStatic = (path) => String(path).replace(/^pplr\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN)
const throwingI18n = () => { throw new Error('Need to install with `app.use` function') }
const hostI18n = (table) => () => ({ t: (k) => (k in table ? table[k] : k) })
// ── 1. no i18n on the host — the case seven bundles are actually in ─────────────────────────────
{
const { useOptionalT } = make(tStatic, throwingI18n)
const t = useOptionalT('pplr')
ok(t('greeting', { name: 'Ada' }) === 'Hello Ada', 'bare host: falls back to the catalogue AND interpolates')
ok(t('nested.deep') === 'Deep value', 'bare host: resolves a nested key')
ok(t('missing.key') === 'missing.key', 'bare host: a missing key renders as the KEY, never blank')
ok(t('pplr.greeting', { name: 'Ada' }) === 'Hello Ada', 'bare host: an already-qualified key is not double-prefixed')
}
// ── 2. host HAS i18n and has the string — the host must win ─────────────────────────────────────
{
const { useOptionalT } = make(tStatic, hostI18n({ 'pplr.greeting': 'Bonjour' }))
ok(useOptionalT('pplr')('greeting') === 'Bonjour', 'host i18n wins when it has the key -- a host managing locale keeps control')
}
// ── 3. host HAS i18n but LACKS the string: i18n.t returns the key unchanged ─────────────────────
{
const { useOptionalT } = make(tStatic, hostI18n({}))
const t = useOptionalT('pplr')
ok(t('greeting', { name: 'Ada' }) === 'Hello Ada',
'host i18n present but missing the key falls through to the catalogue -- the case every old copy handled with `v === key`')
}
// ── 4. the pure helpers ────────────────────────────────────────────────────────────────────────
{
const { fillParams, qualify } = make(tStatic, throwingI18n)
ok(fillParams('a {x} b', { x: 1 }) === 'a 1 b', 'fillParams interpolates')
ok(fillParams('a {x} b', {}) === 'a {x} b', 'fillParams leaves an unknown placeholder visible rather than blanking it')
ok(fillParams('plain') === 'plain', 'fillParams with no params is a no-op')
ok(qualify('pplr', 'x') === 'pplr.x' && qualify('pplr', 'pplr.x') === 'pplr.x', 'qualify prefixes once, never twice')
}
// ── 5. EQUIVALENCE with the old hand-written implementation, branch for branch ──────────────────
{
const fill = (s, p) => (p ? String(s).replace(/\{(\w+)\}/g, (m, k) => (k in p ? p[k] : m)) : s)
const enLookup = (key) => String(key).replace(/^pplr\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN)
const oldBare = (k, p) => fill(enLookup(k) ?? String(k), p)
const { useOptionalT } = make(tStatic, throwingI18n)
const neu = useOptionalT('pplr')
for (const [k, p] of [['greeting', { name: 'Ada' }], ['nested.deep', null], ['missing.key', null], ['greeting', null]]) {
ok(oldBare(k, p) === neu(k, p), `EQUIVALENT on the bare-host path for ${JSON.stringify(k)}: both give ${JSON.stringify(oldBare(k, p))}`)
}
}
// ── 6. NEG CONTROLS ────────────────────────────────────────────────────────────────────────────
{
const { useOptionalT } = make(() => undefined, throwingI18n)
ok(useOptionalT('pplr')('greeting') === 'greeting',
'NEG CONTROL: with a catalogue that knows nothing, the key comes back -- so the passes above were really reading the catalogue')
const { useOptionalT: u2 } = make(tStatic, hostI18n({ 'pplr.greeting': 'Bonjour' }))
ok(u2('pplr')('greeting') !== 'Hello Ada', 'NEG CONTROL: the host path and the catalogue path are distinguishable')
}
ok(/import \{ tStatic \}/.test(src), 'CONTROL: the shipped module really does route through tStatic (not a catalogue import of its own)')
// ★ codeOnly, because this assertion failed on its OWN DOCUMENTATION: useOptionalT.js's header QUOTES
// '@map/i18n/locales/en/pplr.json' as the import it removed, and a whole-file grep counted the prose as
// the thing it forbids. Fifth time this session that a detector has read a comment as code.
ok(!/locales\/en\//.test(codeOnly(src)), 'CONTROL: and it imports NO per-area English JSON in CODE -- the redundant second copy is gone')
// ── 7. THE PER-SITE REGISTER. One row per migrated site; later waves append here. ───────────────
// Each row asserts the two things a migration can get wrong: that the site actually routes through
// the shared guard, and that it still carries ITS OWN fallback rather than silently inheriting the
// catalogue. The second matters more than it looks: routing these through tStatic would change what
// seven bare-host bundles render, and would start translating strings that are English today.
import { readFileSync as rf } from 'node:fs'
const SITES = [
// file, helper it must use, a fragment of the fallback it must still pass
['library/components/legends/ApproxPinNote.vue', 'useOptionalT', 'APPROX_FALLBACK[key]'],
['library/components/search/FilterDropdown.vue', 'useOptionalT', 'FALLBACK[key]'],
['library/components/legends/SemanticTreeLegend.vue', 'useOptionalI18n', 'humanizeKey(k)'],
['library/components/poster/PosterWall.vue', 'useOptionalI18n', 'humanizeKey(k)'],
['library/components/hints/PinHint.vue', 'useOptionalI18n', 'PINHINT_FALLBACK[key]'],
['plugin-designs/pplr-data-maps/src/composables/usePplrT.js', 'useOptionalT', null],
['plugin-designs/upg-neighbor/src/composables/useNeighborT.js', 'useOptionalT', null],
]
for (const [file, helper, fallbackFrag] of SITES) {
const code = codeOnly(rf(new URL('../' + file, import.meta.url), 'utf8'))
const short = file.split('/').pop()
ok(new RegExp(`\\b${helper}\\(`).test(code), `${short}: routes through ${helper}()`)
ok(!/useI18n\(/.test(code), `${short}: no bare useI18n() left in CODE (comments explaining it are fine)`)
if (fallbackFrag) {
ok(code.includes(fallbackFrag),
`${short}: still passes its OWN fallback (${fallbackFrag}) -- migrating it to the catalogue would change what a bare host renders`)
} else {
ok(!/fallback:/.test(code), `${short}: takes the CATALOGUE fallback, as it always did`)
}
}
// ── 8. the fallback option and te/locale, behaviourally ────────────────────────────────────────
{
const { useOptionalT, useOptionalI18n } = make(tStatic, throwingI18n)
const mine = useOptionalT('pplr', { fallback: (k) => (k === 'pplr.greeting' ? 'MINE {name}' : undefined) })
ok(mine('greeting', { name: 'Ada' }) === 'MINE Ada',
'opts.fallback WINS over the catalogue -- that is what keeps each migrated site rendering its own English')
ok(mine('nested.deep') === 'nested.deep',
'and when the site fallback has nothing, the KEY shows (it does NOT silently fall through to the catalogue)')
const { te, locale } = useOptionalI18n('pplr')
ok(te('greeting') === false, 'te() is FALSE on a bare host -- PinHint and SemanticTreeLegend depend on that to use their own defaults')
ok(locale === 'en', 'locale defaults to en on a bare host, as PosterWall had it')
}
{
const { useOptionalI18n } = make(tStatic, hostI18n({ 'pplr.greeting': 'Bonjour' }))
const { te } = useOptionalI18n('pplr')
ok(typeof te === 'function', 'te() is a function when the host HAS i18n')
}
console.log(fails ? `\nC4-OPTIONAL-T: ${fails} FAIL(s)` : '\nC4-OPTIONAL-T: the shared translator matches the hand-written guards on every branch')
process.exit(fails ? 1 : 0)

View file

@ -48,7 +48,7 @@ import { tStatic } from '@map/i18n/index.js'
// custom elements that install no i18n plugin, and an unguarded useI18n() throws on the way in and
// takes the subtree with it. The English stays as the fallback.
const T = (key, en) => tStatic(key) || en
import { useI18n } from 'vue-i18n'
import { useOptionalI18n } from '@map/composables/useOptionalT.js'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
// ★ i18n IS OPTIONAL HERE, NOT REQUIRED. useI18n() THROWS ("Need to install with `app.use`
@ -61,9 +61,9 @@ import { useShadowStyles } from '../../composables/useShadowStyles.js'
// has them, the English literal otherwise. `te` becomes a "no, I don't have it" so every caller
// falls through to its own default.
const PINHINT_FALLBACK = { 'messages.pinHint.tapDot': 'Tap a dot to see who still needs prayer' }
let t = (key) => PINHINT_FALLBACK[key] || key
let te = () => false
try { const i18n = useI18n(); t = i18n.t; te = i18n.te } catch (_) { /* no i18n on this host */ }
// C4: the guard is library/composables/useOptionalI18n now. PINHINT_FALLBACK is passed in unchanged
// and `te` still answers false on a bare host, so every caller keeps its own default.
const { t, te } = useOptionalI18n('', { fallback: (key) => PINHINT_FALLBACK[key] })
const uiStore = inject('uiStore', null)
const profileConfig = inject('profileConfig', null)
const isDark = computed(() => uiStore?.theme === 'dark')

View file

@ -9,7 +9,7 @@
-->
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useOptionalT } from '@map/composables/useOptionalT.js'
const props = defineProps({
/** Names of the OTHER groups on this pin's coordinate; empty = not shared. */
@ -23,8 +23,9 @@ const APPROX_FALLBACK = {
'pins.approximate': 'The dot marks where this people is found, not a town or a boundary.',
'pins.sharedPoint': 'It sits on the same point as {names} — {n} groups share this coordinate, so the position tells you the locality and not which group is where.',
}
let t = (key, vars = {}) => (APPROX_FALLBACK[key] || key).replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m))
try { t = useI18n().t } catch (_) { /* no i18n on this host */ }
// C4: the guard is library/composables/useOptionalT.js now. The fallback MAP stays exactly as it was,
// passed in, so a bare host renders the same English it rendered before.
const t = useOptionalT('', { fallback: (key) => APPROX_FALLBACK[key] })
const second = computed(() => props.shared.length
? t('pins.sharedPoint', { names: props.shared.join(', '), n: props.shared.length + 1 })
: '')

View file

@ -19,7 +19,7 @@ import { tStatic } from '@map/i18n/index.js'
// This component's chrome, read out of the catalogue. tStatic and not useI18n: these mount inside
// custom elements that install no i18n plugin, and an unguarded useI18n() throws on the way in.
const T = (key, en) => tStatic(key) || en
import { useI18n } from 'vue-i18n'
import { useOptionalI18n } from '@map/composables/useOptionalT.js'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
import { useInstance } from '../../composables/usePplrInstance.js'
import LegendProgressBar from './LegendProgressBar.vue'
@ -43,9 +43,10 @@ import { useViewportScrim } from '../../utils/viewportScrim.js'
// falls through to its own default — which is what the defaultTab and columnLabel paths expect.
const humanizeKey = (k) => String(k).split('.').pop()
.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
let t = (k) => humanizeKey(k)
let te = () => false
try { const i18n = useI18n(); t = i18n.t; te = i18n.te } catch (_) { /* no i18n on this host */ }
// C4: the guard is library/composables/useOptionalI18n now. humanizeKey stays the fallback -- NOT the
// catalogue -- so a bare host still reads 'Name' for legend.columns.name, and `te` still answers false
// so defaultTab and columnLabel keep falling through to their own defaults.
const { t, te } = useOptionalI18n('', { fallback: (k) => humanizeKey(k) })
// Translate a tab's display name from the `tabs.<key>` catalog, keyed by the
// tab's stable `i18nKey` (preferred) or `id`. Falls back to the hardcoded

View file

@ -47,7 +47,7 @@ import { tStatic } from '../../i18n/index.js'
const T = (key, en) => tStatic(key) || en
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { usePosterPrints } from '@map/composables/usePosterPrints.js'
import { useI18n } from 'vue-i18n'
import { useOptionalI18n } from '@map/composables/useOptionalT.js'
// Export-modal geometry. A fixed;inset:0 scrim covers only the CONTENT PANE in the cap-5 shell
// (contain:layout on .vtl-content). Measured 1024x793 @ (256,107) at 1280x900 — rail clickable
// behind the "modal". See library/utils/viewportScrim.js for the measurements and rejected fixes.
@ -385,9 +385,9 @@ async function exportPoster(item) {
// key shown to a reader.
const humanizeKey = (k) => String(k).split('.').pop()
.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
let t = (k) => humanizeKey(k)
let wallLocale = 'en'
try { const i18n = useI18n(); t = i18n.t; wallLocale = i18n.locale } catch (_) { /* no i18n on this host */ }
// C4: the guard is library/composables/useOptionalI18n now. humanizeKey stays the fallback and the
// bare-host locale stays 'en', so nothing this wall prints changes.
const { t, locale: wallLocale } = useOptionalI18n('', { fallback: (k) => humanizeKey(k) })
// ⚠ ONE exportState, not two. The composable owns a ref of its own; if the HUD
// keeps rendering the wall's while the print actions write to the composable's, a
// card-deck print shows NO progress and its errors vanish silently — the mechanism

View file

@ -24,7 +24,7 @@
* filter-clear { name } — the selection was cleared.
*/
import { ref, computed, nextTick, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useOptionalT } from '@map/composables/useOptionalT.js'
const props = defineProps({
label: { type: String, required: true },
@ -45,8 +45,9 @@ const props = defineProps({
// in the studio and inside every composite that nests it (measured 2026-09-25: 2 DOM nodes, page
// error on mount). Use the host's translations when it has them, English otherwise.
const FALLBACK = { 'buttons.clearSelection': 'Clear selection', 'buttons.noOptions': 'No options found' }
let t = (key) => FALLBACK[key] || key
try { t = useI18n().t } catch (_) { /* no i18n on this host: keep the English fallback */ }
// C4: the guard is library/composables/useOptionalT.js now; FALLBACK is passed in unchanged, so the
// grid that this component once blanked renders exactly the same strings.
const t = useOptionalT('', { fallback: (key) => FALLBACK[key] })
const emit = defineEmits(['filter-change', 'filter-clear'])

View file

@ -47,21 +47,54 @@ export function qualify(namespace, key) {
return namespace && !k.startsWith(`${namespace}.`) ? `${namespace}.${k}` : k
}
export function useOptionalT(namespace) {
const viaStatic = (key, params) => {
const q = qualify(namespace, key)
return fillParams(tStatic(q) ?? key, params)
}
/**
* ★ `fallback` EXISTS BECAUSE THE FIVE LIBRARY COMPONENTS DO NOT ALL FALL BACK TO THE CATALOGUE, and
* swapping what they fall back to is a PRODUCT change, not a consolidation. Measured:
* ApproxPinNote / FilterDropdown a literal fallback MAP
* SemanticTreeLegend / PosterWall humanizeKey() — 'legend.columns.name' renders as 'Name'
* PinHint a one-key fallback map
* Routing those through tStatic would change what seven bare-host bundles render (and, because tStatic
* honours the runtime locale, would start translating strings that are English today). That may well be
* an improvement, but it is a separate decision from "store this fact once", so each site passes the
* fallback it already had and renders byte-identically.
* @param {(key:string, params?:object)=>string} [opts.fallback] used instead of tStatic when the host
* has no i18n, or when its catalogue lacks the key. Omit it to get the catalogue (the default).
*/
export function useOptionalT(namespace, opts = {}) {
const viaFallback = typeof opts.fallback === 'function'
? (key, params) => fillParams(opts.fallback(qualify(namespace, key), params) ?? key, params)
: (key, params) => fillParams(tStatic(qualify(namespace, key)) ?? key, params)
try {
const i18n = useI18n()
return (key, params) => {
const q = qualify(namespace, key)
const v = i18n.t(q, params || {})
return v === q ? viaStatic(key, params) : v
return v === q ? viaFallback(key, params) : v
}
} catch (_) {
// No i18n on this host. Not an error: seven bundles deliberately install none.
return viaStatic
return viaFallback
}
}
/**
* The same guard, for callers that need more than `t`.
*
* ★ `te` KEEPS ITS EXISTING CONTRACT DELIBERATELY: on a host with no i18n it answers FALSE, never
* "true because the catalogue has it". PinHint and SemanticTreeLegend both record why — "te becomes a
* 'no, I don't have it' so every caller falls through to its own default". Making it consult tStatic
* would silently take those callers off their own defaults, which is the same class of mistake as
* moving a fallback.
*
* @returns {{ t: Function, te: (key:string)=>boolean, locale: any }}
*/
export function useOptionalI18n(namespace, opts = {}) {
const t = useOptionalT(namespace, opts)
try {
const i18n = useI18n()
return { t, te: (key) => i18n.te(qualify(namespace, key)), locale: i18n.locale }
} catch (_) {
return { t, te: () => false, locale: opts.locale ?? 'en' }
}
}

View file

@ -154,6 +154,7 @@
"check:no-silent-engine:self-test": "node internal/check-no-silent-engine-substitution.mjs --self-test",
"check:one-palette": "node internal/check-one-palette.mjs",
"check:one-palette:self-test": "node internal/check-one-palette.mjs --self-test",
"check:optional-t-equivalence": "node internal/check-optional-t-equivalence.mjs",
"check:packet-browser": "node internal/check-packet-browser.mjs",
"check:packet-fixture": "node internal/generate-packet-fixture.mjs --check",
"check:packet-render": "node internal/check-packet-render.mjs",
@ -223,7 +224,7 @@
"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:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs 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: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",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs 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: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",
"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",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
@ -361,6 +362,7 @@
"check:no-eval": "no built bundle contains new Function or eval (a strict CSP without 'unsafe-eval' must work), and vue-i18n in JIT mode renders real catalogue messages in en and ar with eval blocked. Headless.",
"check:no-free-identifiers": "every name a .vue script uses is imported, declared, a parameter or a known global (scope-aware, across plugin-designs and library). A free identifier compiles and then throws at runtime in exactly the path nobody clicked.",
"check:no-silent-engine": "A profile must NEVER fall back to a different map engine than its provider asked for. `engineGlobal(provider) || window.mapboxgl` looks defensive and is not: engineGlobal already returns the right global for the requested provider (useMapInstance.js:115 falls back to mapbox's NAME only for an UNKNOWN provider, never a missing engine), so the || fires in exactly one case — the page asked for maplibre and maplibre failed to load — and hands back the TOKEN-GATED engine to a page that asked for the KEYLESS one. Worse, accessToken is gated on !isMaplibre, so the substitute arrives WITHOUT a token: 401s on every tile instead of a legible failure. Found by regions' C3 read 2026-09-26; 2 offenders remained (cities-internet, gospel-resources) after my-upg-100-list was fixed. NOT a duplicate of check:leaf-engine-per-provider / check:provider-switch / check:auto-engine — all three assert the HAPPY path (what loads) and this bug only appears when something does NOT load, so all three were green while it existed. A SOURCE check on purpose: reproducing it live means serving a leaf with MapLibre deliberately blocked, spending a browser slot on what a grep decides. Exits 75 when no file calls engineGlobal — 'found nothing' is not 'found nothing wrong'. Run :self-test to prove the pattern still matches.",
"check:optional-t-equivalence": "C4's shared guarded translator (library/composables/useOptionalT.js) must behave like the twenty hand-written try{useI18n()}catch blocks it replaces: all three host states, and the per-site register asserts each migrated file still passes ITS OWN fallback rather than inheriting the catalogue, because routing them through tStatic would change what seven bare-host bundles render. EVERY LATER C4 WAVE APPENDS A ROW. Extract-and-executes the module source, since useOptionalT imports vue-i18n and an i18n index that calls import.meta.glob.",
"check:packet-browser": "the packet the READER gets — the legend's Export > Prayer packet path, proving the shared renderer runs in a BROWSER. Asserts the same 12 points / 12 wheel labels / page-1 geography / print layer as check:packet-render, which it did not until 2026-09-24. CONDITIONAL: SKIPs without a server over the repo root (python3 -m http.server 4898) and a reachable records API.",
"check:packet-fixture": "the committed fixture must still match clusters.json and must never carry image_url (CI would fetch 43 photos from someone else's CDN).",
"check:packet-render:expect-funnel": "check:packet-render with the page-2 FUNNEL declared REQUIRED. Exists because of a real near-miss: a merge resolved with `git checkout --ours` deleted the printPacket call site that supplies the funnel data (60 lines) and EVERY GATE STAYED GREEN — it had to, because the funnel page is OPTIONAL by design (buildPacket omits it when the arg is null), so a packet without page 2 is not malformed, just smaller. Always requiring it would be the wrong fix: the 11 pre-funnel laos packets and any host with no funnel data would go red, and a false red costs as much as a false green. So the CALLER, which knows whether this packet was built with funnel data, declares it — an absence is then either declared (no flag) or a failure (flag passed, page missing), never a silent nothing. Pass it for any packet from a profile that wires packetFunnel(). Browser gate.",