Restore three-section tab structure: Dashboards / Prayer Mobilization / Gospel Research
Big Picture now lives in its own Dashboards section alongside a renamed Simple Research (was Research Map, recategorised as the mobile-default dashboard). Prayer Mobilization narrows to UPG-100 List + Prayer Progress; the renamed Gospel Research section takes Cities with Internet + Gospel Resources. The device rule changes shape to match: every row now stays in the rail on every width (nothing is hidden any more), Big Picture is disabled rather than removed on mobile with a "desktop only" badge and an explanatory toast on click, and the auto-selected tab swaps to Simple Research on mobile / Big Picture on desktop. Added a loud, module-load-time assertion that no tab id is declared twice across the curated sections, on top of the existing silent runtime dedupe -- this is the gate the duplicate-key regression needed and didn't have.
This commit is contained in:
parent
c48d1d401a
commit
4c74bd717e
4 changed files with 186 additions and 94 deletions
|
|
@ -133,6 +133,12 @@
|
|||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- THE DEVICE-RULE NOTICE — fires when a mobile user clicks the disabled Big Picture row (see
|
||||
onSelect's intercept). A transient toast rather than a modal: the point is to explain why
|
||||
nothing switched, not to block the app. Sibling of the panes, not inside one, so it survives
|
||||
whichever tab is currently active. -->
|
||||
<div v-if="disabledNotice" class="dms-notice" role="status">{{ disabledNotice }}</div>
|
||||
</VerticalTabsLayout>
|
||||
</template>
|
||||
|
||||
|
|
@ -148,66 +154,57 @@ import { SWITCHER_LANGUAGES, loadLocale, saveLocale, getLanguage } from '@map/la
|
|||
// exist in this SPA are OMITTED (never paint a dead nav row — nav.js's own navForTarget prunes empty
|
||||
// groups the same way). `folder:true` (Experimental) renders collapsible, starting closed.
|
||||
const NAV_MODEL = [
|
||||
// ── NOTE: there is deliberately NO separate "Dashboards" section. The ONE dashboard (the Driver's
|
||||
// ruling: "we don't need multiple dashboards") is `dashboard-plus-grid` — the coupled
|
||||
// research-dashboard + people-groups-grid on a single scroll-page, what the Driver calls the Big
|
||||
// Picture. It is the FIRST row of Prayer Mobilization below, which makes it tab-1/the landing
|
||||
// surface AND the opening step of the onboarding ramp.
|
||||
// ── THREE SECTIONS, PER THE SPEC (2026-09-22 audio, transcribed to
|
||||
// _qa-cycle/01-ideation-research/SPEC-the-six-map-product-and-its-tab-order.md — that file is
|
||||
// AUTHORITATIVE; this comment explains how the code satisfies it, not the ruling itself).
|
||||
//
|
||||
// WHY IT IS NOT ALSO ITS OWN SECTION: it used to be, and that is exactly what painted "Big
|
||||
// Picture" TWICE in the rail (a one-row "Dashboards" section whose single row was re-declared as
|
||||
// the ramp's step 1). Two rows sharing one tab id is not merely cosmetic — the layout keys its
|
||||
// tab-ref map and its `active` class BY ID, so both copies highlight at once and keyboard nav
|
||||
// stalls on the repeated key. One section owning the row fixes all of that and matches the
|
||||
// Driver's stated order (Big Picture → UPG-100 → Prayer Progress → Gospel Resources → Research).
|
||||
// Driver, verbatim: "in one of the earlier messages i mentioned i wanted 3 tab sections. one
|
||||
// called dashboards, one called prayer mobilization, one called gospel research. with two maps
|
||||
// inside of each of them."
|
||||
//
|
||||
// Prayer Mobilization FIRST among the map sections (the prayer map is the mobilisation landing).
|
||||
//
|
||||
// ORDER IS THE DRIVER'S, AND THE ORDER IS THE TEACHING. Big Picture → UPG-100 List → Prayer
|
||||
// Progress → Gospel Resources. The non-obvious step is UPG-100 BEFORE the prayer map, and his
|
||||
// rationale is worth preserving verbatim: "if you're trying to mobilize prayer, UPG-100 list
|
||||
// probably comes first because it's a NEW thing to people — you want them introduced to it
|
||||
// first." i.e. the rail is an ONBOARDING RAMP, not an inventory: show the big picture, hand the
|
||||
// newcomer the concrete new artifact (the 100 list), THEN ask them to pray it, then give them
|
||||
// the resources to act. Re-sorting these rows by any other logic (alphabetical, "maps first",
|
||||
// most-used) silently breaks that ramp — so do not reorder without the Driver.
|
||||
// ★ DESKTOP vs MOBILE — `onlyWhen` gates a row on viewport width (Driver 2026-09-22, verbatim).
|
||||
// DESKTOP: Big Picture Dashboard opens by default, then UPG-100, Prayer Progress, Gospel
|
||||
// Resources, Cities & Internet, then Research Map under Research.
|
||||
// MOBILE: "starts with the Research Map, then UPG-100 List, Prayer Progress, Gospel Resources,
|
||||
// Cities & Internet — and the Big Picture Dashboard does NOT appear on mobile at all."
|
||||
// So the ramp's FIRST rung swaps: the desktop rung is the dashboard composite, the mobile rung is
|
||||
// the research-map composite. Everything below rung 1 is IDENTICAL on both, which is why only the
|
||||
// first two rows carry `onlyWhen` — do not sprinkle it further without the Driver.
|
||||
//
|
||||
// WHY A ROW-LEVEL FLAG rather than two NAV_MODELs: one model keeps the teaching order in ONE place.
|
||||
// Two parallel models would be two things to keep in sync, and the failure would be silent (a row
|
||||
// added to one and forgotten in the other simply never appears on that form factor).
|
||||
// A PRIOR CUT REMOVED DASHBOARDS ENTIRELY (see git history / the spec's "duplicate-key trap"),
|
||||
// because Big Picture had been declared TWICE — once as a one-row Dashboards section and once as
|
||||
// Prayer Mobilization's first rung — and VerticalTabsLayout keys its tab-ref map and `active`
|
||||
// highlight BY ID, so two rows sharing one id painted both active at once and stalled keyboard
|
||||
// nav. That was a REGRESSION, not a fix for the underlying constraint. The constraint (no id
|
||||
// painted twice) is real and still enforced (see dedupeRows + the assertNoDuplicateTabKeys
|
||||
// dev-time gate below); the fix is that Dashboards now owns TWO DISTINCT rows of its own — Big
|
||||
// Picture never appears in Prayer Mobilization again — so it is not a one-row section that begs
|
||||
// to be folded away.
|
||||
{ key: 'dashboards', label: 'Dashboards', icon: 'chart',
|
||||
tabs: [
|
||||
// 1. Big Picture — desktop default. Present on EVERY width (the device rule is "same tab
|
||||
// order everywhere"), but DISABLED on mobile rather than hidden — see isBigPictureDisabled /
|
||||
// onSelect's intercept and the `disabled`-row note above navGroups. Clicking it on mobile
|
||||
// shows a message instead of switching; it never disappears from the rail.
|
||||
{ id: 'dashboard-plus-grid', icon: 'chart' },
|
||||
// 2. Simple Research — mobile default; RENAMED from "Research Map" and RECATEGORISED here as a
|
||||
// dashboard (it used to live as Prayer Mobilization's mobile-only rung 1). It is the research
|
||||
// map + People Groups grid composite — "the mobile dashboard" per the spec — and it stays
|
||||
// reachable on desktop too, as the second dashboard row.
|
||||
{ id: 'research-map-plus-grid', icon: 'map' },
|
||||
] },
|
||||
// Prayer Mobilization — now TWO rows only: the onboarding ramp's steps 2 and 3. Big Picture (former
|
||||
// step 1) moved to Dashboards above; Gospel Resources (former step 4) moved to Gospel Research below
|
||||
// per the spec's table. UPG-100 BEFORE Prayer Progress is still the Driver's deliberate order —
|
||||
// "if you're trying to mobilize prayer, UPG-100 list probably comes first because it's a NEW thing
|
||||
// to people — you want them introduced to it first" — hand the newcomer the concrete new artifact,
|
||||
// THEN ask them to pray it.
|
||||
{ key: 'prayer-mobilization', label: 'Prayer Mobilization', icon: 'heart',
|
||||
tabs: [
|
||||
// 1a. DESKTOP rung 1 — Big Picture. Omitted entirely on mobile, per the Driver.
|
||||
{ id: 'dashboard-plus-grid', icon: 'chart', onlyWhen: 'desktop' },
|
||||
// 1b. MOBILE rung 1 — the research map + grid composite opens the app instead.
|
||||
{ id: 'research-map-plus-grid', icon: 'map', onlyWhen: 'mobile' },
|
||||
{ id: 'upg-100', icon: 'list' }, // 2. UPG-100 List — the NEW thing, introduced early
|
||||
{ id: 'prayer-map', icon: 'embed' }, // 3. Prayer Progress — now pray the list
|
||||
{ id: 'gospel-resources', icon: 'book' }, // 4. Gospel Resources — then act
|
||||
{ id: 'upg-100', icon: 'list' }, // 1. UPG-100 List — the NEW thing, introduced first
|
||||
{ id: 'prayer-map', icon: 'embed' }, // 2. Prayer Progress — now pray the list
|
||||
] },
|
||||
// Research — a peer section (nav.js: "a section to the same degree as Laborer"). The Driver's
|
||||
// order: Cities & Internet FIRST, then Gospel Resources, then the simple research app at the
|
||||
// bottom.
|
||||
//
|
||||
// ★ Gospel Resources is DECLARED here as well as in the prayer ramp above, on purpose — it is the
|
||||
// "then act" step of that ramp AND a research surface in its own right. It is declared twice but
|
||||
// RENDERED ONCE: dedupeRows() (see below) gives the FIRST declaration the row and drops this
|
||||
// later one, so the rail holds exactly one node per tab id. Leaving the declaration here is not
|
||||
// dead weight — it documents the intent, and if the prayer section is ever re-cut this section
|
||||
// inherits the row automatically.
|
||||
{ key: 'research', label: 'Research', icon: 'search',
|
||||
// Gospel Research — RENAMED section (was "Research"). Cities with Internet then Gospel Resources,
|
||||
// per the spec's table (rows 5-6). The plain, uncomposited "Research Map" tab (doxa-research-map)
|
||||
// is no longer one of the six curated tabs — its research-map+grid composite now IS Dashboards row
|
||||
// 2 (Simple Research) — so the standalone map is left OUT of every curated section and falls
|
||||
// through to Experimental automatically (see curatedBundles/experimentalTabsFromManifest); that is
|
||||
// correct, not an omission.
|
||||
{ key: 'gospel-research', label: 'Gospel Research', icon: 'search',
|
||||
tabs: [
|
||||
{ id: 'cities-internet', icon: 'wifi' },
|
||||
{ id: 'gospel-resources', icon: 'book' },
|
||||
{ id: 'research-map', icon: 'map' }, // the simple research app, at the bottom
|
||||
{ id: 'cities-internet', icon: 'wifi' }, // renamed label: "Cities with Internet"
|
||||
{ id: 'gospel-resources', icon: 'book' },
|
||||
] },
|
||||
// Experimental — a collapsible FOLDER at the bottom (nav.js: kind 'folder', open:false). Starts
|
||||
// CLOSED; click the heading to open.
|
||||
|
|
@ -283,16 +280,22 @@ const TAB_DEFS = [
|
|||
bundle: 'doxa-research-map', profile: 'research-map'
|
||||
},
|
||||
{
|
||||
// THE MOBILE COUNTERPART OF 'dashboard-plus-grid'. Driver 2026-09-22, having reversed an earlier
|
||||
// idea: "We made the wrong choice of saying that we should swap elements based on mobile and
|
||||
// desktop... It'd be easier just to have two separate capability-4 plugins." So there are two
|
||||
// cap-4 composites, neither aware of the other, and THIS SHELL picks between them by width —
|
||||
// which is what `onlyWhen` below expresses. Both stay independently embeddable, which a
|
||||
// self-swapping element could not be (an embedder at a fixed size could never ask for one).
|
||||
// RENAMED (spec): "Research Map" → "Simple Research", and RECATEGORISED as a Dashboards row (the
|
||||
// mobile-default dashboard). Driver 2026-09-22, having reversed an earlier idea: "We made the
|
||||
// wrong choice of saying that we should swap elements based on mobile and desktop... It'd be
|
||||
// easier just to have two separate capability-4 plugins." So there are two cap-4 composites,
|
||||
// neither aware of the other — this one and 'dashboard-plus-grid' above — and the shell decides
|
||||
// which is AUTO-SELECTED by width (see firstVisibleIndex/isBigPictureDisabled), never which is
|
||||
// SHOWN: per the device rule both rows are always in the rail. Both composites stay independently
|
||||
// embeddable, which a self-swapping element could not be (an embedder at a fixed size could never
|
||||
// ask for one).
|
||||
//
|
||||
// NOTE the `id` stays 'research-map-plus-grid': the id is the stable join key between NAV_MODEL
|
||||
// and TAB_DEFS, so only the human-facing LABEL/sub change on a rename — never the id.
|
||||
//
|
||||
// Same cap-4 tier as its desktop sibling: research-map-plus-grid.meta.json declares
|
||||
// {"type":"nested-elements-mfe"}, verified against the built manifest.
|
||||
id: 'research-map-plus-grid', label: 'Research Map', sub: 'research map + people-groups grid', emoji: '🗺️',
|
||||
id: 'research-map-plus-grid', label: 'Simple Research', sub: 'research map + people-groups grid', emoji: '🗺️',
|
||||
bundle: 'research-map-plus-grid', profile: 'research-map-plus-grid'
|
||||
},
|
||||
{
|
||||
|
|
@ -310,7 +313,9 @@ const TAB_DEFS = [
|
|||
bundle: 'doxa-simple-map', profile: 'doxa-simple-map', instance: 'prayer'
|
||||
},
|
||||
{
|
||||
id: 'cities-internet', label: 'Cities & Internet', sub: 'connectivity of key cities', emoji: '📡',
|
||||
// RENAMED (spec): "Cities & Internet" → "Cities with Internet" — the Gospel Research section's
|
||||
// first row. NOTE the `id` stays 'cities-internet' for the same stable-join-key reason as above.
|
||||
id: 'cities-internet', label: 'Cities with Internet', sub: 'connectivity of key cities', emoji: '📡',
|
||||
bundle: 'cities-internet', profile: 'connectivity-map'
|
||||
},
|
||||
{
|
||||
|
|
@ -327,6 +332,11 @@ const TAB_DEFS = [
|
|||
// zero edits to this file.
|
||||
]
|
||||
|
||||
// THE SPEC'S REQUIRED GATE, run once at module load — see assertNoDuplicateTabKeys below for why this
|
||||
// exists as a LOUD check on top of dedupeRows' silent runtime safety net. `function` declarations
|
||||
// hoist, so this call is valid even though the function body is defined later in the file.
|
||||
assertNoDuplicateTabKeys(NAV_MODEL)
|
||||
|
||||
// MAP ELEMENTS — the rows this shell DESTROYS on deactivate rather than hiding.
|
||||
//
|
||||
// ★ THIS IS A WebGL CONTEXT BUDGET, NOT A STYLE CHOICE. The browser allows ~8-16 live WebGL contexts.
|
||||
|
|
@ -421,6 +431,40 @@ function dedupeRows(rows, seen) {
|
|||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* THE SPEC'S REQUIRED GATE — "any implementation must assert that no tab key appears twice across
|
||||
* all sections" (SPEC-the-six-map-product-and-its-tab-order.md, "the duplicate-key trap"). dedupeRows
|
||||
* above is the RUNTIME safety net (silently keeps the first declaration, so a duplicate can never
|
||||
* stall the rail); this is the LOUD check that a duplicate should not exist among the CURATED
|
||||
* sections in the first place — the previous regression happened because nobody had this and a
|
||||
* silent safety net alone does not stop a duplicate from being written again by a future edit.
|
||||
*
|
||||
* Runs once at module load (see the call below NAV_MODEL/TAB_DEFS), so a duplicate is a build/dev-
|
||||
* console error the moment the file is edited wrong, not something only Playwright would catch.
|
||||
* Never throws in production: a duplicate is a code defect to fix, not a reason to take the whole
|
||||
* SPA down for a live user, so this reports loudly and returns rather than throwing.
|
||||
*/
|
||||
function assertNoDuplicateTabKeys(navModel) {
|
||||
const seen = new Map() // id -> section key that first claimed it
|
||||
const dupes = []
|
||||
for (const g of navModel) {
|
||||
if (g.key === 'experimental') continue // the catch-all is generic by construction; nothing to assert
|
||||
for (const r of g.tabs || []) {
|
||||
if (!r || !r.id) continue
|
||||
const first = seen.get(r.id)
|
||||
if (first === undefined) { seen.set(r.id, g.key); continue }
|
||||
if (first !== g.key || dupes.indexOf(r.id) === -1) dupes.push(r.id + ' (' + first + ' + ' + g.key + ')')
|
||||
}
|
||||
}
|
||||
if (dupes.length && typeof console !== 'undefined') {
|
||||
console.error('[doxa-maps-spa] duplicate tab key(s) across NAV_MODEL sections: ' + dupes.join(', ') +
|
||||
' — VerticalTabsLayout keys its tab-ref map and `active` highlight by id, so a row declared in ' +
|
||||
'two sections paints twice and stalls keyboard nav. See the duplicate-key trap in ' +
|
||||
'SPEC-the-six-map-product-and-its-tab-order.md.')
|
||||
}
|
||||
return dupes
|
||||
}
|
||||
|
||||
// Turn a bundle id into a human label: "all-people-groups" → "All People Groups". Small, local and
|
||||
// dependency-free; a bundle may override it by shipping a profile `title` in the manifest.
|
||||
function titleize(id) {
|
||||
|
|
@ -627,14 +671,18 @@ export default defineComponent({
|
|||
// inherits its language from the parent via the `language` prop") can never drift: see
|
||||
// showLocaleSwitcher. A future host that wants the SPA's own chrome flips this to true.
|
||||
showHeader: false,
|
||||
// ★ VIEWPORT WIDTH AS REACTIVE STATE, for the desktop/mobile tab swap (see NAV_MODEL onlyWhen).
|
||||
// ★ VIEWPORT WIDTH AS REACTIVE STATE, for the device rule (default row + Big Picture disable).
|
||||
// 760px is THE SAME BREAKPOINT library/layouts/VerticalTabsLayout.vue uses for its rail/drawer
|
||||
// switch. The layout keeps its own `narrow` flag PRIVATE (it emits only update:active), so the
|
||||
// shell cannot read it and must watch the same query itself. That duplicates a CONSTANT, not
|
||||
// logic — but if either side ever moves, move BOTH: a shell that thinks it is on desktop while
|
||||
// the rail has become a drawer would show the Big Picture row inside a mobile drawer, which is
|
||||
// the rail has become a drawer would leave Big Picture enabled inside a mobile drawer, which is
|
||||
// precisely what the Driver asked to never happen.
|
||||
narrow: false, _mql: null, _onMedia: null,
|
||||
// The transient "desktop only" message shown when Big Picture is clicked while disabled (see
|
||||
// onSelect's intercept). '' renders nothing; _noticeTimer clears any earlier message's timeout
|
||||
// so two quick clicks don't fight over when it disappears.
|
||||
disabledNotice: '', _noticeTimer: null,
|
||||
// i18n locale switcher (reuses app-wrapper-material/languages.js — persisted, offline, no dep).
|
||||
locale: loadLocale(),
|
||||
localeOpen: false,
|
||||
|
|
@ -676,6 +724,13 @@ export default defineComponent({
|
|||
// which onSelect maps back to the tab index the mount machinery uses — so the sections are pure
|
||||
// presentation, the mount path is unchanged. Rows whose tab id has no matching TAB_DEF are
|
||||
// dropped, and a group with no surviving rows is omitted (never paint a dead section/row).
|
||||
//
|
||||
// ★ THE DEVICE RULE, PER THE SPEC — "it shouldn't change the tab order, it should open you up to
|
||||
// the 2nd tab by default on mobile and disable the 1st tab." That is why there is NO width
|
||||
// filter on rows here any more (the old `onlyWhen:'desktop'|'mobile'` hide-a-row mechanism is
|
||||
// gone — every row is in the rail on every width, same order, always). The ONLY width-driven
|
||||
// thing left is which row is DISABLED (Big Picture, on mobile — see isBigPictureDisabled) and
|
||||
// which row auto-opens (see firstVisibleIndex) — both presentation, neither touches NAV_MODEL.
|
||||
navGroups() {
|
||||
const byId = Object.fromEntries(this.tabs.map(t => [t.id, t]))
|
||||
// ONE `seen` set threaded across ALL groups (not per-group): the rail must hold one node per tab
|
||||
|
|
@ -687,15 +742,20 @@ export default defineComponent({
|
|||
.map(g => {
|
||||
let rows = dedupeRows(
|
||||
(g.tabs || [])
|
||||
// ★ WIDTH GATE FIRST, before the tab-exists filter. A row may declare
|
||||
// onlyWhen:'desktop'|'mobile'; anything else (the common case, no flag) shows on both.
|
||||
// Applied here rather than in `tabs` so the underlying TAB_DEFS/mount machinery is
|
||||
// untouched — the swap is PURE PRESENTATION, exactly like the sections themselves.
|
||||
.filter(r => !r.onlyWhen || r.onlyWhen === (this.narrow ? 'mobile' : 'desktop'))
|
||||
.filter(r => byId[r.id])
|
||||
.map(r => {
|
||||
const t = byId[r.id]
|
||||
return { key: t.id, label: t.label, sub: t.sub, icon: r.icon, badge: r.badge }
|
||||
// Big Picture is DISABLED (not hidden) on mobile — the one row-level exception the
|
||||
// device rule requires. `disabled`/`badge` are read by THIS shell's onSelect intercept
|
||||
// and CSS below; VerticalTabsLayout itself does not know about `disabled` (it has no
|
||||
// such concept — see the comment on isBigPictureDisabled), so the row stays clickable
|
||||
// at the layout level and this shell is what turns that click into a message instead
|
||||
// of a mount.
|
||||
const disabled = this.isBigPictureDisabled(t.id)
|
||||
return {
|
||||
key: t.id, label: t.label, sub: t.sub, icon: r.icon,
|
||||
badge: disabled ? 'desktop only' : r.badge, disabled
|
||||
}
|
||||
}),
|
||||
seen)
|
||||
// The Experimental folder is not authored — it is FILLED with every discovered tab (any
|
||||
|
|
@ -786,7 +846,7 @@ export default defineComponent({
|
|||
},
|
||||
methods: {
|
||||
setSlot(i, el) { if (el) this._slots = Object.assign(this._slots || {}, { [i]: el }) },
|
||||
// ── VIEWPORT WATCH (the desktop/mobile tab swap) ──────────────────────────────────────────
|
||||
// ── VIEWPORT WATCH (the device rule: same rail, different default + one disabled row) ────────
|
||||
// Mirrors the SAME (max-width:760px) query VerticalTabsLayout uses. See the `narrow` note in
|
||||
// data() for why the shell must watch it independently.
|
||||
//
|
||||
|
|
@ -801,11 +861,11 @@ export default defineComponent({
|
|||
const wasNarrow = this.narrow
|
||||
this.narrow = !!e.matches
|
||||
if (wasNarrow === this.narrow) return
|
||||
// A breakpoint crossing can REMOVE the row that is currently active (Big Picture exists only
|
||||
// on desktop). Leaving activeIndex pointing at a row the rail no longer paints leaves the
|
||||
// user on a mounted-but-unlisted tab with no highlight — visibly broken. Snap to the first
|
||||
// row that survives the crossing.
|
||||
if (!this.isVisibleId(this.activeKey)) this.select(this.firstVisibleIndex())
|
||||
// A breakpoint crossing can DISABLE the row that is currently active (Big Picture, going
|
||||
// desktop→mobile). Every row stays IN the rail on both widths per the device rule — nothing
|
||||
// is removed any more — but a disabled active tab must not be left mounted/highlighted with
|
||||
// no way to reach it, so snap to the default row for the new width.
|
||||
if (this.isBigPictureDisabled(this.activeKey)) this.select(this.firstVisibleIndex())
|
||||
}
|
||||
// addEventListener is the modern API; addListener is the deprecated fallback some webviews
|
||||
// still need. Try both rather than assume — a missed listener means the swap silently never
|
||||
|
|
@ -813,21 +873,30 @@ export default defineComponent({
|
|||
if (this._mql.addEventListener) this._mql.addEventListener('change', this._onMedia)
|
||||
else if (this._mql.addListener) this._mql.addListener(this._onMedia)
|
||||
},
|
||||
// Is this tab id painted in the rail at the CURRENT width? Derived from navGroups (the thing the
|
||||
// user actually sees) rather than re-deriving the onlyWhen rule, so the two can never disagree.
|
||||
isVisibleId(id) {
|
||||
return this.navGroups.some(g => g.rows.some(r => r.key === id))
|
||||
// THE ONE ROW THE DEVICE RULE DISABLES. Driver, verbatim: "it shouldn't change the tab order, it
|
||||
// should open you up to the 2nd tab by default on mobile and disable the 1st tab." Named as a
|
||||
// predicate (not a hardcoded width filter in navGroups) so the ONE place that knows "Big Picture"
|
||||
// means 'dashboard-plus-grid' is this method — navGroups, onSelect and watchWidth all call it
|
||||
// rather than each re-encoding the id + the width test.
|
||||
isBigPictureDisabled(id) {
|
||||
return id === 'dashboard-plus-grid' && this.narrow
|
||||
},
|
||||
// Index (into this.tabs) of the first row the rail currently paints. Falls back to 0 so the app
|
||||
// always opens something, even if a future NAV_MODEL edit leaves a width with no curated rows.
|
||||
// Index (into this.tabs) of the row the rail should auto-open at the CURRENT width. Desktop opens
|
||||
// tab 1 (Big Picture); mobile opens tab 2 (Simple Research) because tab 1 is disabled there — the
|
||||
// rail itself is UNCHANGED, only the default selection moves. Falls back to the first row of the
|
||||
// first surviving group (then 0) so the app always opens something even if a future NAV_MODEL
|
||||
// edit leaves a width with no curated rows.
|
||||
firstVisibleIndex() {
|
||||
let fallback = -1
|
||||
for (const g of this.navGroups) {
|
||||
for (const r of g.rows) {
|
||||
const i = this.tabs.findIndex(t => t.id === r.key)
|
||||
if (i >= 0) return i
|
||||
if (i < 0) continue
|
||||
if (fallback < 0) fallback = i
|
||||
if (!r.disabled) return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
return fallback >= 0 ? fallback : 0
|
||||
},
|
||||
// Fetch the manifest ONCE and use it for BOTH jobs: complete the curated tabs (tag + scriptUrl)
|
||||
// and fill the Experimental folder. One read, because the two answers must describe the same
|
||||
|
|
@ -888,7 +957,21 @@ export default defineComponent({
|
|||
},
|
||||
// The shared rail emits the selected tab's `key` (its id); map it back to the index the
|
||||
// pane-mount machinery below is keyed on.
|
||||
//
|
||||
// ★ THE DISABLED-ROW INTERCEPT. VerticalTabsLayout has no `disabled` concept of its own (its
|
||||
// `pick(key)` emits update:active unconditionally on click — see the layout file's `pick`), and
|
||||
// this shell does not own that file (see the studio-wide file-ownership rule), so Big Picture's
|
||||
// "disabled on mobile" behavior is enforced HERE: the click still arrives, but on mobile it is
|
||||
// turned into a transient message instead of a mount. The row stays visibly present (greyed via
|
||||
// the `disabled`-badge styling below), never removed — exactly the device rule's "disable, don't
|
||||
// hide."
|
||||
onSelect(key) {
|
||||
if (this.isBigPictureDisabled(key)) {
|
||||
this.disabledNotice = 'Big Picture only supports desktop — open this page on a desktop to use it.'
|
||||
clearTimeout(this._noticeTimer)
|
||||
this._noticeTimer = setTimeout(() => { this.disabledNotice = '' }, 4000)
|
||||
return
|
||||
}
|
||||
const i = this.tabs.findIndex(t => t.id === key)
|
||||
if (i !== -1) this.select(i)
|
||||
},
|
||||
|
|
@ -1065,6 +1148,15 @@ export default defineComponent({
|
|||
.dms-hint.err { color: #f87171; }
|
||||
.dms-hint small { color: var(--dx-muted, #6d766e); }
|
||||
|
||||
/* THE DEVICE-RULE NOTICE — the transient "desktop only" toast for the disabled Big Picture row on
|
||||
mobile. Material light only (surface/border/text/muted tokens), matching the rest of this file. */
|
||||
.dms-notice {
|
||||
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 40;
|
||||
max-width: 320px; text-align: center; font-size: 13px; line-height: 1.4;
|
||||
color: #3b463d; background: #fff; border: 1px solid #d1d4cd; border-radius: 10px;
|
||||
padding: 10px 16px; box-shadow: 0 8px 24px rgba(0,0,0,.12);
|
||||
}
|
||||
|
||||
/* #brand logo mark (left of the shell title). */
|
||||
.dms-logo { display: inline-flex; align-items: center; }
|
||||
.dms-logo svg { display: block; }
|
||||
|
|
|
|||
16
public/doxa-maps/doxa-maps-spa/doxa-maps-spa.js
generated
16
public/doxa-maps/doxa-maps-spa/doxa-maps-spa.js
generated
File diff suppressed because one or more lines are too long
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
|
||||
<script src="../../doxa-maps-spa.js" integrity="sha384-GJiuI3T69Om//h3hE5AEeqIF3H6EnXrQVNj5BiAyLZcABodDXAA/pDYVxXsDT1Pq" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-maps-spa.js" integrity="sha384-IWw27w4r5O6tsh92zPMqVoxt4so2pmIjsJBu/G8XQ0S3QEyEGKIW1QykAdRDioQA" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
|
||||
<script src="../doxa-maps-spa.js" integrity="sha384-GJiuI3T69Om//h3hE5AEeqIF3H6EnXrQVNj5BiAyLZcABodDXAA/pDYVxXsDT1Pq" crossorigin="anonymous"></script>
|
||||
<script src="../doxa-maps-spa.js" integrity="sha384-4DqIPeaMZZODDw5iecKF7xbDq1t11yMC9Sppgik+YhOpGyb45Pfx/8MysX8WEnjO" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
Loading…
Reference in a new issue