/doxa-maps: the full maps SPA on the site + 'See all DOXA maps' pointer

- The drop-in now carries the bundler's app shell beside doxa-maps/ (index.html,
  nav, icons, pane-wrapper, external bundles, assets), so /js/doxa-maps-build/
  index.html IS the composed single-page app — every map, lazy-loaded.
- New page /doxa-maps frames it full-bleed (replaces the /maps easter egg).
- The research page gains the pointer: 'See all DOXA maps →' (mirrors the
  prayer→research pattern).
- server route serves the shell's ./api/maps/token.json from the site's own
  NUXT_PUBLIC_MAPBOX_TOKEN — no secret in the drop-in.
This commit is contained in:
doxa-maps 2026-09-16 03:27:10 +00:00
parent 9d33a08113
commit 3c41b741de
11 changed files with 2528 additions and 40 deletions

42
app/pages/doxa-maps.vue Normal file
View file

@ -0,0 +1,42 @@
<script setup lang="ts">
// doxa.life/doxa-maps — the DOXA maps SPA (every map, one app).
//
// Frames the bundler's single-page app (the app-wrapper shell) from the
// drop-in at public/js/doxa-maps-build/ — every map, table and dashboard the
// bundler ships, composed into one lazy-loaded app, always exactly as current
// as the build in this repo. Pointed to from the research page
// ("See all DOXA maps →"). The shell resolves its Mapbox token from
// ./api/maps/token.json, which this site serves from its own runtime config
// (see server/routes/js/doxa-maps-build/api/maps/token.json.get.ts).
useHead({
title: 'All DOXA Maps',
meta: [{ name: 'robots', content: 'noindex' }],
})
</script>
<template>
<div class="maps-room">
<iframe
src="/js/doxa-maps-build/index.html"
title="The DOXA maps app"
class="maps-room-frame"
loading="eager"
allowfullscreen
/>
</div>
</template>
<style scoped>
.maps-room {
/* Full-bleed room: the staging index brings its own chrome. */
position: fixed;
inset: 0;
background: #0a0e15;
}
.maps-room-frame {
width: 100%;
height: 100%;
border: 0;
display: block;
}
</style>

View file

@ -1,40 +0,0 @@
<script setup lang="ts">
// doxa.life/maps — the map room (an unlisted easter egg).
//
// Every map the doxa-maps bundler ships, in one place: this page frames the
// bundler's own staging index from the drop-in at public/js/doxa-maps-build/,
// so it is always exactly as current as the build in this repo — no separate
// gallery to maintain. Linked from nowhere on purpose; those who find it,
// find all the maps.
useHead({
title: 'Maps — DOXA',
meta: [{ name: 'robots', content: 'noindex' }],
})
</script>
<template>
<div class="maps-room">
<iframe
src="/js/doxa-maps-build/doxa-maps/index.html"
title="All DOXA maps"
class="maps-room-frame"
loading="eager"
allowfullscreen
/>
</div>
</template>
<style scoped>
.maps-room {
/* Full-bleed room: the staging index brings its own chrome. */
position: fixed;
inset: 0;
background: #0a0e15;
}
.maps-room-frame {
width: 100%;
height: 100%;
border: 0;
display: block;
}
</style>

View file

@ -39,6 +39,7 @@ useTextHighlight()
<div class="stack stack--xs text-center">
<h1 class="highlight" data-highlight-last>{{ t('Find a UUPG') }}</h1>
<p><NuxtLink class="light-link" :to="localePath('/regions')">{{ t('By Region') }} &rarr;</NuxtLink></p>
<p><NuxtLink class="light-link" to="/doxa-maps">{{ t('See all DOXA maps') }} &rarr;</NuxtLink></p>
</div>
<!-- Research map embed — sits directly under the H1, above the UUPG

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,62 @@
/**
* icons.js — SELF-HOSTED icon set. No CDN, no icon font, no Google/YouTube request.
*
* These are hand-written 24x24 stroke paths in the Lucide idiom (ISC-licensed style:
* 2px stroke, round cap/join, 24x24 box). Shipping them as inline SVG means:
* - zero network requests (works offline, works in Capacitor from local assets)
* - F-Droid safe (no proprietary blobs, no tracking, reproducible from source)
* - no FOUT/icon-font flash
*
* To swap in a real icon package later, replace these path strings — the API
* (`icon(name)` returning an <svg> string) stays the same.
*/
const P = {
menu: '<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>',
map: '<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21"/><line x1="9" y1="3" x2="9" y2="18"/><line x1="15" y1="6" x2="15" y2="21"/>',
globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18"/>',
heart: '<path d="M19 14c1.5-1.5 3-3.5 3-5.5A4.5 4.5 0 0 0 12 6a4.5 4.5 0 0 0-10 2.5C2 10.5 3.5 12.5 5 14l7 7Z"/>',
// `prayer` = Prayer Mobilization — two hands pressed together (praying): pointed
// fingertips at top, knuckle bulges on the outer edges, wrists curling in at the base.
prayer: '<path d="M12 4.2c-.9 1.4-1.9 2.6-2.9 3.8-1 1.2-1.6 2.6-1.6 4.2v3.3c0 1.3.7 2.4 1.8 3 .5.3 1.1-.1 1.1-.7"/><path d="M12 4.2c.9 1.4 1.9 2.6 2.9 3.8 1 1.2 1.6 2.6 1.6 4.2v3.3c0 1.3-.7 2.4-1.8 3-.5.3-1.1-.1-1.1-.7"/><path d="M12 4.2V17"/>',
list: '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="4" cy="6" r="1"/><circle cx="4" cy="12" r="1"/><circle cx="4" cy="18" r="1"/>',
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.9"/><path d="M16 3.1a4 4 0 0 1 0 7.8"/>',
flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V4s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/>',
target: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1"/>',
pin: '<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0z"/><circle cx="12" cy="10" r="3"/>',
monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
chart: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
layers: '<polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/>',
flask: '<path d="M9 3h6v5l5 11a2 2 0 0 1-2 3H6a2 2 0 0 1-2-3l5-11z"/><line x1="9" y1="3" x2="15" y2="3"/><line x1="6" y1="15" x2="18" y2="15"/>',
share: '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.6" y1="13.5" x2="15.4" y2="17.5"/><line x1="15.4" y1="6.5" x2="8.6" y2="10.5"/>',
beaker: '<path d="M6 3h12"/><path d="M8 3v7l-3 8a2 2 0 0 0 2 3h10a2 2 0 0 0 2-3l-3-8V3"/>',
chevron: '<polyline points="9 18 15 12 9 6"/>',
grid: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
tree: '<rect x="9" y="3" width="6" height="5" rx="1"/><rect x="2" y="16" width="6" height="5" rx="1"/><rect x="16" y="16" width="6" height="5" rx="1"/><path d="M12 8v4M5 16v-2h14v2"/>',
dots: '<circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/>',
// `embed` = the <> script/code-embed mark. Signals "this map is the embeddable
// one you paste into a partner site" (2026-07-29, for Prayer Progress).
embed: '<polyline points="9 7 4 12 9 17"/><polyline points="15 7 20 12 15 17"/>',
// `send` = Laborer Mobilization (sending labourers) — a paper-plane/send mark,
// deliberately distinct from `heart` (Prayer Mobilization) and `users` (People Groups).
send: '<path d="M21 3 L3 10.5 L9.5 13.5 L12.5 20 Z"/><line x1="21" y1="3" x2="9.5" y2="13.5"/>',
// `poster` = a printable poster asset (Country Posters / Key City Posters) —
// a framed sheet with a pinned marker, distinct from `map` and `pin`.
poster: '<rect x="4" y="3" width="16" height="18" rx="2"/><circle cx="12" cy="10" r="2.5"/><path d="M8 17h8"/>',
// `search` = Material "search" magnifying glass, hand-drawn to Material's geometry
// (circle r=7.5 centred at 10.5,10.5 with the handle running to ~20,20 at 45°).
// Written out locally — NEVER fetched from a Google/Material CDN. That is a standing
// law, not a style choice: a CDN icon request leaks the end user's IP to Google,
// which in a persecution context is a safety risk. Self-hosted also = works offline
// and inside Capacitor/F-Droid from local files.
search: '<circle cx="10.5" cy="10.5" r="7.5"/><line x1="15.8" y1="15.8" x2="20.5" y2="20.5"/>',
// `book` = Gospel Access (an open book / the Word reaching a place).
book: '<path d="M12 6.5C12 6.5 9.5 4.5 4 4.5v13C9.5 17.5 12 19.5 12 19.5"/><path d="M12 6.5C12 6.5 14.5 4.5 20 4.5v13C14.5 17.5 12 19.5 12 19.5"/>',
// `wifi` = Cities with Internet (connectivity arcs + node). Local, no CDN.
wifi: '<path d="M3.5 9a13 13 0 0 1 17 0"/><path d="M6.8 12.4a8.5 8.5 0 0 1 10.4 0"/><path d="M10 15.7a4 4 0 0 1 4 0"/><circle cx="12" cy="19" r="1"/>',
}
export function icon(name, cls = '') {
const d = P[name] || P.map
return `<svg class="ic ${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${d}</svg>`
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,62 @@
/**
* languages.js — the language list for the app wrapper's locale dropdown.
*
* PORTED from dx-life's single source of truth: `config/languages.ts`
* (LANGUAGES / ENABLED_LANGUAGES / hideFromSwitcher). Kept DELIBERATELY MINIMAL:
* dx-life's version also carries DeepL codes, Bible translation ids, glossary ids
* and font flags — all of which belong to the CMS/translation pipeline, not to a
* map wrapper. Only the four fields the switcher actually renders are kept, plus
* `dir` (RTL) because the wrapper has to flip layout direction.
*
* WHY NOT A LIBRARY: the switcher is ~30 lines of state over this array. Adding a
* dependency to render a <select> would be more complexity, not less — and every
* candidate assumes a router (dx-life's uses `switchLocalePath()` because it
* routes /es, /fr …). The wrapper is a STATIC app with no locale routing: it just
* needs to emit a code. So: same DATA, simpler MECHANISM.
*
* THE CONTRACT (already built into the bundles — do not reinvent):
* ProfileLoader reads `profile-config.locale` (falls back to `.lang`), lowercases
* it, strips any region suffix ('pt-BR' -> 'pt'), drives the bundle's i18n global
* locale from it, and provides `lang` to descendants. So switching language =
* re-setting profile-config.locale on the mounted element. Nothing else.
*/
export const LANGUAGES = [
{ code: 'en', name: 'English', nativeName: 'English', flag: '🇺🇸' },
{ code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸' },
{ code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷' },
{ code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇵🇹' },
{ code: 'ar', name: 'Arabic', nativeName: 'العربية', flag: '🇸🇦', dir: 'rtl' },
{ code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺' },
// Present in dx-life but hidden from its public switcher (hideFromSwitcher: true).
// Kept here, flagged, so the list stays a faithful mirror of the source of truth.
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪', hideFromSwitcher: true },
{ code: 'hi', name: 'Hindi', nativeName: 'हिन्दी', flag: '🇮🇳', hideFromSwitcher: true },
// enabled:false in dx-life (routable/translatable but not offered): it, zh, ro.
]
/** Languages offered in the dropdown. */
export const SWITCHER_LANGUAGES = LANGUAGES.filter(l => !l.hideFromSwitcher)
export function getLanguage(code) {
return LANGUAGES.find(l => l.code === code)
}
/** Normalise any incoming tag to a supported code ('pt-BR' -> 'pt'; unknown -> 'en'). */
export function normaliseLocale(tag) {
const base = String(tag || '').split('-')[0].toLowerCase()
return LANGUAGES.some(l => l.code === base) ? base : 'en'
}
/** Persisted choice (survives reload), defaulting to the browser language. */
const KEY = 'doxa-maps:locale'
export function loadLocale() {
try {
const saved = localStorage.getItem(KEY)
if (saved) return normaliseLocale(saved)
} catch (_) { /* storage may be unavailable */ }
return normaliseLocale(navigator?.language)
}
export function saveLocale(code) {
try { localStorage.setItem(KEY, code) } catch (_) { /* non-fatal */ }
}

View file

@ -0,0 +1,128 @@
/**
* nav.js — the sidebar data model for the app-wrapper-material layout.
*
* Extends a conventional `SidebarNavItem` shape ({ to, label, icon, children })
* with the two things doxa-maps needs:
* - `kind` : 'item' | 'section' | 'folder'
* section = always-open, labelled group (YouTube "Subscriptions" style)
* folder = collapsible group (click to open/close)
* - `target` : 'both' | 'desktop' | 'mobile' <-- per-target packaging flag.
* 'desktop' items are hidden on mobile AND excluded from the
* Capacitor build's webDir (the mobile build copies fewer bundles).
*
* Order matters: this array renders top-to-bottom, exactly as authored.
* Icons are LOCAL names resolved from ./icons/<name>.svg — no CDN, no icon font.
* (F-Droid requires self-hosted, non-proprietary assets.)
*/
export const NAV = [
// ── PRAYER MOBILIZATION FIRST: the prayer map is the landing view, so this
// section sits at the TOP and its first child is the DEFAULT profile.
// (2026-07-29: "so that the first map they see is the prayer map".)
{
kind: 'section', id: 'prayer-mobilization', label: 'Prayer Mobilization', icon: 'heart', target: 'both',
children: [
// Prayer Progress = the research map opened on its PRAYER tab (colorStrategy
// 'prayerProgress'). That tab is tabs[0] in the bundle, so it is already the
// default view — no separate bundle needed. The `embed` icon (<>) marks it as
// the embeddable/share-first map.
{ kind: 'item', id: 'prayer-progress', label: 'Prayer Progress', icon: 'embed', target: 'both' },
{ kind: 'item', id: 'upg-100-list', label: 'My UPG 100 List', icon: 'list', target: 'both' },
// REMOVED 'Prayer List' (2026-07-29): not a real app.
],
},
// ── LABORER MOBILIZATION — mobilising the labourers/disciple-makers themselves
// (as distinct from mobilising prayer). UPG Neighbor MOVED here from Prayer
// Mobilization: it is a labourer/sending tool, not a prayer tool.
// (2026-07-29).
{
kind: 'section', id: 'laborer-mobilization', label: 'Laborer Mobilization', icon: 'send', target: 'both',
children: [
{ kind: 'item', id: 'upg-neighbor', label: 'UPG Neighbor', icon: 'users', target: 'both' },
],
},
// ── RESEARCH — a TOP-LEVEL section, PEER of Prayer/Laborer Mobilization
// (2026-07-29: "a section to the same degree as Laborer", i.e. a
// sibling, NOT nested under it). Holds the research/general maps.
{
kind: 'section', id: 'research', label: 'Research', icon: 'search', target: 'both',
children: [
{ kind: 'item', id: 'research-map', label: 'Research Map', icon: 'map', target: 'both' },
// 'All People Groups' = the FULL global people-group set (~15,000), as distinct
// from the ~2,100-UPG DX cluster the prayer map plots. Different datasets —
// keep them straight. (The "15k" suffix was dropped from the label at the
// maintainer's request 2026-07-29; the distinction is recorded here instead.)
// ⚠ NO BUNDLE YET — renders the placeholder card.
{ kind: 'item', id: 'all-people-groups', label: 'All People Groups', badge: 'soon', icon: 'globe', target: 'both' },
// REMOVED 'Reached People Groups' (2026-07-29) — added and withdrawn the
// same round; do not re-add without an explicit ask.
// REMOVED 'Simple Map' (2026-07-29). REASON WORTH KEEPING: the
// doxa-simple-map BUNDLE is not one map — it carries THREE (prayer, adoption,
// engagement). doxa-maps only wants to preview PRAYER, and that is already
// surfaced as 'Prayer Progress' in Prayer Mobilization. Exposing "Simple Map"
// would advertise adoption+engagement views we are not previewing here.
// => If a simple prayer-only view is ever wanted as its own entry, add a
// profile-config that pins doxa-simple-map to its prayer profile only.
],
},
// ── GOSPEL ACCESS — where the gospel can actually REACH: the infrastructure
// (internet-connected cities) and the assets (gospel resources) that make
// access possible. Populated by the maintainer 2026-07-29.
// Both items are wired to real bundles (cities-internet + gospel-resources).
{
kind: 'section', id: 'gospel-access', label: 'Gospel Access', icon: 'book', target: 'both',
children: [
{ kind: 'item', id: 'cities-with-internet', label: 'Cities with Internet', icon: 'wifi', target: 'both' },
{ kind: 'item', id: 'gospel-resources', label: 'Gospel Resources', icon: 'book', target: 'both' },
],
},
// ── 'People Groups' section REMOVED (2026-07-29): it is now empty —
// Countries -> Prayer Mobilization as Country Posters; Impact Map -> Experimental
// maps; People Pins -> deleted outright.
// ── DASHBOARDS — a SECTION (always visible, no folder to open), DESKTOP ONLY
// (excluded from the mobile/Capacitor build). "Big Picture" carries a Desktop Only badge. ──
{
kind: 'section', id: 'dashboards', label: 'Dashboards', icon: 'monitor', target: 'desktop',
children: [
{ kind: 'item', id: 'research-dashboard', label: 'Big Picture', icon: 'chart', target: 'desktop' },
],
},
// ── a FOLDER at the BOTTOM (design note: "experimental maps") ──
{
kind: 'folder', id: 'experimental', label: 'Experimental maps', icon: 'flask', target: 'both',
open: false,
children: [
{ kind: 'item', id: 'nationmapper', label: 'Nationmapper', badge: 'soon', icon: 'share', target: 'both' },
// Country Posters MOVED here from Prayer Mobilization to Experimental.
{ kind: 'item', id: 'countries-map', label: 'Country Posters', icon: 'poster', target: 'both' },
// Outreach Planner = geo-steward, renamed + moved out of the Dashboard folder.
{ kind: 'item', id: 'geo-steward', label: 'Outreach Planner', badge: 'soon', icon: 'layers', target: 'both' },
// Key City Posters MOVED here from Laborer Mobilization.
{ kind: 'item', id: 'key-city-posters', label: 'Key City Posters', icon: 'poster', target: 'both' },
// Full UPG-Neighbor hub (all lenses: cockpit / flower / lang-cluster / send).
// The production 'UPG Neighbor' in Laborer Mobilization is the focused lang-cluster map.
{ kind: 'item', id: 'upg-neighbor-lab', label: 'UPG Neighbor Lab', icon: 'flask', target: 'both' },
],
},
]
/**
* Filter the tree for a build/runtime target. Used by BOTH the UI and the packager.
* RECURSIVE — the tree is no longer only two levels deep (a section may hold a
* 'subgroup', e.g. Laborer Mobilization > Research > Research Map), so a flat
* one-level filter would silently drop or mis-filter nested entries.
*/
export function navForTarget(nav, target /* 'desktop' | 'mobile' */) {
const keep = (n) => n.target === 'both' || n.target === target
const walk = (nodes) => nodes
.filter(keep)
.map((n) => (n.children ? { ...n, children: walk(n.children) } : n))
// prune groups whose children were all filtered out (an empty group is noise)
.filter((n) => !n.children || n.children.length > 0)
return walk(nav)
}

View file

@ -0,0 +1,176 @@
/**
* pane-wrapper — the MFE pane GRID, a layout nested inside app-wrapper-material.
*
* WHAT IT IS
* `app-wrapper-material` owns the chrome (header, side menu, the View fieldset).
* This layout owns the STAGE INSIDE it: a parameterized grid of panes, each pane
* holding a different app-profile MFE that is another VIEW of the same subject.
* `my-upg-100-list` is a family — a map view and a poster-wall view — not one app
* with tabs. Together the two layouts form the app harness that displays the
* custom elements shipped in the vite bundles.
*
* THE MOTION CONTRACT (the design decision everything else follows from)
* • VERTICAL neighbours are ONE SCROLLABLE PAGE. Both panes are live; you scroll
* between them. The edge strips (useEdgeScrollAffordance) are what let you
* escape a scroll-capturing child like a Mapbox canvas.
* • HORIZONTAL movement is a FLY-TO. The target column is NOT loaded until you
* go there, and the origin column is released on arrival.
* So AT MOST TWO PANES ARE EVER ALIVE. That is not an arbitrary cap — it is what
* preserves the ~16-WebGL-context budget the wrapper's teardown has always
* protected. A naive "keep all four panes mounted" grid would break on a phone.
*
* WHY A COLUMN IS ONE SCROLLING PAGE RATHER THAN TWO SWAPPED VIEWS
* A pane you scroll to keeps its sibling on screen during the transition, so the
* relationship between the views stays legible ("the list is UNDER the map"). A
* swap teaches nothing.
*
* 2×2 IS A DEFAULT, NOT A CEILING. Panes carry [row, col] coordinates rather than
* a fixed enum, so the grid expands (2×3, 3×3 …) and contracts (a single column,
* or one pane) from the same code. A profile with no `panes` key behaves exactly
* as before this layout existed.
*
* AR is the reason the grid exists at all: today you see one pane (or one
* scrolling column), but the coordinates mean a future AR client can zoom out and
* see every pane laid out like posters on a wall.
*/
export const PANE_GRID_STYLES = `
/* The column that scrolls. Vertical neighbours stack here and the user scrolls
between them; scroll-snap makes each pane settle cleanly instead of stopping
half-way between two views. */
.pane-col{
position:absolute; inset:0; overflow-y:auto; overflow-x:hidden;
scroll-snap-type:y mandatory; scroll-behavior:smooth;
-webkit-overflow-scrolling:touch;
/* DOUBLE SCROLLBAR FIX. A pane's own content (e.g. the poster wall) scrolls
inside the pane; this column only scrolls BETWEEN panes. With both visible
the desktop showed two bars side by side. The column's bar is hidden — pane
navigation is driven by the View switcher and scroll-snap, not by dragging a
bar — while the inner content keeps its own, which is the one a user wants.
Scrolling still works here; only the indicator is suppressed. */
scrollbar-width:none; /* Firefox */
-ms-overflow-style:none; /* legacy Edge */
}
.pane-col::-webkit-scrollbar{ width:0; height:0; } /* WebKit/Blink */
/* Each pane fills the stage exactly, so "scroll down" moves you one full view. */
.pane{
position:relative; width:100%; height:100%;
scroll-snap-align:start; scroll-snap-stop:always;
}
/* The live custom element fills its pane. */
.pane > .pane-mount{ position:absolute; inset:0; }
/* Placeholder copy for a pane whose profile is not built yet — same card styling
as the wrapper's own placeholder so an unbuilt pane never looks broken. */
.pane-placeholder{
position:absolute; inset:0; display:grid; place-items:center; padding:24px;
text-align:center;
}
.pane-placeholder .pane-card{
max-width:520px; background:var(--card,#fff); border-radius:14px; padding:22px 26px;
box-shadow:0 1px 3px rgba(0,0,0,.12),0 6px 20px rgba(0,0,0,.08);
}
.pane-placeholder h2{ margin:0 0 8px; font-size:18px; font-weight:600 }
.pane-placeholder p{ margin:0; font-size:13px; line-height:1.55; opacity:.78 }
/* A small persistent hint that there IS something below — the affordance the
edge strips reinforce once you start interacting. Hidden on the last pane. */
.pane-more{
position:absolute; left:50%; bottom:10px; transform:translateX(-50%);
display:flex; align-items:center; gap:6px; padding:5px 12px; border-radius:999px;
background:rgba(0,0,0,.55); color:#fff; font-size:11px; letter-spacing:.02em;
pointer-events:none; z-index:6; opacity:.9;
}
`
/**
* Build the pane column for a profile.
*
* @param {Object} spec the PROFILES registry entry
* @param {Object} opts
* @param {HTMLElement} opts.host container to render into (the wrapper's #slot)
* @param {(pane:Object, mountEl:HTMLElement) => Promise<void>} opts.mountPane
* called for each pane that should be LIVE; the caller owns element creation
* so this layout never needs to know about bundles, tags or tokens.
* @returns {{ panes:Object[], scrollTo:(paneId:string)=>void, destroy:()=>void }}
*/
export function buildPaneColumn(spec, { host, mountPane }) {
const panes = Array.isArray(spec?.panes) ? spec.panes : []
// No pane declaration → not a pane profile. The caller falls back to its
// original single-mount path, so nothing changes for existing profiles.
if (panes.length < 2) return null
// v1 renders ONE COLUMN (col 0). Other columns are reachable by fly-to, which is
// a later slice — but the coordinates are already carried on every pane, so that
// work does not require reshaping this data.
const column = panes
.filter(p => (p.at?.[1] ?? 0) === 0)
.sort((a, b) => (a.at?.[0] ?? 0) - (b.at?.[0] ?? 0))
if (column.length < 2) return null
const col = document.createElement('div')
col.className = 'pane-col'
const mounts = new Map()
column.forEach((pane, i) => {
const el = document.createElement('div')
el.className = 'pane'
el.dataset.paneId = pane.id
const mount = document.createElement('div')
mount.className = 'pane-mount'
el.appendChild(mount)
mounts.set(pane.id, mount)
// A pane with no bundle yet still renders — as a labelled placeholder, so the
// column is scrollable and testable before every view profile exists.
if (pane.placeholder) {
const ph = document.createElement('div')
ph.className = 'pane-placeholder'
ph.innerHTML = `<div class="pane-card"><h2>${pane.label}</h2><p>${pane.placeholder}</p></div>`
el.appendChild(ph)
}
// "scroll for more" hint on every pane except the last.
// OPT-OUT per profile via `paneHint: false` in the PROFILES registry entry.
// The header already carries a Map | Posters view switcher, so on a profile
// where that is visible this pill is a second, redundant control that just
// sits on top of the map. Left ON by default so existing profiles are
// unchanged. (design note 2026-08-01: "component that says posters below should
// not exist.")
if (i < column.length - 1 && spec?.paneHint !== false) {
const more = document.createElement('div')
more.className = 'pane-more'
more.innerHTML = `<span>${column[i + 1].label} below</span><span aria-hidden="true">↓</span>`
el.appendChild(more)
}
col.appendChild(el)
})
host.appendChild(col)
return {
panes: column,
/** Animate to a pane by id — this is what the View fieldset tabs call. */
scrollTo(paneId) {
const target = col.querySelector(`.pane[data-pane-id="${paneId}"]`)
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' })
},
/** Which pane is currently filling the viewport (for URL sync). */
current() {
const tops = column.map(p => {
const el = col.querySelector(`.pane[data-pane-id="${p.id}"]`)
return { id: p.id, d: Math.abs((el?.offsetTop ?? 0) - col.scrollTop) }
})
tops.sort((a, b) => a.d - b.d)
return tops[0]?.id || column[0].id
},
onScroll(cb) {
let t = null
const h = () => { if (t) clearTimeout(t); t = setTimeout(cb, 120) }
col.addEventListener('scroll', h, { passive: true })
return () => { if (t) clearTimeout(t); col.removeEventListener('scroll', h) }
},
mountEl: (paneId) => mounts.get(paneId) || null,
destroy() { col.remove(); mounts.clear() },
}
}

View file

@ -0,0 +1,8 @@
// The doxa-maps SPA shell (served from public/js/doxa-maps-build/) resolves its
// Mapbox token from ./api/maps/token.json relative to itself — which is THIS
// route. Serving it from runtime config means the drop-in ships no secret and
// the site's own NUXT_PUBLIC_MAPBOX_TOKEN powers the whole SPA.
export default defineEventHandler((event) => {
const token = (useRuntimeConfig(event).public as { mapboxToken?: string }).mapboxToken || ''
return { token, type: token.slice(0, 2) }
})