All cache-clearing buttons and the IDAA clear-caches page previously cleared IDB/localStorage but left service worker registrations and Cache Storage intact. On the next reload the SW re-served the old JS bundle, leaving users stuck on stale code despite appearing to reload. This caused recurring stale-state reports from IDAA and other clients for 4+ months. Two gaps closed: 1. Every clear path (root page buttons, sys bar, help tech, idaa/clear-caches) now unregisters SW registrations and clears Cache Storage before touching IDB and localStorage. Order: SW → Cache Storage → IDB → localStorage. 2. Added controllerchange listener in +layout.svelte effect 4. When the new SW activates and calls clients.claim(), this listener reloads the page so open tabs run the new JS bundle instead of keeping old code in memory indefinitely. Without this, skipWaiting + clients.claim work correctly on the SW side but the page side never picks up the update. Also added thorough code comments and updated REFERENCE__Common_Agent_Mistakes (#15) and BOOTSTRAP doc (#8) to document the full root cause so this cannot be silently re-broken by a future agent or refactor. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
570 lines
22 KiB
Svelte
570 lines
22 KiB
Svelte
<script lang="ts">
|
|
/** @type {import('./$types').LayoutData} */
|
|
|
|
let log_lvl: number = 0;
|
|
|
|
// *** Import Svelte specific
|
|
import { onMount, untrack } from 'svelte';
|
|
import { goto, invalidateAll } from '$app/navigation';
|
|
|
|
import '../app.css';
|
|
|
|
// *** Import other supporting libraries
|
|
import {
|
|
RefreshCw,
|
|
WifiOff,
|
|
ChevronDown,
|
|
} from '@lucide/svelte';
|
|
|
|
// Highlight JS
|
|
import hljs from 'highlight.js/lib/core';
|
|
import 'highlight.js/styles/github-dark.css';
|
|
|
|
import { browser } from '$app/environment';
|
|
import { online } from 'svelte/reactivity/window';
|
|
import xml from 'highlight.js/lib/languages/xml'; // for HTML
|
|
import css from 'highlight.js/lib/languages/css';
|
|
import javascript from 'highlight.js/lib/languages/javascript';
|
|
import typescript from 'highlight.js/lib/languages/typescript';
|
|
|
|
hljs.registerLanguage('xml', xml); // for HTML
|
|
hljs.registerLanguage('css', css);
|
|
hljs.registerLanguage('javascript', javascript);
|
|
hljs.registerLanguage('typescript', typescript);
|
|
|
|
// *** Import Aether specific variables and functions
|
|
// import Analytics from '$lib/app_components/e_app_analytics.svelte';
|
|
import {
|
|
ae_loc,
|
|
ae_sess,
|
|
ae_api,
|
|
slct,
|
|
slct_trigger,
|
|
ae_auth_error
|
|
} from '$lib/stores/ae_stores';
|
|
import { LoaderCircle } from '@lucide/svelte';
|
|
|
|
import E_app_debug_menu from '$lib/app_components/e_app_debug_menu.svelte';
|
|
import E_app_sys_bar from '$lib/app_components/e_app_sys_bar.svelte';
|
|
import { pwa_install } from '$lib/pwa/pwa_install.svelte';
|
|
|
|
interface Props {
|
|
data: any;
|
|
children?: import('svelte').Snippet;
|
|
}
|
|
|
|
let { data, children }: Props = $props();
|
|
|
|
// Remove the pre-JS loader from app.html as soon as Svelte mounts.
|
|
// The existing is_hydrating overlay takes over from here.
|
|
onMount(() => {
|
|
document.getElementById('ae_loader')?.remove();
|
|
});
|
|
|
|
// STABLE DERIVATION: Using prop data directly to avoid store loops
|
|
let ae_acct = $derived(data[data.account_id]);
|
|
|
|
let flag_clear_idb: boolean = $state(false);
|
|
let flag_clear_local: boolean = $state(false);
|
|
let flag_clear_sess: boolean = $state(false);
|
|
let flag_reload: boolean = $state(false);
|
|
let flag_hard_reload: boolean = $state(false);
|
|
|
|
let flag_new_ver: boolean = $state(false);
|
|
let flag_expired: boolean = $state(false);
|
|
let flag_denied: boolean = $state(false);
|
|
|
|
// Connection Status
|
|
let is_offline = $derived(browser && online.current === false);
|
|
let api_unreachable = $derived($ae_loc?.account_id === 'ghost');
|
|
let api_error_msg = $derived($ae_loc?.account_name || 'API Server Unreachable');
|
|
let show_connection_details = $state(true);
|
|
|
|
let last_reload_time = 0;
|
|
let sw_heal_in_flight = false;
|
|
|
|
// Shallow equality guard — avoids triggering Svelte store updates when the merged
|
|
// object is functionally identical to the current one. Comparing JSON.stringify on
|
|
// large objects like $ae_loc (site config, device info, flags) is expensive and
|
|
// runs on every navigation. Key-by-key identity check is O(n keys), not O(n chars).
|
|
function shallow_equal(
|
|
a: Record<string, any>,
|
|
b: Record<string, any>
|
|
): boolean {
|
|
const keys_a = Object.keys(a);
|
|
const keys_b = Object.keys(b);
|
|
if (keys_a.length !== keys_b.length) return false;
|
|
for (const k of keys_a) {
|
|
if (a[k] !== b[k]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// 1. CONSOLIDATED SYNC EFFECT (One single point of entry for store updates)
|
|
$effect(() => {
|
|
if (!browser || !ae_acct) return;
|
|
|
|
untrack(() => {
|
|
if (log_lvl > 1) console.log('ROOT: Running Sync Effect');
|
|
|
|
// A. Sync Global Stores
|
|
const current_api = $ae_api;
|
|
const new_api = { ...current_api, ...(ae_acct.api || {}) };
|
|
// Deep check for JWT specifically to avoid extra triggers
|
|
if (current_api.jwt !== $ae_loc.jwt) new_api.jwt = $ae_loc.jwt;
|
|
if (!shallow_equal(current_api, new_api)) {
|
|
$ae_api = new_api;
|
|
}
|
|
|
|
const current_loc = $ae_loc;
|
|
const new_loc = { ...current_loc, ...(ae_acct.loc || {}) };
|
|
// Restore structural integrity if clobbered
|
|
if (!new_loc.sys_menu)
|
|
new_loc.sys_menu = {
|
|
hide: false,
|
|
hide_access_type: false,
|
|
expand_access_type: false,
|
|
hide_edit_mode: false,
|
|
expand_edit_mode: false,
|
|
hide_user: false,
|
|
expand_user: false,
|
|
hide_theme: false,
|
|
expand_theme: false,
|
|
hide_cfg: false,
|
|
expand_cfg: false
|
|
};
|
|
if (!new_loc.debug_menu)
|
|
new_loc.debug_menu = { hide: false, expand: false };
|
|
|
|
if (!shallow_equal(current_loc, new_loc)) {
|
|
$ae_loc = new_loc;
|
|
}
|
|
|
|
const current_slct = $slct;
|
|
const new_slct = { ...current_slct, ...(ae_acct.slct || {}) };
|
|
if (!shallow_equal(current_slct, new_slct)) {
|
|
$slct = new_slct;
|
|
}
|
|
|
|
// B. Version & Sanity Check
|
|
if (new_loc.ver && $ae_sess.ver && new_loc.ver !== $ae_sess.ver) {
|
|
if (!flag_new_ver) {
|
|
console.log(
|
|
'ROOT: Version mismatch detected; clearing stale service workers and caches before reload'
|
|
);
|
|
flag_new_ver = true;
|
|
flag_hard_reload = true;
|
|
flag_reload = true;
|
|
}
|
|
}
|
|
|
|
// C. Access Check (Idempotent)
|
|
if (new_loc.site_access_key || new_loc.site_domain_access_key) {
|
|
const key = new_loc.allow_access;
|
|
const match =
|
|
new_loc.site_access_key === key ||
|
|
new_loc.site_domain_access_key === key;
|
|
if (!match && !new_loc.trusted_access) {
|
|
if (new_loc.allow_access !== false)
|
|
$ae_loc.allow_access = false;
|
|
if (!flag_denied) flag_denied = true;
|
|
}
|
|
} else if (new_loc.allow_access !== true) {
|
|
$ae_loc.allow_access = true;
|
|
}
|
|
|
|
// D. Theme Initialization (Once)
|
|
if (!current_loc.theme_mode) {
|
|
if (window.matchMedia('(prefers-color-scheme: dark)').matches)
|
|
$ae_loc.theme_mode = 'dark';
|
|
else $ae_loc.theme_mode = 'light';
|
|
}
|
|
});
|
|
});
|
|
|
|
// 2. RELOAD THROTTLE EFFECT
|
|
$effect(() => {
|
|
if (!browser) return;
|
|
|
|
if (flag_reload) {
|
|
untrack(() => {
|
|
const now = Date.now();
|
|
if (now - last_reload_time < 10000) {
|
|
console.warn(
|
|
'ROOT: Critical loop prevention - reload suppressed'
|
|
);
|
|
flag_reload = false;
|
|
return;
|
|
}
|
|
last_reload_time = now;
|
|
flag_reload = false;
|
|
|
|
if (flag_clear_local) clear_local();
|
|
if (flag_clear_sess) clear_sess();
|
|
|
|
if (flag_hard_reload) {
|
|
flag_hard_reload = false;
|
|
console.log('ROOT: Executing hard reload after service worker heal');
|
|
void clear_stale_service_worker_state().finally(() => {
|
|
location.reload();
|
|
});
|
|
return;
|
|
}
|
|
|
|
console.log('ROOT: Executing throttled reload');
|
|
invalidateAll();
|
|
});
|
|
}
|
|
});
|
|
|
|
// 3. UI STATE & THEME DOM EFFECT
|
|
let is_hydrating = $state(true);
|
|
$effect(() => {
|
|
if (!browser) return;
|
|
|
|
// Theme name — controls which Skeleton color palette is active
|
|
document.documentElement.setAttribute(
|
|
'data-theme',
|
|
$ae_loc?.theme_name ?? 'nouveau'
|
|
);
|
|
|
|
// Dark/light class — controls Tailwind v4 class-based dark mode variant.
|
|
// @custom-variant dark in app.css registers this; without it the class does nothing.
|
|
if ($ae_loc?.theme_mode === 'dark') {
|
|
document.documentElement.classList.add('dark');
|
|
document.documentElement.classList.remove('light');
|
|
} else {
|
|
document.documentElement.classList.add('light');
|
|
document.documentElement.classList.remove('dark');
|
|
}
|
|
|
|
// Font size mode — cycles default | larger | smaller.
|
|
// CSS classes are defined in app.css; no class = browser default (16px).
|
|
document.documentElement.classList.remove(
|
|
'font-size-larger',
|
|
'font-size-smaller'
|
|
);
|
|
if ($ae_loc?.font_size_mode === 'larger') {
|
|
document.documentElement.classList.add('font-size-larger');
|
|
} else if ($ae_loc?.font_size_mode === 'smaller') {
|
|
document.documentElement.classList.add('font-size-smaller');
|
|
}
|
|
|
|
// Hydration overlay timer
|
|
if ($ae_loc?.account_id) {
|
|
const timer = setTimeout(() => (is_hydrating = false), 500);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
});
|
|
|
|
function clear_idb() {
|
|
indexedDB.deleteDatabase('ae_archives_db');
|
|
indexedDB.deleteDatabase('ae_core_db');
|
|
indexedDB.deleteDatabase('ae_events_db');
|
|
indexedDB.deleteDatabase('ae_journals_db');
|
|
indexedDB.deleteDatabase('ae_posts_db');
|
|
indexedDB.deleteDatabase('ae_sponsorships_db');
|
|
}
|
|
|
|
function clear_local() {
|
|
localStorage.clear();
|
|
}
|
|
|
|
function clear_sess() {
|
|
sessionStorage.clear();
|
|
}
|
|
|
|
// CRITICAL — all three storage layers must be cleared together or the fix is incomplete.
|
|
//
|
|
// The service worker (SW) maintains its own Cache Storage that is entirely separate from
|
|
// IndexedDB, localStorage, and sessionStorage. The SW uses Cache Storage to serve the
|
|
// app's JS/CSS bundles and HTML shell directly from disk — bypassing the network entirely
|
|
// for those assets. This means:
|
|
//
|
|
// Clearing IDB + localStorage ONLY:
|
|
// → SW is still registered and still serving old JS bundles from its cache.
|
|
// → Next reload: SW intercepts, returns OLD cached JS → user is still stuck.
|
|
//
|
|
// Clearing SW registrations ONLY:
|
|
// → Cache Storage still contains old entries. A newly registered SW may serve them
|
|
// before it rebuilds the cache from the network, causing a transient stale state.
|
|
//
|
|
// Clearing BOTH SW registrations AND Cache Storage, THEN IDB/localStorage:
|
|
// → Next reload: no SW intercepts → fresh HTML from server → fresh JS bundle
|
|
// filenames → fresh JS loaded → user is on current code. ✅
|
|
//
|
|
// This function is called by the version-mismatch detection path (flag_hard_reload).
|
|
// All manual cache-clearing buttons in the app must follow the same order.
|
|
async function clear_stale_service_worker_state() {
|
|
if (!browser || sw_heal_in_flight) return;
|
|
sw_heal_in_flight = true;
|
|
|
|
try {
|
|
if ('serviceWorker' in navigator) {
|
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
for (const registration of registrations) {
|
|
await registration.unregister();
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('ROOT: Service worker unregister failed during heal:', err);
|
|
}
|
|
|
|
try {
|
|
if ('caches' in window) {
|
|
const cache_keys = await caches.keys();
|
|
for (const cache_key of cache_keys) {
|
|
await caches.delete(cache_key);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('ROOT: Cache cleanup failed during heal:', err);
|
|
} finally {
|
|
sw_heal_in_flight = false;
|
|
}
|
|
}
|
|
|
|
// 4. EXTERNAL INTERFACES EFFECT
|
|
$effect(() => {
|
|
if (!browser) return;
|
|
|
|
// SW update → page reload bridge. DO NOT REMOVE.
|
|
//
|
|
// How SW updates propagate (all three steps are required):
|
|
// 1. service-worker.js calls skipWaiting() on install → new SW activates immediately
|
|
// instead of waiting for all existing tabs to close.
|
|
// 2. service-worker.js calls clients.claim() on activate → new SW takes control of
|
|
// all currently open tabs without requiring a navigation.
|
|
// 3. THIS LISTENER: when the new SW takes control, `controllerchange` fires on every
|
|
// open tab. We reload so the tab runs the new JS bundle rather than keeping the
|
|
// old JS that was already loaded in memory.
|
|
//
|
|
// Without step 3, users can be stuck on old code indefinitely after a deployment —
|
|
// even though the SW correctly updated under them. The tab shows no error; it just
|
|
// silently runs whatever JS it had at the time the SW changed. This caused IDAA and
|
|
// other users to run stale code for weeks at a time across multiple deployments.
|
|
// Added 2026-06-22 to close this gap.
|
|
const on_controller_change = () => window.location.reload();
|
|
if ('serviceWorker' in navigator) {
|
|
navigator.serviceWorker.addEventListener('controllerchange', on_controller_change);
|
|
}
|
|
|
|
// Initialise PWA install prompt listener (captures beforeinstallprompt early).
|
|
pwa_install.init();
|
|
|
|
// Save DS to local
|
|
let ae_ds = ae_acct?.ds;
|
|
if (ae_ds) {
|
|
for (let [key, value] of Object.entries(ae_ds)) {
|
|
localStorage.setItem(`ae_ds__${key}`, JSON.stringify(value));
|
|
}
|
|
}
|
|
|
|
// Message Bridge
|
|
const handler = (event: MessageEvent) => {
|
|
if (event.data.type === 'api_download_blob') {
|
|
$ae_sess.api_download_kv[event.data.task_id] = { ...event.data };
|
|
} else if (event.data.type === 'api_post_json_form') {
|
|
$ae_sess.api_upload_kv[event.data.task_id] = { ...event.data };
|
|
}
|
|
};
|
|
window.addEventListener('message', handler);
|
|
|
|
// Iframe Detection
|
|
let iframe = data.url.searchParams.get('iframe');
|
|
// let iframe = $derived(data.url.searchParams.get('iframe'));
|
|
|
|
if (iframe === 'true') {
|
|
$ae_loc.iframe = true;
|
|
// Hide the AE system bar by default in iframe embeds — it's nav chrome
|
|
// the host page doesn't need. Trusted admins can override with show_menu=true
|
|
// to access the menu while testing an embedded page (e.g. video_conferences).
|
|
$ae_loc.sys_menu.hide = true;
|
|
} else if (iframe === 'false') {
|
|
$ae_loc.iframe = false;
|
|
}
|
|
|
|
// show_menu=true — override to show the AE system bar even inside an iframe.
|
|
// Intended for trusted/admin users who need menu access while testing an embed.
|
|
// hide_menu=true — explicitly hide the bar outside of iframe contexts.
|
|
// const menu_override = data.url.searchParams.get('show_menu');
|
|
// if (menu_override === 'true') $ae_loc.sys_menu.hide = false;
|
|
// else if (data.url.searchParams.get('hide_menu') === 'true')
|
|
// $ae_loc.sys_menu.hide = true;
|
|
|
|
const menu_override = data.url.searchParams.get('show_menu');
|
|
if (menu_override === 'true') {
|
|
$ae_loc.sys_menu.hide = false;
|
|
} else if (data.url.searchParams.get('hide_menu') === 'true') {
|
|
$ae_loc.sys_menu.hide = true;
|
|
} else if (data.url.searchParams.get('iframe') === 'true') {
|
|
$ae_loc.sys_menu.hide = true;
|
|
} else if (data.url.searchParams.get('iframe') === 'false') {
|
|
$ae_loc.sys_menu.hide = false;
|
|
}
|
|
// console.log('iframe:', $ae_loc.iframe, 'sys_menu.hide:', $ae_loc.sys_menu.hide);
|
|
|
|
// Theme URL params — ?theme=AE_Firefly_SteelBlue&theme_mode=dark
|
|
// Applied once on load, then silently removed from the URL (no history entry).
|
|
const url_theme = data.url.searchParams.get('theme');
|
|
const url_theme_mode = data.url.searchParams.get('theme_mode');
|
|
if (url_theme || url_theme_mode) {
|
|
if (url_theme) {
|
|
// Mark that the user (or URL) explicitly chose a theme so site defaults
|
|
// won't overwrite it later on subsequent loads.
|
|
ae_loc.update((l: any) => ({ ...l, theme_name: url_theme, user_theme_selected: true }));
|
|
}
|
|
if (url_theme_mode === 'light' || url_theme_mode === 'dark') {
|
|
ae_loc.update((l: any) => ({ ...l, theme_mode: url_theme_mode }));
|
|
}
|
|
const clean_url = new URL(data.url.href);
|
|
clean_url.searchParams.delete('theme');
|
|
clean_url.searchParams.delete('theme_mode');
|
|
goto(clean_url.pathname + clean_url.search + clean_url.hash, {
|
|
replaceState: true,
|
|
noScroll: true,
|
|
keepFocus: true
|
|
});
|
|
}
|
|
|
|
// Electron Detection
|
|
if ((window as any).native_app || (window as any).aetherNative) {
|
|
if (!$ae_loc.is_native) $ae_loc.is_native = true;
|
|
}
|
|
|
|
return () => {
|
|
window.removeEventListener('message', handler);
|
|
if ('serviceWorker' in navigator) {
|
|
navigator.serviceWorker.removeEventListener('controllerchange', on_controller_change);
|
|
}
|
|
};
|
|
});
|
|
|
|
// 5. SESSION EXPIRED EFFECT — watches ae_auth_error and raises the banner when the API signals 401/403.
|
|
// Guards on $ae_loc.jwt so that unauthenticated first-loads (no stored JWT) never trigger it —
|
|
// 401s are expected on first visit and should not look like a session expiry.
|
|
$effect(() => {
|
|
if ($ae_auth_error?.type === 'expired') {
|
|
untrack(() => {
|
|
if ($ae_loc?.jwt) flag_expired = true;
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<link rel="stylesheet" href={ae_acct?.loc?.site_style_href} />
|
|
</svelte:head>
|
|
|
|
{#if browser && (is_offline || api_unreachable)}
|
|
{#if show_connection_details}
|
|
<!-- Expanded banner -->
|
|
<div
|
|
class="preset-filled-error-200-800 fixed top-0 right-0 left-0 z-100 flex items-center justify-center gap-4 p-4 text-center shadow-2xl print:hidden">
|
|
<WifiOff size="1.2em" class="shrink-0 opacity-70" />
|
|
<div class="flex flex-col items-center gap-0.5">
|
|
<span class="text-xl font-bold leading-tight">
|
|
{is_offline ? 'Device Offline' : api_error_msg}
|
|
</span>
|
|
<span class="text-xs opacity-75">
|
|
{#if is_offline}
|
|
Cached event data is still available for search and print · Edits require network
|
|
{:else}
|
|
The Aether server could not be reached — check your connection or contact support
|
|
{/if}
|
|
</span>
|
|
</div>
|
|
<button
|
|
class="btn btn-sm preset-tonal-surface"
|
|
onclick={() => window.location.reload()}>
|
|
<RefreshCw size="1em" class="opacity-60" /> Retry
|
|
</button>
|
|
<button
|
|
class="btn btn-sm preset-tonal-surface"
|
|
title="Collapse"
|
|
onclick={() => { show_connection_details = false; }}>
|
|
<ChevronDown size="1em" />
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<!-- Collapsed indicator — small chip, top-right corner -->
|
|
<button
|
|
class="preset-filled-error-200-800 fixed top-2 right-2 z-100 flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-bold shadow-lg print:hidden"
|
|
title={is_offline ? 'Device Offline — click to expand' : `${api_error_msg} — click to expand`}
|
|
onclick={() => { show_connection_details = true; }}>
|
|
<WifiOff size="0.9em" class="shrink-0" />
|
|
{is_offline ? 'Offline' : 'API Error'}
|
|
</button>
|
|
{/if}
|
|
{/if}
|
|
|
|
{#if browser && flag_expired}
|
|
<div
|
|
class="preset-filled-warning-500 fixed top-0 right-0 left-0 z-50 flex items-center justify-between gap-4 px-4 py-2 shadow-xl print:hidden">
|
|
<p class="text-sm font-semibold">
|
|
Your session has expired. Please reload or sign in again.
|
|
</p>
|
|
<div class="flex shrink-0 items-center gap-2">
|
|
<button
|
|
class="btn btn-sm preset-tonal-surface"
|
|
onclick={() => window.location.reload()}>
|
|
<RefreshCw size="1em" class="opacity-60" /> Reload
|
|
</button>
|
|
<button
|
|
class="btn btn-sm preset-outlined-surface"
|
|
onclick={() => {
|
|
flag_expired = false;
|
|
ae_auth_error.set({ type: null, ts: null });
|
|
}}>✕</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if browser && $ae_loc?.allow_access}
|
|
{@render children?.()}
|
|
|
|
{#if is_hydrating}
|
|
<div
|
|
class="bg-surface-50/80 dark:bg-surface-900/80 fixed inset-0 z-99 flex flex-col items-center justify-center backdrop-blur-sm transition-opacity duration-500 print:hidden">
|
|
<div
|
|
class="preset-filled-surface-100-900 flex flex-col items-center gap-4 rounded-2xl p-8 shadow-2xl">
|
|
<LoaderCircle
|
|
size="3rem"
|
|
class="text-primary-500 animate-spin" />
|
|
<div class="text-center text-xl font-bold">
|
|
<!-- Hydrating Aether... -->
|
|
Loading Aether data...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{:else if browser && flag_denied}
|
|
<div class="flex h-screen flex-col items-center justify-center gap-6 p-8">
|
|
<h1 class="text-error-500 text-4xl font-black">Access Denied</h1>
|
|
<button
|
|
class="btn preset-filled-primary"
|
|
onclick={() => window.location.reload()}>Reload</button>
|
|
</div>
|
|
{:else if browser}
|
|
<div class="flex h-screen items-center justify-center">
|
|
<LoaderCircle size="2.5rem" class="animate-spin opacity-20" />
|
|
</div>
|
|
{/if}
|
|
|
|
{#if browser && (!$ae_loc?.iframe || !$ae_loc?.sys_menu?.hide || $ae_loc?.administrator_access || ($ae_loc?.trusted_access && $ae_loc.edit_mode))}
|
|
<!-- print:hidden wrapper: sys/debug menus are fixed overlays — must not appear on printed pages -->
|
|
<div class="print:hidden">
|
|
<E_app_sys_bar
|
|
{data}
|
|
bind:hide={$ae_loc.sys_menu.hide}
|
|
bind:expand={$ae_sess.sys_menu.expand} />
|
|
|
|
<!-- You must be in Edit Mode to initially see the Debug expand button. Once expanded, you can toggle the Edit Mode while still seeing the expanded Debug content. -->
|
|
{#if $ae_loc.edit_mode || $ae_loc.debug_menu.expand}
|
|
<E_app_debug_menu
|
|
bind:hide={$ae_loc.debug_menu.hide}
|
|
bind:expand={$ae_loc.debug_menu.expand} />
|
|
{/if}
|
|
</div>
|
|
{/if}
|