// check-share-policy — one written rule for WHICH MAPS CARRY A SHARE BUTTON. // // the agent: "Some maps need a map-level share button and some do not. The Prayer // Progress map is the one intended for embedding on other people's websites, so it // MUST have a share button. UPG-100 must not (the header already provides it). // Decide + record the per-map rule, then apply it." // // The BEHAVIOUR already existed on both of those. What did not exist was the RULE, // so nothing stopped the next agent adding a share button to a map that should not // have one, or leaving a published map with no way to share it at all. Surveying // all 11 map bundles found FOUR different patterns in use and two published maps // with no sharing path by any mechanism. // // ⚠ WHAT THIS GATE CANNOT SEE — read this before trusting a green run. // It counts occurrences in SOURCE. A button that is present in source // and RENDERS NOWHERE counts exactly the same as a working one. Measured twice: // - mutation test 2026-09-24: gating all-people-groups' toolbar off left this gate // reporting "1 button / own-button", green, with no button in the DOM; // - for real, same day: prayer-walk-planner mounts its SFC directly as the custom // element and so installed no i18n. ShareButton calls useI18n(), which THROWS in // setup(), and a thrown setup() removes the shadow-DOM subtree SILENTLY. Five // toolbar buttons rendered, the sixth did not exist, and this gate passed. // smoke:share-all is the gate that catches that: it clicks the real control, follows // the url, and asserts the SAME custom element reopens. Keep every published map in // its list — this file decides the RULE, that one proves the BEHAVIOUR. // // ── THE RULE ──────────────────────────────────────────────────────────────── // A map bundle must have EXACTLY ONE share affordance, and it belongs at the // OUTERMOST surface a reader actually sees: // // own-button the map IS the product surface -> in the map // header a composite wraps it with a header -> the header owns sharing, // the map omits the button // composite a parent stacks it and overrides // shareUrl so the PARENT is shared -> child button is fine, // parent supplies the url // none not published — nothing to share yet // // TWO share buttons on one surface is the defect this prevents (it shipped once: // BUG-UPG100-DOUBLE-SHARE). ZERO on a published map is the opposite defect, and it // is currently REAL on two bundles — recorded below as known debt rather than // silently passing, so the count cannot drift without someone deciding. import { readFileSync, existsSync, readdirSync } from 'node:fs' import { resolve, join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import { TREE_NAME } from './_tree-name.mjs' // Same fix as check-zone-census.mjs, same reason. DOXA_REPO still wins; only the FALLBACK moved // off an absolute path into one author's worktree. check:no-abs-paths rejects those outright — "a // clone must work on any machine" — and it runs inside build:unlocked, so the hardcoded default // turned the whole build red for every builder. This is the idiom that gate names in its own // failure text, and the one every sibling script here uses. const REPO = process.env.DOXA_REPO || resolve(dirname(fileURLToPath(import.meta.url)), '..') const DESIGNS = join(REPO, 'plugin-designs') // The decided policy, per bundle. A map bundle MUST appear here — an unlisted one // fails, so adding a map forces the sharing decision rather than deferring it. const POLICY = { 'doxa-simple-map': 'own-button', // Prayer Progress — the embed flagship 'doxa-research-map': 'own-button', 'country-posters': 'header', // country-posters-mfe header owns it 'cities-internet': 'own-button', 'gospel-resources': 'own-button', 'demo-map': 'own-button', 'my-upg-100-list': 'header', // upg-100-mfe.vue header owns it 'research-map-plus-grid': 'composite', // overrides child shareUrl to share itself 'key-cities': 'none', // not published 'template-bundle': 'none', // the STARTER, not published: a copy adds ShareButton when it becomes a real map 'all-people-groups': 'own-button', // was debt-no-share; ShareButton added 2026-09-24 // ⚠ prayer-walk-planner took a SECOND LOOK before this flipped, and the reason is worth // keeping: it builds a PERSONAL WALKING ROUTE from polygons the reader draws, so a share // link that silently drops them arrives as an empty planner — a button that reliably // produces a blank map is worse than none, because it LOOKS like sharing worked. // That read as a semantics question needing a ruling. It was not: the profile stores // polygons in localStorage and has NO BACKEND (its own file header says so), so a URL // CANNOT carry a route. There is no design freedom — the shareable artefact is the // planner itself, exactly as every other map shares its own page. The failure mode only // exists if the link CLAIMS to carry the route, so the button says "tool" and the status // strip tells the reader their zones stay on the device. 'prayer-walk-planner': 'own-button', // was debt-no-share; resolved 2026-09-24 } const isMap = (src) => /mapboxgl|maplibregl|useMapInstance/.test(src) const manifest = JSON.parse(readFileSync(join(REPO, 'public', TREE_NAME, 'manifest.json'), 'utf8')) const published = new Set(manifest.bundles.map((b) => b.bundle)) const fails = [] const rows = [] for (const name of readdirSync(DESIGNS)) { const dir = join(DESIGNS, name) let vues = [] try { vues = readdirSync(dir).filter((f) => f.endsWith('.vue')) } catch { continue } if (!vues.length) continue const src = vues.map((f) => readFileSync(join(dir, f), 'utf8')).join('\n') if (!isMap(src)) continue const buttons = (src.match(/ — ` + (buttons === 0 ? 'the embed surface has no way to be shared' : 'two affordances on one surface')) } if (want === 'header' && buttons !== 1) { // UPG-100's single button lives in the MFE header, not the map — still exactly one. fails.push(`${name}: policy "header" expects exactly one share affordance in the bundle, found ${buttons}`) } // ⚠ 'header' HAS A CONSEQUENCE FOR smoke:share-all, and it drifted once. The button is // in the MFE SHELL, so the bundle's LEAF url correctly has none — and smoke:share-all // was still pointing at the leaf for both header bundles, reporting 2 failures that // were the policy working as designed (measured 2026-09-24: 10/12, both failures // 'header'). When a bundle moves to 'header', its entry in smoke:share-all must move // from // to // or the suite tests a surface the policy // says should be empty. if (want === 'debt-no-share' && buttons !== 0) { fails.push(`${name}: listed as known debt (no sharing) but now has ${buttons} — update POLICY`) } if (want === 'none' && published.has(name)) { fails.push(`${name}: policy "none" assumes unpublished, but it IS published — it needs a share path`) } } console.log(' bundle published buttons policy') for (const r of rows.sort((a, b) => a.name.localeCompare(b.name))) { console.log(` ${r.name.padEnd(24)} ${(r.published ? 'yes' : 'no').padEnd(10)} ${String(r.buttons).padEnd(8)} ${r.want || '(UNDECIDED)'}`) } const debt = rows.filter((r) => r.want === 'debt-no-share') if (debt.length) { console.log(`\n known debt: ${debt.length} published map(s) with NO sharing path — ` + debt.map((d) => d.name).join(', ')) } if (fails.length) { console.error(`\nSHARE-POLICY: FAIL — ${fails.length} problem(s)`) for (const f of fails) console.error(' - ' + f) process.exit(1) } console.log(`\nSHARE-POLICY: PASS — ${rows.length} map bundles, each with exactly one decided sharing rule.`)