fix(spa): make embedded maps survive the sandboxed plugin iframe
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

Three fixes for the power-app SPA running as a go-base plugin (sandboxed
iframe, origin 'null' — where localStorage throws and root-absolute asset
paths misresolve under the /plugin-assets/<name>/ subpath):

- localStorage: wrap every theme/preference read+write in try/catch across
  the library (ProfileLoader, useMapTheme, uiStore). `typeof localStorage`
  is not enough — in a sandbox the object exists but ACCESS throws
  SecurityError. Sandboxed maps now boot themeless instead of crashing.
- assets: the SPA shell now injects an absolute assetsBase (derived from its
  own document.baseURI) into every mounted element's profile-config, so
  bundles fetch tables/vendor (Tabulator) from the correct subpath instead
  of a root-absolute /assets that 404s under /plugin-assets/doxa-maps-spa/.
- nav: revert the Prayer Mobilization section icon to 'heart' (the prayer
  glyph read as a header it is not).

Rebuilds every bundle (library-wide localStorage change).
This commit is contained in:
doxa-maps 2026-09-12 21:07:26 +00:00
parent 30a516a6a3
commit 1cd2b46cd5
17 changed files with 46 additions and 32 deletions

View file

@ -975,6 +975,10 @@ async function mount(id){
// THE LOCALE HAND-OFF: ProfileLoader reads profile-config.locale, normalises it
// and drives the bundle's i18n from it. This one field is the whole contract.
cfg.locale = locale
// ASSET BASE: bundles fetch tables/vendor (Tabulator) from `${assetsBase}/...`. Served under
// a subpath (e.g. /plugin-assets/doxa-maps-spa/), a bare '/assets' or './assets' misresolves,
// so hand each bundle an ABSOLUTE base derived from THIS shell's own document — correct at any depth.
if (!cfg.assetsBase) cfg.assetsBase = new URL('assets', document.baseURI).href.replace(/\/+$/, '')
el.setAttribute('profile-config', JSON.stringify(cfg))
// ── PANE COLUMN vs SINGLE MOUNT ─────────────────────────────────────────
@ -997,6 +1001,7 @@ async function mount(id){
const pcfg = { ...spec.config, ...pane.config }
if (SHELL_TOKEN) pcfg.tk = SHELL_TOKEN
pcfg.locale = locale
if (!pcfg.assetsBase) pcfg.assetsBase = new URL('assets', document.baseURI).href.replace(/\/+$/, '')
pel.setAttribute('profile-config', JSON.stringify(pcfg))
column.mountEl(pane.id)?.appendChild(pel)
extras.push(pel)

View file

@ -19,7 +19,7 @@ export const NAV = [
// 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: 'prayer', target: 'both',
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

View file

@ -144,10 +144,12 @@ const dataStore = useDataStore()
// profiles that DO hydrate later in onMounted. init() (onMounted) still runs for
// the ResizeObserver / matchMedia listeners; setTheme is idempotent so the re-apply
// is harmless. (staging bug, coder 2026-06-25: dark selected + reload → map dark, UI light.)
if (typeof localStorage !== 'undefined') {
const savedTheme = localStorage.getItem('theme')
// Sandboxed iframe (no allow-same-origin, origin 'null') → localStorage EXISTS but THROWS
// SecurityError on access, so a bare typeof-guard isn't enough. try/catch → skip persisted theme.
try {
const savedTheme = (typeof localStorage !== 'undefined') ? localStorage.getItem('theme') : null
if (savedTheme) uiStore.setTheme?.(savedTheme)
}
} catch (e) { /* sandboxed: no persisted theme */ }
provide('uiStore', uiStore)
provide('mapStore', mapStore)

View file

@ -53,8 +53,9 @@ export function useMapTheme(uiStore) {
// session can never drag the UI back to dark.
function isDarkTheme() {
if (!THEME_TOGGLE) return false
return uiStore?.theme === 'dark'
|| (typeof localStorage !== 'undefined' && localStorage.getItem('theme') === 'dark')
if (uiStore?.theme === 'dark') return true
try { return typeof localStorage !== 'undefined' && localStorage.getItem('theme') === 'dark' }
catch (e) { return false } // sandboxed iframe → localStorage throws
}
// The Standard-style lightPreset for the current theme.

View file

@ -412,8 +412,8 @@ export const useUIStore = defineStore('ui', {
const isDark = this.isDarkTheme;
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
// Save to localStorage
localStorage.setItem('theme', theme);
// Save to localStorage (sandboxed iframe throws — persistence is best-effort)
try { localStorage.setItem('theme', theme); } catch (e) { /* sandboxed: no persistence */ }
},
@ -978,8 +978,9 @@ export const useUIStore = defineStore('ui', {
* Load saved preferences, set up listeners
*/
init() {
// Load theme from localStorage
const savedTheme = localStorage.getItem('theme');
// Load theme from localStorage (sandboxed iframe throws — skip if unavailable)
let savedTheme = null;
try { savedTheme = localStorage.getItem('theme'); } catch (e) { /* sandboxed */ }
if (savedTheme) {
this.setTheme(savedTheme);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -496,7 +496,7 @@ body.rail .side .subgrp .row{padding-left:0;margin-left:8px;width:calc(100% - 16
}
// The pane-wrapper LAYOUT, nested inside this one: app-wrapper-material owns the
// chrome (header, side menu, View fieldset); pane-wrapper owns the stage inside it.
import { buildPaneColumn, PANE_GRID_STYLES } from '../pane-wrapper/pane-wrapper.js'
import { buildPaneColumn, PANE_GRID_STYLES } from './pane-wrapper/pane-wrapper.js'
import { NAV, navForTarget } from './nav.js'
import { icon } from './icons.js'
@ -975,6 +975,10 @@ async function mount(id){
// THE LOCALE HAND-OFF: ProfileLoader reads profile-config.locale, normalises it
// and drives the bundle's i18n from it. This one field is the whole contract.
cfg.locale = locale
// ASSET BASE: bundles fetch tables/vendor (Tabulator) from `${assetsBase}/...`. Served under
// a subpath (e.g. /plugin-assets/doxa-maps-spa/), a bare '/assets' or './assets' misresolves,
// so hand each bundle an ABSOLUTE base derived from THIS shell's own document — correct at any depth.
if (!cfg.assetsBase) cfg.assetsBase = new URL('assets', document.baseURI).href.replace(/\/+$/, '')
el.setAttribute('profile-config', JSON.stringify(cfg))
// ── PANE COLUMN vs SINGLE MOUNT ─────────────────────────────────────────
@ -997,6 +1001,7 @@ async function mount(id){
const pcfg = { ...spec.config, ...pane.config }
if (SHELL_TOKEN) pcfg.tk = SHELL_TOKEN
pcfg.locale = locale
if (!pcfg.assetsBase) pcfg.assetsBase = new URL('assets', document.baseURI).href.replace(/\/+$/, '')
pel.setAttribute('profile-config', JSON.stringify(pcfg))
column.mountEl(pane.id)?.appendChild(pel)
extras.push(pel)

View file

@ -19,7 +19,7 @@ export const NAV = [
// 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: 'prayer', target: 'both',
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