/* App — top-level shell, hash routing, login gate.
   Loaded LAST so all other components are on window when this runs.
   React hooks are destructured once in api.jsx. */

/* Restore theme / density / accent preferences before first paint so
   the chrome doesn't flicker. Settings panel writes these keys; we
   read them here. The 'system' theme value follows the OS dark/light
   preference live and updates if the OS changes. */
function applySystemTheme() {
  const wantsDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
  document.body.dataset.theme = wantsDark ? 'dark' : 'light';
}
(function restoreUiPrefs() {
  try {
    const t = localStorage.getItem('aquaos.pref.theme');
    if (t === 'system') {
      applySystemTheme();
      if (window.matchMedia) {
        const mq = window.matchMedia('(prefers-color-scheme: dark)');
        const handler = () => {
          /* Re-apply only if user is still on system mode. */
          if (localStorage.getItem('aquaos.pref.theme') === 'system') applySystemTheme();
        };
        mq.addEventListener ? mq.addEventListener('change', handler) : mq.addListener(handler);
      }
    } else if (t) {
      document.body.dataset.theme = t;
    }
    const d = localStorage.getItem('aquaos.pref.density');
    if (d) document.body.dataset.density = d;
    const a = localStorage.getItem('aquaos.pref.accent');
    if (a) document.body.dataset.accent = a;
    const sb = localStorage.getItem('aquaos.sidebar.collapsed');
    if (sb === '1') document.body.dataset.sidebar = 'compact';
  } catch (_) { /* localStorage may be disabled — fall through */ }
})();

/* Map of registered pages. As each migration group lands, the page
   is added here. Anything not registered shows a clearly-labelled
   placeholder so the chrome stays consistent.
   Detail pages take a `param` prop derived from the second hash segment
   (e.g. #screen/REEF-01 → SingleScreenScreen with param="REEF-01"). */
function getPageRegistry() {
  return {
    dashboard: window.Dashboard,
    library: window.SpeciesLibrary,
    displays: window.DisplayScreensScreen,
    /* `zones` page removed per 2026-05-15 audit (LOW-1) — the
       Displays page absorbed zone management. The hash redirector
       below keeps existing #zones / #zones/<id> bookmarks pointing
       to #displays. zones.jsx is no longer registered as a route. */
    screen: window.SingleScreenScreen,
    site: window.SiteScreen,
    sites: window.SitesScreen,
    species: window.SpeciesEditorScreen,

    /* G3 — Campaigns */
    campaigns: window.CampaignsScreen,
    campaign: window.SingleCampaignScreen,
    'campaign-edit': window.CampaignEditorScreen,
    /* v84 (2026-05-19) — Marketing IA Phase 2 / B2. Group detail page
       reached by clicking a campaign cover in the marketing gallery.
       param carries the group id. */
    'campaign-group': window.CampaignGroupDetailScreen,
    /* Phase 3 Playlists reverted by v78 (2026-05-17) — scheduling
       moved into the slide-first Where & when modal in campaigns.jsx.
       Route intentionally not registered; #playlists stale bookmarks
       fall through to the dashboard via the readHashRoute default. */

    /* G4 — People & access. The unified Users page now hosts the directory
       AND the Roles editor (as a sub-tab) AND the per-user access drawer.
       The standalone #user/:id and #permission-templates routes are gone;
       see the redirector in App() for back-compat with saved bookmarks. */
    users: window.UsersScreen,
    settings: window.SettingsScreen,
    account: window.AccountScreen,

    /* G5 — Auth previews (full-bleed; useful for design review) */
    sso: window.SsoScreen,
    invite: window.InviteScreen,
    /* Auth-flow routes reached from an email link while logged OUT.
       'accept-invite' is the real invitation URL (the bare 'invite'
       above is the design-review preview); 'reset-password' handles
       both the "Forgot password?" request form (no token) and the
       set-new-password form (#/reset-password/:token). Both render
       pre-auth via PRE_AUTH_ROUTES in the login gate below. */
    'accept-invite': window.InviteScreen,
    'reset-password': window.ResetScreen,

    /* G6 — Platform / operator */
    orgs: window.OrgsScreen,
    'global-species': window.GlobalSpeciesScreen,
    /* TEMP — Video Backgrounds dev surface (Kling 2.5 etc.) */
    'video-backgrounds': window.VideoBackgroundsScreen,
    /* Variant A operator dashboard. Falls back to PlatformAnalyticsScreen
       if the new module failed to load (defensive). */
    analytics: window.AnalyticsScreen || window.PlatformAnalyticsScreen,
    /* Phase 1B drill-down detail pages — sibling routes (not nested
       under /analytics/...) so the simple first-segment hash router
       doesn't need a rewrite. ROUTE_ACTIVE_NAV maps each one back to
       the Analytics nav item. */
    'analytics-campaign': window.AnalyticsCampaignDetail,
    'analytics-species':  window.AnalyticsSpeciesDetail,
    'analytics-screen':   window.AnalyticsScreenDetail,
    'analytics-funnel':   window.AnalyticsFunnelScreen,
    'analytics-engagement': window.AnalyticsEngagementDetail,
    /* Custom dashboards — list at #dashboards, single at #dashboards/:id.
       Single-route component DashboardsList branches on `param`: if it's
       set, it delegates to DashboardScreen; otherwise it renders the
       list/index. Keeps the router unchanged (one-segment route head). */
    dashboards: window.DashboardsList,
    'org-admin': window.OrgAdminScreen,
    billing: window.BillingScreen,

    /* G7 — Activity & approvals */
    notifications: window.NotificationsScreen,
    /* `notifications-popover` removed per 2026-05-15 audit (LOW-1) —
       it was a preview/design-review-only page; the real bell-icon
       popover lives in sidebar.jsx Topbar. No nav points to this
       route. */
    approvals: window.ApprovalsScreen,
    audit: window.AuditLogScreen,
    versions: window.VersionHistoryScreen,
    /* Phase 2 — Alerts (rules / feed / anomalies). Route param carries
       the active tab id, e.g. #alerts/feed. */
    alerts: window.AlertsScreen,

    /* G8 — Library & onboarding */
    media: window.MediaLibraryScreen,
    templates: window.TemplatesScreen,
    onboarding: window.OnboardingScreen,
    search: window.SearchScreen,

    /* G9 — Edge cases */
    errors: window.ErrorStatesScreen,
    /* `reports` + `pairing` removed per 2026-05-15 audit (LOW-1).
       reports.jsx was a hardcoded-demo duplicate of the alerts-rules
       "Scheduled reports" tab; pairing.jsx was a marketing/visual
       preview with a fake code. Real pairing UI lives in
       /display/pair.html. */

    /* Phase 5 */
    bulk: window.BulkScreen,
  };
}

const PAGE_TITLES = {
  dashboard: 'Dashboard',
  campaigns: 'Marketing',
  library: 'Species Library',
  displays: 'Displays',
  orgs: 'Organisations',
  'global-species': 'Global Species',
  'video-backgrounds': 'Video Backgrounds',
  users: 'Users',
  analytics: 'Platform Analytics',
  'analytics-campaign': 'Campaign analytics',
  'analytics-species':  'Species analytics',
  'analytics-screen':   'Screen analytics',
  'analytics-funnel':   'Conversion funnel',
  'analytics-engagement': 'Species engagement',
  bulk: 'Bulk Operations',
  review: 'Pending review',
  fleet: 'Fleet health',
  intel: 'Triage Intel',
  screen: 'Display screen',
  site: 'Site',
  sites: 'Sites',
  species: 'Edit species',
  campaign: 'Campaign',
  'campaign-edit': 'Edit campaign',
  'campaign-group': 'Campaign',
  settings: 'Settings',
  account: 'Account',
  sso: 'SSO redirect',
  invite: 'Accept invitation',
  'accept-invite': 'Accept invitation',
  'reset-password': 'Reset password',
  'org-admin': 'Operations',
  billing: 'Billing & usage',
  alerts: 'Alerts',
  dashboards: 'Dashboards',
  notifications: 'Notifications',
  approvals: 'Approvals',
  audit: 'Audit log',
  versions: 'Version history',
  media: 'Media library',
  templates: 'Templates',
  onboarding: 'Onboarding',
  search: 'Search',
  errors: 'Error states',
};

/* Which sidebar item lights up for each route — detail pages map back
   to their parent list. */
const ROUTE_ACTIVE_NAV = {
  screen: 'displays',
  site: 'dashboard',
  species: 'library',
  campaign: 'campaigns',
  'campaign-edit': 'campaigns',
  'campaign-group': 'campaigns',
  account: 'settings',
  audit: 'review',
  versions: 'review',
  approvals: 'review',
  notifications: 'review',
  'notifications-popover': 'review',
  media: 'library',
  templates: 'library',
  onboarding: 'dashboard',
  search: 'dashboard',
  errors: 'dashboard',
  reports: 'analytics',
  'analytics-campaign': 'analytics',
  'analytics-species':  'analytics',
  'analytics-screen':   'analytics',
  'analytics-funnel':   'analytics',
  'analytics-engagement': 'analytics',
  /* Custom dashboards live under Insights in the sidebar — light up
     "Dashboards" when viewing both the list (#dashboards) and a single
     dashboard (#dashboards/abc-123). The single-route handler branches
     on `param`, but ROUTE_ACTIVE_NAV is keyed on the route head only. */
  dashboards: 'dashboards',
  pairing: 'displays',
  'org-admin': 'dashboard',
  billing: 'orgs',
};

/* Routes that bypass the App shell — full-bleed previews + auth flows. */
const FULL_BLEED_ROUTES = new Set(['sso', 'invite', 'accept-invite', 'reset-password']);

/* Auth-flow routes that must render for a logged-OUT visitor (opened from
   an email link). Without this allow-list the !user gate would show the
   sign-in form instead of the token-consuming screen. */
const PRE_AUTH_ROUTES = new Set(['accept-invite', 'reset-password']);

const VALID_ROUTES = new Set(Object.keys(PAGE_TITLES));
const DEFAULT_ROUTE = 'dashboard';

function readHashRoute() {
  let raw = (window.location.hash || '').replace(/^#/, '').replace(/^\//, '');
  /* Strip a "?query=part" off the raw hash BEFORE splitting on slashes.
     Otherwise #species/new?platform=1 yields param="new?platform=1",
     which the species editor's `param === 'new'` check misses — it
     instead tries to fetch /api/species/new%3Fplatform%3D1 and Postgres
     rejects the cast to uuid ("invalid input syntax for type uuid").
     Bug filed 2026-05-12 by Oli (feedback id ea6ad108-…). The query
     part is parsed into key/value pairs and exposed alongside `param`
     so the species editor (and any future route that wants flags) can
     read them without re-parsing. */
  let queryStr = '';
  const qIdx = raw.indexOf('?');
  if (qIdx !== -1) {
    queryStr = raw.slice(qIdx + 1);
    raw = raw.slice(0, qIdx);
  }
  const query = {};
  if (queryStr) {
    for (const pair of queryStr.split('&')) {
      if (!pair) continue;
      const [k, v] = pair.split('=');
      try { query[decodeURIComponent(k)] = v == null ? '' : decodeURIComponent(v); }
      catch (_) { /* malformed pair — ignore */ }
    }
  }

  /* Split on first slash: "screen/REEF-01" → route="screen", param="REEF-01" */
  const [head, ...rest] = raw.split('/');

  /* Back-compat redirects after the perms-v2 simplification (2026-05-04):
     #user/:id and #permission-templates were folded into the unified Users
     page. Old bookmarks land on Users; the embedded drawer/sub-tab handles
     the rest. We don't rewrite the location so the user can still copy a
     direct route if they want — just resolve them in-memory here. */
  if (head === 'user') return { route: 'users', param: null, query };
  if (head === 'permission-templates') return { route: 'users', param: 'tab=roles', query };
  // #zones merged into #displays — zones now render as collapsible
  // headers on the Displays page. Redirect in-memory so old bookmarks
  // still work without us rewriting the URL.
  if (head === 'zones') return { route: 'displays', param: null, query };
  /* Phase 1 of the Marketing IA overhaul (2026-05-17): "slide" is now
     the canonical name for a leaf creative row; "campaign" refers to
     the group-level rollup. Both #slide-edit/:id and #campaign-edit/:id
     resolve to the same editor without rewriting the URL so old links
     keep working. */
  if (head === 'slide-edit') return { route: 'campaign-edit', param: rest.join('/') || null, query };

  const route = VALID_ROUTES.has(head) ? head : DEFAULT_ROUTE;
  const param = rest.length > 0 ? rest.join('/') : null;
  return { route, param, query };
}

/* Route-level error boundary. A bug in any one screen (a TypeError on
   a missing field, an unexpected backend payload) shouldn't take down
   the whole app. We catch render errors per-route, show a contained
   "this screen crashed" card with a Reload button + a Go to dashboard
   escape hatch, and log the actual error to the console so devs can
   debug. Boundary key is the current route, so navigating away resets
   the boundary's caught state. */
class RouteErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { error: null };
  }
  static getDerivedStateFromError(error) {
    return { error };
  }
  componentDidCatch(error, info) {
    /* Surface to the console so the dev tools network/console tab
       still has actionable info. We don't ship a remote logger here. */
    console.error('Route crashed:', error, info && info.componentStack);
  }
  render() {
    if (this.state.error) {
      const message = (this.state.error && this.state.error.message) || String(this.state.error);
      return (
        <div className="aq-content">
          <div className="x-err-stage" style={{ minHeight: 380 }}>
            <div className="x-err-card">
              <div className="x-err-code">Screen error</div>
              <div className="x-err-glyph is-warn"><Icon name="alert" size={28} /></div>
              <h1 className="x-err-headline">Something went wrong on this page</h1>
              <p className="x-err-body">
                The rest of Slate is still up. Try reloading the page,
                or jump back to the dashboard. If this keeps happening,
                let us know — the technical details are below.
              </p>
              <pre style={{
                margin: '14px auto 18px', maxWidth: 520,
                padding: '10px 12px', borderRadius: 6,
                background: 'var(--aq-surface-2)',
                border: '1px solid var(--aq-line)',
                fontFamily: 'var(--aq-ff-mono)', fontSize: 11,
                color: 'var(--aq-text-faint)',
                whiteSpace: 'pre-wrap', wordBreak: 'break-word',
                textAlign: 'left',
              }}>{message}</pre>
              <div className="x-err-actions">
                <button className="x-btn ghost" onClick={() => window.location.reload()}>
                  Reload page
                </button>
                <button className="x-btn" onClick={() => { window.location.hash = '#dashboard'; this.setState({ error: null }); }}>
                  Go to dashboard
                </button>
              </div>
            </div>
          </div>
        </div>
      );
    }
    return this.props.children;
  }
}

function ComingSoon({ title }) {
  /* Each page now renders its own scroll container, so the placeholder
     does too. */
  return (
    <div className="aq-content">
      <div className="x-err-stage" style={{ minHeight: 380 }}>
        <div className="x-err-card">
          <div className="x-err-code">Migration in progress</div>
          <div className="x-err-glyph"><Icon name="sparkle" size={28} /></div>
          <h1 className="x-err-headline">{title}</h1>
          <p className="x-err-body">
            This screen is on the migration plan but hasn't been ported yet.
            The chrome and tokens you see around it are already in place.
          </p>
        </div>
      </div>
    </div>
  );
}

function App() {
  const [user, setUser] = useState(Auth.getUser());
  const [hashState, setHashState] = useState(readHashRoute());

  useEffect(() => {
    const onHash = () => {
      setHashState(readHashRoute());
      /* Close the mobile nav drawer on any navigation so picking an
         item dismisses it. No-op on desktop where the class is unused. */
      document.body.classList.remove('aq-nav-open');
    };
    window.addEventListener('hashchange', onHash);
    return () => window.removeEventListener('hashchange', onHash);
  }, []);

  /* Global ⌘K / Ctrl+K — jump to the search palette. Restored from
     the original CMS keyboard shortcut. Skip when typing in an input
     so the user can still type slashes / k's into form fields. */
  useEffect(() => {
    function onKey(e) {
      if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
        const tag = (document.activeElement && document.activeElement.tagName) || '';
        const isEditable = tag === 'INPUT' || tag === 'TEXTAREA' || (document.activeElement && document.activeElement.isContentEditable);
        /* ⌘K from inside an input still wins — Cmd-anything is editor-
           level intent, not text input. */
        e.preventDefault();
        window.location.hash = '#search';
      }
    }
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);

  useEffect(() => {
    /* On boot: if we have a token, validate it. If invalid, clear.
       Capture the token in scope BEFORE the async fetch so a sign-out
       mid-flight (which clears localStorage) can't cause us to call
       Auth.setSession(null, …) — that would write `Bearer null` on
       the next request. */
    const token = Auth.getToken();
    if (token && !user) {
      apiFetch('/api/auth/me')
        .then((r) => { Auth.setSession(token, r.user); setUser(r.user); })
        .catch(() => Auth.clear());
    }
  }, []);

  useEffect(() => {
    /* Tenant theme — once we know the user's org, ask the resolver to
       hydrate Layer B tokens (typography, brand). Phase 23 disables the
       brand bits, but typography + accent still flow through. Failures
       are non-fatal — tokens.css ships Slate defaults. */
    if (user && user.organisation_id && window.AquaOSTheme) {
      try {
        window.AquaOSTheme.resolve({ org: user.organisation_id });
      } catch (_) { /* ignore — surface keeps default tokens */ }
    }
  }, [user]);

  /* v80 follow-up (2026-05-18) — sidebar approvals badge.
     Reviewers (anyone with Auth.canApprove()) get a numeric chip next
     to the Approvals nav entry showing how many campaigns are sitting
     in pending_review for their org. We poll on a 60s interval, plus
     re-fetch on tab focus so the count is fresh after they approve
     something in another tab. Other roles skip the fetch entirely. */
  const [approvalCount, setApprovalCount] = useState(0);
  useEffect(() => {
    if (!user) return undefined;
    const canApprove = (typeof Auth !== 'undefined' && Auth.canApprove && Auth.canApprove());
    if (!canApprove) { setApprovalCount(0); return undefined; }
    let cancelled = false;
    async function refresh() {
      try {
        const r = await apiFetch('/api/campaigns?status=pending_review');
        if (cancelled) return;
        setApprovalCount(((r && r.campaigns) || []).length);
      } catch (_) { /* non-fatal — badge just stays stale */ }
    }
    refresh();
    const tick = setInterval(refresh, 60_000);
    const onFocus = () => refresh();
    window.addEventListener('focus', onFocus);
    return () => {
      cancelled = true;
      clearInterval(tick);
      window.removeEventListener('focus', onFocus);
    };
  }, [user && user.id]);

  if (!user) {
    /* Auth-flow links (invitation acceptance, password reset) arrive while
       logged out and must render their own screen — not the sign-in form. */
    if (PRE_AUTH_ROUTES.has(hashState.route)) {
      const PreAuthComp = getPageRegistry()[hashState.route];
      if (PreAuthComp) {
        return (
          <>
            <RouteErrorBoundary key={hashState.route}>
              <PreAuthComp param={hashState.param} query={hashState.query} />
            </RouteErrorBoundary>
            {window.ToastTray && <window.ToastTray />}
          </>
        );
      }
    }
    return (
      <>
        <LoginScreen onAuthed={(u) => {
          setUser(u);
          if (!window.location.hash) window.location.hash = '#dashboard';
        }} />
        {/* Pre-login dev switcher so testers can pick a seed role
            without typing credentials. */}
        {window.DevRoleSwitcher && <window.DevRoleSwitcher />}
        {window.ToastTray && <window.ToastTray />}
      </>
    );
  }

  const { route, param, query } = hashState;

  /* Approval workflow paused (2026-06-03, Oli): the Approvals nav entry is
     hidden (sidebar.jsx) and campaigns publish directly. Bounce any stale
     bookmark / direct #approvals hash back to Campaigns, but show a toast
     so the user understands why the redirect happened (silent redirects are
     disorienting). The ApprovalsScreen route registration + /api/approvals
     endpoints are left intact for a one-line revert. */
  if (route === 'approvals') {
    window.toast && window.toast.info('Approvals is temporarily paused — campaigns now publish directly.');
    window.location.hash = '#campaigns';
    return null;
  }

  const pages = getPageRegistry();
  const PageComponent = pages[route];
  const title = PAGE_TITLES[route] || 'Slate';
  const activeNav = ROUTE_ACTIVE_NAV[route] || route;

  function pick(id) {
    window.location.hash = `#${id}`;
  }

  /* SSO + Invite render full-bleed — they're auth-flow previews
     that don't fit inside the chrome. */
  if (FULL_BLEED_ROUTES.has(route) && PageComponent) {
    return (
      <RouteErrorBoundary key={route}>
        <PageComponent param={param} query={query} />
      </RouteErrorBoundary>
    );
  }

  /* Campaign editor early-return REMOVED — the editor now uses the
     dashboard's standard layout (Sidebar slot + bordered .aq-main).
     The Sidebar slot is swapped for the editor's tools rail in the
     main return below when route === 'campaign-edit'. Keeping a
     separate full-bleed branch hid the breadcrumbs / sidebar entirely. */

  /* Detail pages get richer breadcrumbs. The top-of-tree "Slate" anchor
     always points at the dashboard so it doubles as a "home" affordance. */
  const crumbs = (() => {
    const home = { label: 'Slate', href: '#dashboard' };
    if (route === 'screen')   return [home, { label: 'Displays', href: '#displays' }, { label: param || 'Screen' }];
    if (route === 'site')     return [home, { label: 'Dashboard', href: '#dashboard' }, { label: param || 'Site' }];
    if (route === 'species')  {
      /* If we arrived from the Global Species admin page (carry-flag
         on the hash query), the breadcrumb should reflect that context
         so the curator can hop back to global mgmt instead of being
         dumped into the regular library. Bug feedback ea70888e. */
      if (query && query.from === 'global') {
        return [home, { label: 'Global Species', href: '#global-species' }, { label: 'Edit' }];
      }
      return [home, { label: 'Species Library', href: '#library' }, { label: 'Edit' }];
    }
    if (route === 'campaign') return [home, { label: 'Campaigns', href: '#campaigns' }, { label: param || 'Campaign' }];
    if (route === 'campaign-edit') return [home, { label: 'Campaigns', href: '#campaigns' }, { label: 'Edit' }];
    if (route === 'campaign-group') return [home, { label: 'Marketing', href: '#campaigns' }, { label: 'Campaign' }];
    if (route === 'account')  return [home, { label: 'Settings', href: '#settings' }, { label: 'Account' }];
    if (route === 'analytics-campaign') return [home, { label: 'Analytics', href: '#analytics' }, { label: 'Campaign' }];
    if (route === 'analytics-species')  return [home, { label: 'Analytics', href: '#analytics' }, { label: 'Species' }];
    if (route === 'analytics-screen')   return [home, { label: 'Analytics', href: '#analytics' }, { label: 'Screen' }];
    if (route === 'analytics-funnel')   return [home, { label: 'Analytics', href: '#analytics' }, { label: 'Funnel' }];
    if (route === 'analytics-engagement') return [home, { label: 'Analytics', href: '#analytics' }, { label: 'Engagement' }];
    if (route === 'dashboards' && param)  return [home, { label: 'Dashboards', href: '#dashboards' }, { label: 'View' }];
    return [home, { label: title }];
  })();

  return (
    <div className="aq-app">
      <div className="aq-atmosphere" />
      {/* On the campaign editor route, swap the global Sidebar
          (Dashboard / Campaigns / Library / etc.) for the editor's
          tools rail. The editor portals its <aside class="ce-left">
          into #aq-page-rail via ReactDOM.createPortal — same slot, same
          width, same flat-on-bg styling as the system sidebar. Other
          routes get the regular system sidebar. */}
      {route === 'campaign-edit' ? (
        <div id="aq-page-rail" className="aq-page-rail aq-sidebar" />
      ) : (
        <Sidebar
          active={activeNav}
          onPick={pick}
          user={user}
          badges={{ approvals: approvalCount > 0 ? approvalCount : undefined }}
        />
      )}
      {/* Mobile nav drawer scrim — sits behind the slide-in sidebar and
          above page content. Tap to dismiss. Visible only when
          body.aq-nav-open is set AND we're at mobile width (see
          mobile.css); display:none everywhere else so desktop is
          untouched. */}
      <div
        className="aq-nav-scrim"
        onClick={() => document.body.classList.remove('aq-nav-open')}
        aria-hidden="true"
      />
      <main className="aq-main">
        <Topbar crumbs={crumbs} showMenu={route !== 'campaign-edit'} />
        {/* Page-transition fade. The key on the boundary forces a
            remount on route or param change; the .aq-page-fade class
            on the wrapping div re-fires its 160ms entry animation
            each time. Linear-style: subtle buffer between context
            switches without feeling like a slow slide. */}
        <RouteErrorBoundary key={`${route}/${param || ''}`}>
          <div className="aq-page-fade" key={`${route}/${param || ''}`}>
            {PageComponent
              ? <PageComponent param={param} query={query} />
              : <ComingSoon title={title} />}
          </div>
        </RouteErrorBoundary>
      </main>
      {/* Dev-only: floating role switcher for cycling through the
          seeded demo accounts in one click. Self-hides on non-local
          hosts so production never sees it. */}
      {window.DevRoleSwitcher && <window.DevRoleSwitcher />}
      {window.ToastTray && <window.ToastTray />}
      {/* Floating Add-Species queue pill — shows when items are
          in-flight so a refresh / nav-away keeps the curator one
          click from the bulk import. Renders nothing when queue is
          empty or when the bulk modal is already open. */}
      {window.AquaOSAddQueuePill && <window.AquaOSAddQueuePill />}
    </div>
  );
}

window.App = App;

/* Mount once everything is loaded. Babel transpiles each script
   tag in order; this last script picks up all the components. */
(function mount() {
  const root = ReactDOM.createRoot(document.getElementById('root'));
  root.render(<App />);
})();
