UPG-100: lead with Prayer Resources, map second
Some checks are pending
build-gate / build (push) Waiting to run

The first tab a new user meets is now the poster/resources pane, not the
map. That pane already explains what a UPG-100 list is at the top and shows
the posters, so it needs no explanation; a choropleth of zones does. Maps
need not be primary — a familiar list or grid is the on-ramp people use
before they transition into the map.

Renames the tab from "Prayer Lists" to "Prayer Resources".

Pane order lives in at[0], not in array order: buildPaneColumn sorts by
at[0] while ViewSelect renders the array verbatim, so both are swapped
together or the tab strip desyncs from the scroll column. The active view
is now derived from that same sort instead of a hardcoded 'map'.

In the wrapper shell the map pane is resolved by PROFILE rather than by
"pane 0 is the map": under the positional rule, reordering mounted the map
element into the poster pane, and "Show on map" scrolled to the wall you
clicked from. The shell's tab strip also resets to a profile's first view
on switch, since a stale 'map' id is valid in nearly every profile.

ViewSelect keeps each tab on one line (nowrap + ellipsis), ported from the
shell's own mobile rule that the original extraction left behind. Measured
at 390px: "Prayer Resources" wrapped inside its button and grew the strip
45 -> 60px; it now holds 45px at every width from 1280 down to 390.
This commit is contained in:
doxa-maps-88655c70 2026-09-23 20:33:00 +00:00
parent 1d4fe185e3
commit 378ecb9ae6
3 changed files with 97 additions and 17 deletions

View file

@ -62,7 +62,16 @@ useShadowStyles(
'.seg button{ position:relative; display:flex; align-items:center; gap:6px;' +
' border:0; background:none; color:var(--ui-text-muted,#6d766e);' +
' padding:6px 12px 7px; font-size:13px; font-weight:500; cursor:pointer;' +
' transition:color .15s; font-family:inherit; }' +
' transition:color .15s; font-family:inherit;' +
// ONE LINE PER TAB. Ported from the shell's own mobile rule (app-wrapper-material
// index.html, max-width:960px: white-space:nowrap + ellipsis) — the extraction that
// created this component copied the DESKTOP metrics verbatim but left that hardening
// behind, so the leaf and the shell disagreed on narrow viewports.
// MEASURED at 390px with the label "Prayer Resources": the fieldset is width-capped
// by the header, the TEXT wrapped inside the button (28 -> 43px) and the strip grew
// 45 -> 60px, while the shell held 45px. The row of tabs never wrapped; only the
// words inside one did. Ellipsis is the better failure than a taller header.
' white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }' +
'.seg button::after{ content:""; position:absolute; left:8px; right:8px; bottom:0;' +
' height:2px; border-radius:2px 2px 0 0; background:transparent;' +
' transition:background .15s; }' +

View file

@ -631,6 +631,11 @@ const VIEWS = [
{ id:'tree', label:'Tree', icon:'tree' },
]
let view = 'map'
// Which profile the tab strip was last drawn FOR, so drawSeg can tell "same profile,
// user picked a tab" from "profile changed, reset to its first view". Declared with
// `var`-like hoisting in mind: it is read inside drawSeg, which only ever runs after
// this module body has executed, so a `let` here is safe (cf. DEFAULT_PROFILE below).
let segProfile = null
// The tabs are PER-PROFILE, not a fixed app-wide set. A profile that declares
// `panes` shows exactly those and no others — upg-100 has a map and a poster wall,
// so it shows two tabs, not three with a dead one. A profile with no `panes` key
@ -666,7 +671,15 @@ function drawSeg(){
if (_sel) _sel.style.display = views.length <= 1 ? 'none' : ''
// If the active view is not one this profile has (e.g. you were on Tree, then
// switched to a two-pane profile), fall back to its first pane.
if (!views.some(v => v.id === view)) view = views[0].id
//
// ALSO fall back when the profile CHANGED, even if the old view id happens to exist
// here too. `view` is one global across profiles and 'map' is a valid id in nearly
// every one, so an id-existence test alone let a stale 'map' survive a switch into a
// profile whose FIRST pane is not the map (upg-100-list leads with Prayer Resources).
// The column always opens at scroll position 0, so the strip then painted a pressed
// tab that did not match the pane on screen. A new profile opens on its own first view.
if (!views.some(v => v.id === view) || segProfile !== active) view = views[0].id
segProfile = active
$('#seg').innerHTML = views.map(v =>
`<button data-v="${v.id}" aria-pressed="${v.id===view}">${icon(v.icon)}<span>${v.label}</span></button>`
).join('')
@ -826,10 +839,23 @@ const PROFILES = {
// (index.js does import.meta.glob('./*.vue'), so poster-wall.vue registers
// itself). Same tag, same src, different `profile` value: one download serves
// both panes and they share data/clusters.json rather than fetching twice.
// RESOURCES FIRST, MAP SECOND (Driver 2026-09-23). "By default the first tab
// shouldn't be the map. The first tab should be the prayer resources" — that pane
// already explains what a UPG-100 list IS at the top and shows the posters, so a
// new user needs no explanation for it; a choropleth of zones does. Stated by him
// as a general principle: "maps don't have to be primary. You can use lists and
// grids for the primary view and maps as a secondary view… then they transition
// into the map." Familiarity is the on-ramp.
//
// Both panes carry an EXPLICIT `config.profile`, including the map. The mount code
// used to rely on "pane 0 is the map" positionally — reordering under that rule
// would have mounted the map element into the poster pane. Naming the profile on
// every pane is what makes the order data rather than convention.
panes: [
{ id:'map', label:'Map', at:[0,0] },
{ id:'list', label:'Prayer Lists', at:[1,0],
{ id:'list', label:'Prayer Resources', at:[0,0],
config: { profile:'poster-wall', instanceId:'wrapper-upg-100' } },
{ id:'map', label:'Map', at:[1,0],
config: { profile:'upg-100-list', instanceId:'wrapper-upg-100' } },
],
},
// COUNTRY POSTERS — one poster per country, the same shape as upg-100-list:
@ -1046,13 +1072,25 @@ async function mount(id){
slot.innerHTML = ''
const column = buildPaneColumn(spec, { host: slot })
if (column){
// Pane 0 gets the element already built above; every OTHER pane that declares
// its own `config` gets a sibling element from the SAME bundle (same tag, same
// src — one download) with a different `profile`. A pane with no config keeps
// its placeholder, so an unbuilt view never blocks the column.
column.mountEl(column.panes[0].id)?.appendChild(el)
// The element already built above goes to the pane that ASKED FOR ITS PROFILE;
// every OTHER pane that declares its own `config` gets a sibling element from
// the SAME bundle (same tag, same src — one download) with a different
// `profile`. A pane with no config keeps its placeholder, so an unbuilt view
// never blocks the column.
//
// WHY BY PROFILE, NOT BY INDEX. This used to be `panes[0]`, encoding "the map is
// pane 0" as a positional convention. That silently breaks the moment a profile
// wants a non-map pane first — upg-100-list now leads with its poster wall — and
// the failure is the worst kind: the MAP element mounts into the POSTER pane and
// both panes render the map. Panes that name no profile cannot match, so every
// other multi-pane profile still lands on pane 0 exactly as before.
const hostPane =
column.panes.find(p => p.config?.profile && p.config.profile === spec.config?.profile)
|| column.panes[0]
column.mountEl(hostPane.id)?.appendChild(el)
const extras = [el]
for (const pane of column.panes.slice(1)){
for (const pane of column.panes){
if (pane.id === hostPane.id) continue
if (!pane.config) continue
const pel = document.createElement(spec.tag)
const pcfg = { ...spec.config, ...pane.config }
@ -1092,8 +1130,11 @@ async function mount(id){
}
column._showOnMap = (ev) => {
const d = ev?.detail || {}
// The map is pane 0 by convention (the element built above mounts there).
const mapPaneId = column.panes[0]?.id
// The map is the pane the SHELL-BUILT element mounted into — resolved by
// profile, not by position. "Pane 0 is the map" stopped being true when
// upg-100-list put its poster wall first; under the old rule "Show on map"
// scrolled to the poster wall you clicked FROM, which looks like a dead button.
const mapPaneId = hostPane?.id || column.panes[0]?.id
if (mapPaneId) column.scrollTo(mapPaneId)
// Forward to every live element in this column; the map profile picks it
// up and selects the zone. A DESELECT arrives as listId === null, which

View file

@ -15,8 +15,9 @@
* regions-dashboard could reuse it instead of copying it a third time.
*
* Copied conventions (see layouts/app-wrapper-material/index.html, 'upg-100-list'):
* • panes: { id:'map', label:'Map', at:[0,0] } and
* { id:'list', label:'Prayer Lists', at:[1,0], config:{ profile:'poster-wall' } }
* • panes: { id:'list', label:'Prayer Resources', at:[0,0], config:{ profile:'poster-wall' } }
* and { id:'map', label:'Map', at:[1,0] } — RESOURCES FIRST; see the note
* on `panes` below for why the familiar view is the on-ramp to the map.
* • per-pane element config = { ...spec.config, ...pane.config } — same tag, same
* bundle, different `profile`; one download serves both panes.
* • ONE shared instanceId for both panes (as the shell does) so the poster wall's
@ -53,9 +54,21 @@ const spec = computed(() => ({
id: 'upg-100-mfe',
tag: TAG,
config: { profile: 'upg-100-list', dataSource: cfg.value?.dataSource || 'pray-tools', instanceId: instanceId.value },
// RESOURCES FIRST, MAP SECOND (Driver 2026-09-23). "By default the first tab
// shouldn't be the map. The first tab should be the prayer resources" — that pane
// already explains what a UPG-100 list IS at the top and shows the posters, so it
// needs no explanation; a choropleth of zones does. Generalised by him as a design
// principle: "maps don't have to be primary. You can use lists and grids for the
// primary view and maps as a secondary view… then they transition into the map."
//
// ORDER LIVES IN `at[0]`, NOT IN THIS ARRAY'S ORDER. buildPaneColumn SORTS by
// at[0] (pane-wrapper.js), while ViewSelect renders this array VERBATIM. Swapping
// only one of the two desynchronises the tab strip from the scroll column — the
// strip would read "Prayer Resources | Map" while scroll position 0 still held the
// map. Both are swapped here, and `view` below is initialised from the same sort.
panes: [
{ id: 'map', label: 'Map', at: [0, 0], config: { profile: 'upg-100-list' } },
{ id: 'list', label: 'Prayer Lists', at: [1, 0], config: { profile: 'poster-wall' } },
{ id: 'list', label: 'Prayer Resources', at: [0, 0], config: { profile: 'poster-wall' } },
{ id: 'map', label: 'Map', at: [1, 0], config: { profile: 'upg-100-list' } },
],
// The View switcher lives in this element's own header, so the layout's
// "… below ↓" pill would be a second, redundant control (shell design note 2026-08-01).
@ -63,7 +76,24 @@ const spec = computed(() => ({
}))
const stage = ref(null)
const view = ref('map')
// DERIVED from the pane order, never a hardcoded id. This was `ref('map')`, which
// silently became wrong the moment the panes were reordered: the column scrolled to
// the resources pane while the strip still painted "Map" as pressed. Sorting by
// at[0] here is the SAME rule buildPaneColumn applies, so the pressed tab and the
// pane at scroll-position 0 cannot disagree.
const firstPaneId = computed(() => {
const col = spec.value.panes
.filter(p => (p.at?.[1] ?? 0) === 0)
.sort((a, b) => (a.at?.[0] ?? 0) - (b.at?.[0] ?? 0))
return col[0]?.id || 'list'
})
const viewRaw = ref(null)
// Until the column exists, the active view IS the first pane — so the strip paints a
// pressed tab on the very first render rather than flashing with nothing selected.
const view = computed({
get: () => viewRaw.value || firstPaneId.value,
set: (v) => { viewRaw.value = v },
})
let column = null
let offScroll = null